From 9793704a87b55e6b185164c712a0c3234e1c991a Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Sat, 11 Apr 2026 23:40:25 -0400 Subject: [PATCH 01/13] Implement FR-012 Phase 1: QuantUIApp class (app.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates all notebook widget logic (cells 3–10) into a single QuantUIApp class. Also adds None guard to optimizer.py calculate() to satisfy mypy. Co-Authored-By: Claude Sonnet 4.6 --- quantui/__init__.py | 7 + quantui/app.py | 1917 ++++++++++++++++++++++++++++++++++++++++++ quantui/optimizer.py | 3 + 3 files changed, 1927 insertions(+) create mode 100644 quantui/app.py diff --git a/quantui/__init__.py b/quantui/__init__.py index 9d5c104..1fb2cbb 100644 --- a/quantui/__init__.py +++ b/quantui/__init__.py @@ -146,6 +146,11 @@ VISUALIZATION_AVAILABLE = False PY3DMOL_AVAILABLE = False +# App class — imported last so all package symbols are defined first. +# app.py imports from submodules directly, but placing this last is an +# extra safeguard against accidental circular-import issues in the future. +from .app import QuantUIApp + __all__ = [ # Config constants "MOLECULE_LIBRARY", @@ -187,6 +192,8 @@ "plot_orbital_diagram", "orbital_summary_html", "parse_cube_file", + # App class + "QuantUIApp", # Comparison "CalcSummary", "summary_from_session_result", diff --git a/quantui/app.py b/quantui/app.py new file mode 100644 index 0000000..64f8484 --- /dev/null +++ b/quantui/app.py @@ -0,0 +1,1917 @@ +""" +QuantUI-local application class. + +All widget creation, state management, callbacks, and tab wiring live here. +The notebook is a thin launcher:: + + from quantui.app import QuantUIApp + QuantUIApp().display() + +CSS is injected inside ``display()`` — not on import — so importing this +module in tests or tutorials does not pollute the IPython display. +""" + +from __future__ import annotations + +import io +import re +import threading +import time +from pathlib import Path +from typing import Any, List, Optional + +import ipywidgets as widgets +from IPython.display import HTML, Javascript, display + +import quantui +import quantui.calc_log as _calc_log + +# Import directly from submodules to avoid circular-import issues. +# quantui/__init__.py imports this module (app.py), so using +# `from quantui import X` at module load time would see a partially- +# initialised package namespace (symbols defined after the app import +# in __init__.py would not yet exist). +from quantui.config import ( + DEFAULT_BASIS, + DEFAULT_CHARGE, + DEFAULT_METHOD, + DEFAULT_MULTIPLICITY, + MOLECULE_LIBRARY, + SUPPORTED_BASIS_SETS, + SUPPORTED_METHODS, +) +from quantui.help_content import HELP_TOPICS +from quantui.molecule import Molecule, parse_xyz_input +from quantui.progress import StepProgress +from quantui.utils import get_session_resources + +# ── Availability flags (computed once at import, not per-instantiation) ─────── +try: + from quantui.ase_bridge import ASE_AVAILABLE +except ImportError: + ASE_AVAILABLE = False + +try: + from quantui.visualization_py3dmol import display_molecule as _display_molecule + + VISUALIZATION_AVAILABLE = True +except ImportError: + VISUALIZATION_AVAILABLE = False + _display_molecule = None # type: ignore[assignment] + +try: + from quantui.pubchem import student_friendly_fetch as _student_friendly_fetch + + PUBCHEM_AVAILABLE = True +except ImportError: + PUBCHEM_AVAILABLE = False + _student_friendly_fetch = None # type: ignore[assignment] + +try: + from quantui.session_calc import SessionResult, run_in_session # noqa: F401 + + _PYSCF_AVAILABLE = True +except (ImportError, AttributeError): + _PYSCF_AVAILABLE = False + +try: + from quantui.preopt import preoptimize + + _PREOPT_AVAILABLE = True +except (ImportError, AttributeError): + _PREOPT_AVAILABLE = False + +# ── Module-level constants ──────────────────────────────────────────────────── +_THEME_HUE: dict = {"Dark": 180, "Dark Blue": 200, "Dark Maroon": 160} + +_APP_CSS: str = """""" + + +# ── SCF regex (module-level so _LogCapture can use them) ───────────────────── +_RE_CYCLE = re.compile( + 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\.]+)") + + +# ══ LOG CAPTURE ══════════════════════════════════════════════════════════════ + + +class _LogCapture: + """Write PySCF output to an Output widget and capture it to a buffer.""" + + def __init__( + self, + output_widget: widgets.Output, + status_label: Optional[widgets.Label] = None, + ) -> None: + self._w = output_widget + self._buf = io.StringIO() + self._line_buf = "" + self._status = status_label + + def write(self, text: str) -> None: + if not text: + return + self._w.append_stdout(text) + self._buf.write(text) + self._line_buf += text + while "\n" in self._line_buf: + line, self._line_buf = self._line_buf.split("\n", 1) + m = _RE_CYCLE.search(line) + if m and self._status is not None: + n, delta = m.group(1), m.group(3) + try: + self._status.value = f"SCF cycle {n} · ΔE = {float(delta):.4g} Ha" + except Exception: + 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 ✓" + + def flush(self) -> None: + pass + + def getvalue(self) -> str: + return self._buf.getvalue() + + +# ══ APP CLASS ════════════════════════════════════════════════════════════════ + + +class QuantUIApp: + """ + Self-contained QuantUI-local application widget. + + Instantiate once; call ``display()`` to inject CSS and show the UI:: + + app = QuantUIApp() + app.display() + """ + + def __init__(self) -> None: + # ── Instance state ──────────────────────────────────────────────── + self._molecule: Optional[Molecule] = None + self._last_result: Any = None + self._results: List = [] + + # Availability (copied from module-level flags) + self._pyscf_available: bool = _PYSCF_AVAILABLE + self._preopt_available: bool = _PREOPT_AVAILABLE + + # ── Build → wire → assemble ─────────────────────────────────────── + self._build_widgets() + self._wire_callbacks() + self._assemble_tabs() + + # Log startup + _calc_log.log_event("startup", f"QuantUI-local {quantui.__version__} started") + + def display(self) -> None: + """Inject global CSS and render the application widget.""" + display(HTML(_APP_CSS)) + display( + widgets.VBox( + [ + widgets.HBox( + [self.theme_btn], + layout=widgets.Layout( + justify_content="flex-end", margin="0 0 4px" + ), + ), + self._theme_style, + self._status_html, + self.root_tab, + ] + ) + ) + + @property + def widget(self) -> widgets.Tab: + """The root tab widget (for callers that want the widget object).""" + return self.root_tab + + # ══ BUILD METHODS ════════════════════════════════════════════════════════ + + def _build_widgets(self) -> None: + self._build_theme_selector() + self._build_status_panel() + self._build_shared_widgets() + self._build_molecule_section() + self._build_calc_setup() + self._build_run_section() + self._build_results_section() + self._build_history_section() + self._build_compare_section() + self._build_output_tab() + self._build_help_section() + + # ── Theme selector ──────────────────────────────────────────────────── + + def _build_theme_selector(self) -> None: + self._theme_style = widgets.Output( + layout=widgets.Layout( + height="0px", overflow="hidden", margin="0", padding="0" + ) + ) + self.theme_btn = widgets.ToggleButtons( + options=["Light", "Dark", "Dark Blue", "Dark Maroon"], + value="Dark", + description="Theme:", + style={"description_width": "48px", "button_width": "90px"}, + layout=widgets.Layout(margin="0"), + ) + # Apply Dark theme immediately + with self._theme_style: + display(HTML(self._theme_css("Dark"))) + + def _theme_css(self, theme: str) -> str: + """Return the CSS filter block for *theme*, or '' for Light.""" + if theme not in _THEME_HUE: + return "" + deg = _THEME_HUE[theme] + return ( + "" + ) + + # ── Status panel ────────────────────────────────────────────────────── + + 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" + + 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._status_html = widgets.HTML( + f'
' + f'' + f"QuantUI-local {quantui.__version__}" + f'{_rows}
' + f"
" + ) + + # ── 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=widgets.Layout(min_height="50px")) + self.run_output = widgets.Output( + layout=widgets.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.comparison_output = widgets.Output() + 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=widgets.Layout(width="260px"), + ) + self.basis_dd = widgets.Dropdown( + options=SUPPORTED_BASIS_SETS, + value=DEFAULT_BASIS, + description="Basis Set:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="260px"), + ) + self.charge_si = widgets.BoundedIntText( + value=DEFAULT_CHARGE, + min=-10, + max=10, + description="Charge:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="190px"), + ) + self.mult_si = widgets.BoundedIntText( + value=DEFAULT_MULTIPLICITY, + min=1, + max=10, + description="Multiplicity:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="190px"), + ) + self.preopt_cb = widgets.Checkbox( + value=False, + description="Pre-optimize geometry (fast LJ force-field)", + disabled=not _PREOPT_AVAILABLE, + layout=widgets.Layout(width="400px"), + ) + + # Calculation type + extra options + self.calc_type_dd = widgets.Dropdown( + options=["Single Point", "Geometry Opt", "Frequency", "UV-Vis (TD-DFT)"], + value="Single Point", + description="Calc. Type:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="310px"), + ) + self.fmax_fi = widgets.BoundedFloatText( + value=0.05, + min=0.001, + max=1.0, + step=0.005, + description="Force thr. (eV/Å):", + style={"description_width": "130px"}, + layout=widgets.Layout(width="270px"), + ) + self.max_steps_si = widgets.BoundedIntText( + value=200, + min=10, + max=1000, + description="Max steps:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="200px"), + ) + self.nstates_si = widgets.BoundedIntText( + value=10, + min=1, + max=50, + description="# states:", + style={"description_width": "100px"}, + layout=widgets.Layout(width="180px"), + ) + self.calc_extra_opts = widgets.VBox([]) + + # Context-help buttons + self.method_help_btn = widgets.Button( + description="?", + button_style="", + layout=widgets.Layout(width="28px", height="28px"), + tooltip="RHF vs UHF — opens Help tab", + ) + self.basis_help_btn = widgets.Button( + description="?", + button_style="", + layout=widgets.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=widgets.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=widgets.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=widgets.Layout(width="190px"), + ) + self.clear_btn = widgets.Button( + description="Clear", + button_style="warning", + icon="trash", + layout=widgets.Layout(width="100px"), + ) + self.export_btn = widgets.Button( + description="Export Script", + button_style="", + icon="download", + disabled=True, + layout=widgets.Layout(width="160px"), + ) + self.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=widgets.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=widgets.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=widgets.Layout(width="380px"), + ) + self.pubchem_btn = widgets.Button( + description="Search", + button_style="info", + icon="search", + disabled=not PUBCHEM_AVAILABLE, + layout=widgets.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=widgets.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=widgets.Layout(align_items="center", gap="12px", padding="6px 0"), + ) + self.mol_input_container = widgets.VBox( + [self.mol_input_expanded, self.mol_info_html, self.viz_output], + layout=widgets.Layout(margin="0 0 4px 0"), + ) + + # ── 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=widgets.Layout( + align_items="center", gap="4px" + ), + ), + widgets.HBox( + [self.basis_dd, self.basis_help_btn], + layout=widgets.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, + self.notes_output, + ] + ) + + # ── 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=widgets.Layout( + align_items="center", + justify_content="space-between", + margin="10px 0 4px", + max_width="460px", + ), + ), + self.run_output, + ] + ) + + # ── Results panel (Cell 7) ──────────────────────────────────────────── + + def _build_results_section(self) -> None: + self.results_panel = widgets.VBox( + [ + widgets.HTML('

Results

'), + self.result_output, + ] + ) + + # ── 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=widgets.Layout(width="500px"), + ) + self.past_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=widgets.Layout(width="100px"), + tooltip="Rescan the results directory", + ) + self.copy_path_btn = widgets.Button( + description="Copy path", + button_style="", + icon="clipboard", + layout=widgets.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=widgets.Layout(width="110px"), + tooltip="Open the full PySCF output log in the Output tab", + ) + + # 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=widgets.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=widgets.Layout(width="190px"), + ) + self._reset_confirm_no = widgets.Button( + description="Cancel", + button_style="", + icon="times", + layout=widgets.Layout(width="90px"), + ) + self._reset_confirm_box = widgets.VBox( + [ + self._reset_confirm_html, + widgets.HBox( + [self._reset_confirm_yes, self._reset_confirm_no], + layout=widgets.Layout(gap="8px", margin="4px 0 0"), + ), + ], + layout=widgets.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=widgets.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") + + 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=widgets.Layout(align_items="center", gap="8px"), + ), + self.results_path_lbl, + self.past_output, + self._perf_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=widgets.Layout(width="100%"), + ) + self.compare_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=widgets.Layout(width="100px"), + ) + self.compare_btn = widgets.Button( + description="Compare selected", + button_style="primary", + icon="bar-chart", + disabled=True, + layout=widgets.Layout(width="180px"), + ) + self.compare_clear_btn = widgets.Button( + description="Clear", + button_style="warning", + icon="times", + layout=widgets.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=widgets.Layout(gap="8px", margin="6px 0"), + ), + self.compare_output, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # Export accordion (Advanced) + _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]), + ] + ) + self.advanced_accordion = widgets.Accordion(children=[_export_content]) + self.advanced_accordion.set_title(0, "Export Script") + 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=widgets.Layout(width="80px"), + ) + self.log_tab_panel = widgets.VBox( + [ + widgets.HTML( + '

' + "Full PySCF output for the most recent calculation. " + "Use View log in the History tab to load a saved result's log.

" + ), + widgets.HBox( + [self._log_clear_btn], + layout=widgets.Layout(margin="0 0 8px"), + ), + self._log_source_lbl, + self._log_output_html, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # ── Help section (Cell 10) ──────────────────────────────────────────── + + 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=widgets.Layout(width="460px"), + ) + self.help_content_html = widgets.HTML() + self._render_help_topic() # render first topic immediately + + 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=widgets.Layout(padding="8px 0"), + ) + + # ── Tab assembly (Cell 10) ──────────────────────────────────────────── + + def _assemble_tabs(self) -> None: + _calculate_content = widgets.VBox( + [ + self.step_progress.widget, + self.mol_input_container, + self.calc_setup_panel, + self.run_panel, + self.results_panel, + self.advanced_accordion, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + self.root_tab = widgets.Tab( + children=[ + _calculate_content, + self.history_panel, + self.compare_panel, + self.log_tab_panel, + self.help_tab_panel, + ] + ) + self.root_tab.set_title(0, "Calculate") + self.root_tab.set_title(1, "History") + self.root_tab.set_title(2, "Compare") + self.root_tab.set_title(3, "Output") + self.root_tab.set_title(4, "Help") + + # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ + + def _wire_callbacks(self) -> None: + # Theme + self.theme_btn.observe(self._on_theme_changed, names="value") + # Molecule input + self.preset_dd.observe(self._on_load_preset, names="value") + self.xyz_btn.on_click(self._on_load_xyz) + self.pubchem_btn.on_click(self._on_search_pubchem) + self.change_mol_btn.on_click(self._on_expand_mol_input) + # Calc type + self.calc_type_dd.observe(self._on_calc_type_changed, names="value") + # Notes + estimate + self.method_dd.observe(self._update_notes, names="value") + self.basis_dd.observe(self._update_notes, names="value") + self.method_dd.observe(self._update_estimate, names="value") + self.basis_dd.observe(self._update_estimate, names="value") + # Help buttons + self.method_help_btn.on_click(self._on_method_help) + self.basis_help_btn.on_click(self._on_basis_help) + # Run + self.run_btn.on_click(self._on_run_clicked) + self.log_clear_btn.on_click(self._on_clear_log) + # Accumulate / export + self.accumulate_btn.on_click(self._on_accumulate) + self.clear_btn.on_click(self._on_clear) + self.export_btn.on_click(self._on_export) + # History + self.past_dd.observe(self._on_past_dd_changed, names="value") + self.past_refresh_btn.on_click(self._on_past_refresh) + self.copy_path_btn.on_click(self._on_copy_results_path) + self.view_log_btn.on_click(self._on_view_log) + # Perf stats reset + self._reset_btn.on_click(self._on_reset_click) + self._reset_confirm_yes.on_click(self._on_confirm_yes) + self._reset_confirm_no.on_click(self._on_confirm_no) + # Compare + self.compare_refresh_btn.on_click(self._on_compare_refresh) + self.compare_btn.on_click(self._on_compare) + self.compare_clear_btn.on_click(self._on_compare_clear) + # Output log + self._log_clear_btn.on_click(self._on_log_clear) + # Help + self.help_topic_dd.observe(self._on_help_topic_changed, names="value") + + # ══ 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)) + + # ── Molecule input ──────────────────────────────────────────────────── + + def _on_load_preset(self, change) -> None: + name = change["new"] + if name.startswith("("): + return + d = MOLECULE_LIBRARY[name] + self._set_molecule( + Molecule( + atoms=d["atoms"], + coordinates=d["coordinates"], + charge=d["charge"], + multiplicity=d["multiplicity"], + ), + d["description"], + ) + + def _on_load_xyz(self, btn) -> None: + try: + atoms, coords = parse_xyz_input(self.xyz_area.value.strip()) + mol = Molecule(atoms=atoms, coordinates=coords) + self._set_molecule(mol, "Loaded from XYZ input") + self.xyz_msg.value = "" + except Exception as exc: + self.xyz_msg.value = f"Parse error: {exc}" + + def _on_search_pubchem(self, btn) -> None: + query = self.pubchem_txt.value.strip() + if not query: + self.pubchem_msg.value = "Enter a molecule name or SMILES." + return + if _student_friendly_fetch is None: + self.pubchem_msg.value = "PubChem module not available." + return + self.pubchem_msg.value = f'Searching for "{query}"...' + self.pubchem_btn.disabled = True + + def _do(): + try: + xyz_str, _msg = _student_friendly_fetch(query) + if xyz_str is None: + raise ValueError(_msg) + atoms, coords = parse_xyz_input(xyz_str) + mol = Molecule(atoms=atoms, coordinates=coords) + self._set_molecule(mol, f"PubChem: {query}") + self.pubchem_msg.value = f"Loaded {mol.get_formula()} from PubChem." + except Exception as exc: + self.pubchem_msg.value = f"Not found: {exc}" + finally: + self.pubchem_btn.disabled = False + + threading.Thread(target=_do, daemon=True).start() + + def _on_expand_mol_input(self, btn) -> None: + self.mol_input_container.children = [ + self.mol_input_expanded, + self.mol_info_html, + self.viz_output, + ] + + # ── 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=widgets.Layout(gap="8px"), + ), + ] + 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." + ), + ] + else: + self.calc_extra_opts.children = [] + + # ── Help buttons ────────────────────────────────────────────────────── + + def _on_method_help(self, btn) -> None: + self._show_help_topic("method") + + def _on_basis_help(self, btn) -> None: + self._show_help_topic("basis_set") + + # ── Run ─────────────────────────────────────────────────────────────── + + def _on_run_clicked(self, btn) -> None: + threading.Thread(target=self._do_run, daemon=True).start() + + def _on_clear_log(self, btn) -> None: + self.run_output.clear_output() + + # ── Accumulate / export ─────────────────────────────────────────────── + + def _on_accumulate(self, btn) -> None: + r = self._last_result + if r is None: + return + self._results.append(r) + self._refresh_comparison() + + def _on_clear(self, btn) -> None: + self._results.clear() + self.comparison_output.clear_output() + + 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}" + + # ── Compare ─────────────────────────────────────────────────────────── + + def _on_compare_refresh(self, btn) -> None: + self._populate_compare_list() + + 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 = [] + for path_str in selected: + if not path_str: + continue + try: + data = load_result(Path(path_str)) + summaries.append(summary_from_saved_result(data)) + 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 + + def _on_compare_clear(self, btn) -> None: + self.compare_select.value = () + self.compare_output.clear_output() + + # ── History ─────────────────────────────────────────────────────────── + + def _on_past_dd_changed(self, change) -> None: + path_str = change["new"] + if not path_str: + self.past_output.clear_output() + return + self.past_output.clear_output(wait=True) + try: + from quantui import load_result + + data = load_result(Path(path_str)) + self.past_output.append_display_data(HTML(self._format_past_result(data))) + except Exception as exc: + self.past_output.append_stdout(f"Could not load result: {exc}\n") + + def _on_past_refresh(self, btn) -> None: + self._refresh_results_browser() + + 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() + + def _on_view_log(self, btn) -> None: + path_str = self.past_dd.value + if not path_str: + return + log_path = Path(path_str) / "pyscf.log" + if log_path.exists(): + text = log_path.read_text(encoding="utf-8", errors="replace") + label = Path(path_str).name + else: + text = "(No pyscf.log found for this result.)" + label = "" + self._update_log_panel(text, label) + self._goto_output_tab() + + # ── Perf stats reset ────────────────────────────────────────────────── + + def _on_reset_click(self, btn) -> None: + self._reset_confirm_box.layout.display = "" + + 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() + + def _on_confirm_no(self, btn) -> None: + self._reset_confirm_box.layout.display = "none" + + # ── Output log ──────────────────────────────────────────────────────── + + def _on_log_clear(self, btn) -> None: + self._log_output_html.value = ( + 'Log cleared.' + ) + self._log_source_lbl.value = "" + + # ── Help ────────────────────────────────────────────────────────────── + + def _on_help_topic_changed(self, change=None) -> None: + self._render_help_topic() + + # ══ LOGIC METHODS ════════════════════════════════════════════════════════ + + def _set_molecule(self, mol: Molecule, label: str = "") -> None: + """Update shared state and refresh dependent widgets.""" + self._molecule = mol + self.run_btn.disabled = False + self.export_btn.disabled = False + + try: + n_e = mol.get_electron_count() + e_str = f"{n_e} electrons" + except Exception: + e_str = "" + + _lbl = f'
{label}' if label else "" + _summary = ( + f'{mol.get_formula()}' + f' ' + f"{len(mol.atoms)} atoms" + + (f" • {e_str}" if e_str else "") + + f" • charge {mol.charge} • mult {mol.multiplicity}" + + f"{_lbl}" + ) + self.mol_info_html.value = _summary + self.mol_summary_compact.value = ( + f'
' + f"{_summary}
" + ) + + self.charge_si.value = mol.charge + self.mult_si.value = mol.multiplicity + if mol.multiplicity > 1 and self.method_dd.value == "RHF": + self.method_dd.value = "UHF" + + self.viz_output.clear_output(wait=True) + if _display_molecule is not None: + with self.viz_output: + _display_molecule(mol) + + self._update_notes() + + # Advance step indicator + if self.step_progress._states[2] != "active": + if self.step_progress._states[2] in ("done", "fail"): + self.step_progress.reset() + self.step_progress.complete(0) + self.step_progress.start(1) + + self._update_estimate() + + # Collapse molecule input to compact view + self.mol_input_container.children = [self.mol_input_collapsed, self.viz_output] + + def _do_run(self) -> None: + """Main calculation dispatch — runs in a background thread.""" + mol = self._molecule + if mol is None: + self.run_status.value = "Load a molecule first." + return + self.run_btn.disabled = True + self.run_status.value = "Starting..." + self.run_output.clear_output() + self.result_output.clear_output() + + self.step_progress.complete(1) + self.step_progress.start(2) + + _calc_log.log_event( + "calc_start", + f"{self.method_dd.value}/{self.basis_dd.value} on {mol.get_formula()}", + n_atoms=len(mol.atoms), + ) + _run_wall_t = time.perf_counter() + log = _LogCapture(self.run_output, self.run_status) + + try: + calc_mol = mol + if self.preopt_cb.value and _PREOPT_AVAILABLE: + self.run_status.value = "Pre-optimizing..." + calc_mol, _rmsd = preoptimize(mol) + self._set_molecule( + calc_mol, + f"Geometry pre-optimized (LJ, RMSD={_rmsd:.3f} Å)", + ) + + ct = self.calc_type_dd.value + result: Any = None + result_html: str = "" + save_spectra: dict = {} + save_type: str = "single_point" + if ct == "Geometry Opt": + self.run_status.value = "Optimizing geometry..." + from quantui import optimize_geometry + + result = optimize_geometry( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + fmax=self.fmax_fi.value, + steps=self.max_steps_si.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_opt_result(result) + save_spectra, save_type = {}, "geometry_opt" + elif ct == "Frequency": + self.run_status.value = "Computing frequencies (SCF + Hessian)..." + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_freq_result(result) + save_spectra = { + "ir": { + "frequencies_cm1": result.frequencies_cm1, + "ir_intensities": result.ir_intensities, + "zpve_hartree": result.zpve_hartree, + } + } + save_type = "frequency" + elif ct == "UV-Vis (TD-DFT)": + self.run_status.value = "Running TD-DFT excited states..." + from quantui.tddft_calc import run_tddft_calc + + result = run_tddft_calc( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + nstates=self.nstates_si.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_tddft_result(result) + save_spectra = { + "uv_vis": { + "excitation_energies_ev": result.excitation_energies_ev, + "oscillator_strengths": result.oscillator_strengths, + "wavelengths_nm": result.wavelengths_nm(), + } + } + save_type = "tddft" + else: # Single Point + self.run_status.value = "Calculating..." + from quantui import run_in_session + + result = run_in_session( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_result(result) + save_spectra, save_type = {}, "single_point" + + _elapsed = time.perf_counter() - _run_wall_t + self._last_result = result + 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.step_progress.complete(2) + self.step_progress.complete(3) + + # Persist to disk + try: + from quantui import save_result + + save_result( + result, + pyscf_log=log.getvalue(), + calc_type=save_type, + spectra=save_spectra, + ) + self._refresh_results_browser() + self._populate_compare_list() + self._update_log_panel( + log.getvalue(), + f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}", + ) + except Exception: + pass + + # Log performance + try: + _calc_log.log_calculation( + formula=result.formula, + n_atoms=len(calc_mol.atoms), + n_electrons=calc_mol.get_electron_count(), + method=result.method, + basis=result.basis, + n_iterations=getattr(result, "n_iterations", -1), + elapsed_s=_elapsed, + converged=result.converged, + ) + _calc_log.log_event( + "calc_done", + f"{result.method}/{result.basis} on {result.formula}", + elapsed_s=round(_elapsed, 2), + converged=result.converged, + ) + self._update_estimate() + except Exception: + pass + + except ImportError as _import_err: + _err_detail = str(_import_err) + log.write( + f"Import error: {_err_detail}\n\n" + "A required calculation dependency could not be loaded.\n" + "On Windows: use the Apptainer container.\n" + " apptainer run quantui-local.sif\n" + ) + self.run_status.value = "Import error — see output." + self.step_progress.fail(2, _err_detail[:60]) + _calc_log.log_event("calc_error", _err_detail[:200]) + + except Exception as exc: + import traceback as _tb + + _elapsed = time.perf_counter() - _run_wall_t + log.write(f"Error: {exc}\n\n{_tb.format_exc()}") + self.run_status.value = "Error — see Calculation Output below." + self.step_progress.fail(2, str(exc)[:60]) + _calc_log.log_event( + "calc_error", str(exc)[:200], elapsed_s=round(_elapsed, 2) + ) + + finally: + self.run_btn.disabled = False + + 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 + + def _update_estimate(self, change=None) -> None: + if self._molecule is None: + self.perf_estimate_html.value = "" + return + try: + 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, + ) + self.perf_estimate_html.value = _calc_log.format_estimate(est) + except Exception: + self.perf_estimate_html.value = "" + + 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)", "")] + + 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 + + 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 + + def _show_help_topic(self, topic: str) -> None: + if topic in HELP_TOPICS: + self.help_topic_dd.value = topic + self.root_tab.selected_index = 4 + + def _update_log_panel(self, log_text: str, label: str = "") -> None: + self._render_log(log_text, label) + + def _goto_output_tab(self) -> None: + self.root_tab.selected_index = 3 + + def _render_log(self, text: str, source_label: str = "") -> None: + import html as _html_mod + + lines = text.splitlines() + rows = [] + for line in lines: + esc = _html_mod.escape(line) + if "converged SCF energy" in line or "SCF converged" in line: + style = "color:#16a34a;font-weight:600" + elif "cycle=" in line and "E=" in line: + style = "color:#475569" + elif "HOMO" in line or "LUMO" in line: + style = "color:#2563eb" + elif "Warning" in line or "warning" in line: + style = "color:#d97706" + elif "Error" in line or "error" in line or "failed" in line: + style = "color:#dc2626" + else: + style = "color:#1e293b" + rows.append(f'
{esc}
') + self._log_output_html.value = ( + '
' + + "".join(rows) + + "
" + ) + self._log_source_lbl.value = ( + f'Source: {source_label}' + if source_label + else "" + ) + + def _render_help_topic(self, change=None) -> None: + key = self.help_topic_dd.value + if key and key in HELP_TOPICS: + entry = HELP_TOPICS[key] + self.help_content_html.value = ( + f'
' + f'

' + f'{entry["title"]}

' + f'
' + f'{entry["body"]}
' + f"
" + ) + + def _refresh_perf_stats(self) -> None: + self._perf_stats_html.value = self._build_perf_stats_html() + self._perf_events_html.value = self._build_events_html() + + def _build_perf_stats_html(self) -> str: + from quantui.calc_log import get_perf_history + + records = get_perf_history() + if not records: + return ( + '' + "No performance data recorded yet." + ) + groups: dict = {} + for r in records: + key = (r.get("method", "?"), r.get("basis", "?")) + groups.setdefault(key, []).append(r) + rows = "" + for (meth, bas), recs in sorted(groups.items()): + times = [r["elapsed_s"] for r in recs if "elapsed_s" in r] + n = len(recs) + if times: + avg = sum(times) / len(times) + rows += ( + "" + f'{meth}' + f'{bas}' + f'{n}' + f'{avg:.1f} s' + f'{min(times):.1f} s' + f'{max(times):.1f} s' + "" + ) + header = ( + "" + 'Method' + 'Basis' + 'Runs' + 'Avg' + 'Min' + 'Max' + "" + ) + return ( + '' + f"{header}{rows}
" + ) + + def _build_events_html(self) -> str: + from quantui.calc_log import get_recent_events + + events = get_recent_events(20) + if not events: + return ( + '' + "No events recorded yet." + ) + rows = "" + for e in reversed(events): + ts = e.get("timestamp", "")[:19].replace("T", " ") + evt = e.get("event", "") + msg = e.get("message", "") + rows += ( + "" + f'{ts}' + f'{evt}' + f'{msg}' + "" + ) + return ( + '' + f"{rows}
" + ) + + # ══ 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", str(r.n_iterations), "#000"), + ] + ) + return ( + f'
' + f"{r.formula} — {r.method}/{r.basis}" + f'' + f"{_rows}
" + ) + + 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}
" + ) + + 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)" + ) + return ( + f'
' + f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})" + f'' + f"{_rows}
" + ) + + 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}
" + ) + + def _format_past_result(self, data: dict) -> str: + _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", str(data.get("n_iterations", "?")), "#000"), + ] + ) + ts = data.get("timestamp", "") + return ( + f'
' + f'{data["formula"]} — {data["method"]}/{data["basis"]}' + f' {ts}' + f'' + f"{_rows}
" + ) + + # ══ HELPERS ══════════════════════════════════════════════════════════════ + + def _get_results_dir(self) -> Path: + from quantui.results_storage import _default_results_dir + + return _default_results_dir().resolve() diff --git a/quantui/optimizer.py b/quantui/optimizer.py index c5dac21..fe9fa2e 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -116,6 +116,9 @@ def calculate( _sink = io.StringIO() # absorb all PySCF output + if self.atoms is None: + raise RuntimeError("No Atoms object attached to calculator.") + # Build PySCF molecule from the current ASE geometry mol = gto.Mole() mol.atom = [ From c740ffc88758aa671a91f8e4c791e5edf6887804 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 16 Apr 2026 16:33:03 -0400 Subject: [PATCH 02/13] Add Copilot context, app tests, and container checks Add stable AI assistant context and developer test artifacts. Introduces .github/copilot-instructions.md describing repo structure, architecture, constraints, and development guidance for QuantUI-local. Adds notebooks/_inspect_nb.py (notebook inspector) and a temporary notebooks/app_test.ipynb to exercise the QuantUIApp UI, plus a pre-FR012 backup notebook (molecule_computations.pre-fr012.ipynb). Streamlines/updates notebooks/molecule_computations.ipynb in preparation for the FR-012 app refactor. Update apptainer/quantui-local.def to run import checks for QuantUIApp during build/runtime. Add tests/test_app.py and update tests/test_pubchem.py to support the new test coverage. --- .github/copilot-instructions.md | 394 ++++ apptainer/quantui-local.def | 2 + notebooks/_inspect_nb.py | 10 + notebooks/app_test.ipynb | 43 + notebooks/molecule_computations.ipynb | 1726 +--------------- .../molecule_computations.pre-fr012.ipynb | 1791 +++++++++++++++++ tests/test_app.py | 338 ++++ tests/test_pubchem.py | 1 + 8 files changed, 2581 insertions(+), 1724 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 notebooks/_inspect_nb.py create mode 100644 notebooks/app_test.ipynb create mode 100644 notebooks/molecule_computations.pre-fr012.ipynb create mode 100644 tests/test_app.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fd887c7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,394 @@ +# QuantUI-local — AI Assistant Context + +> Stable project context for GitHub Copilot, Claude, and other AI coding assistants. +> Describes what the project IS and how it is built — not where development currently +> stands (see `planning/SESSION-HANDOFF.md` for that). Update this file when +> architecture or conventions change, not every session. + +--- + +## Overview + +QuantUI-local is an interactive Jupyter/Voilà interface for running PySCF quantum +chemistry calculations locally — no cluster account, no SLURM, no queueing. Students +design molecules, launch RHF/UHF/DFT calculations in their own Python kernel, and +visualize results in minutes. It is a downstream port of the cluster-focused +`QuantUI` repo with all SLURM infrastructure removed. + +**Target audience:** Undergraduate chemistry students at North Carolina Central +University. The UI runs as a Voilà app — students never see code. + +--- + +## Repository Structure + +``` +QuantUI-local/ +├── quantui/ ← Main Python package (imports as `quantui`) +│ ├── app.py ← QuantUIApp class — all widgets, callbacks, state +│ ├── molecule.py ← Molecule dataclass + XYZ/SMILES parsing +│ ├── session_calc.py ← In-session PySCF runner (run_in_session) +│ ├── optimizer.py ← QM geometry optimization (ASE-BFGS + PySCF) +│ ├── freq_calc.py ← Frequency / vibrational analysis +│ ├── tddft_calc.py ← TD-DFT UV-Vis excited states +│ ├── calculator.py ← PySCFCalculation abstraction +│ ├── comparison.py ← Side-by-side result comparison table +│ ├── results_storage.py ← Persist/reload calculation results (JSON + log) +│ ├── calc_log.py ← Performance + event logging (JSONL) +│ ├── pubchem.py ← PubChem molecule search +│ ├── visualization_py3dmol.py ← 3D molecular viewer (py3Dmol) +│ ├── ase_bridge.py ← ASE structure I/O + molecule library +│ ├── preopt.py ← ASE force-field pre-optimisation (fast, no PySCF) +│ ├── progress.py ← StepProgress widget +│ ├── help_content.py ← HELP_TOPICS dict — in-app educational text +│ ├── orbital_visualization.py ← Orbital energy diagrams, cube file viewer +│ ├── config.py ← All constants/defaults (methods, basis sets, etc.) +│ ├── utils.py ← Session resource checks, sanitize_filename, etc. +│ └── security.py ← SecurityError exception class +├── notebooks/ +│ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) +│ └── tutorials/ ← 01–05 step-by-step tutorial notebooks +├── tests/ ← pytest suite (~440 tests) +├── planning/ ← Planning docs (not committed to git) +│ ├── SESSION-HANDOFF.md ← Start here each session — current state +│ ├── feature-requests.md ← FR backlog +│ └── FR-*.md ← Individual feature request specs +├── apptainer/ +│ ├── quantui-local.def ← Apptainer container definition +│ └── build.sh ← Build script +├── local-setup/ ← Conda environment YAMLs +├── launch-app.bat ← Windows double-click launcher (Voilà app mode) +├── launch-dev.bat ← Windows double-click launcher (JupyterLab mode) +├── pyproject.toml ← Package config (name: quantui-local, imports as quantui) +└── pytest.ini ← pytest configuration +``` + +--- + +## Architecture + +``` +notebooks/molecule_computations.ipynb + Cell 0: Markdown title + Cell 1: Conda env check (skip-execution, remove-input) + Cell 2: from quantui.app import QuantUIApp; QuantUIApp().display() + │ + ▼ + quantui/app.py — QuantUIApp + ┌──────────────────────────────────────────────────────────┐ + │ _build_shared_widgets() → StepProgress, run_output │ + │ _build_molecule_section() → mol_input_container │ + │ _build_calc_setup() → method_dd, basis_dd, etc. │ + │ _build_run_section() → run_btn, run_panel │ + │ _build_results_section() → results_panel │ + │ _build_history_section() → history_panel │ + │ _build_compare_section() → compare_panel │ + │ _build_output_tab() → log viewer │ + │ _build_help_section() → help panel │ + │ _assemble_tabs() → root_tab (Tab widget) │ + └──────────────────────────────────────────────────────────┘ + │ _do_run() dispatches by calc_type_dd.value: + ▼ + ┌──────────────────────────────────────────────────────────────┐ + │ Single Point → session_calc.run_in_session() │ + │ Geometry Opt → optimizer.optimize_geometry() │ + │ Frequency → freq_calc.run_freq_calc() │ + │ UV-Vis → tddft_calc.run_tddft_calc() │ + └──────────────────────────────────────────────────────────────┘ + │ + ▼ + results_storage.save_result() calc_log.log_perf_record() +``` + +**Tab order in the app:** Calculate (0) → History (1) → Compare (2) → Output (3) → Help (4) + +--- + +## Key Files + +| File | Purpose | +| ---- | ------- | +| `quantui/app.py` | **Primary development target.** All widget logic lives here. | +| `quantui/session_calc.py` | `run_in_session()` — PySCF SCF runner; returns `SessionResult` | +| `quantui/molecule.py` | `Molecule` dataclass, `parse_xyz_input()` | +| `quantui/config.py` | `SUPPORTED_METHODS`, `SUPPORTED_BASIS_SETS`, `MOLECULE_LIBRARY`, widget layout constants | +| `quantui/results_storage.py` | `save_result()`, `load_result()`, `list_results()` — result.json schema v2 | +| `quantui/calc_log.py` | `perf_log.jsonl` + `event_log.jsonl` under `~/.quantui/logs/` | +| `quantui/optimizer.py` | `optimize_geometry()` — ASE-BFGS + custom `_QuantUIPySCFCalc` | +| `quantui/freq_calc.py` | `run_freq_calc()` — vibrational analysis via `pyscf.hessian` | +| `quantui/tddft_calc.py` | `run_tddft_calc()` — excited states via `pyscf.tddft` | +| `notebooks/molecule_computations.ipynb` | Thin launcher — 3 cells only (do not add logic here) | +| `planning/SESSION-HANDOFF.md` | **Read this first every session** — current state, git log, open tasks | +| `planning/feature-requests.md` | FR backlog | + +--- + +## Critical Constraints + +> These are hard rules that must be respected in all implementation work. + +1. **PySCF is Linux/macOS/WSL only.** Never assume PySCF is available. All + PySCF-dependent code must be guarded with `try/except ImportError`. The + availability flags in `app.py` (`_PYSCF_AVAILABLE`, `_PREOPT_AVAILABLE`, + `ASE_AVAILABLE`, `VISUALIZATION_AVAILABLE`) are computed once at module + import — check these instead of importing inline. + +2. **No notebook cell logic.** `notebooks/molecule_computations.ipynb` is a + three-cell thin launcher. Never add widget creation, callbacks, or business + logic to notebook cells. All logic belongs in `quantui/app.py`. + +3. **Thread-safe widget updates only.** `_do_run()` runs in a background thread. + Widget updates from threads must use `.value =` assignment, + `.append_stdout()`, or `.append_display_data()`. Never call `display()` inside + `with output_widget:` from a background thread. + +4. **No new top-level dependencies** without updating both `pyproject.toml` + and the Apptainer container `apptainer/quantui-local.def`. + +5. **All constants in `config.py`.** Method names, basis sets, layout widths, + and other shared literals must be defined in `config.py` and imported from + there — never hard-coded in `app.py` or other modules. + +6. **Result schema versioning.** `results_storage.py` uses `_SCHEMA_VERSION = 2`. + Any new fields must be additive (never remove or rename existing keys). Bump + `_SCHEMA_VERSION` only when a breaking change is unavoidable. + +--- + +## Supported Calculations + +| Calc type | Module | Key function | Returns | +| --- | --- | --- | --- | +| Single Point | `session_calc` | `run_in_session()` | `SessionResult` | +| Geometry Opt | `optimizer` | `optimize_geometry()` | `OptResult` | +| Frequency | `freq_calc` | `run_freq_calc()` | `FreqResult` | +| UV-Vis TD-DFT | `tddft_calc` | `run_tddft_calc()` | `TDDFTResult` | + +**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X (defined in +`config.SUPPORTED_METHODS`). + +**Supported basis sets:** STO-3G, 3-21G, 6-31G, 6-31G\*, 6-31G\*\*, cc-pVDZ, +cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). + +--- + +## `QuantUIApp` Class (`quantui/app.py`) + +### Construction order + +``` +__init__() + _build_shared_widgets() + _build_molecule_section() + _build_calc_setup() + _build_run_section() # uses self.calc_type_dd from _build_calc_setup + _build_results_section() + _build_history_section() + _build_compare_section() + _build_output_tab() + _build_help_section() + _assemble_tabs() # builds self.root_tab + _wire_callbacks() # all .observe() and .on_click() wiring +``` + +### Key instance state + +| Attribute | Type | Purpose | +| --- | --- | --- | +| `self._molecule` | `Optional[Molecule]` | Currently loaded molecule | +| `self._last_result` | `Optional[...]` | Most recent calculation result | +| `self._results` | `list` | All results from this session | +| `self._pyscf_available` | `bool` | Mirrors module-level `_PYSCF_AVAILABLE` | +| `self.root_tab` | `widgets.Tab` | Top-level displayed widget | +| `self.method_dd` | `widgets.Dropdown` | Selected QC method | +| `self.basis_dd` | `widgets.Dropdown` | Selected basis set | +| `self.calc_type_dd` | `widgets.Dropdown` | Single Point / Geo Opt / Frequency / UV-Vis | + +### Molecule collapse/expand pattern + +`mol_input_container` is a `widgets.VBox` whose `.children` is swapped: +- **Expanded** (initial): `[mol_input_expanded, mol_info_html, viz_output]` +- **Collapsed** (after `_set_molecule()`): `[mol_input_collapsed, viz_output]` + +Clicking "Change molecule" re-expands. + +### CSS injection + +`display(HTML(_APP_CSS))` fires inside `display()` before `display(self.root_tab)`. +Never import this module in a context where IPython display is not available without +catching the resulting error — or just don't call `.display()` (instantiation is safe). + +--- + +## Result Storage (`quantui/results_storage.py`) + +Results are saved to timestamped subdirectories: +``` +/___/ + result.json ← schema v2 (see below) + pyscf.log ← raw PySCF stdout (may be absent) +``` + +Default results dir: `Path("results")` relative to cwd, or `$QUANTUI_RESULTS_DIR`. +In the Apptainer container: `$HOME/.quantui/results`. + +### result.json schema (version 2) + +```json +{ + "_schema_version": 2, + "timestamp": "YYYY-MM-DD_HH-MM-SS-ffffff", + "calc_type": "single_point | geometry_opt | frequency | tddft", + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + "energy_hartree": -75.0, + "energy_ev": -2040.8, + "homo_lumo_gap_ev": 12.3, + "converged": true, + "n_iterations": 12, + "spectra": { + "ir": {"frequencies_cm1": [...], "ir_intensities": [...], "zpve_hartree": 0.021}, + "uv_vis": {"excitation_energies_ev": [...], "oscillator_strengths": [...], "wavelengths_nm": [...]} + } +} +``` + +Timestamp includes microseconds (`-%f`) to prevent same-second directory collisions. + +--- + +## Performance Logging (`quantui/calc_log.py`) + +Two JSONL files under `~/.quantui/logs/` (override with `$QUANTUI_LOG_DIR`): + +| File | Contents | Retention | +| --- | --- | --- | +| `perf_log.jsonl` | One record per converged run: formula, n_atoms, n_electrons, method, basis, elapsed_s | Permanent | +| `event_log.jsonl` | Startup / calc_start / calc_done / calc_error events | 7-day auto-prune | + +Key API: `log_perf_record()`, `get_perf_history()`, `get_recent_events(n)`, +`reset_perf_log()`, `estimate_time(n_atoms, n_electrons, method, basis)`. + +--- + +## Naming Conventions + +- **Functions:** `verb_noun()` — e.g., `parse_xyz_input()`, `run_in_session()`, `save_result()` +- **Classes:** `PascalCase` — e.g., `QuantUIApp`, `SessionResult`, `FreqResult` +- **Private methods/helpers:** leading underscore — e.g., `_do_run()`, `_set_molecule()` +- **Builder methods in `QuantUIApp`:** `_build_
()` — e.g., `_build_calc_setup()` +- **Callback methods:** `_on__()` — e.g., `_on_run_clicked()`, `_on_theme_changed()` +- **Module-level availability flags:** `_PYSCF_AVAILABLE`, `_PREOPT_AVAILABLE`, `ASE_AVAILABLE` +- **Config constants:** `ALL_CAPS_SNAKE_CASE` in `config.py` +- **Section banners in `app.py`:** `# ══ SECTION NAME ══` delimiters for VS Code outline navigation + +--- + +## How to Run + +```powershell +# Activate environment (Windows PowerShell) +& "$env:USERPROFILE\miniconda3\shell\condabin\conda-hook.ps1" +conda activate quantui-local + +# Voilà app mode (student-facing — no code visible) +voila notebooks/molecule_computations.ipynb + +# JupyterLab (development) +jupyter lab notebooks/molecule_computations.ipynb + +# Run tests +python -m pytest --tb=short -q + +# Install/update package +pip install -e ".[dev]" + +# Verify app.py import +python -c "from quantui.app import QuantUIApp; print('OK')" +``` + +**Python executable:** `C:\Users\schul\miniconda3\envs\quantui-local\python.exe` + +Note: PySCF calculations will show "unavailable" on Windows — this is expected. +All UI, molecule, visualization, and PubChem features work natively on Windows. + +--- + +## Testing + +Test files in `tests/`: + +| File | What it covers | +| --- | --- | +| `test_molecule.py` | Molecule parsing, validation, formula | +| `test_session_calc.py` | `run_in_session()` — PySCF-gated with `pyscf_only` marker | +| `test_notebook_workflows.py` | End-to-end HF/DFT/preopt/thread-safety — PySCF-gated | +| `test_optimizer.py` | `optimize_geometry()` — PySCF + ASE required | +| `test_comparison.py` | Result comparison tables | +| `test_results_storage.py` | Save/load/list round-trip | +| `test_security.py` | `SecurityError`, `sanitize_filename()` | +| `test_phase1.py` | `QuantUIApp` instantiation (no display) | + +**PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. +On Windows, these become skips — not failures. + +--- + +## Optional Dependencies + +| Extra | Packages | Gated by | +| --- | --- | --- | +| `pyscf` | `pyscf>=2.3.0` | `_PYSCF_AVAILABLE` flag | +| `ase` | `ase>=3.22.0` | `ASE_AVAILABLE` flag | +| `app` | `voila, jupyterlab` | Always present in the conda env | + +Install all: `pip install -e ".[pyscf,ase,app,dev]"` + +--- + +## Environment Variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `QUANTUI_RESULTS_DIR` | `./results` | Where calculation results are saved | +| `QUANTUI_LOG_DIR` | `~/.quantui/logs` | Where perf_log and event_log live | + +--- + +## Apptainer Container + +The container at `apptainer/quantui-local.def` bundles Python + PySCF + ASE + +py3Dmol + Voilà into a single portable `.sif` file. This is the supported path +for Windows users. + +- Build: `bash apptainer/build.sh --clean` (requires Linux/WSL with Apptainer ≥ 1.0) +- Run (app mode): `apptainer run quantui-local.sif app` +- Run (JupyterLab): `apptainer run quantui-local.sif` +- Verify: `apptainer test quantui-local.sif` + +The container sets `QUANTUI_RESULTS_DIR=$HOME/.quantui/results` so results survive +across kernel restarts and are accessible from the host (home dir is bind-mounted). + +--- + +## Relationship to Source Repo + +QuantUI-local is a downstream port of `NCCU-Schultz-Lab/QuantUI` (the cluster version). +Bug fixes and module updates originate in `QuantUI` and are ported here. +Never make independent architectural changes in this repo — propose them in `QuantUI` first. + +| Removed from source | Reason | +| --- | --- | +| `job_manager.py` | SLURM batch submission | +| `storage.py` | SLURM job metadata | +| `slurm_errors.py` | SLURM error translation | +| `visualization.py` | PlotlyMol fallback (excluded here) | +| SLURM templates in `config.py` | No cluster | + +--- + +## Active Development Branch + +Branch: `app-restructure` — FR-012 App Module Refactor in progress. +See `planning/SESSION-HANDOFF.md` for current phase and uncommitted changes. diff --git a/apptainer/quantui-local.def b/apptainer/quantui-local.def index 32e3358..00434d6 100644 --- a/apptainer/quantui-local.def +++ b/apptainer/quantui-local.def @@ -73,6 +73,7 @@ FROM: continuumio/miniconda3:latest python -c "import ase; print('ASE OK')" python -c "from quantui import optimize_geometry; print('optimize_geometry OK')" python -c "from quantui import run_freq_calc, run_tddft_calc; print('freq/tddft OK')" + python -c "from quantui.app import QuantUIApp; print('QuantUIApp OK')" echo "Cleaning up unnecessary files..." # Version control and caches @@ -125,4 +126,5 @@ EOF python -c "import py3Dmol" || exit 1 python -c "import ase" || exit 1 python -c "from quantui import Molecule; print('Molecule OK')" || exit 1 + python -c "from quantui.app import QuantUIApp; print('QuantUIApp OK')" || exit 1 echo "All tests passed!" diff --git a/notebooks/_inspect_nb.py b/notebooks/_inspect_nb.py new file mode 100644 index 0000000..34f6288 --- /dev/null +++ b/notebooks/_inspect_nb.py @@ -0,0 +1,10 @@ +import json +with open('notebooks/molecule_computations.ipynb', encoding='utf-8') as f: + nb = json.load(f) +print("nbformat: {}.{}".format(nb["nbformat"], nb["nbformat_minor"])) +print("Total cells: {}".format(len(nb["cells"]))) +for i, cell in enumerate(nb["cells"]): + cid = cell.get("id", "N/A") + src_preview = "".join(cell["source"])[:70].replace("\n", " ") + tags = cell.get("metadata", {}).get("tags", []) + print(" Cell {}: {} id={} tags={} src={}".format(i, cell["cell_type"], cid, tags, repr(src_preview))) diff --git a/notebooks/app_test.ipynb b/notebooks/app_test.ipynb new file mode 100644 index 0000000..bc61992 --- /dev/null +++ b/notebooks/app_test.ipynb @@ -0,0 +1,43 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# QuantUI-local — App Class Test (FR-012 Phase 2)\n", + "\n", + "Side-by-side test notebook for the `QuantUIApp` class before replacing the production notebook.\n", + "\n", + "**Verify:**\n", + "- All 5 tabs render correctly (Calculate, History, Compare, Output, Help)\n", + "- Theme toggle (Light / Dark / Dark Blue / Dark Maroon) works\n", + "- Molecule input (library, XYZ, PubChem search) loads and collapses correctly\n", + "- Calculation type dropdown (Single Point / Geometry Opt / Frequency / UV-Vis) shows/hides extra options\n", + "- Run button dispatches correctly (PySCF unavailable on Windows — error message expected)\n", + "- History tab loads and displays past results\n", + "- Compare tab allows adding and comparing results\n", + "\n", + "> This notebook is temporary — delete after Phase 3 is complete." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from quantui.app import QuantUIApp\n", + "QuantUIApp().display()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb index 6140e0d..a242219 100644 --- a/notebooks/molecule_computations.ipynb +++ b/notebooks/molecule_computations.ipynb @@ -48,1730 +48,8 @@ "metadata": {}, "outputs": [], "source": [ - "import threading\n", - "import ipywidgets as widgets\n", - "from IPython.display import display, HTML\n", - "\n", - "import quantui\n", - "from quantui import (\n", - " Molecule, parse_xyz_input,\n", - " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", - " DEFAULT_METHOD, DEFAULT_BASIS, DEFAULT_CHARGE, DEFAULT_MULTIPLICITY,\n", - " session_can_handle, get_session_resources,\n", - " PUBCHEM_AVAILABLE, VISUALIZATION_AVAILABLE, ASE_AVAILABLE,\n", - " QUICK_START_TEMPLATES,\n", - ")\n", - "\n", - "# Optional — degrade gracefully if unavailable\n", - "try:\n", - " from quantui import run_in_session, SessionResult\n", - " PYSCF_AVAILABLE = True\n", - "except (ImportError, AttributeError):\n", - " PYSCF_AVAILABLE = False\n", - "\n", - "try:\n", - " from quantui import student_friendly_fetch\n", - "except (ImportError, AttributeError):\n", - " student_friendly_fetch = None\n", - "\n", - "try:\n", - " from quantui import display_molecule\n", - "except (ImportError, AttributeError):\n", - " display_molecule = None\n", - "\n", - "try:\n", - " from quantui import preoptimize\n", - " PREOPT_AVAILABLE = True\n", - "except (ImportError, AttributeError):\n", - " PREOPT_AVAILABLE = False\n", - "\n", - "# Mutable session state shared across all callbacks\n", - "_state = {\"molecule\": None, \"last_result\": None, \"results\": []}\n", - "\n", - "# ── Global app styles ─────────────────────────────────────────────────────────\n", - "# Permanent — not toggled by dark mode (dark mode uses filter inversion on top).\n", - "display(HTML(\"\"\"\"\"\"))\n", - "\n", - "# ── Dark mode toggle ─────────────────────────────────────────────────────────\n", - "# Uses CSS filter invert+hue-rotate on the html element so it works with all\n", - "# inline-styled elements. canvas/img/iframe are re-inverted to keep their\n", - "# original appearance (e.g. the py3Dmol 3D viewer).\n", - "# Map theme name → hue-rotate angle. Light uses no filter.\n", - "_THEME_HUE = {\"Dark\": 180, \"Dark Blue\": 200, \"Dark Maroon\": 160}\n", - "\n", - "\n", - "def _theme_css(theme: str) -> str:\n", - " \"\"\"Return the CSS filter block for *theme*, or '' for Light.\"\"\"\n", - " if theme not in _THEME_HUE:\n", - " return \"\"\n", - " deg = _THEME_HUE[theme]\n", - " return (\n", - " \"\"\n", - " )\n", - "\n", - "\n", - "_theme_style = widgets.Output(\n", - " layout=widgets.Layout(height=\"0px\", overflow=\"hidden\", margin=\"0\", padding=\"0\")\n", - ")\n", - "\n", - "theme_btn = widgets.ToggleButtons(\n", - " options=[\"Light\", \"Dark\", \"Dark Blue\", \"Dark Maroon\"],\n", - " value=\"Dark\",\n", - " description=\"Theme:\",\n", - " style={\n", - " \"description_width\": \"48px\",\n", - " \"button_width\": \"90px\",\n", - " },\n", - " layout=widgets.Layout(margin=\"0\"),\n", - ")\n", - "\n", - "\n", - "def _toggle_theme(change):\n", - " _theme_style.clear_output()\n", - " css = _theme_css(change[\"new\"])\n", - " if css:\n", - " with _theme_style:\n", - " display(HTML(css))\n", - "\n", - "\n", - "theme_btn.observe(_toggle_theme, names=\"value\")\n", - "\n", - "# Apply Dark theme on startup\n", - "with _theme_style:\n", - " display(HTML(_theme_css(\"Dark\")))\n", - "\n", - "display(widgets.HBox(\n", - " [theme_btn],\n", - " layout=widgets.Layout(justify_content=\"flex-end\", margin=\"0 0 4px\"),\n", - "))\n", - "display(_theme_style)\n", - "\n", - "# ── Status panel ────────────────────────────────────────────────────────────\n", - "_cores, _mem_gb = get_session_resources()\n", - "_mem = f\"{_mem_gb} GB\" if _mem_gb is not None else \"unknown\"\n", - "\n", - "\n", - "def _ok(flag, extra=\"\"):\n", - " tick = ''\n", - " cross = ''\n", - " return (tick if flag else cross) + (\" \" + extra if extra else \"\")\n", - "\n", - "\n", - "_items = [\n", - " (\"PySCF (calculations)\", _ok(PYSCF_AVAILABLE,\n", - " \"\" if PYSCF_AVAILABLE else \"— Linux / macOS / WSL required\")),\n", - " (\"ASE (structure I/O, opt.)\", _ok(ASE_AVAILABLE)),\n", - " (\"PubChem search\", _ok(PUBCHEM_AVAILABLE)),\n", - " (\"3D viewer (py3Dmol)\", _ok(VISUALIZATION_AVAILABLE)),\n", - " (\"CPU cores / Memory\", f\"{_cores} cores / {_mem}\"),\n", - "]\n", - "_rows = \"\".join(\n", - " f'{k}'\n", - " f'{v}'\n", - " for k, v in _items\n", - ")\n", - "display(HTML(\n", - " f'
'\n", - " f''\n", - " f\"QuantUI-local {quantui.__version__}\"\n", - " f'{_rows}
'\n", - " f\"
\"\n", - "))\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "import io\n", - "import re\n", - "import time\n", - "\n", - "from quantui.progress import StepProgress\n", - "import quantui.calc_log as _calc_log\n", - "\n", - "# ── Shared output widgets ────────────────────────────────────────────────────\n", - "mol_info_html = widgets.HTML(\n", - " value='No molecule loaded yet.'\n", - ")\n", - "mol_summary_compact = widgets.HTML(value=\"\")\n", - "viz_output = widgets.Output(layout=widgets.Layout(min_height=\"50px\"))\n", - "run_output = widgets.Output(\n", - " layout=widgets.Layout(\n", - " border=\"1px solid #c0ccd8\", min_height=\"80px\", max_height=\"400px\",\n", - " padding=\"8px\", overflow_y=\"auto\",\n", - " )\n", - ")\n", - "with run_output:\n", - " display(HTML(\n", - " '

'\n", - " \"No calculation run yet. PySCF output and any errors will appear here.\"\n", - " \"

\"\n", - " ))\n", - "result_output = widgets.Output()\n", - "comparison_output = widgets.Output()\n", - "notes_output = widgets.Output()\n", - "perf_estimate_html = widgets.HTML()\n", - "\n", - "# ── Step indicator ────────────────────────────────────────────────────────────\n", - "step_progress = StepProgress([\"Choose molecule\", \"Set method\", \"Run\", \"Results\"])\n", - "step_progress.start(0)\n", - "\n", - "# ── Calculation setup (defined here so _set_molecule can update them) ────────\n", - "method_dd = widgets.Dropdown(\n", - " options=SUPPORTED_METHODS, value=DEFAULT_METHOD,\n", - " description=\"Method:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"260px\"),\n", - ")\n", - "basis_dd = widgets.Dropdown(\n", - " options=SUPPORTED_BASIS_SETS, value=DEFAULT_BASIS,\n", - " description=\"Basis Set:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"260px\"),\n", - ")\n", - "charge_si = widgets.BoundedIntText(\n", - " value=DEFAULT_CHARGE, min=-10, max=10,\n", - " description=\"Charge:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "mult_si = widgets.BoundedIntText(\n", - " value=DEFAULT_MULTIPLICITY, min=1, max=10,\n", - " description=\"Multiplicity:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "preopt_cb = widgets.Checkbox(\n", - " value=False,\n", - " description=\"Pre-optimize geometry (fast LJ force-field)\",\n", - " disabled=not PREOPT_AVAILABLE,\n", - " layout=widgets.Layout(width=\"400px\"),\n", - ")\n", - "\n", - "# ── Calculation type + extra options ──────────────────────────────────────────\n", - "calc_type_dd = widgets.Dropdown(\n", - " options=[\"Single Point\", \"Geometry Opt\", \"Frequency\", \"UV-Vis (TD-DFT)\"],\n", - " value=\"Single Point\",\n", - " description=\"Calc. Type:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"310px\"),\n", - ")\n", - "fmax_fi = widgets.BoundedFloatText(\n", - " value=0.05, min=0.001, max=1.0, step=0.005,\n", - " description=\"Force thr. (eV/Å):\",\n", - " style={\"description_width\": \"130px\"},\n", - " layout=widgets.Layout(width=\"270px\"),\n", - ")\n", - "max_steps_si = widgets.BoundedIntText(\n", - " value=200, min=10, max=1000,\n", - " description=\"Max steps:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"200px\"),\n", - ")\n", - "nstates_si = widgets.BoundedIntText(\n", - " value=10, min=1, max=50,\n", - " description=\"# states:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"180px\"),\n", - ")\n", - "calc_extra_opts = widgets.VBox([])\n", - "\n", - "\n", - "def _on_calc_type_change(change):\n", - " ct = change[\"new\"]\n", - " if ct == \"Geometry Opt\":\n", - " calc_extra_opts.children = [\n", - " widgets.HBox(\n", - " [fmax_fi, max_steps_si],\n", - " layout=widgets.Layout(gap=\"8px\"),\n", - " ),\n", - " ]\n", - " elif ct == \"UV-Vis (TD-DFT)\":\n", - " calc_extra_opts.children = [\n", - " nstates_si,\n", - " widgets.HTML(\n", - " '⚠ Requires a DFT '\n", - " \"functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) \"\n", - " \"instead.\"\n", - " ),\n", - " ]\n", - " else:\n", - " calc_extra_opts.children = []\n", - "\n", - "\n", - "calc_type_dd.observe(_on_calc_type_change, names=\"value\")\n", - "\n", - "# ── Context-help buttons (next to Method and Basis dropdowns) ────────────────\n", - "method_help_btn = widgets.Button(\n", - " description=\"?\", button_style=\"\",\n", - " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", - " tooltip=\"RHF vs UHF — opens Help tab\",\n", - ")\n", - "basis_help_btn = widgets.Button(\n", - " description=\"?\", button_style=\"\",\n", - " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", - " tooltip=\"Choosing a basis set — opens Help tab\",\n", - ")\n", - "\n", - "# ── Run widgets ──────────────────────────────────────────────────────────────\n", - "run_btn = widgets.Button(\n", - " description=\"Run Calculation\", button_style=\"success\", icon=\"play\",\n", - " disabled=True, layout=widgets.Layout(width=\"200px\", height=\"36px\"),\n", - ")\n", - "run_status = widgets.Label()\n", - "\n", - "# ── Log clear button ─────────────────────────────────────────────────────────\n", - "log_clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"\", icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\", height=\"26px\"),\n", - " tooltip=\"Clear calculation output\",\n", - ")\n", - "\n", - "def _clear_log(btn):\n", - " run_output.clear_output()\n", - "\n", - "log_clear_btn.on_click(_clear_log)\n", - "\n", - "# ── Comparison / export widgets ───────────────────────────────────────────────\n", - "accumulate_btn = widgets.Button(\n", - " description=\"Add to Comparison\", button_style=\"info\", icon=\"plus\",\n", - " disabled=True, layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"warning\", icon=\"trash\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "export_btn = widgets.Button(\n", - " description=\"Export Script\", button_style=\"\", icon=\"download\",\n", - " disabled=True, layout=widgets.Layout(width=\"160px\"),\n", - ")\n", - "export_status = widgets.Label()\n", - "\n", - "\n", - "# ── Thread-safe log capture ───────────────────────────────────────────────────\n", - "_RE_CYCLE = re.compile(\n", - " r\"cycle=\\s*(\\d+)\\s+E=\\s*([\\-\\d\\.]+)\\s+delta_E=\\s*([\\-\\d\\.Ee+\\-]+)\"\n", - ")\n", - "_RE_CONV = re.compile(r\"converged SCF energy\\s*=\\s*([\\-\\d\\.]+)\")\n", - "\n", - "\n", - "class _LogCapture:\n", - " '''Write PySCF output to an Output widget and capture it to a buffer.'''\n", - " def __init__(self, output_widget):\n", - " self._w = output_widget\n", - " self._buf = io.StringIO()\n", - " self._line_buf = \"\"\n", - "\n", - " def write(self, text: str) -> None:\n", - " if not text:\n", - " return\n", - " self._w.append_stdout(text)\n", - " self._buf.write(text)\n", - " # Scan complete lines for SCF progress and update the status label.\n", - " self._line_buf += text\n", - " while \"\\n\" in self._line_buf:\n", - " line, self._line_buf = self._line_buf.split(\"\\n\", 1)\n", - " m = _RE_CYCLE.search(line)\n", - " if m:\n", - " n, delta = m.group(1), m.group(3)\n", - " try:\n", - " run_status.value = f\"SCF cycle {n} · ΔE = {float(delta):.4g} Ha\"\n", - " except Exception:\n", - " run_status.value = f\"SCF cycle {n}\"\n", - " continue\n", - " m = _RE_CONV.search(line)\n", - " if m:\n", - " run_status.value = \"SCF converged ✓\"\n", - "\n", - " def flush(self) -> None:\n", - " pass\n", - "\n", - " def getvalue(self) -> str:\n", - " return self._buf.getvalue()\n", - "\n", - "\n", - "# Placeholders — overwritten by later cells once they execute.\n", - "_refresh_results_browser = lambda: None # noqa: E731\n", - "_show_help_topic = lambda topic: None # noqa: E731\n", - "_populate_compare_list = lambda: None # noqa: E731\n", - "_update_log_panel = lambda log_text, label=\"\": None # noqa: E731\n", - "_goto_output_tab = lambda: None # noqa: E731\n", - "\n", - "\n", - "# ── Callbacks ─────────────────────────────────────────────────────────────────\n", - "\n", - "def _set_molecule(mol, label=\"\"):\n", - " '''Update shared state and refresh dependent widgets.'''\n", - " _state[\"molecule\"] = mol\n", - " run_btn.disabled = False\n", - " export_btn.disabled = False\n", - "\n", - " try:\n", - " n_e = mol.get_electron_count()\n", - " e_str = f\"{n_e} electrons\"\n", - " except Exception:\n", - " e_str = \"\"\n", - "\n", - " _lbl = f'
{label}' if label else \"\"\n", - " _summary = (\n", - " f'{mol.get_formula()}'\n", - " f' '\n", - " f\"{len(mol.atoms)} atoms\"\n", - " + (f\" • {e_str}\" if e_str else \"\")\n", - " + f\" • charge {mol.charge} • mult {mol.multiplicity}\"\n", - " + f\"{_lbl}\"\n", - " )\n", - " mol_info_html.value = _summary\n", - " mol_summary_compact.value = (\n", - " f'
'\n", - " f\"{_summary}
\"\n", - " )\n", - "\n", - " charge_si.value = mol.charge\n", - " mult_si.value = mol.multiplicity\n", - " if mol.multiplicity > 1 and method_dd.value == \"RHF\":\n", - " method_dd.value = \"UHF\"\n", - "\n", - " viz_output.clear_output(wait=True)\n", - " if display_molecule is not None:\n", - " with viz_output:\n", - " display_molecule(mol)\n", - "\n", - " _update_notes()\n", - "\n", - " # Advance step indicator — but not during a running calculation (e.g. preopt)\n", - " if step_progress._states[2] != \"active\":\n", - " if step_progress._states[2] in (\"done\", \"fail\"):\n", - " # Fresh molecule after a completed run — reset indicator\n", - " step_progress.reset()\n", - " step_progress.complete(0)\n", - " step_progress.start(1)\n", - "\n", - " # Update time estimate for the newly loaded molecule\n", - " _update_estimate()\n", - "\n", - " # Collapse the molecule input panel to the compact summary + 3D viewer\n", - " mol_input_container.children = [mol_input_collapsed, viz_output]\n", - "\n", - "\n", - "def _format_result(r):\n", - " _conv = \"Yes\" if r.converged else \"No (treat results with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " _gap = (\n", - " f\"{r.homo_lumo_gap_ev:.4f} eV\"\n", - " if r.homo_lumo_gap_ev is not None else \"N/A\"\n", - " )\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Total energy\",\n", - " f\"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)\", \"#000\"),\n", - " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", - " (\"SCF converged\", _conv, _cc),\n", - " (\"SCF iterations\", str(r.n_iterations), \"#000\"),\n", - " ]\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"{r.formula} — {r.method}/{r.basis}\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_opt_result(r):\n", - " '''Format an OptimizationResult as an HTML result card.'''\n", - " _conv = \"Yes\" if r.converged else \"No (max steps reached)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Final energy\", f\"{r.energy_hartree:.8f} Ha\", \"#000\"),\n", - " (\"Energy change\", f\"{r.energy_change_hartree:+.6f} Ha\", \"#000\"),\n", - " (\"Opt converged\", _conv, _cc),\n", - " (\"Steps taken\", str(r.n_steps), \"#000\"),\n", - " (\"Geometry RMSD\", f\"{r.rmsd_angstrom:.4f} Å\", \"#000\"),\n", - " ]\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_freq_result(r):\n", - " '''Format a FreqResult as an HTML result card.'''\n", - " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " n_real = r.n_real_modes()\n", - " n_imag = r.n_imaginary_modes()\n", - " real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]\n", - " freq_str = \" \".join(f\"{f:.1f}\" for f in real_freqs)\n", - " if len([f for f in r.frequencies_cm1 if f > 0]) > 6:\n", - " freq_str += \" …\"\n", - " imag_note = \"\"\n", - " if n_imag > 0:\n", - " imag_note = (\n", - " f'Imaginary modes'\n", - " f'{n_imag} — geometry may not be a minimum'\n", - " )\n", - " _rows = (\n", - " f'SCF energy'\n", - " f'{r.energy_hartree:.8f} Ha'\n", - " f'SCF converged'\n", - " f'{_conv}'\n", - " f'Real modes'\n", - " f'{n_real}'\n", - " + imag_note\n", - " + (f'Frequencies (cm⁻¹)'\n", - " f'{freq_str or \"none\"}'\n", - " if real_freqs else \"\")\n", - " + f'ZPVE'\n", - " f'{r.zpve_hartree:.6f} Ha '\n", - " f'({r.zpve_hartree * 27.211386245988:.4f} eV)'\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"Frequency Analysis — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_tddft_result(r):\n", - " '''Format a TDDFTResult as an HTML result card with excitation table.'''\n", - " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " header_rows = (\n", - " f'Ground-state energy'\n", - " f'{r.energy_hartree:.8f} Ha'\n", - " f'SCF converged'\n", - " f'{_conv}'\n", - " f'States computed'\n", - " f'{len(r.excitation_energies_ev)}'\n", - " )\n", - " exc_table = \"\"\n", - " if r.excitation_energies_ev:\n", - " wl = r.wavelengths_nm()\n", - " exc_rows = []\n", - " for i, (e_ev, f_osc) in enumerate(\n", - " zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1\n", - " ):\n", - " bold = \"font-weight:bold\" if f_osc > 0.05 else \"\"\n", - " exc_rows.append(\n", - " f''\n", - " f'S{i}'\n", - " f'{e_ev:.3f} eV'\n", - " f'{wl[i - 1]:.1f} nm'\n", - " f'f = {f_osc:.4f}'\n", - " f\"\"\n", - " )\n", - " if len(r.excitation_energies_ev) > 8:\n", - " exc_rows.append(\n", - " f'… '\n", - " f\"and {len(r.excitation_energies_ev) - 8} more states\"\n", - " )\n", - " exc_table = (\n", - " ''\n", - " \"Vertical excitations:\"\n", - " ''\n", - " 'State'\n", - " 'Energy'\n", - " 'λ'\n", - " 'Osc. str.'\n", - " + \"\".join(exc_rows)\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{header_rows}{exc_table}
\"\n", - " )\n", - "\n", - "\n", - "def _do_run(btn):\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " run_status.value = \"Load a molecule first.\"\n", - " return\n", - " run_btn.disabled = True\n", - " run_status.value = \"Starting...\"\n", - " run_output.clear_output()\n", - " result_output.clear_output()\n", - "\n", - " # Advance step indicator: method confirmed → running\n", - " step_progress.complete(1)\n", - " step_progress.start(2)\n", - "\n", - " _calc_log.log_event(\n", - " \"calc_start\",\n", - " f\"{method_dd.value}/{basis_dd.value} on {mol.get_formula()}\",\n", - " n_atoms=len(mol.atoms),\n", - " )\n", - " _run_wall_t = time.perf_counter()\n", - "\n", - " def _thread():\n", - " log = _LogCapture(run_output)\n", - " try:\n", - " calc_mol = mol\n", - " if preopt_cb.value and PREOPT_AVAILABLE:\n", - " run_status.value = \"Pre-optimizing...\"\n", - " calc_mol, _rmsd = preoptimize(mol)\n", - " _set_molecule(calc_mol, f\"Geometry pre-optimized (LJ, RMSD={_rmsd:.3f} Å)\")\n", - "\n", - " # ── Dispatch to the right backend based on Calc. Type ─────────────\n", - " ct = calc_type_dd.value\n", - " if ct == \"Geometry Opt\":\n", - " run_status.value = \"Optimizing geometry...\"\n", - " from quantui import optimize_geometry\n", - " result = optimize_geometry(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " fmax=fmax_fi.value,\n", - " steps=max_steps_si.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_opt_result(result)\n", - " save_spectra, save_type = {}, \"geometry_opt\"\n", - " elif ct == \"Frequency\":\n", - " run_status.value = \"Computing frequencies (SCF + Hessian)...\"\n", - " from quantui.freq_calc import run_freq_calc\n", - " result = run_freq_calc(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_freq_result(result)\n", - " save_spectra = {\"ir\": {\n", - " \"frequencies_cm1\": result.frequencies_cm1,\n", - " \"ir_intensities\": result.ir_intensities,\n", - " \"zpve_hartree\": result.zpve_hartree,\n", - " }}\n", - " save_type = \"frequency\"\n", - " elif ct == \"UV-Vis (TD-DFT)\":\n", - " run_status.value = \"Running TD-DFT excited states...\"\n", - " from quantui.tddft_calc import run_tddft_calc\n", - " result = run_tddft_calc(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " nstates=nstates_si.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_tddft_result(result)\n", - " save_spectra = {\"uv_vis\": {\n", - " \"excitation_energies_ev\": result.excitation_energies_ev,\n", - " \"oscillator_strengths\": result.oscillator_strengths,\n", - " \"wavelengths_nm\": result.wavelengths_nm(),\n", - " }}\n", - " save_type = \"tddft\"\n", - " else: # Single Point\n", - " run_status.value = \"Calculating...\"\n", - " result = run_in_session(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_result(result)\n", - " save_spectra, save_type = {}, \"single_point\"\n", - " _elapsed = time.perf_counter() - _run_wall_t\n", - "\n", - " _state[\"last_result\"] = result\n", - " accumulate_btn.disabled = False\n", - "\n", - " result_output.append_display_data(HTML(result_html))\n", - " run_status.value = f\"Done in {_elapsed:.1f} s.\"\n", - "\n", - " # Advance step indicator: run complete → results ready\n", - " step_progress.complete(2)\n", - " step_progress.complete(3)\n", - "\n", - " # Persist result to disk\n", - " try:\n", - " from quantui import save_result\n", - " save_result(\n", - " result, pyscf_log=log.getvalue(),\n", - " calc_type=save_type, spectra=save_spectra,\n", - " )\n", - " _refresh_results_browser()\n", - " _populate_compare_list()\n", - " _update_log_panel(\n", - " log.getvalue(),\n", - " f\"{result.formula} {method_dd.value}/{basis_dd.value}\",\n", - " )\n", - " except Exception:\n", - " pass\n", - "\n", - " # Log performance record and refresh the estimate widget\n", - " try:\n", - " _calc_log.log_calculation(\n", - " formula=result.formula,\n", - " n_atoms=len(calc_mol.atoms),\n", - " n_electrons=calc_mol.get_electron_count(),\n", - " method=result.method,\n", - " basis=result.basis,\n", - " n_iterations=getattr(result, \"n_iterations\", -1),\n", - " elapsed_s=_elapsed,\n", - " converged=result.converged,\n", - " )\n", - " _calc_log.log_event(\n", - " \"calc_done\",\n", - " f\"{result.method}/{result.basis} on {result.formula}\",\n", - " elapsed_s=round(_elapsed, 2),\n", - " converged=result.converged,\n", - " )\n", - " _update_estimate()\n", - " except Exception:\n", - " pass\n", - "\n", - " except ImportError as _import_err:\n", - " _err_detail = str(_import_err)\n", - " log.write(\n", - " f\"Import error: {_err_detail}\\n\\n\"\n", - " \"A required calculation dependency could not be loaded.\\n\"\n", - " \"On Windows: use the Apptainer container.\\n\"\n", - " \" apptainer run quantui-local.sif\\n\"\n", - " )\n", - " run_status.value = \"Import error — see output.\"\n", - " step_progress.fail(2, _err_detail[:60])\n", - " _calc_log.log_event(\"calc_error\", _err_detail[:200])\n", - "\n", - " except Exception as exc:\n", - " import traceback\n", - " _elapsed = time.perf_counter() - _run_wall_t\n", - " log.write(f\"Error: {exc}\\n\\n{traceback.format_exc()}\")\n", - " run_status.value = \"Error — see Calculation Output below.\"\n", - " step_progress.fail(2, str(exc)[:60])\n", - " _calc_log.log_event(\"calc_error\", str(exc)[:200], elapsed_s=round(_elapsed, 2))\n", - "\n", - " finally:\n", - " run_btn.disabled = False\n", - "\n", - " threading.Thread(target=_thread, daemon=True).start()\n", - "\n", - "run_btn.on_click(_do_run)\n", - "\n", - "\n", - "def _update_notes(change=None):\n", - " notes_output.clear_output(wait=True)\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " return\n", - " try:\n", - " from quantui import PySCFCalculation\n", - " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", - " notes = calc.get_educational_notes()\n", - " if notes:\n", - " safe = (\n", - " notes\n", - " .replace(\"**\", \"\", 1)\n", - " .replace(\"**\", \"\", 1)\n", - " .replace(\"\\n\\n\", \"

\")\n", - " )\n", - " with notes_output:\n", - " display(HTML(\n", - " '
'\n", - " + safe + \"
\"\n", - " ))\n", - " except Exception:\n", - " pass\n", - "\n", - "method_dd.observe(_update_notes, names=\"value\")\n", - "basis_dd.observe(_update_notes, names=\"value\")\n", - "\n", - "\n", - "def _update_estimate(change=None):\n", - " '''Refresh the time-estimate label based on current molecule + method/basis.'''\n", - " mol = _state.get(\"molecule\")\n", - " if mol is None:\n", - " perf_estimate_html.value = \"\"\n", - " return\n", - " try:\n", - " est = _calc_log.estimate_time(\n", - " n_atoms=len(mol.atoms),\n", - " n_electrons=mol.get_electron_count(),\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " )\n", - " perf_estimate_html.value = _calc_log.format_estimate(est)\n", - " except Exception:\n", - " perf_estimate_html.value = \"\"\n", - "\n", - "method_dd.observe(_update_estimate, names=\"value\")\n", - "basis_dd.observe(_update_estimate, names=\"value\")\n", - "\n", - "# Log startup event\n", - "_calc_log.log_event(\"startup\", f\"QuantUI-local {quantui.__version__} started\")\n", - "\n", - "\n", - "def _do_accumulate(btn):\n", - " r = _state[\"last_result\"]\n", - " if r is None:\n", - " return\n", - " _state[\"results\"].append(r)\n", - " _refresh_comparison()\n", - "\n", - "accumulate_btn.on_click(_do_accumulate)\n", - "\n", - "\n", - "def _refresh_comparison():\n", - " from quantui import summary_from_session_result, comparison_table_html\n", - " comparison_output.clear_output(wait=True)\n", - " results = _state[\"results\"]\n", - " if not results:\n", - " return\n", - " summaries = [summary_from_session_result(r) for r in results]\n", - " with comparison_output:\n", - " display(HTML(comparison_table_html(summaries)))\n", - " if len(summaries) > 1:\n", - " try:\n", - " from quantui import plot_comparison\n", - " plot_comparison(summaries)\n", - " except Exception:\n", - " pass\n", - "\n", - "\n", - "def _do_clear(btn):\n", - " _state[\"results\"].clear()\n", - " comparison_output.clear_output()\n", - "\n", - "clear_btn.on_click(_do_clear)\n", - "\n", - "\n", - "def _do_export(btn):\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " export_status.value = \"Load a molecule first.\"\n", - " return\n", - " try:\n", - " from quantui import PySCFCalculation\n", - " from pathlib import Path\n", - " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", - " fname = f\"{mol.get_formula()}_{method_dd.value}_{basis_dd.value}.py\"\n", - " calc.generate_calculation_script(Path(fname))\n", - " export_status.value = f\"Saved: {fname}\"\n", - " except Exception as exc:\n", - " export_status.value = f\"Error: {exc}\"\n", - "\n", - "export_btn.on_click(_do_export)\n", - "\n", - "\n", - "def _on_method_help(btn):\n", - " _show_help_topic(\"method\")\n", - "\n", - "def _on_basis_help(btn):\n", - " _show_help_topic(\"basis_set\")\n", - "\n", - "method_help_btn.on_click(_on_method_help)\n", - "basis_help_btn.on_click(_on_basis_help)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# ── Preset Library ─────────────────────────────────────────────────────────\n", - "_preset_opts = [\"(select a molecule)\"] + list(MOLECULE_LIBRARY.keys())\n", - "preset_dd = widgets.Dropdown(\n", - " options=_preset_opts, value=\"(select a molecule)\",\n", - " description=\"Molecule:\", style={\"description_width\": \"90px\"},\n", - " layout=widgets.Layout(width=\"320px\"),\n", - ")\n", - "\n", - "def _load_preset(change):\n", - " name = change[\"new\"]\n", - " if name.startswith(\"(\"):\n", - " return\n", - " d = MOLECULE_LIBRARY[name]\n", - " _set_molecule(\n", - " Molecule(\n", - " atoms=d[\"atoms\"], coordinates=d[\"coordinates\"],\n", - " charge=d[\"charge\"], multiplicity=d[\"multiplicity\"],\n", - " ),\n", - " d[\"description\"],\n", - " )\n", - "\n", - "preset_dd.observe(_load_preset, names=\"value\")\n", - "\n", - "# ── XYZ Input ──────────────────────────────────────────────────────────────\n", - "xyz_area = widgets.Textarea(\n", - " placeholder=(\n", - " \"Paste XYZ coordinates (symbol x y z):\\n\"\n", - " \"O 0.000 0.000 0.000\\n\"\n", - " \"H 0.757 0.587 0.000\\n\"\n", - " \"H -0.757 0.587 0.000\"\n", - " ),\n", - " layout=widgets.Layout(width=\"440px\", height=\"130px\"),\n", - ")\n", - "xyz_btn = widgets.Button(description=\"Load XYZ\", button_style=\"info\", icon=\"upload\")\n", - "xyz_msg = widgets.Label()\n", - "\n", - "def _load_xyz(btn):\n", - " try:\n", - " mol = parse_xyz_input(xyz_area.value.strip())\n", - " _set_molecule(mol, \"Loaded from XYZ input\")\n", - " xyz_msg.value = \"\"\n", - " except Exception as exc:\n", - " xyz_msg.value = f\"Parse error: {exc}\"\n", - "\n", - "xyz_btn.on_click(_load_xyz)\n", - "\n", - "# ── PubChem Search ─────────────────────────────────────────────────────────\n", - "pubchem_txt = widgets.Text(\n", - " placeholder=\"name or SMILES (e.g. aspirin, caffeine, CC(=O)O)\",\n", - " layout=widgets.Layout(width=\"380px\"),\n", - ")\n", - "pubchem_btn = widgets.Button(\n", - " description=\"Search\", button_style=\"info\", icon=\"search\",\n", - " disabled=not PUBCHEM_AVAILABLE,\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "pubchem_msg = widgets.Label(\n", - " value=\"\" if PUBCHEM_AVAILABLE else \"PubChem unavailable — check internet connection\"\n", - ")\n", - "\n", - "def _search_pubchem(btn):\n", - " query = pubchem_txt.value.strip()\n", - " if not query:\n", - " pubchem_msg.value = \"Enter a molecule name or SMILES.\"\n", - " return\n", - " if student_friendly_fetch is None:\n", - " pubchem_msg.value = \"PubChem module not available.\"\n", - " return\n", - " pubchem_msg.value = f'Searching for \"{query}\"...'\n", - " pubchem_btn.disabled = True\n", - "\n", - " def _do():\n", - " try:\n", - " mol = student_friendly_fetch(query)\n", - " _set_molecule(mol, f\"PubChem: {query}\")\n", - " pubchem_msg.value = f\"Loaded {mol.get_formula()} from PubChem.\"\n", - " except Exception as exc:\n", - " pubchem_msg.value = f\"Not found: {exc}\"\n", - " finally:\n", - " pubchem_btn.disabled = False\n", - "\n", - " threading.Thread(target=_do, daemon=True).start()\n", - "\n", - "pubchem_btn.on_click(_search_pubchem)\n", - "\n", - "# ── Assemble input tab ─────────────────────────────────────────────────────\n", - "_hint = '

'\n", - "tab_preset = widgets.VBox([\n", - " widgets.HTML(_hint + \"Choose from 20+ curated educational molecules.

\"),\n", - " preset_dd,\n", - "])\n", - "tab_xyz = widgets.VBox([\n", - " widgets.HTML(_hint + \"Paste XYZ coordinates (element x y z, one atom per line).

\"),\n", - " xyz_area,\n", - " widgets.HBox([xyz_btn, xyz_msg]),\n", - "])\n", - "tab_pubchem = widgets.VBox([\n", - " widgets.HTML(_hint + \"Search by name or SMILES. Requires internet connection.

\"),\n", - " widgets.HBox([pubchem_txt, pubchem_btn]),\n", - " pubchem_msg,\n", - "])\n", - "\n", - "input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])\n", - "for _i, _t in enumerate([\"Preset Library\", \"XYZ Input\", \"PubChem Search\"]):\n", - " input_tab.set_title(_i, _t)\n", - "\n", - "# ── Collapsible molecule input container ──────────────────────────────────\n", - "mol_input_expanded = widgets.VBox([\n", - " widgets.HTML('

Molecule Input

'),\n", - " input_tab,\n", - "])\n", - "\n", - "# Change button — re-expands the input panel\n", - "change_mol_btn = widgets.Button(\n", - " description=\"Change\", button_style=\"\", icon=\"pencil\",\n", - " layout=widgets.Layout(width=\"100px\", height=\"32px\"),\n", - " tooltip=\"Re-expand the molecule input panel\",\n", - ")\n", - "\n", - "# Collapsed view: compact summary pill + Change button\n", - "mol_input_collapsed = widgets.HBox(\n", - " [mol_summary_compact, change_mol_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"12px\", padding=\"6px 0\"),\n", - ")\n", - "\n", - "# Container starts expanded; _set_molecule() collapses it after first load.\n", - "mol_input_container = widgets.VBox(\n", - " [mol_input_expanded, mol_info_html, viz_output],\n", - " layout=widgets.Layout(margin=\"0 0 4px 0\"),\n", - ")\n", - "\n", - "def _expand_mol_input(btn):\n", - " mol_input_container.children = [mol_input_expanded, mol_info_html, viz_output]\n", - "\n", - "change_mol_btn.on_click(_expand_mol_input)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "calc_setup_panel = widgets.VBox([\n", - " widgets.HTML('

Calculation Setup

'),\n", - " widgets.HBox([\n", - " widgets.VBox([\n", - " widgets.HBox(\n", - " [method_dd, method_help_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", - " ),\n", - " widgets.HBox(\n", - " [basis_dd, basis_help_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", - " ),\n", - " ]),\n", - " widgets.HTML(\"  \"),\n", - " widgets.VBox([charge_si, mult_si]),\n", - " ]),\n", - " calc_type_dd,\n", - " calc_extra_opts,\n", - " preopt_cb,\n", - " notes_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "run_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

Run Calculation

'\n", - " '

PySCF runs in this kernel. '\n", - " \"Output appears live below. Large molecules or high-accuracy basis sets may take \"\n", - " \"several minutes on a laptop.

\"\n", - " ),\n", - " perf_estimate_html,\n", - " widgets.HBox([run_btn, run_status]),\n", - " widgets.HBox(\n", - " [\n", - " widgets.HTML(\n", - " ''\n", - " \"Calculation Output\"\n", - " ),\n", - " log_clear_btn,\n", - " ],\n", - " layout=widgets.Layout(align_items=\"center\", justify_content=\"space-between\",\n", - " margin=\"10px 0 4px\", max_width=\"460px\"),\n", - " ),\n", - " run_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "results_panel = widgets.VBox([\n", - " widgets.HTML('

Results

'),\n", - " result_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "\n", - "# ── Past Results browser ──────────────────────────────────────────────────────\n", - "past_dd = widgets.Dropdown(\n", - " description=\"Load:\",\n", - " options=[(\"(no saved results)\", \"\")],\n", - " style={\"description_width\": \"50px\"},\n", - " layout=widgets.Layout(width=\"500px\"),\n", - ")\n", - "past_refresh_btn = widgets.Button(\n", - " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - " tooltip=\"Rescan the results directory\",\n", - ")\n", - "copy_path_btn = widgets.Button(\n", - " description=\"Copy path\", button_style=\"\", icon=\"clipboard\",\n", - " layout=widgets.Layout(width=\"120px\"),\n", - " tooltip=\"Copy the results directory path to clipboard\",\n", - ")\n", - "results_path_lbl = widgets.HTML()\n", - "past_output = widgets.Output()\n", - "\n", - "\n", - "def _get_results_dir() -> Path:\n", - " from quantui.results_storage import _default_results_dir\n", - " return _default_results_dir().resolve()\n", - "\n", - "\n", - "def _format_past_result(data: dict) -> str:\n", - " _conv = \"Yes\" if data.get(\"converged\") else \"No (treat results with caution)\"\n", - " _cc = \"green\" if data.get(\"converged\") else \"#c00\"\n", - " _gap = (\n", - " f\"{data['homo_lumo_gap_ev']:.4f} eV\"\n", - " if data.get(\"homo_lumo_gap_ev\") is not None else \"N/A\"\n", - " )\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Total energy\",\n", - " f\"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)\", \"#000\"),\n", - " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", - " (\"SCF converged\", _conv, _cc),\n", - " (\"SCF iterations\", str(data.get(\"n_iterations\", \"?\")), \"#000\"),\n", - " ]\n", - " )\n", - " ts = data.get(\"timestamp\", \"\")\n", - " return (\n", - " f'
'\n", - " f'{data[\"formula\"]} — {data[\"method\"]}/{data[\"basis\"]}'\n", - " f' {ts}'\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _refresh_results_browser():\n", - " '''Repopulate the past-results dropdown. Called on startup and after each run.'''\n", - " try:\n", - " from quantui import list_results, load_result\n", - " except ImportError:\n", - " return\n", - " results_path_lbl.value = f'{_get_results_dir()}'\n", - " dirs = list_results()\n", - " if not dirs:\n", - " past_dd.options = [(\"(no saved results)\", \"\")]\n", - " return\n", - " options = []\n", - " for d in dirs:\n", - " try:\n", - " data = load_result(d)\n", - " ts = data.get(\"timestamp\", d.name)\n", - " label = f\"{ts} · {data['formula']} {data['method']}/{data['basis']}\"\n", - " options.append((label, str(d)))\n", - " except Exception:\n", - " pass\n", - " past_dd.options = options if options else [(\"(no saved results)\", \"\")]\n", - "\n", - "\n", - "def _load_past_result(change):\n", - " path_str = change[\"new\"]\n", - " if not path_str:\n", - " past_output.clear_output()\n", - " return\n", - " past_output.clear_output(wait=True)\n", - " try:\n", - " from quantui import load_result\n", - " data = load_result(Path(path_str))\n", - " past_output.append_display_data(HTML(_format_past_result(data)))\n", - " except Exception as exc:\n", - " past_output.append_stdout(f\"Could not load result: {exc}\\n\")\n", - "\n", - "\n", - "def _copy_results_path(btn):\n", - " '''Copy the results directory path to clipboard via browser JS.'''\n", - " p = _get_results_dir()\n", - " p.mkdir(parents=True, exist_ok=True)\n", - " path_str = str(p).replace(\"\\\\\", \"\\\\\\\\\").replace(\"'\", \"\\\\'\")\n", - " from IPython.display import Javascript\n", - " display(Javascript(f\"navigator.clipboard.writeText(\\'{path_str}\\')\"))\n", - " # Brief confirmation in the label\n", - " import threading\n", - " results_path_lbl.value = (\n", - " f'Copied: {p}'\n", - " )\n", - " def _reset():\n", - " import time; time.sleep(3)\n", - " results_path_lbl.value = f'{p}'\n", - " threading.Thread(target=_reset, daemon=True).start()\n", - "\n", - "\n", - "view_log_btn = widgets.Button(\n", - " description=\"View log\",\n", - " button_style=\"\",\n", - " icon=\"file-text-o\",\n", - " layout=widgets.Layout(width=\"110px\"),\n", - " tooltip=\"Open the full PySCF output log for this result in the Output tab\",\n", - ")\n", - "\n", - "\n", - "def _view_log(btn):\n", - " path_str = past_dd.value\n", - " if not path_str:\n", - " return\n", - " log_path = Path(path_str) / \"pyscf.log\"\n", - " if log_path.exists():\n", - " text = log_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n", - " label = Path(path_str).name\n", - " else:\n", - " text = \"(No pyscf.log found for this result.)\"\n", - " label = \"\"\n", - " _update_log_panel(text, label)\n", - " _goto_output_tab()\n", - "\n", - "\n", - "past_dd.observe(_load_past_result, names=\"value\")\n", - "past_refresh_btn.on_click(lambda _: _refresh_results_browser())\n", - "copy_path_btn.on_click(_copy_results_path)\n", - "view_log_btn.on_click(_view_log)\n", - "\n", - "# Populate on startup and make the real function visible to _do_run in Cell 3.\n", - "_refresh_results_browser()\n", - "\n", - "# ── Performance stats widgets ───────────────────────────────────────────────────────────────────\n", - "_perf_stats_html = widgets.HTML()\n", - "_perf_events_html = widgets.HTML()\n", - "\n", - "_reset_btn = widgets.Button(\n", - " description=\"Reset performance database\",\n", - " button_style=\"danger\",\n", - " icon=\"trash\",\n", - " layout=widgets.Layout(width=\"230px\"),\n", - ")\n", - "_reset_confirm_html = widgets.HTML(\n", - " ''\n", - " \"Warning: This will permanently delete all performance records. \"\n", - " \"Time estimates will reset to “no data”.\"\n", - ")\n", - "_reset_confirm_yes = widgets.Button(\n", - " description=\"Yes, delete all records\",\n", - " button_style=\"danger\",\n", - " icon=\"check\",\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "_reset_confirm_no = widgets.Button(\n", - " description=\"Cancel\",\n", - " button_style=\"\",\n", - " icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\"),\n", - ")\n", - "_reset_confirm_box = widgets.VBox(\n", - " [\n", - " _reset_confirm_html,\n", - " widgets.HBox(\n", - " [_reset_confirm_yes, _reset_confirm_no],\n", - " layout=widgets.Layout(gap=\"8px\", margin=\"4px 0 0\"),\n", - " ),\n", - " ],\n", - " layout=widgets.Layout(\n", - " display=\"none\",\n", - " border=\"1px solid #fca5a5\",\n", - " padding=\"8px 10px\",\n", - " margin=\"6px 0 0\",\n", - " ),\n", - ")\n", - "\n", - "\n", - "def _build_perf_stats_html() -> str:\n", - " from quantui.calc_log import get_perf_history\n", - " records = get_perf_history()\n", - " if not records:\n", - " return (\n", - " ''\n", - " \"No performance data recorded yet.\"\n", - " )\n", - " groups: dict = {}\n", - " for r in records:\n", - " key = (r.get(\"method\", \"?\"), r.get(\"basis\", \"?\"))\n", - " groups.setdefault(key, []).append(r)\n", - " rows = \"\"\n", - " for (meth, bas), recs in sorted(groups.items()):\n", - " times = [r[\"elapsed_s\"] for r in recs if \"elapsed_s\" in r]\n", - " n = len(recs)\n", - " if times:\n", - " avg = sum(times) / len(times)\n", - " rows += (\n", - " \"\"\n", - " f'{meth}'\n", - " f'{bas}'\n", - " f'{n}'\n", - " f'{avg:.1f} s'\n", - " f'{min(times):.1f} s'\n", - " f'{max(times):.1f} s'\n", - " \"\"\n", - " )\n", - " header = (\n", - " \"\"\n", - " 'Method'\n", - " 'Basis'\n", - " 'Runs'\n", - " 'Avg'\n", - " 'Min'\n", - " 'Max'\n", - " \"\"\n", - " )\n", - " return (\n", - " ''\n", - " f\"{header}{rows}
\"\n", - " )\n", - "\n", - "\n", - "def _build_events_html() -> str:\n", - " from quantui.calc_log import get_recent_events\n", - " events = get_recent_events(20)\n", - " if not events:\n", - " return (\n", - " ''\n", - " \"No events recorded yet.\"\n", - " )\n", - " rows = \"\"\n", - " for e in reversed(events):\n", - " ts = e.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n", - " evt = e.get(\"event\", \"\")\n", - " msg = e.get(\"message\", \"\")\n", - " rows += (\n", - " \"\"\n", - " f'{ts}'\n", - " f'{evt}'\n", - " f'{msg}'\n", - " \"\"\n", - " )\n", - " return (\n", - " ''\n", - " f\"{rows}
\"\n", - " )\n", - "\n", - "\n", - "def _refresh_perf_stats():\n", - " _perf_stats_html.value = _build_perf_stats_html()\n", - " _perf_events_html.value = _build_events_html()\n", - "\n", - "\n", - "def _on_reset_click(btn):\n", - " _reset_confirm_box.layout.display = \"\"\n", - "\n", - "\n", - "def _on_confirm_yes(btn):\n", - " from quantui.calc_log import reset_perf_log\n", - " reset_perf_log()\n", - " _reset_confirm_box.layout.display = \"none\"\n", - " _refresh_perf_stats()\n", - "\n", - "\n", - "def _on_confirm_no(btn):\n", - " _reset_confirm_box.layout.display = \"none\"\n", - "\n", - "\n", - "_reset_btn.on_click(_on_reset_click)\n", - "_reset_confirm_yes.on_click(_on_confirm_yes)\n", - "_reset_confirm_no.on_click(_on_confirm_no)\n", - "_refresh_perf_stats()\n", - "\n", - "_perf_stats_panel = widgets.VBox([\n", - " _perf_stats_html,\n", - " widgets.HTML(\n", - " '

'\n", - " \"Recent events (last 20)

\"\n", - " ),\n", - " _perf_events_html,\n", - " widgets.HBox(\n", - " [_reset_btn],\n", - " layout=widgets.Layout(margin=\"14px 0 4px\"),\n", - " ),\n", - " _reset_confirm_box,\n", - "])\n", - "\n", - "_perf_accordion = widgets.Accordion(children=[_perf_stats_panel], selected_index=None)\n", - "_perf_accordion.set_title(0, \"Performance stats\")\n", - "\n", - "# ── History panel (assembled widget for the History tab) ─────────────────\n", - "history_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

'\n", - " \"Calculations are saved automatically. Select one below to view its results.

\"\n", - " ),\n", - " widgets.HBox(\n", - " [past_dd, past_refresh_btn, copy_path_btn, view_log_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"8px\"),\n", - " ),\n", - " results_path_lbl,\n", - " past_output,\n", - " _perf_accordion,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "# ── Compare tab widgets ────────────────────────────────────────────────────────\n", - "compare_select = widgets.SelectMultiple(\n", - " options=[(\"(no saved results)\", \"\")],\n", - " rows=8,\n", - " description=\"\",\n", - " layout=widgets.Layout(width=\"100%\"),\n", - ")\n", - "compare_refresh_btn = widgets.Button(\n", - " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "compare_btn = widgets.Button(\n", - " description=\"Compare selected\", button_style=\"primary\", icon=\"bar-chart\",\n", - " disabled=True,\n", - " layout=widgets.Layout(width=\"180px\"),\n", - ")\n", - "compare_clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"warning\", icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\"),\n", - ")\n", - "compare_output = widgets.Output()\n", - "\n", - "\n", - "def _populate_compare_list():\n", - " '''Repopulate compare_select with all saved results.'''\n", - " from quantui.results_storage import list_results, load_result\n", - " dirs = list_results()\n", - " if not dirs:\n", - " compare_select.options = [(\"(no saved results)\", \"\")]\n", - " compare_btn.disabled = True\n", - " return\n", - " options = []\n", - " for d in dirs:\n", - " try:\n", - " data = load_result(d)\n", - " ts = data.get(\"timestamp\", d.name[:19])\n", - " label = f\"{ts} {data['formula']} {data['method']}/{data['basis']}\"\n", - " options.append((label, str(d)))\n", - " except Exception:\n", - " options.append((d.name, str(d)))\n", - " compare_select.options = options\n", - " compare_btn.disabled = False\n", - "\n", - "\n", - "def _on_compare_refresh(btn):\n", - " _populate_compare_list()\n", - "\n", - "\n", - "def _on_compare(btn):\n", - " selected = compare_select.value # tuple of selected path strings\n", - " if not selected or selected == (\"\",):\n", - " return\n", - " compare_output.clear_output(wait=True)\n", - " from quantui.results_storage import load_result\n", - " from quantui import summary_from_saved_result, comparison_table_html, plot_comparison\n", - " summaries = []\n", - " for path_str in selected:\n", - " if not path_str:\n", - " continue\n", - " try:\n", - " data = load_result(Path(path_str))\n", - " summaries.append(summary_from_saved_result(data))\n", - " except Exception as exc:\n", - " with compare_output:\n", - " display(HTML(f'

Error loading result: {exc}

'))\n", - " if not summaries:\n", - " return\n", - " with compare_output:\n", - " display(HTML(comparison_table_html(summaries)))\n", - " if len(summaries) > 1:\n", - " try:\n", - " import matplotlib.pyplot as plt\n", - " fig = plot_comparison(summaries)\n", - " display(fig)\n", - " plt.close(fig)\n", - " except Exception:\n", - " pass\n", - "\n", - "\n", - "def _on_compare_clear(btn):\n", - " compare_select.value = ()\n", - " compare_output.clear_output()\n", - "\n", - "\n", - "compare_refresh_btn.on_click(_on_compare_refresh)\n", - "compare_btn.on_click(_on_compare)\n", - "compare_clear_btn.on_click(_on_compare_clear)\n", - "\n", - "# Populate on load; expose to Cell-3 placeholder so _do_run refreshes it.\n", - "_populate_compare_list()\n", - "globals()[\"_populate_compare_list\"] = _populate_compare_list\n", - "\n", - "# ── Compare panel (assembled widget for the Compare tab) ──────────────────────\n", - "compare_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

Compare Calculations

'\n", - " '

'\n", - " \"Select two or more saved calculations to compare side-by-side. \"\n", - " \"Hold Ctrl (or ⌘) to select multiple entries.

\"\n", - " ),\n", - " widgets.HBox([compare_refresh_btn]),\n", - " compare_select,\n", - " widgets.HBox(\n", - " [compare_btn, compare_clear_btn],\n", - " layout=widgets.Layout(gap=\"8px\", margin=\"6px 0\"),\n", - " ),\n", - " compare_output,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Advanced accordion (Export only) ──────────────────────────────────────────\n", - "_export_content = widgets.VBox([\n", - " widgets.HTML(\n", - " '

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

\"\n", - " ),\n", - " widgets.HBox([export_btn, export_status]),\n", - "])\n", - "advanced_accordion = widgets.Accordion(children=[_export_content])\n", - "advanced_accordion.set_title(0, \"Export Script\")\n", - "advanced_accordion.selected_index = None # collapsed by default\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "from quantui.help_content import HELP_TOPICS\n", - "\n", - "# ── Help tab content ──────────────────────────────────────────────────────────\n", - "_help_keys = list(HELP_TOPICS.keys())\n", - "_help_labels = [HELP_TOPICS[k][\"title\"] for k in _help_keys]\n", - "\n", - "help_topic_dd = widgets.Dropdown(\n", - " options=list(zip(_help_labels, _help_keys)),\n", - " description=\"Topic:\",\n", - " style={\"description_width\": \"60px\"},\n", - " layout=widgets.Layout(width=\"460px\"),\n", - ")\n", - "help_content_html = widgets.HTML()\n", - "\n", - "def _render_help_topic(change=None):\n", - " key = help_topic_dd.value\n", - " if key and key in HELP_TOPICS:\n", - " entry = HELP_TOPICS[key]\n", - " help_content_html.value = (\n", - " f'
'\n", - " f'

'\n", - " f'{entry[\"title\"]}

'\n", - " f'
'\n", - " f'{entry[\"body\"]}
'\n", - " f'
'\n", - " )\n", - "\n", - "help_topic_dd.observe(_render_help_topic, names=\"value\")\n", - "_render_help_topic() # render first topic on load\n", - "\n", - "help_tab_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

'\n", - " \"Browse help topics below. Click ? next to the Method or Basis Set \"\n", - " \"dropdown in the Calculate tab to jump directly to a relevant topic.

\"\n", - " ),\n", - " help_topic_dd,\n", - " help_content_html,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Assemble root tab ─────────────────────────────────────────────────────────\n", - "_calculate_content = widgets.VBox([\n", - " step_progress.widget,\n", - " mol_input_container,\n", - " calc_setup_panel,\n", - " run_panel,\n", - " results_panel,\n", - " advanced_accordion,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Output log tab ────────────────────────────────────────────────────────────────\n", - "_log_output_html = widgets.HTML(\n", - " ''\n", - " \"No log yet — run a calculation first, or use \"\n", - " \"View log in the History tab.\"\n", - ")\n", - "_log_source_lbl = widgets.HTML()\n", - "_log_clear_btn = widgets.Button(\n", - " description=\"Clear\",\n", - " button_style=\"\",\n", - " icon=\"times\",\n", - " layout=widgets.Layout(width=\"80px\"),\n", - ")\n", - "\n", - "\n", - "def _render_log(text: str, source_label: str = \"\") -> None:\n", - " # Render text as syntax-highlighted HTML in the log panel.\n", - " import html as _html_mod\n", - " lines = text.splitlines()\n", - " rows = []\n", - " for line in lines:\n", - " esc = _html_mod.escape(line)\n", - " if \"converged SCF energy\" in line or \"SCF converged\" in line:\n", - " style = \"color:#16a34a;font-weight:600\"\n", - " elif \"cycle=\" in line and \"E=\" in line:\n", - " style = \"color:#475569\"\n", - " elif \"HOMO\" in line or \"LUMO\" in line:\n", - " style = \"color:#2563eb\"\n", - " elif \"Warning\" in line or \"warning\" in line:\n", - " style = \"color:#d97706\"\n", - " elif \"Error\" in line or \"error\" in line or \"failed\" in line:\n", - " style = \"color:#dc2626\"\n", - " else:\n", - " style = \"color:#1e293b\"\n", - " rows.append(f'
{esc}
')\n", - " _log_output_html.value = (\n", - " '
'\n", - " + \"\".join(rows)\n", - " + \"
\"\n", - " )\n", - " _log_source_lbl.value = (\n", - " f'Source: {source_label}'\n", - " if source_label\n", - " else \"\"\n", - " )\n", - "\n", - "\n", - "def _update_log_panel(log_text: str, label: str = \"\") -> None:\n", - " _render_log(log_text, label)\n", - "\n", - "\n", - "def _goto_output_tab() -> None:\n", - " root_tab.selected_index = 3\n", - "\n", - "\n", - "def _clear_log(_):\n", - " _log_output_html.value = (\n", - " 'Log cleared.'\n", - " )\n", - " _log_source_lbl.value = \"\"\n", - "\n", - "\n", - "_log_clear_btn.on_click(_clear_log)\n", - "\n", - "log_tab_panel = widgets.VBox(\n", - " [\n", - " widgets.HTML(\n", - " '

'\n", - " \"Full PySCF output for the most recent calculation. \"\n", - " \"Use View log in the History tab to load a saved result's log.

\"\n", - " ),\n", - " widgets.HBox(\n", - " [_log_clear_btn],\n", - " layout=widgets.Layout(margin=\"0 0 8px\"),\n", - " ),\n", - " _log_source_lbl,\n", - " _log_output_html,\n", - " ],\n", - " layout=widgets.Layout(padding=\"8px 0\"),\n", - ")\n", - "\n", - "# Overwrite Cell-3 placeholders so later widgets can be reached.\n", - "globals()[\"_update_log_panel\"] = _update_log_panel\n", - "globals()[\"_goto_output_tab\"] = _goto_output_tab\n", - "\n", - "root_tab = widgets.Tab(children=[\n", - " _calculate_content,\n", - " history_panel,\n", - " compare_panel,\n", - " log_tab_panel,\n", - " help_tab_panel,\n", - "])\n", - "root_tab.set_title(0, \"Calculate\")\n", - "root_tab.set_title(1, \"History\")\n", - "root_tab.set_title(2, \"Compare\")\n", - "root_tab.set_title(3, \"Output\")\n", - "root_tab.set_title(4, \"Help\")\n", - "\n", - "# ── \"?\" button callbacks: jump to Help tab with specific topic ────────────────\n", - "def _show_help_topic(topic: str) -> None:\n", - " if topic in HELP_TOPICS:\n", - " help_topic_dd.value = topic\n", - " root_tab.selected_index = 4\n", - "\n", - "# Overwrite Cell-3 placeholder so \"?\" buttons find the real function.\n", - "globals()[\"_show_help_topic\"] = _show_help_topic\n", - "\n", - "# ── Single root display call ──────────────────────────────────────────────────\n", - "display(root_tab)\n" + "from quantui.app import QuantUIApp\n", + "QuantUIApp().display()" ] } ], diff --git a/notebooks/molecule_computations.pre-fr012.ipynb b/notebooks/molecule_computations.pre-fr012.ipynb new file mode 100644 index 0000000..6140e0d --- /dev/null +++ b/notebooks/molecule_computations.pre-fr012.ipynb @@ -0,0 +1,1791 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# QuantUI-local — Quantum Chemistry Calculator\n", + "\n", + "Run PySCF quantum chemistry calculations directly in this notebook.\n", + "\n", + "**How to use:**\n", + "1. Select or enter a molecule in **Molecule Input**\n", + "2. Choose a method and basis set in **Calculation Setup**\n", + "3. Click **Run Calculation** — results appear below\n", + "4. Optionally add results to **Compare** or **Export** a standalone script\n", + "\n", + "> **Platform note:** PySCF requires Linux, macOS, or WSL. \\\n", + "> Windows users: `apptainer run quantui-local.sif`\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": { + "tags": [ + "skip-execution", + "remove-input" + ] + }, + "outputs": [], + "source": [ + "# Environment check — verifies correct conda environment.\n", + "# Tagged skip-execution and remove-input so it is hidden in Voilà.\n", + "import sys as _sys\n", + "_env = _sys.prefix\n", + "if \"quantui\" not in _env.lower():\n", + " print(f\"Warning: active environment may not be quantui-local\")\n", + " print(f\"Active: {_env}\")\n", + " print(\"Run: conda activate quantui-local\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import threading\n", + "import ipywidgets as widgets\n", + "from IPython.display import display, HTML\n", + "\n", + "import quantui\n", + "from quantui import (\n", + " Molecule, parse_xyz_input,\n", + " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", + " DEFAULT_METHOD, DEFAULT_BASIS, DEFAULT_CHARGE, DEFAULT_MULTIPLICITY,\n", + " session_can_handle, get_session_resources,\n", + " PUBCHEM_AVAILABLE, VISUALIZATION_AVAILABLE, ASE_AVAILABLE,\n", + " QUICK_START_TEMPLATES,\n", + ")\n", + "\n", + "# Optional — degrade gracefully if unavailable\n", + "try:\n", + " from quantui import run_in_session, SessionResult\n", + " PYSCF_AVAILABLE = True\n", + "except (ImportError, AttributeError):\n", + " PYSCF_AVAILABLE = False\n", + "\n", + "try:\n", + " from quantui import student_friendly_fetch\n", + "except (ImportError, AttributeError):\n", + " student_friendly_fetch = None\n", + "\n", + "try:\n", + " from quantui import display_molecule\n", + "except (ImportError, AttributeError):\n", + " display_molecule = None\n", + "\n", + "try:\n", + " from quantui import preoptimize\n", + " PREOPT_AVAILABLE = True\n", + "except (ImportError, AttributeError):\n", + " PREOPT_AVAILABLE = False\n", + "\n", + "# Mutable session state shared across all callbacks\n", + "_state = {\"molecule\": None, \"last_result\": None, \"results\": []}\n", + "\n", + "# ── Global app styles ─────────────────────────────────────────────────────────\n", + "# Permanent — not toggled by dark mode (dark mode uses filter inversion on top).\n", + "display(HTML(\"\"\"\"\"\"))\n", + "\n", + "# ── Dark mode toggle ─────────────────────────────────────────────────────────\n", + "# Uses CSS filter invert+hue-rotate on the html element so it works with all\n", + "# inline-styled elements. canvas/img/iframe are re-inverted to keep their\n", + "# original appearance (e.g. the py3Dmol 3D viewer).\n", + "# Map theme name → hue-rotate angle. Light uses no filter.\n", + "_THEME_HUE = {\"Dark\": 180, \"Dark Blue\": 200, \"Dark Maroon\": 160}\n", + "\n", + "\n", + "def _theme_css(theme: str) -> str:\n", + " \"\"\"Return the CSS filter block for *theme*, or '' for Light.\"\"\"\n", + " if theme not in _THEME_HUE:\n", + " return \"\"\n", + " deg = _THEME_HUE[theme]\n", + " return (\n", + " \"\"\n", + " )\n", + "\n", + "\n", + "_theme_style = widgets.Output(\n", + " layout=widgets.Layout(height=\"0px\", overflow=\"hidden\", margin=\"0\", padding=\"0\")\n", + ")\n", + "\n", + "theme_btn = widgets.ToggleButtons(\n", + " options=[\"Light\", \"Dark\", \"Dark Blue\", \"Dark Maroon\"],\n", + " value=\"Dark\",\n", + " description=\"Theme:\",\n", + " style={\n", + " \"description_width\": \"48px\",\n", + " \"button_width\": \"90px\",\n", + " },\n", + " layout=widgets.Layout(margin=\"0\"),\n", + ")\n", + "\n", + "\n", + "def _toggle_theme(change):\n", + " _theme_style.clear_output()\n", + " css = _theme_css(change[\"new\"])\n", + " if css:\n", + " with _theme_style:\n", + " display(HTML(css))\n", + "\n", + "\n", + "theme_btn.observe(_toggle_theme, names=\"value\")\n", + "\n", + "# Apply Dark theme on startup\n", + "with _theme_style:\n", + " display(HTML(_theme_css(\"Dark\")))\n", + "\n", + "display(widgets.HBox(\n", + " [theme_btn],\n", + " layout=widgets.Layout(justify_content=\"flex-end\", margin=\"0 0 4px\"),\n", + "))\n", + "display(_theme_style)\n", + "\n", + "# ── Status panel ────────────────────────────────────────────────────────────\n", + "_cores, _mem_gb = get_session_resources()\n", + "_mem = f\"{_mem_gb} GB\" if _mem_gb is not None else \"unknown\"\n", + "\n", + "\n", + "def _ok(flag, extra=\"\"):\n", + " tick = ''\n", + " cross = ''\n", + " return (tick if flag else cross) + (\" \" + extra if extra else \"\")\n", + "\n", + "\n", + "_items = [\n", + " (\"PySCF (calculations)\", _ok(PYSCF_AVAILABLE,\n", + " \"\" if PYSCF_AVAILABLE else \"— Linux / macOS / WSL required\")),\n", + " (\"ASE (structure I/O, opt.)\", _ok(ASE_AVAILABLE)),\n", + " (\"PubChem search\", _ok(PUBCHEM_AVAILABLE)),\n", + " (\"3D viewer (py3Dmol)\", _ok(VISUALIZATION_AVAILABLE)),\n", + " (\"CPU cores / Memory\", f\"{_cores} cores / {_mem}\"),\n", + "]\n", + "_rows = \"\".join(\n", + " f'{k}'\n", + " f'{v}'\n", + " for k, v in _items\n", + ")\n", + "display(HTML(\n", + " f'
'\n", + " f''\n", + " f\"QuantUI-local {quantui.__version__}\"\n", + " f'{_rows}
'\n", + " f\"
\"\n", + "))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import io\n", + "import re\n", + "import time\n", + "\n", + "from quantui.progress import StepProgress\n", + "import quantui.calc_log as _calc_log\n", + "\n", + "# ── Shared output widgets ────────────────────────────────────────────────────\n", + "mol_info_html = widgets.HTML(\n", + " value='No molecule loaded yet.'\n", + ")\n", + "mol_summary_compact = widgets.HTML(value=\"\")\n", + "viz_output = widgets.Output(layout=widgets.Layout(min_height=\"50px\"))\n", + "run_output = widgets.Output(\n", + " layout=widgets.Layout(\n", + " border=\"1px solid #c0ccd8\", min_height=\"80px\", max_height=\"400px\",\n", + " padding=\"8px\", overflow_y=\"auto\",\n", + " )\n", + ")\n", + "with run_output:\n", + " display(HTML(\n", + " '

'\n", + " \"No calculation run yet. PySCF output and any errors will appear here.\"\n", + " \"

\"\n", + " ))\n", + "result_output = widgets.Output()\n", + "comparison_output = widgets.Output()\n", + "notes_output = widgets.Output()\n", + "perf_estimate_html = widgets.HTML()\n", + "\n", + "# ── Step indicator ────────────────────────────────────────────────────────────\n", + "step_progress = StepProgress([\"Choose molecule\", \"Set method\", \"Run\", \"Results\"])\n", + "step_progress.start(0)\n", + "\n", + "# ── Calculation setup (defined here so _set_molecule can update them) ────────\n", + "method_dd = widgets.Dropdown(\n", + " options=SUPPORTED_METHODS, value=DEFAULT_METHOD,\n", + " description=\"Method:\", style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"260px\"),\n", + ")\n", + "basis_dd = widgets.Dropdown(\n", + " options=SUPPORTED_BASIS_SETS, value=DEFAULT_BASIS,\n", + " description=\"Basis Set:\", style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"260px\"),\n", + ")\n", + "charge_si = widgets.BoundedIntText(\n", + " value=DEFAULT_CHARGE, min=-10, max=10,\n", + " description=\"Charge:\", style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"190px\"),\n", + ")\n", + "mult_si = widgets.BoundedIntText(\n", + " value=DEFAULT_MULTIPLICITY, min=1, max=10,\n", + " description=\"Multiplicity:\", style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"190px\"),\n", + ")\n", + "preopt_cb = widgets.Checkbox(\n", + " value=False,\n", + " description=\"Pre-optimize geometry (fast LJ force-field)\",\n", + " disabled=not PREOPT_AVAILABLE,\n", + " layout=widgets.Layout(width=\"400px\"),\n", + ")\n", + "\n", + "# ── Calculation type + extra options ──────────────────────────────────────────\n", + "calc_type_dd = widgets.Dropdown(\n", + " options=[\"Single Point\", \"Geometry Opt\", \"Frequency\", \"UV-Vis (TD-DFT)\"],\n", + " value=\"Single Point\",\n", + " description=\"Calc. Type:\",\n", + " style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"310px\"),\n", + ")\n", + "fmax_fi = widgets.BoundedFloatText(\n", + " value=0.05, min=0.001, max=1.0, step=0.005,\n", + " description=\"Force thr. (eV/Å):\",\n", + " style={\"description_width\": \"130px\"},\n", + " layout=widgets.Layout(width=\"270px\"),\n", + ")\n", + "max_steps_si = widgets.BoundedIntText(\n", + " value=200, min=10, max=1000,\n", + " description=\"Max steps:\",\n", + " style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"200px\"),\n", + ")\n", + "nstates_si = widgets.BoundedIntText(\n", + " value=10, min=1, max=50,\n", + " description=\"# states:\",\n", + " style={\"description_width\": \"100px\"},\n", + " layout=widgets.Layout(width=\"180px\"),\n", + ")\n", + "calc_extra_opts = widgets.VBox([])\n", + "\n", + "\n", + "def _on_calc_type_change(change):\n", + " ct = change[\"new\"]\n", + " if ct == \"Geometry Opt\":\n", + " calc_extra_opts.children = [\n", + " widgets.HBox(\n", + " [fmax_fi, max_steps_si],\n", + " layout=widgets.Layout(gap=\"8px\"),\n", + " ),\n", + " ]\n", + " elif ct == \"UV-Vis (TD-DFT)\":\n", + " calc_extra_opts.children = [\n", + " nstates_si,\n", + " widgets.HTML(\n", + " '⚠ Requires a DFT '\n", + " \"functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) \"\n", + " \"instead.\"\n", + " ),\n", + " ]\n", + " else:\n", + " calc_extra_opts.children = []\n", + "\n", + "\n", + "calc_type_dd.observe(_on_calc_type_change, names=\"value\")\n", + "\n", + "# ── Context-help buttons (next to Method and Basis dropdowns) ────────────────\n", + "method_help_btn = widgets.Button(\n", + " description=\"?\", button_style=\"\",\n", + " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", + " tooltip=\"RHF vs UHF — opens Help tab\",\n", + ")\n", + "basis_help_btn = widgets.Button(\n", + " description=\"?\", button_style=\"\",\n", + " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", + " tooltip=\"Choosing a basis set — opens Help tab\",\n", + ")\n", + "\n", + "# ── Run widgets ──────────────────────────────────────────────────────────────\n", + "run_btn = widgets.Button(\n", + " description=\"Run Calculation\", button_style=\"success\", icon=\"play\",\n", + " disabled=True, layout=widgets.Layout(width=\"200px\", height=\"36px\"),\n", + ")\n", + "run_status = widgets.Label()\n", + "\n", + "# ── Log clear button ─────────────────────────────────────────────────────────\n", + "log_clear_btn = widgets.Button(\n", + " description=\"Clear\", button_style=\"\", icon=\"times\",\n", + " layout=widgets.Layout(width=\"90px\", height=\"26px\"),\n", + " tooltip=\"Clear calculation output\",\n", + ")\n", + "\n", + "def _clear_log(btn):\n", + " run_output.clear_output()\n", + "\n", + "log_clear_btn.on_click(_clear_log)\n", + "\n", + "# ── Comparison / export widgets ───────────────────────────────────────────────\n", + "accumulate_btn = widgets.Button(\n", + " description=\"Add to Comparison\", button_style=\"info\", icon=\"plus\",\n", + " disabled=True, layout=widgets.Layout(width=\"190px\"),\n", + ")\n", + "clear_btn = widgets.Button(\n", + " description=\"Clear\", button_style=\"warning\", icon=\"trash\",\n", + " layout=widgets.Layout(width=\"100px\"),\n", + ")\n", + "export_btn = widgets.Button(\n", + " description=\"Export Script\", button_style=\"\", icon=\"download\",\n", + " disabled=True, layout=widgets.Layout(width=\"160px\"),\n", + ")\n", + "export_status = widgets.Label()\n", + "\n", + "\n", + "# ── Thread-safe log capture ───────────────────────────────────────────────────\n", + "_RE_CYCLE = re.compile(\n", + " r\"cycle=\\s*(\\d+)\\s+E=\\s*([\\-\\d\\.]+)\\s+delta_E=\\s*([\\-\\d\\.Ee+\\-]+)\"\n", + ")\n", + "_RE_CONV = re.compile(r\"converged SCF energy\\s*=\\s*([\\-\\d\\.]+)\")\n", + "\n", + "\n", + "class _LogCapture:\n", + " '''Write PySCF output to an Output widget and capture it to a buffer.'''\n", + " def __init__(self, output_widget):\n", + " self._w = output_widget\n", + " self._buf = io.StringIO()\n", + " self._line_buf = \"\"\n", + "\n", + " def write(self, text: str) -> None:\n", + " if not text:\n", + " return\n", + " self._w.append_stdout(text)\n", + " self._buf.write(text)\n", + " # Scan complete lines for SCF progress and update the status label.\n", + " self._line_buf += text\n", + " while \"\\n\" in self._line_buf:\n", + " line, self._line_buf = self._line_buf.split(\"\\n\", 1)\n", + " m = _RE_CYCLE.search(line)\n", + " if m:\n", + " n, delta = m.group(1), m.group(3)\n", + " try:\n", + " run_status.value = f\"SCF cycle {n} · ΔE = {float(delta):.4g} Ha\"\n", + " except Exception:\n", + " run_status.value = f\"SCF cycle {n}\"\n", + " continue\n", + " m = _RE_CONV.search(line)\n", + " if m:\n", + " run_status.value = \"SCF converged ✓\"\n", + "\n", + " def flush(self) -> None:\n", + " pass\n", + "\n", + " def getvalue(self) -> str:\n", + " return self._buf.getvalue()\n", + "\n", + "\n", + "# Placeholders — overwritten by later cells once they execute.\n", + "_refresh_results_browser = lambda: None # noqa: E731\n", + "_show_help_topic = lambda topic: None # noqa: E731\n", + "_populate_compare_list = lambda: None # noqa: E731\n", + "_update_log_panel = lambda log_text, label=\"\": None # noqa: E731\n", + "_goto_output_tab = lambda: None # noqa: E731\n", + "\n", + "\n", + "# ── Callbacks ─────────────────────────────────────────────────────────────────\n", + "\n", + "def _set_molecule(mol, label=\"\"):\n", + " '''Update shared state and refresh dependent widgets.'''\n", + " _state[\"molecule\"] = mol\n", + " run_btn.disabled = False\n", + " export_btn.disabled = False\n", + "\n", + " try:\n", + " n_e = mol.get_electron_count()\n", + " e_str = f\"{n_e} electrons\"\n", + " except Exception:\n", + " e_str = \"\"\n", + "\n", + " _lbl = f'
{label}' if label else \"\"\n", + " _summary = (\n", + " f'{mol.get_formula()}'\n", + " f' '\n", + " f\"{len(mol.atoms)} atoms\"\n", + " + (f\" • {e_str}\" if e_str else \"\")\n", + " + f\" • charge {mol.charge} • mult {mol.multiplicity}\"\n", + " + f\"{_lbl}\"\n", + " )\n", + " mol_info_html.value = _summary\n", + " mol_summary_compact.value = (\n", + " f'
'\n", + " f\"{_summary}
\"\n", + " )\n", + "\n", + " charge_si.value = mol.charge\n", + " mult_si.value = mol.multiplicity\n", + " if mol.multiplicity > 1 and method_dd.value == \"RHF\":\n", + " method_dd.value = \"UHF\"\n", + "\n", + " viz_output.clear_output(wait=True)\n", + " if display_molecule is not None:\n", + " with viz_output:\n", + " display_molecule(mol)\n", + "\n", + " _update_notes()\n", + "\n", + " # Advance step indicator — but not during a running calculation (e.g. preopt)\n", + " if step_progress._states[2] != \"active\":\n", + " if step_progress._states[2] in (\"done\", \"fail\"):\n", + " # Fresh molecule after a completed run — reset indicator\n", + " step_progress.reset()\n", + " step_progress.complete(0)\n", + " step_progress.start(1)\n", + "\n", + " # Update time estimate for the newly loaded molecule\n", + " _update_estimate()\n", + "\n", + " # Collapse the molecule input panel to the compact summary + 3D viewer\n", + " mol_input_container.children = [mol_input_collapsed, viz_output]\n", + "\n", + "\n", + "def _format_result(r):\n", + " _conv = \"Yes\" if r.converged else \"No (treat results with caution)\"\n", + " _cc = \"green\" if r.converged else \"#c00\"\n", + " _gap = (\n", + " f\"{r.homo_lumo_gap_ev:.4f} eV\"\n", + " if r.homo_lumo_gap_ev is not None else \"N/A\"\n", + " )\n", + " _rows = \"\".join(\n", + " f''\n", + " f'{k}'\n", + " f'{v}'\n", + " f\"\"\n", + " for k, v, vc in [\n", + " (\"Total energy\",\n", + " f\"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)\", \"#000\"),\n", + " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", + " (\"SCF converged\", _conv, _cc),\n", + " (\"SCF iterations\", str(r.n_iterations), \"#000\"),\n", + " ]\n", + " )\n", + " return (\n", + " f'
'\n", + " f\"{r.formula} — {r.method}/{r.basis}\"\n", + " f''\n", + " f\"{_rows}
\"\n", + " )\n", + "\n", + "\n", + "def _format_opt_result(r):\n", + " '''Format an OptimizationResult as an HTML result card.'''\n", + " _conv = \"Yes\" if r.converged else \"No (max steps reached)\"\n", + " _cc = \"green\" if r.converged else \"#c00\"\n", + " _rows = \"\".join(\n", + " f''\n", + " f'{k}'\n", + " f'{v}'\n", + " f\"\"\n", + " for k, v, vc in [\n", + " (\"Final energy\", f\"{r.energy_hartree:.8f} Ha\", \"#000\"),\n", + " (\"Energy change\", f\"{r.energy_change_hartree:+.6f} Ha\", \"#000\"),\n", + " (\"Opt converged\", _conv, _cc),\n", + " (\"Steps taken\", str(r.n_steps), \"#000\"),\n", + " (\"Geometry RMSD\", f\"{r.rmsd_angstrom:.4f} Å\", \"#000\"),\n", + " ]\n", + " )\n", + " return (\n", + " f'
'\n", + " f\"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})\"\n", + " f''\n", + " f\"{_rows}
\"\n", + " )\n", + "\n", + "\n", + "def _format_freq_result(r):\n", + " '''Format a FreqResult as an HTML result card.'''\n", + " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", + " _cc = \"green\" if r.converged else \"#c00\"\n", + " n_real = r.n_real_modes()\n", + " n_imag = r.n_imaginary_modes()\n", + " real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]\n", + " freq_str = \" \".join(f\"{f:.1f}\" for f in real_freqs)\n", + " if len([f for f in r.frequencies_cm1 if f > 0]) > 6:\n", + " freq_str += \" …\"\n", + " imag_note = \"\"\n", + " if n_imag > 0:\n", + " imag_note = (\n", + " f'Imaginary modes'\n", + " f'{n_imag} — geometry may not be a minimum'\n", + " )\n", + " _rows = (\n", + " f'SCF energy'\n", + " f'{r.energy_hartree:.8f} Ha'\n", + " f'SCF converged'\n", + " f'{_conv}'\n", + " f'Real modes'\n", + " f'{n_real}'\n", + " + imag_note\n", + " + (f'Frequencies (cm⁻¹)'\n", + " f'{freq_str or \"none\"}'\n", + " if real_freqs else \"\")\n", + " + f'ZPVE'\n", + " f'{r.zpve_hartree:.6f} Ha '\n", + " f'({r.zpve_hartree * 27.211386245988:.4f} eV)'\n", + " )\n", + " return (\n", + " f'
'\n", + " f\"Frequency Analysis — {r.formula} ({r.method}/{r.basis})\"\n", + " f''\n", + " f\"{_rows}
\"\n", + " )\n", + "\n", + "\n", + "def _format_tddft_result(r):\n", + " '''Format a TDDFTResult as an HTML result card with excitation table.'''\n", + " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", + " _cc = \"green\" if r.converged else \"#c00\"\n", + " header_rows = (\n", + " f'Ground-state energy'\n", + " f'{r.energy_hartree:.8f} Ha'\n", + " f'SCF converged'\n", + " f'{_conv}'\n", + " f'States computed'\n", + " f'{len(r.excitation_energies_ev)}'\n", + " )\n", + " exc_table = \"\"\n", + " if r.excitation_energies_ev:\n", + " wl = r.wavelengths_nm()\n", + " exc_rows = []\n", + " for i, (e_ev, f_osc) in enumerate(\n", + " zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1\n", + " ):\n", + " bold = \"font-weight:bold\" if f_osc > 0.05 else \"\"\n", + " exc_rows.append(\n", + " f''\n", + " f'S{i}'\n", + " f'{e_ev:.3f} eV'\n", + " f'{wl[i - 1]:.1f} nm'\n", + " f'f = {f_osc:.4f}'\n", + " f\"\"\n", + " )\n", + " if len(r.excitation_energies_ev) > 8:\n", + " exc_rows.append(\n", + " f'… '\n", + " f\"and {len(r.excitation_energies_ev) - 8} more states\"\n", + " )\n", + " exc_table = (\n", + " ''\n", + " \"Vertical excitations:\"\n", + " ''\n", + " 'State'\n", + " 'Energy'\n", + " 'λ'\n", + " 'Osc. str.'\n", + " + \"\".join(exc_rows)\n", + " )\n", + " return (\n", + " f'
'\n", + " f\"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})\"\n", + " f''\n", + " f\"{header_rows}{exc_table}
\"\n", + " )\n", + "\n", + "\n", + "def _do_run(btn):\n", + " mol = _state[\"molecule\"]\n", + " if mol is None:\n", + " run_status.value = \"Load a molecule first.\"\n", + " return\n", + " run_btn.disabled = True\n", + " run_status.value = \"Starting...\"\n", + " run_output.clear_output()\n", + " result_output.clear_output()\n", + "\n", + " # Advance step indicator: method confirmed → running\n", + " step_progress.complete(1)\n", + " step_progress.start(2)\n", + "\n", + " _calc_log.log_event(\n", + " \"calc_start\",\n", + " f\"{method_dd.value}/{basis_dd.value} on {mol.get_formula()}\",\n", + " n_atoms=len(mol.atoms),\n", + " )\n", + " _run_wall_t = time.perf_counter()\n", + "\n", + " def _thread():\n", + " log = _LogCapture(run_output)\n", + " try:\n", + " calc_mol = mol\n", + " if preopt_cb.value and PREOPT_AVAILABLE:\n", + " run_status.value = \"Pre-optimizing...\"\n", + " calc_mol, _rmsd = preoptimize(mol)\n", + " _set_molecule(calc_mol, f\"Geometry pre-optimized (LJ, RMSD={_rmsd:.3f} Å)\")\n", + "\n", + " # ── Dispatch to the right backend based on Calc. Type ─────────────\n", + " ct = calc_type_dd.value\n", + " if ct == \"Geometry Opt\":\n", + " run_status.value = \"Optimizing geometry...\"\n", + " from quantui import optimize_geometry\n", + " result = optimize_geometry(\n", + " molecule=calc_mol,\n", + " method=method_dd.value,\n", + " basis=basis_dd.value,\n", + " fmax=fmax_fi.value,\n", + " steps=max_steps_si.value,\n", + " progress_stream=log,\n", + " )\n", + " result_html = _format_opt_result(result)\n", + " save_spectra, save_type = {}, \"geometry_opt\"\n", + " elif ct == \"Frequency\":\n", + " run_status.value = \"Computing frequencies (SCF + Hessian)...\"\n", + " from quantui.freq_calc import run_freq_calc\n", + " result = run_freq_calc(\n", + " molecule=calc_mol,\n", + " method=method_dd.value,\n", + " basis=basis_dd.value,\n", + " progress_stream=log,\n", + " )\n", + " result_html = _format_freq_result(result)\n", + " save_spectra = {\"ir\": {\n", + " \"frequencies_cm1\": result.frequencies_cm1,\n", + " \"ir_intensities\": result.ir_intensities,\n", + " \"zpve_hartree\": result.zpve_hartree,\n", + " }}\n", + " save_type = \"frequency\"\n", + " elif ct == \"UV-Vis (TD-DFT)\":\n", + " run_status.value = \"Running TD-DFT excited states...\"\n", + " from quantui.tddft_calc import run_tddft_calc\n", + " result = run_tddft_calc(\n", + " molecule=calc_mol,\n", + " method=method_dd.value,\n", + " basis=basis_dd.value,\n", + " nstates=nstates_si.value,\n", + " progress_stream=log,\n", + " )\n", + " result_html = _format_tddft_result(result)\n", + " save_spectra = {\"uv_vis\": {\n", + " \"excitation_energies_ev\": result.excitation_energies_ev,\n", + " \"oscillator_strengths\": result.oscillator_strengths,\n", + " \"wavelengths_nm\": result.wavelengths_nm(),\n", + " }}\n", + " save_type = \"tddft\"\n", + " else: # Single Point\n", + " run_status.value = \"Calculating...\"\n", + " result = run_in_session(\n", + " molecule=calc_mol,\n", + " method=method_dd.value,\n", + " basis=basis_dd.value,\n", + " progress_stream=log,\n", + " )\n", + " result_html = _format_result(result)\n", + " save_spectra, save_type = {}, \"single_point\"\n", + " _elapsed = time.perf_counter() - _run_wall_t\n", + "\n", + " _state[\"last_result\"] = result\n", + " accumulate_btn.disabled = False\n", + "\n", + " result_output.append_display_data(HTML(result_html))\n", + " run_status.value = f\"Done in {_elapsed:.1f} s.\"\n", + "\n", + " # Advance step indicator: run complete → results ready\n", + " step_progress.complete(2)\n", + " step_progress.complete(3)\n", + "\n", + " # Persist result to disk\n", + " try:\n", + " from quantui import save_result\n", + " save_result(\n", + " result, pyscf_log=log.getvalue(),\n", + " calc_type=save_type, spectra=save_spectra,\n", + " )\n", + " _refresh_results_browser()\n", + " _populate_compare_list()\n", + " _update_log_panel(\n", + " log.getvalue(),\n", + " f\"{result.formula} {method_dd.value}/{basis_dd.value}\",\n", + " )\n", + " except Exception:\n", + " pass\n", + "\n", + " # Log performance record and refresh the estimate widget\n", + " try:\n", + " _calc_log.log_calculation(\n", + " formula=result.formula,\n", + " n_atoms=len(calc_mol.atoms),\n", + " n_electrons=calc_mol.get_electron_count(),\n", + " method=result.method,\n", + " basis=result.basis,\n", + " n_iterations=getattr(result, \"n_iterations\", -1),\n", + " elapsed_s=_elapsed,\n", + " converged=result.converged,\n", + " )\n", + " _calc_log.log_event(\n", + " \"calc_done\",\n", + " f\"{result.method}/{result.basis} on {result.formula}\",\n", + " elapsed_s=round(_elapsed, 2),\n", + " converged=result.converged,\n", + " )\n", + " _update_estimate()\n", + " except Exception:\n", + " pass\n", + "\n", + " except ImportError as _import_err:\n", + " _err_detail = str(_import_err)\n", + " log.write(\n", + " f\"Import error: {_err_detail}\\n\\n\"\n", + " \"A required calculation dependency could not be loaded.\\n\"\n", + " \"On Windows: use the Apptainer container.\\n\"\n", + " \" apptainer run quantui-local.sif\\n\"\n", + " )\n", + " run_status.value = \"Import error — see output.\"\n", + " step_progress.fail(2, _err_detail[:60])\n", + " _calc_log.log_event(\"calc_error\", _err_detail[:200])\n", + "\n", + " except Exception as exc:\n", + " import traceback\n", + " _elapsed = time.perf_counter() - _run_wall_t\n", + " log.write(f\"Error: {exc}\\n\\n{traceback.format_exc()}\")\n", + " run_status.value = \"Error — see Calculation Output below.\"\n", + " step_progress.fail(2, str(exc)[:60])\n", + " _calc_log.log_event(\"calc_error\", str(exc)[:200], elapsed_s=round(_elapsed, 2))\n", + "\n", + " finally:\n", + " run_btn.disabled = False\n", + "\n", + " threading.Thread(target=_thread, daemon=True).start()\n", + "\n", + "run_btn.on_click(_do_run)\n", + "\n", + "\n", + "def _update_notes(change=None):\n", + " notes_output.clear_output(wait=True)\n", + " mol = _state[\"molecule\"]\n", + " if mol is None:\n", + " return\n", + " try:\n", + " from quantui import PySCFCalculation\n", + " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", + " notes = calc.get_educational_notes()\n", + " if notes:\n", + " safe = (\n", + " notes\n", + " .replace(\"**\", \"\", 1)\n", + " .replace(\"**\", \"\", 1)\n", + " .replace(\"\\n\\n\", \"

\")\n", + " )\n", + " with notes_output:\n", + " display(HTML(\n", + " '
'\n", + " + safe + \"
\"\n", + " ))\n", + " except Exception:\n", + " pass\n", + "\n", + "method_dd.observe(_update_notes, names=\"value\")\n", + "basis_dd.observe(_update_notes, names=\"value\")\n", + "\n", + "\n", + "def _update_estimate(change=None):\n", + " '''Refresh the time-estimate label based on current molecule + method/basis.'''\n", + " mol = _state.get(\"molecule\")\n", + " if mol is None:\n", + " perf_estimate_html.value = \"\"\n", + " return\n", + " try:\n", + " est = _calc_log.estimate_time(\n", + " n_atoms=len(mol.atoms),\n", + " n_electrons=mol.get_electron_count(),\n", + " method=method_dd.value,\n", + " basis=basis_dd.value,\n", + " )\n", + " perf_estimate_html.value = _calc_log.format_estimate(est)\n", + " except Exception:\n", + " perf_estimate_html.value = \"\"\n", + "\n", + "method_dd.observe(_update_estimate, names=\"value\")\n", + "basis_dd.observe(_update_estimate, names=\"value\")\n", + "\n", + "# Log startup event\n", + "_calc_log.log_event(\"startup\", f\"QuantUI-local {quantui.__version__} started\")\n", + "\n", + "\n", + "def _do_accumulate(btn):\n", + " r = _state[\"last_result\"]\n", + " if r is None:\n", + " return\n", + " _state[\"results\"].append(r)\n", + " _refresh_comparison()\n", + "\n", + "accumulate_btn.on_click(_do_accumulate)\n", + "\n", + "\n", + "def _refresh_comparison():\n", + " from quantui import summary_from_session_result, comparison_table_html\n", + " comparison_output.clear_output(wait=True)\n", + " results = _state[\"results\"]\n", + " if not results:\n", + " return\n", + " summaries = [summary_from_session_result(r) for r in results]\n", + " with comparison_output:\n", + " display(HTML(comparison_table_html(summaries)))\n", + " if len(summaries) > 1:\n", + " try:\n", + " from quantui import plot_comparison\n", + " plot_comparison(summaries)\n", + " except Exception:\n", + " pass\n", + "\n", + "\n", + "def _do_clear(btn):\n", + " _state[\"results\"].clear()\n", + " comparison_output.clear_output()\n", + "\n", + "clear_btn.on_click(_do_clear)\n", + "\n", + "\n", + "def _do_export(btn):\n", + " mol = _state[\"molecule\"]\n", + " if mol is None:\n", + " export_status.value = \"Load a molecule first.\"\n", + " return\n", + " try:\n", + " from quantui import PySCFCalculation\n", + " from pathlib import Path\n", + " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", + " fname = f\"{mol.get_formula()}_{method_dd.value}_{basis_dd.value}.py\"\n", + " calc.generate_calculation_script(Path(fname))\n", + " export_status.value = f\"Saved: {fname}\"\n", + " except Exception as exc:\n", + " export_status.value = f\"Error: {exc}\"\n", + "\n", + "export_btn.on_click(_do_export)\n", + "\n", + "\n", + "def _on_method_help(btn):\n", + " _show_help_topic(\"method\")\n", + "\n", + "def _on_basis_help(btn):\n", + " _show_help_topic(\"basis_set\")\n", + "\n", + "method_help_btn.on_click(_on_method_help)\n", + "basis_help_btn.on_click(_on_basis_help)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# ── Preset Library ─────────────────────────────────────────────────────────\n", + "_preset_opts = [\"(select a molecule)\"] + list(MOLECULE_LIBRARY.keys())\n", + "preset_dd = widgets.Dropdown(\n", + " options=_preset_opts, value=\"(select a molecule)\",\n", + " description=\"Molecule:\", style={\"description_width\": \"90px\"},\n", + " layout=widgets.Layout(width=\"320px\"),\n", + ")\n", + "\n", + "def _load_preset(change):\n", + " name = change[\"new\"]\n", + " if name.startswith(\"(\"):\n", + " return\n", + " d = MOLECULE_LIBRARY[name]\n", + " _set_molecule(\n", + " Molecule(\n", + " atoms=d[\"atoms\"], coordinates=d[\"coordinates\"],\n", + " charge=d[\"charge\"], multiplicity=d[\"multiplicity\"],\n", + " ),\n", + " d[\"description\"],\n", + " )\n", + "\n", + "preset_dd.observe(_load_preset, names=\"value\")\n", + "\n", + "# ── XYZ Input ──────────────────────────────────────────────────────────────\n", + "xyz_area = widgets.Textarea(\n", + " placeholder=(\n", + " \"Paste XYZ coordinates (symbol x y z):\\n\"\n", + " \"O 0.000 0.000 0.000\\n\"\n", + " \"H 0.757 0.587 0.000\\n\"\n", + " \"H -0.757 0.587 0.000\"\n", + " ),\n", + " layout=widgets.Layout(width=\"440px\", height=\"130px\"),\n", + ")\n", + "xyz_btn = widgets.Button(description=\"Load XYZ\", button_style=\"info\", icon=\"upload\")\n", + "xyz_msg = widgets.Label()\n", + "\n", + "def _load_xyz(btn):\n", + " try:\n", + " mol = parse_xyz_input(xyz_area.value.strip())\n", + " _set_molecule(mol, \"Loaded from XYZ input\")\n", + " xyz_msg.value = \"\"\n", + " except Exception as exc:\n", + " xyz_msg.value = f\"Parse error: {exc}\"\n", + "\n", + "xyz_btn.on_click(_load_xyz)\n", + "\n", + "# ── PubChem Search ─────────────────────────────────────────────────────────\n", + "pubchem_txt = widgets.Text(\n", + " placeholder=\"name or SMILES (e.g. aspirin, caffeine, CC(=O)O)\",\n", + " layout=widgets.Layout(width=\"380px\"),\n", + ")\n", + "pubchem_btn = widgets.Button(\n", + " description=\"Search\", button_style=\"info\", icon=\"search\",\n", + " disabled=not PUBCHEM_AVAILABLE,\n", + " layout=widgets.Layout(width=\"100px\"),\n", + ")\n", + "pubchem_msg = widgets.Label(\n", + " value=\"\" if PUBCHEM_AVAILABLE else \"PubChem unavailable — check internet connection\"\n", + ")\n", + "\n", + "def _search_pubchem(btn):\n", + " query = pubchem_txt.value.strip()\n", + " if not query:\n", + " pubchem_msg.value = \"Enter a molecule name or SMILES.\"\n", + " return\n", + " if student_friendly_fetch is None:\n", + " pubchem_msg.value = \"PubChem module not available.\"\n", + " return\n", + " pubchem_msg.value = f'Searching for \"{query}\"...'\n", + " pubchem_btn.disabled = True\n", + "\n", + " def _do():\n", + " try:\n", + " mol = student_friendly_fetch(query)\n", + " _set_molecule(mol, f\"PubChem: {query}\")\n", + " pubchem_msg.value = f\"Loaded {mol.get_formula()} from PubChem.\"\n", + " except Exception as exc:\n", + " pubchem_msg.value = f\"Not found: {exc}\"\n", + " finally:\n", + " pubchem_btn.disabled = False\n", + "\n", + " threading.Thread(target=_do, daemon=True).start()\n", + "\n", + "pubchem_btn.on_click(_search_pubchem)\n", + "\n", + "# ── Assemble input tab ─────────────────────────────────────────────────────\n", + "_hint = '

'\n", + "tab_preset = widgets.VBox([\n", + " widgets.HTML(_hint + \"Choose from 20+ curated educational molecules.

\"),\n", + " preset_dd,\n", + "])\n", + "tab_xyz = widgets.VBox([\n", + " widgets.HTML(_hint + \"Paste XYZ coordinates (element x y z, one atom per line).

\"),\n", + " xyz_area,\n", + " widgets.HBox([xyz_btn, xyz_msg]),\n", + "])\n", + "tab_pubchem = widgets.VBox([\n", + " widgets.HTML(_hint + \"Search by name or SMILES. Requires internet connection.

\"),\n", + " widgets.HBox([pubchem_txt, pubchem_btn]),\n", + " pubchem_msg,\n", + "])\n", + "\n", + "input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])\n", + "for _i, _t in enumerate([\"Preset Library\", \"XYZ Input\", \"PubChem Search\"]):\n", + " input_tab.set_title(_i, _t)\n", + "\n", + "# ── Collapsible molecule input container ──────────────────────────────────\n", + "mol_input_expanded = widgets.VBox([\n", + " widgets.HTML('

Molecule Input

'),\n", + " input_tab,\n", + "])\n", + "\n", + "# Change button — re-expands the input panel\n", + "change_mol_btn = widgets.Button(\n", + " description=\"Change\", button_style=\"\", icon=\"pencil\",\n", + " layout=widgets.Layout(width=\"100px\", height=\"32px\"),\n", + " tooltip=\"Re-expand the molecule input panel\",\n", + ")\n", + "\n", + "# Collapsed view: compact summary pill + Change button\n", + "mol_input_collapsed = widgets.HBox(\n", + " [mol_summary_compact, change_mol_btn],\n", + " layout=widgets.Layout(align_items=\"center\", gap=\"12px\", padding=\"6px 0\"),\n", + ")\n", + "\n", + "# Container starts expanded; _set_molecule() collapses it after first load.\n", + "mol_input_container = widgets.VBox(\n", + " [mol_input_expanded, mol_info_html, viz_output],\n", + " layout=widgets.Layout(margin=\"0 0 4px 0\"),\n", + ")\n", + "\n", + "def _expand_mol_input(btn):\n", + " mol_input_container.children = [mol_input_expanded, mol_info_html, viz_output]\n", + "\n", + "change_mol_btn.on_click(_expand_mol_input)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "calc_setup_panel = widgets.VBox([\n", + " widgets.HTML('

Calculation Setup

'),\n", + " widgets.HBox([\n", + " widgets.VBox([\n", + " widgets.HBox(\n", + " [method_dd, method_help_btn],\n", + " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", + " ),\n", + " widgets.HBox(\n", + " [basis_dd, basis_help_btn],\n", + " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", + " ),\n", + " ]),\n", + " widgets.HTML(\"  \"),\n", + " widgets.VBox([charge_si, mult_si]),\n", + " ]),\n", + " calc_type_dd,\n", + " calc_extra_opts,\n", + " preopt_cb,\n", + " notes_output,\n", + "])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "run_panel = widgets.VBox([\n", + " widgets.HTML(\n", + " '

Run Calculation

'\n", + " '

PySCF runs in this kernel. '\n", + " \"Output appears live below. Large molecules or high-accuracy basis sets may take \"\n", + " \"several minutes on a laptop.

\"\n", + " ),\n", + " perf_estimate_html,\n", + " widgets.HBox([run_btn, run_status]),\n", + " widgets.HBox(\n", + " [\n", + " widgets.HTML(\n", + " ''\n", + " \"Calculation Output\"\n", + " ),\n", + " log_clear_btn,\n", + " ],\n", + " layout=widgets.Layout(align_items=\"center\", justify_content=\"space-between\",\n", + " margin=\"10px 0 4px\", max_width=\"460px\"),\n", + " ),\n", + " run_output,\n", + "])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "results_panel = widgets.VBox([\n", + " widgets.HTML('

Results

'),\n", + " result_output,\n", + "])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "# ── Past Results browser ──────────────────────────────────────────────────────\n", + "past_dd = widgets.Dropdown(\n", + " description=\"Load:\",\n", + " options=[(\"(no saved results)\", \"\")],\n", + " style={\"description_width\": \"50px\"},\n", + " layout=widgets.Layout(width=\"500px\"),\n", + ")\n", + "past_refresh_btn = widgets.Button(\n", + " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", + " layout=widgets.Layout(width=\"100px\"),\n", + " tooltip=\"Rescan the results directory\",\n", + ")\n", + "copy_path_btn = widgets.Button(\n", + " description=\"Copy path\", button_style=\"\", icon=\"clipboard\",\n", + " layout=widgets.Layout(width=\"120px\"),\n", + " tooltip=\"Copy the results directory path to clipboard\",\n", + ")\n", + "results_path_lbl = widgets.HTML()\n", + "past_output = widgets.Output()\n", + "\n", + "\n", + "def _get_results_dir() -> Path:\n", + " from quantui.results_storage import _default_results_dir\n", + " return _default_results_dir().resolve()\n", + "\n", + "\n", + "def _format_past_result(data: dict) -> str:\n", + " _conv = \"Yes\" if data.get(\"converged\") else \"No (treat results with caution)\"\n", + " _cc = \"green\" if data.get(\"converged\") else \"#c00\"\n", + " _gap = (\n", + " f\"{data['homo_lumo_gap_ev']:.4f} eV\"\n", + " if data.get(\"homo_lumo_gap_ev\") is not None else \"N/A\"\n", + " )\n", + " _rows = \"\".join(\n", + " f''\n", + " f'{k}'\n", + " f'{v}'\n", + " f\"\"\n", + " for k, v, vc in [\n", + " (\"Total energy\",\n", + " f\"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)\", \"#000\"),\n", + " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", + " (\"SCF converged\", _conv, _cc),\n", + " (\"SCF iterations\", str(data.get(\"n_iterations\", \"?\")), \"#000\"),\n", + " ]\n", + " )\n", + " ts = data.get(\"timestamp\", \"\")\n", + " return (\n", + " f'
'\n", + " f'{data[\"formula\"]} — {data[\"method\"]}/{data[\"basis\"]}'\n", + " f' {ts}'\n", + " f''\n", + " f\"{_rows}
\"\n", + " )\n", + "\n", + "\n", + "def _refresh_results_browser():\n", + " '''Repopulate the past-results dropdown. Called on startup and after each run.'''\n", + " try:\n", + " from quantui import list_results, load_result\n", + " except ImportError:\n", + " return\n", + " results_path_lbl.value = f'{_get_results_dir()}'\n", + " dirs = list_results()\n", + " if not dirs:\n", + " past_dd.options = [(\"(no saved results)\", \"\")]\n", + " return\n", + " options = []\n", + " for d in dirs:\n", + " try:\n", + " data = load_result(d)\n", + " ts = data.get(\"timestamp\", d.name)\n", + " label = f\"{ts} · {data['formula']} {data['method']}/{data['basis']}\"\n", + " options.append((label, str(d)))\n", + " except Exception:\n", + " pass\n", + " past_dd.options = options if options else [(\"(no saved results)\", \"\")]\n", + "\n", + "\n", + "def _load_past_result(change):\n", + " path_str = change[\"new\"]\n", + " if not path_str:\n", + " past_output.clear_output()\n", + " return\n", + " past_output.clear_output(wait=True)\n", + " try:\n", + " from quantui import load_result\n", + " data = load_result(Path(path_str))\n", + " past_output.append_display_data(HTML(_format_past_result(data)))\n", + " except Exception as exc:\n", + " past_output.append_stdout(f\"Could not load result: {exc}\\n\")\n", + "\n", + "\n", + "def _copy_results_path(btn):\n", + " '''Copy the results directory path to clipboard via browser JS.'''\n", + " p = _get_results_dir()\n", + " p.mkdir(parents=True, exist_ok=True)\n", + " path_str = str(p).replace(\"\\\\\", \"\\\\\\\\\").replace(\"'\", \"\\\\'\")\n", + " from IPython.display import Javascript\n", + " display(Javascript(f\"navigator.clipboard.writeText(\\'{path_str}\\')\"))\n", + " # Brief confirmation in the label\n", + " import threading\n", + " results_path_lbl.value = (\n", + " f'Copied: {p}'\n", + " )\n", + " def _reset():\n", + " import time; time.sleep(3)\n", + " results_path_lbl.value = f'{p}'\n", + " threading.Thread(target=_reset, daemon=True).start()\n", + "\n", + "\n", + "view_log_btn = widgets.Button(\n", + " description=\"View log\",\n", + " button_style=\"\",\n", + " icon=\"file-text-o\",\n", + " layout=widgets.Layout(width=\"110px\"),\n", + " tooltip=\"Open the full PySCF output log for this result in the Output tab\",\n", + ")\n", + "\n", + "\n", + "def _view_log(btn):\n", + " path_str = past_dd.value\n", + " if not path_str:\n", + " return\n", + " log_path = Path(path_str) / \"pyscf.log\"\n", + " if log_path.exists():\n", + " text = log_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n", + " label = Path(path_str).name\n", + " else:\n", + " text = \"(No pyscf.log found for this result.)\"\n", + " label = \"\"\n", + " _update_log_panel(text, label)\n", + " _goto_output_tab()\n", + "\n", + "\n", + "past_dd.observe(_load_past_result, names=\"value\")\n", + "past_refresh_btn.on_click(lambda _: _refresh_results_browser())\n", + "copy_path_btn.on_click(_copy_results_path)\n", + "view_log_btn.on_click(_view_log)\n", + "\n", + "# Populate on startup and make the real function visible to _do_run in Cell 3.\n", + "_refresh_results_browser()\n", + "\n", + "# ── Performance stats widgets ───────────────────────────────────────────────────────────────────\n", + "_perf_stats_html = widgets.HTML()\n", + "_perf_events_html = widgets.HTML()\n", + "\n", + "_reset_btn = widgets.Button(\n", + " description=\"Reset performance database\",\n", + " button_style=\"danger\",\n", + " icon=\"trash\",\n", + " layout=widgets.Layout(width=\"230px\"),\n", + ")\n", + "_reset_confirm_html = widgets.HTML(\n", + " ''\n", + " \"Warning: This will permanently delete all performance records. \"\n", + " \"Time estimates will reset to “no data”.\"\n", + ")\n", + "_reset_confirm_yes = widgets.Button(\n", + " description=\"Yes, delete all records\",\n", + " button_style=\"danger\",\n", + " icon=\"check\",\n", + " layout=widgets.Layout(width=\"190px\"),\n", + ")\n", + "_reset_confirm_no = widgets.Button(\n", + " description=\"Cancel\",\n", + " button_style=\"\",\n", + " icon=\"times\",\n", + " layout=widgets.Layout(width=\"90px\"),\n", + ")\n", + "_reset_confirm_box = widgets.VBox(\n", + " [\n", + " _reset_confirm_html,\n", + " widgets.HBox(\n", + " [_reset_confirm_yes, _reset_confirm_no],\n", + " layout=widgets.Layout(gap=\"8px\", margin=\"4px 0 0\"),\n", + " ),\n", + " ],\n", + " layout=widgets.Layout(\n", + " display=\"none\",\n", + " border=\"1px solid #fca5a5\",\n", + " padding=\"8px 10px\",\n", + " margin=\"6px 0 0\",\n", + " ),\n", + ")\n", + "\n", + "\n", + "def _build_perf_stats_html() -> str:\n", + " from quantui.calc_log import get_perf_history\n", + " records = get_perf_history()\n", + " if not records:\n", + " return (\n", + " ''\n", + " \"No performance data recorded yet.\"\n", + " )\n", + " groups: dict = {}\n", + " for r in records:\n", + " key = (r.get(\"method\", \"?\"), r.get(\"basis\", \"?\"))\n", + " groups.setdefault(key, []).append(r)\n", + " rows = \"\"\n", + " for (meth, bas), recs in sorted(groups.items()):\n", + " times = [r[\"elapsed_s\"] for r in recs if \"elapsed_s\" in r]\n", + " n = len(recs)\n", + " if times:\n", + " avg = sum(times) / len(times)\n", + " rows += (\n", + " \"\"\n", + " f'{meth}'\n", + " f'{bas}'\n", + " f'{n}'\n", + " f'{avg:.1f} s'\n", + " f'{min(times):.1f} s'\n", + " f'{max(times):.1f} s'\n", + " \"\"\n", + " )\n", + " header = (\n", + " \"\"\n", + " 'Method'\n", + " 'Basis'\n", + " 'Runs'\n", + " 'Avg'\n", + " 'Min'\n", + " 'Max'\n", + " \"\"\n", + " )\n", + " return (\n", + " ''\n", + " f\"{header}{rows}
\"\n", + " )\n", + "\n", + "\n", + "def _build_events_html() -> str:\n", + " from quantui.calc_log import get_recent_events\n", + " events = get_recent_events(20)\n", + " if not events:\n", + " return (\n", + " ''\n", + " \"No events recorded yet.\"\n", + " )\n", + " rows = \"\"\n", + " for e in reversed(events):\n", + " ts = e.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n", + " evt = e.get(\"event\", \"\")\n", + " msg = e.get(\"message\", \"\")\n", + " rows += (\n", + " \"\"\n", + " f'{ts}'\n", + " f'{evt}'\n", + " f'{msg}'\n", + " \"\"\n", + " )\n", + " return (\n", + " ''\n", + " f\"{rows}
\"\n", + " )\n", + "\n", + "\n", + "def _refresh_perf_stats():\n", + " _perf_stats_html.value = _build_perf_stats_html()\n", + " _perf_events_html.value = _build_events_html()\n", + "\n", + "\n", + "def _on_reset_click(btn):\n", + " _reset_confirm_box.layout.display = \"\"\n", + "\n", + "\n", + "def _on_confirm_yes(btn):\n", + " from quantui.calc_log import reset_perf_log\n", + " reset_perf_log()\n", + " _reset_confirm_box.layout.display = \"none\"\n", + " _refresh_perf_stats()\n", + "\n", + "\n", + "def _on_confirm_no(btn):\n", + " _reset_confirm_box.layout.display = \"none\"\n", + "\n", + "\n", + "_reset_btn.on_click(_on_reset_click)\n", + "_reset_confirm_yes.on_click(_on_confirm_yes)\n", + "_reset_confirm_no.on_click(_on_confirm_no)\n", + "_refresh_perf_stats()\n", + "\n", + "_perf_stats_panel = widgets.VBox([\n", + " _perf_stats_html,\n", + " widgets.HTML(\n", + " '

'\n", + " \"Recent events (last 20)

\"\n", + " ),\n", + " _perf_events_html,\n", + " widgets.HBox(\n", + " [_reset_btn],\n", + " layout=widgets.Layout(margin=\"14px 0 4px\"),\n", + " ),\n", + " _reset_confirm_box,\n", + "])\n", + "\n", + "_perf_accordion = widgets.Accordion(children=[_perf_stats_panel], selected_index=None)\n", + "_perf_accordion.set_title(0, \"Performance stats\")\n", + "\n", + "# ── History panel (assembled widget for the History tab) ─────────────────\n", + "history_panel = widgets.VBox([\n", + " widgets.HTML(\n", + " '

'\n", + " \"Calculations are saved automatically. Select one below to view its results.

\"\n", + " ),\n", + " widgets.HBox(\n", + " [past_dd, past_refresh_btn, copy_path_btn, view_log_btn],\n", + " layout=widgets.Layout(align_items=\"center\", gap=\"8px\"),\n", + " ),\n", + " results_path_lbl,\n", + " past_output,\n", + " _perf_accordion,\n", + "])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "# ── Compare tab widgets ────────────────────────────────────────────────────────\n", + "compare_select = widgets.SelectMultiple(\n", + " options=[(\"(no saved results)\", \"\")],\n", + " rows=8,\n", + " description=\"\",\n", + " layout=widgets.Layout(width=\"100%\"),\n", + ")\n", + "compare_refresh_btn = widgets.Button(\n", + " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", + " layout=widgets.Layout(width=\"100px\"),\n", + ")\n", + "compare_btn = widgets.Button(\n", + " description=\"Compare selected\", button_style=\"primary\", icon=\"bar-chart\",\n", + " disabled=True,\n", + " layout=widgets.Layout(width=\"180px\"),\n", + ")\n", + "compare_clear_btn = widgets.Button(\n", + " description=\"Clear\", button_style=\"warning\", icon=\"times\",\n", + " layout=widgets.Layout(width=\"90px\"),\n", + ")\n", + "compare_output = widgets.Output()\n", + "\n", + "\n", + "def _populate_compare_list():\n", + " '''Repopulate compare_select with all saved results.'''\n", + " from quantui.results_storage import list_results, load_result\n", + " dirs = list_results()\n", + " if not dirs:\n", + " compare_select.options = [(\"(no saved results)\", \"\")]\n", + " compare_btn.disabled = True\n", + " return\n", + " options = []\n", + " for d in dirs:\n", + " try:\n", + " data = load_result(d)\n", + " ts = data.get(\"timestamp\", d.name[:19])\n", + " label = f\"{ts} {data['formula']} {data['method']}/{data['basis']}\"\n", + " options.append((label, str(d)))\n", + " except Exception:\n", + " options.append((d.name, str(d)))\n", + " compare_select.options = options\n", + " compare_btn.disabled = False\n", + "\n", + "\n", + "def _on_compare_refresh(btn):\n", + " _populate_compare_list()\n", + "\n", + "\n", + "def _on_compare(btn):\n", + " selected = compare_select.value # tuple of selected path strings\n", + " if not selected or selected == (\"\",):\n", + " return\n", + " compare_output.clear_output(wait=True)\n", + " from quantui.results_storage import load_result\n", + " from quantui import summary_from_saved_result, comparison_table_html, plot_comparison\n", + " summaries = []\n", + " for path_str in selected:\n", + " if not path_str:\n", + " continue\n", + " try:\n", + " data = load_result(Path(path_str))\n", + " summaries.append(summary_from_saved_result(data))\n", + " except Exception as exc:\n", + " with compare_output:\n", + " display(HTML(f'

Error loading result: {exc}

'))\n", + " if not summaries:\n", + " return\n", + " with compare_output:\n", + " display(HTML(comparison_table_html(summaries)))\n", + " if len(summaries) > 1:\n", + " try:\n", + " import matplotlib.pyplot as plt\n", + " fig = plot_comparison(summaries)\n", + " display(fig)\n", + " plt.close(fig)\n", + " except Exception:\n", + " pass\n", + "\n", + "\n", + "def _on_compare_clear(btn):\n", + " compare_select.value = ()\n", + " compare_output.clear_output()\n", + "\n", + "\n", + "compare_refresh_btn.on_click(_on_compare_refresh)\n", + "compare_btn.on_click(_on_compare)\n", + "compare_clear_btn.on_click(_on_compare_clear)\n", + "\n", + "# Populate on load; expose to Cell-3 placeholder so _do_run refreshes it.\n", + "_populate_compare_list()\n", + "globals()[\"_populate_compare_list\"] = _populate_compare_list\n", + "\n", + "# ── Compare panel (assembled widget for the Compare tab) ──────────────────────\n", + "compare_panel = widgets.VBox([\n", + " widgets.HTML(\n", + " '

Compare Calculations

'\n", + " '

'\n", + " \"Select two or more saved calculations to compare side-by-side. \"\n", + " \"Hold Ctrl (or ⌘) to select multiple entries.

\"\n", + " ),\n", + " widgets.HBox([compare_refresh_btn]),\n", + " compare_select,\n", + " widgets.HBox(\n", + " [compare_btn, compare_clear_btn],\n", + " layout=widgets.Layout(gap=\"8px\", margin=\"6px 0\"),\n", + " ),\n", + " compare_output,\n", + "], layout=widgets.Layout(padding=\"8px 0\"))\n", + "\n", + "# ── Advanced accordion (Export only) ──────────────────────────────────────────\n", + "_export_content = widgets.VBox([\n", + " widgets.HTML(\n", + " '

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

\"\n", + " ),\n", + " widgets.HBox([export_btn, export_status]),\n", + "])\n", + "advanced_accordion = widgets.Accordion(children=[_export_content])\n", + "advanced_accordion.set_title(0, \"Export Script\")\n", + "advanced_accordion.selected_index = None # collapsed by default\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "from quantui.help_content import HELP_TOPICS\n", + "\n", + "# ── Help tab content ──────────────────────────────────────────────────────────\n", + "_help_keys = list(HELP_TOPICS.keys())\n", + "_help_labels = [HELP_TOPICS[k][\"title\"] for k in _help_keys]\n", + "\n", + "help_topic_dd = widgets.Dropdown(\n", + " options=list(zip(_help_labels, _help_keys)),\n", + " description=\"Topic:\",\n", + " style={\"description_width\": \"60px\"},\n", + " layout=widgets.Layout(width=\"460px\"),\n", + ")\n", + "help_content_html = widgets.HTML()\n", + "\n", + "def _render_help_topic(change=None):\n", + " key = help_topic_dd.value\n", + " if key and key in HELP_TOPICS:\n", + " entry = HELP_TOPICS[key]\n", + " help_content_html.value = (\n", + " f'
'\n", + " f'

'\n", + " f'{entry[\"title\"]}

'\n", + " f'
'\n", + " f'{entry[\"body\"]}
'\n", + " f'
'\n", + " )\n", + "\n", + "help_topic_dd.observe(_render_help_topic, names=\"value\")\n", + "_render_help_topic() # render first topic on load\n", + "\n", + "help_tab_panel = widgets.VBox([\n", + " widgets.HTML(\n", + " '

'\n", + " \"Browse help topics below. Click ? next to the Method or Basis Set \"\n", + " \"dropdown in the Calculate tab to jump directly to a relevant topic.

\"\n", + " ),\n", + " help_topic_dd,\n", + " help_content_html,\n", + "], layout=widgets.Layout(padding=\"8px 0\"))\n", + "\n", + "# ── Assemble root tab ─────────────────────────────────────────────────────────\n", + "_calculate_content = widgets.VBox([\n", + " step_progress.widget,\n", + " mol_input_container,\n", + " calc_setup_panel,\n", + " run_panel,\n", + " results_panel,\n", + " advanced_accordion,\n", + "], layout=widgets.Layout(padding=\"8px 0\"))\n", + "\n", + "# ── Output log tab ────────────────────────────────────────────────────────────────\n", + "_log_output_html = widgets.HTML(\n", + " ''\n", + " \"No log yet — run a calculation first, or use \"\n", + " \"View log in the History tab.\"\n", + ")\n", + "_log_source_lbl = widgets.HTML()\n", + "_log_clear_btn = widgets.Button(\n", + " description=\"Clear\",\n", + " button_style=\"\",\n", + " icon=\"times\",\n", + " layout=widgets.Layout(width=\"80px\"),\n", + ")\n", + "\n", + "\n", + "def _render_log(text: str, source_label: str = \"\") -> None:\n", + " # Render text as syntax-highlighted HTML in the log panel.\n", + " import html as _html_mod\n", + " lines = text.splitlines()\n", + " rows = []\n", + " for line in lines:\n", + " esc = _html_mod.escape(line)\n", + " if \"converged SCF energy\" in line or \"SCF converged\" in line:\n", + " style = \"color:#16a34a;font-weight:600\"\n", + " elif \"cycle=\" in line and \"E=\" in line:\n", + " style = \"color:#475569\"\n", + " elif \"HOMO\" in line or \"LUMO\" in line:\n", + " style = \"color:#2563eb\"\n", + " elif \"Warning\" in line or \"warning\" in line:\n", + " style = \"color:#d97706\"\n", + " elif \"Error\" in line or \"error\" in line or \"failed\" in line:\n", + " style = \"color:#dc2626\"\n", + " else:\n", + " style = \"color:#1e293b\"\n", + " rows.append(f'
{esc}
')\n", + " _log_output_html.value = (\n", + " '
'\n", + " + \"\".join(rows)\n", + " + \"
\"\n", + " )\n", + " _log_source_lbl.value = (\n", + " f'Source: {source_label}'\n", + " if source_label\n", + " else \"\"\n", + " )\n", + "\n", + "\n", + "def _update_log_panel(log_text: str, label: str = \"\") -> None:\n", + " _render_log(log_text, label)\n", + "\n", + "\n", + "def _goto_output_tab() -> None:\n", + " root_tab.selected_index = 3\n", + "\n", + "\n", + "def _clear_log(_):\n", + " _log_output_html.value = (\n", + " 'Log cleared.'\n", + " )\n", + " _log_source_lbl.value = \"\"\n", + "\n", + "\n", + "_log_clear_btn.on_click(_clear_log)\n", + "\n", + "log_tab_panel = widgets.VBox(\n", + " [\n", + " widgets.HTML(\n", + " '

'\n", + " \"Full PySCF output for the most recent calculation. \"\n", + " \"Use View log in the History tab to load a saved result's log.

\"\n", + " ),\n", + " widgets.HBox(\n", + " [_log_clear_btn],\n", + " layout=widgets.Layout(margin=\"0 0 8px\"),\n", + " ),\n", + " _log_source_lbl,\n", + " _log_output_html,\n", + " ],\n", + " layout=widgets.Layout(padding=\"8px 0\"),\n", + ")\n", + "\n", + "# Overwrite Cell-3 placeholders so later widgets can be reached.\n", + "globals()[\"_update_log_panel\"] = _update_log_panel\n", + "globals()[\"_goto_output_tab\"] = _goto_output_tab\n", + "\n", + "root_tab = widgets.Tab(children=[\n", + " _calculate_content,\n", + " history_panel,\n", + " compare_panel,\n", + " log_tab_panel,\n", + " help_tab_panel,\n", + "])\n", + "root_tab.set_title(0, \"Calculate\")\n", + "root_tab.set_title(1, \"History\")\n", + "root_tab.set_title(2, \"Compare\")\n", + "root_tab.set_title(3, \"Output\")\n", + "root_tab.set_title(4, \"Help\")\n", + "\n", + "# ── \"?\" button callbacks: jump to Help tab with specific topic ────────────────\n", + "def _show_help_topic(topic: str) -> None:\n", + " if topic in HELP_TOPICS:\n", + " help_topic_dd.value = topic\n", + " root_tab.selected_index = 4\n", + "\n", + "# Overwrite Cell-3 placeholder so \"?\" buttons find the real function.\n", + "globals()[\"_show_help_topic\"] = _show_help_topic\n", + "\n", + "# ── Single root display call ──────────────────────────────────────────────────\n", + "display(root_tab)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..cb8e253 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,338 @@ +""" +Tests for quantui.app.QuantUIApp — FR-012 Phase 4. + +All tests instantiate QuantUIApp() without calling .display(), which is safe +on any platform (display() requires an active IPython kernel; construction does +not). PySCF is unavailable on Windows; calculations are skipped accordingly. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import ipywidgets as widgets +import pytest + +from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _LogCapture +from quantui.molecule import Molecule + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water() -> Molecule: + """Return a minimal water molecule for testing.""" + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + ) + + +# --------------------------------------------------------------------------- +# Instantiation +# --------------------------------------------------------------------------- + + +class TestInstantiation: + """QuantUIApp constructs successfully without calling display().""" + + def test_instantiates(self): + app = QuantUIApp() + assert app is not None + + def test_root_tab_is_widget(self): + app = QuantUIApp() + assert isinstance(app.root_tab, widgets.Tab) + + def test_widget_property_returns_root_tab(self): + app = QuantUIApp() + assert app.widget is app.root_tab + + def test_initial_state(self): + app = QuantUIApp() + assert app._molecule is None + assert app._last_result is None + assert app._results == [] + + def test_initial_molecule_is_none(self): + app = QuantUIApp() + assert app._molecule is None + + def test_run_btn_initially_disabled(self): + app = QuantUIApp() + assert app.run_btn.disabled is True + + def test_export_btn_initially_disabled(self): + app = QuantUIApp() + assert app.export_btn.disabled is True + + +# --------------------------------------------------------------------------- +# Default widget values +# --------------------------------------------------------------------------- + + +class TestDefaultWidgetValues: + """Widget dropdowns and inputs have expected defaults.""" + + def test_method_default(self): + from quantui.config import DEFAULT_METHOD + + app = QuantUIApp() + assert app.method_dd.value == DEFAULT_METHOD + + def test_basis_default(self): + from quantui.config import DEFAULT_BASIS + + app = QuantUIApp() + assert app.basis_dd.value == DEFAULT_BASIS + + def test_calc_type_default(self): + app = QuantUIApp() + assert app.calc_type_dd.value == "Single Point" + + def test_theme_default(self): + app = QuantUIApp() + assert app.theme_btn.value == "Dark" + + def test_charge_default(self): + from quantui.config import DEFAULT_CHARGE + + app = QuantUIApp() + assert app.charge_si.value == DEFAULT_CHARGE + + def test_multiplicity_default(self): + from quantui.config import DEFAULT_MULTIPLICITY + + app = QuantUIApp() + assert app.mult_si.value == DEFAULT_MULTIPLICITY + + +# --------------------------------------------------------------------------- +# Tab structure +# --------------------------------------------------------------------------- + + +class TestTabStructure: + """root_tab has the correct number and titles of tabs.""" + + def test_five_tabs(self): + app = QuantUIApp() + assert len(app.root_tab.children) == 5 + + def test_tab_titles(self): + app = QuantUIApp() + expected = ["Calculate", "History", "Compare", "Output", "Help"] + for i, title in enumerate(expected): + assert app.root_tab.get_title(i) == title + + +# --------------------------------------------------------------------------- +# Molecule input — collapse / expand pattern +# --------------------------------------------------------------------------- + + +class TestMoleculeInputCollapse: + """mol_input_container switches between expanded and collapsed views.""" + + def test_initially_expanded(self): + app = QuantUIApp() + # Expanded: first child is mol_input_expanded + assert app.mol_input_container.children[0] is app.mol_input_expanded + + def test_collapses_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + # Collapsed: first child is mol_input_collapsed + assert app.mol_input_container.children[0] is app.mol_input_collapsed + + def test_molecule_stored_after_set_molecule(self): + app = QuantUIApp() + mol = _water() + app._set_molecule(mol) + assert app._molecule is mol + + def test_run_btn_enabled_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + assert app.run_btn.disabled is False + + def test_export_btn_enabled_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + assert app.export_btn.disabled is False + + def test_mol_info_html_updated(self): + app = QuantUIApp() + app._set_molecule(_water()) + assert "H2O" in app.mol_info_html.value + + def test_expand_restores_expanded_view(self): + app = QuantUIApp() + app._set_molecule(_water()) + # Simulate clicking "Change molecule" + app._on_expand_mol_input(None) + assert app.mol_input_container.children[0] is app.mol_input_expanded + + def test_multiplicity_above_one_switches_to_uhf(self): + app = QuantUIApp() + app.method_dd.value = "RHF" + radical = Molecule( + atoms=["H"], + coordinates=[[0.0, 0.0, 0.0]], + multiplicity=2, + ) + app._set_molecule(radical) + assert app.method_dd.value == "UHF" + + def test_rhf_kept_for_singlet(self): + app = QuantUIApp() + app.method_dd.value = "RHF" + app._set_molecule(_water()) + assert app.method_dd.value == "RHF" + + +# --------------------------------------------------------------------------- +# Step progress +# --------------------------------------------------------------------------- + + +class TestStepProgress: + """Step indicator advances correctly through the workflow.""" + + def test_step_0_active_initially(self): + app = QuantUIApp() + # Step 0 ("Choose molecule") should be active at start + assert app.step_progress._states[0] == "active" + + def test_step_advances_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + # Step 0 done, step 1 should be active + assert app.step_progress._states[0] == "done" + assert app.step_progress._states[1] == "active" + + +# --------------------------------------------------------------------------- +# _LogCapture +# --------------------------------------------------------------------------- + + +class TestLogCapture: + """_LogCapture parses SCF cycle lines and updates the status label.""" + + def _make_capture(self): + out = widgets.Output() + status = widgets.Label() + return _LogCapture(out, status), status + + def test_write_buffers_text(self): + cap, _ = self._make_capture() + cap.write("hello world\n") + assert "hello world" in cap.getvalue() + + def test_cycle_regex_parses_line(self): + line = "cycle= 3 E= -76.031234 delta_E= -0.0042" + m = _RE_CYCLE.search(line) + assert m is not None + assert m.group(1) == "3" + + def test_conv_regex_parses_line(self): + line = "converged SCF energy = -76.031234" + m = _RE_CONV.search(line) + assert m is not None + + def test_status_label_updated_on_cycle(self): + cap, status = self._make_capture() + cap.write("cycle= 2 E= -76.031234 delta_E= -0.0042\n") + assert "SCF cycle 2" in status.value + + def test_status_label_updated_on_convergence(self): + cap, status = self._make_capture() + cap.write("converged SCF energy = -76.031234\n") + assert "converged" in status.value.lower() + + def test_flush_is_noop(self): + cap, _ = self._make_capture() + cap.flush() # Must not raise + + def test_empty_write_is_noop(self): + cap, _ = self._make_capture() + cap.write("") + assert cap.getvalue() == "" + + +# --------------------------------------------------------------------------- +# _do_run dispatch +# --------------------------------------------------------------------------- + + +class TestDoRunDispatch: + """_do_run dispatches to the correct calculation function.""" + + @pytest.fixture + def app_with_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + return app + + def test_single_point_dispatch(self, app_with_molecule): + app = app_with_molecule + app.calc_type_dd.value = "Single Point" + mock_result = MagicMock() + mock_result.energy_hartree = -75.0 + mock_result.homo_lumo_gap_ev = 12.3 + mock_result.converged = True + mock_result.n_iterations = 10 + mock_result.formula = "H2O" + mock_result.method = "RHF" + mock_result.basis = "STO-3G" + with patch("quantui.run_in_session", return_value=mock_result) as mock_run: + with patch("quantui.save_result"): + app._do_run() + mock_run.assert_called_once() + + def test_geo_opt_dispatch(self, app_with_molecule): + app = app_with_molecule + app.calc_type_dd.value = "Geometry Opt" + mock_result = MagicMock() + mock_result.energy_hartree = -75.0 + mock_result.converged = True + mock_result.n_iterations = 5 + mock_result.trajectory = [] + mock_result.formula = "H2O" + mock_result.method = "RHF" + mock_result.basis = "STO-3G" + mock_result.final_molecule = _water() + with patch("quantui.optimize_geometry", return_value=mock_result) as mock_opt: + with patch("quantui.save_result"): + app._do_run() + mock_opt.assert_called_once() + + def test_pyscf_unavailable_shows_error(self, app_with_molecule): + app = app_with_molecule + app.calc_type_dd.value = "Single Point" + with patch("quantui.app._PYSCF_AVAILABLE", False): + app._do_run() + # Should not raise; error message should be in run_output + # (output widget content is opaque, so just verify no exception) + + +# --------------------------------------------------------------------------- +# Availability flags on the instance +# --------------------------------------------------------------------------- + + +class TestAvailabilityFlags: + def test_pyscf_flag_mirrors_module_level(self): + from quantui.app import _PYSCF_AVAILABLE + + app = QuantUIApp() + assert app._pyscf_available == _PYSCF_AVAILABLE + + def test_preopt_flag_mirrors_module_level(self): + from quantui.app import _PREOPT_AVAILABLE + + app = QuantUIApp() + assert app._preopt_available == _PREOPT_AVAILABLE diff --git a/tests/test_pubchem.py b/tests/test_pubchem.py index 8a75141..75a033d 100644 --- a/tests/test_pubchem.py +++ b/tests/test_pubchem.py @@ -320,6 +320,7 @@ def test_real_search_water(self): except PubChemAPIError: pytest.skip("PubChem not accessible") + @rdkit_only def test_real_fetch_caffeine(self): """Test real fetch of caffeine molecule.""" try: From 90267d2c11f8c33689800ade3de6707896d5bf73 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 16 Apr 2026 18:38:35 -0400 Subject: [PATCH 03/13] Implement FR-012 Phases 2-4; fix error display, RDKit imports, themes - app.py: QuantUIApp with all widget logic (FR-012 Phase 1-4) - app.py: Errors now show clean card in Calculate tab; full traceback goes to Output tab and ~/.quantui/logs/error_log.txt - app.py: clear_output fires synchronously on Run click (not in thread) - app.py: Remove Dark Blue/Maroon themes (imperceptible hue shift) - pubchem.py: Fix missing 'from rdkit.Chem import Descriptors' import - environment.yml: Add rdkit>=2022.03.1 - tests/test_app.py: 38 new tests for QuantUIApp - notebooks/molecule_computations.ipynb: Collapsed to 3-cell launcher - apptainer/quantui-local.def: Add QuantUIApp import check - planning/: Full TODO/ planning layout --- .github/copilot-instructions.md | 16 +- local-setup/environment.yml | 1 + notebooks/app_test.ipynb | 43 - .../molecule_computations.pre-fr012.ipynb | 1791 ----------------- quantui/app.py | 69 +- quantui/pubchem.py | 6 +- 6 files changed, 74 insertions(+), 1852 deletions(-) delete mode 100644 notebooks/app_test.ipynb delete mode 100644 notebooks/molecule_computations.pre-fr012.ipynb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd887c7..af6f544 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ > Stable project context for GitHub Copilot, Claude, and other AI coding assistants. > Describes what the project IS and how it is built — not where development currently -> stands (see `planning/SESSION-HANDOFF.md` for that). Update this file when +> stands (see `planning/TODO/STATUS.md` for that). Update this file when > architecture or conventions change, not every session. --- @@ -50,9 +50,13 @@ QuantUI-local/ │ └── tutorials/ ← 01–05 step-by-step tutorial notebooks ├── tests/ ← pytest suite (~440 tests) ├── planning/ ← Planning docs (not committed to git) -│ ├── SESSION-HANDOFF.md ← Start here each session — current state -│ ├── feature-requests.md ← FR backlog -│ └── FR-*.md ← Individual feature request specs +│ ├── TODO/ +│ │ ├── STATUS.md ← Start here each session — current state +│ │ ├── TODO.md ← Milestone task list with acceptance criteria +│ │ ├── DECISIONS.md ← Resolved design decisions +│ │ └── GOTCHAS.md ← Known pitfalls and deliberate deferrals +│ ├── archive/ ← Old SESSION-HANDOFF and FR specs +│ └── feature-requests.md ← FR backlog ├── apptainer/ │ ├── quantui-local.def ← Apptainer container definition │ └── build.sh ← Build script @@ -118,7 +122,7 @@ notebooks/molecule_computations.ipynb | `quantui/freq_calc.py` | `run_freq_calc()` — vibrational analysis via `pyscf.hessian` | | `quantui/tddft_calc.py` | `run_tddft_calc()` — excited states via `pyscf.tddft` | | `notebooks/molecule_computations.ipynb` | Thin launcher — 3 cells only (do not add logic here) | -| `planning/SESSION-HANDOFF.md` | **Read this first every session** — current state, git log, open tasks | +| `planning/TODO/STATUS.md` | **Read this first every session** — current state, git log, open tasks | | `planning/feature-requests.md` | FR backlog | --- @@ -391,4 +395,4 @@ Never make independent architectural changes in this repo — propose them in `Q ## Active Development Branch Branch: `app-restructure` — FR-012 App Module Refactor in progress. -See `planning/SESSION-HANDOFF.md` for current phase and uncommitted changes. +See `planning/TODO/STATUS.md` for current phase and uncommitted changes. diff --git a/local-setup/environment.yml b/local-setup/environment.yml index 098919c..a3876f7 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -16,6 +16,7 @@ dependencies: # Scientific computing - numpy>=1.24.0 - ase>=3.22.0 # Structure I/O, molecule library, geometry optimisation + - rdkit>=2022.03.1 # PubChem SDF→XYZ conversion, SMILES input # Visualization - py3dmol>=2.0.0 # Primary 3D molecular viewer diff --git a/notebooks/app_test.ipynb b/notebooks/app_test.ipynb deleted file mode 100644 index bc61992..0000000 --- a/notebooks/app_test.ipynb +++ /dev/null @@ -1,43 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# QuantUI-local — App Class Test (FR-012 Phase 2)\n", - "\n", - "Side-by-side test notebook for the `QuantUIApp` class before replacing the production notebook.\n", - "\n", - "**Verify:**\n", - "- All 5 tabs render correctly (Calculate, History, Compare, Output, Help)\n", - "- Theme toggle (Light / Dark / Dark Blue / Dark Maroon) works\n", - "- Molecule input (library, XYZ, PubChem search) loads and collapses correctly\n", - "- Calculation type dropdown (Single Point / Geometry Opt / Frequency / UV-Vis) shows/hides extra options\n", - "- Run button dispatches correctly (PySCF unavailable on Windows — error message expected)\n", - "- History tab loads and displays past results\n", - "- Compare tab allows adding and comparing results\n", - "\n", - "> This notebook is temporary — delete after Phase 3 is complete." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "from quantui.app import QuantUIApp\n", - "QuantUIApp().display()" - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/molecule_computations.pre-fr012.ipynb b/notebooks/molecule_computations.pre-fr012.ipynb deleted file mode 100644 index 6140e0d..0000000 --- a/notebooks/molecule_computations.pre-fr012.ipynb +++ /dev/null @@ -1,1791 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# QuantUI-local — Quantum Chemistry Calculator\n", - "\n", - "Run PySCF quantum chemistry calculations directly in this notebook.\n", - "\n", - "**How to use:**\n", - "1. Select or enter a molecule in **Molecule Input**\n", - "2. Choose a method and basis set in **Calculation Setup**\n", - "3. Click **Run Calculation** — results appear below\n", - "4. Optionally add results to **Compare** or **Export** a standalone script\n", - "\n", - "> **Platform note:** PySCF requires Linux, macOS, or WSL. \\\n", - "> Windows users: `apptainer run quantui-local.sif`\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": { - "tags": [ - "skip-execution", - "remove-input" - ] - }, - "outputs": [], - "source": [ - "# Environment check — verifies correct conda environment.\n", - "# Tagged skip-execution and remove-input so it is hidden in Voilà.\n", - "import sys as _sys\n", - "_env = _sys.prefix\n", - "if \"quantui\" not in _env.lower():\n", - " print(f\"Warning: active environment may not be quantui-local\")\n", - " print(f\"Active: {_env}\")\n", - " print(\"Run: conda activate quantui-local\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "import threading\n", - "import ipywidgets as widgets\n", - "from IPython.display import display, HTML\n", - "\n", - "import quantui\n", - "from quantui import (\n", - " Molecule, parse_xyz_input,\n", - " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", - " DEFAULT_METHOD, DEFAULT_BASIS, DEFAULT_CHARGE, DEFAULT_MULTIPLICITY,\n", - " session_can_handle, get_session_resources,\n", - " PUBCHEM_AVAILABLE, VISUALIZATION_AVAILABLE, ASE_AVAILABLE,\n", - " QUICK_START_TEMPLATES,\n", - ")\n", - "\n", - "# Optional — degrade gracefully if unavailable\n", - "try:\n", - " from quantui import run_in_session, SessionResult\n", - " PYSCF_AVAILABLE = True\n", - "except (ImportError, AttributeError):\n", - " PYSCF_AVAILABLE = False\n", - "\n", - "try:\n", - " from quantui import student_friendly_fetch\n", - "except (ImportError, AttributeError):\n", - " student_friendly_fetch = None\n", - "\n", - "try:\n", - " from quantui import display_molecule\n", - "except (ImportError, AttributeError):\n", - " display_molecule = None\n", - "\n", - "try:\n", - " from quantui import preoptimize\n", - " PREOPT_AVAILABLE = True\n", - "except (ImportError, AttributeError):\n", - " PREOPT_AVAILABLE = False\n", - "\n", - "# Mutable session state shared across all callbacks\n", - "_state = {\"molecule\": None, \"last_result\": None, \"results\": []}\n", - "\n", - "# ── Global app styles ─────────────────────────────────────────────────────────\n", - "# Permanent — not toggled by dark mode (dark mode uses filter inversion on top).\n", - "display(HTML(\"\"\"\"\"\"))\n", - "\n", - "# ── Dark mode toggle ─────────────────────────────────────────────────────────\n", - "# Uses CSS filter invert+hue-rotate on the html element so it works with all\n", - "# inline-styled elements. canvas/img/iframe are re-inverted to keep their\n", - "# original appearance (e.g. the py3Dmol 3D viewer).\n", - "# Map theme name → hue-rotate angle. Light uses no filter.\n", - "_THEME_HUE = {\"Dark\": 180, \"Dark Blue\": 200, \"Dark Maroon\": 160}\n", - "\n", - "\n", - "def _theme_css(theme: str) -> str:\n", - " \"\"\"Return the CSS filter block for *theme*, or '' for Light.\"\"\"\n", - " if theme not in _THEME_HUE:\n", - " return \"\"\n", - " deg = _THEME_HUE[theme]\n", - " return (\n", - " \"\"\n", - " )\n", - "\n", - "\n", - "_theme_style = widgets.Output(\n", - " layout=widgets.Layout(height=\"0px\", overflow=\"hidden\", margin=\"0\", padding=\"0\")\n", - ")\n", - "\n", - "theme_btn = widgets.ToggleButtons(\n", - " options=[\"Light\", \"Dark\", \"Dark Blue\", \"Dark Maroon\"],\n", - " value=\"Dark\",\n", - " description=\"Theme:\",\n", - " style={\n", - " \"description_width\": \"48px\",\n", - " \"button_width\": \"90px\",\n", - " },\n", - " layout=widgets.Layout(margin=\"0\"),\n", - ")\n", - "\n", - "\n", - "def _toggle_theme(change):\n", - " _theme_style.clear_output()\n", - " css = _theme_css(change[\"new\"])\n", - " if css:\n", - " with _theme_style:\n", - " display(HTML(css))\n", - "\n", - "\n", - "theme_btn.observe(_toggle_theme, names=\"value\")\n", - "\n", - "# Apply Dark theme on startup\n", - "with _theme_style:\n", - " display(HTML(_theme_css(\"Dark\")))\n", - "\n", - "display(widgets.HBox(\n", - " [theme_btn],\n", - " layout=widgets.Layout(justify_content=\"flex-end\", margin=\"0 0 4px\"),\n", - "))\n", - "display(_theme_style)\n", - "\n", - "# ── Status panel ────────────────────────────────────────────────────────────\n", - "_cores, _mem_gb = get_session_resources()\n", - "_mem = f\"{_mem_gb} GB\" if _mem_gb is not None else \"unknown\"\n", - "\n", - "\n", - "def _ok(flag, extra=\"\"):\n", - " tick = ''\n", - " cross = ''\n", - " return (tick if flag else cross) + (\" \" + extra if extra else \"\")\n", - "\n", - "\n", - "_items = [\n", - " (\"PySCF (calculations)\", _ok(PYSCF_AVAILABLE,\n", - " \"\" if PYSCF_AVAILABLE else \"— Linux / macOS / WSL required\")),\n", - " (\"ASE (structure I/O, opt.)\", _ok(ASE_AVAILABLE)),\n", - " (\"PubChem search\", _ok(PUBCHEM_AVAILABLE)),\n", - " (\"3D viewer (py3Dmol)\", _ok(VISUALIZATION_AVAILABLE)),\n", - " (\"CPU cores / Memory\", f\"{_cores} cores / {_mem}\"),\n", - "]\n", - "_rows = \"\".join(\n", - " f'{k}'\n", - " f'{v}'\n", - " for k, v in _items\n", - ")\n", - "display(HTML(\n", - " f'
'\n", - " f''\n", - " f\"QuantUI-local {quantui.__version__}\"\n", - " f'{_rows}
'\n", - " f\"
\"\n", - "))\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "import io\n", - "import re\n", - "import time\n", - "\n", - "from quantui.progress import StepProgress\n", - "import quantui.calc_log as _calc_log\n", - "\n", - "# ── Shared output widgets ────────────────────────────────────────────────────\n", - "mol_info_html = widgets.HTML(\n", - " value='No molecule loaded yet.'\n", - ")\n", - "mol_summary_compact = widgets.HTML(value=\"\")\n", - "viz_output = widgets.Output(layout=widgets.Layout(min_height=\"50px\"))\n", - "run_output = widgets.Output(\n", - " layout=widgets.Layout(\n", - " border=\"1px solid #c0ccd8\", min_height=\"80px\", max_height=\"400px\",\n", - " padding=\"8px\", overflow_y=\"auto\",\n", - " )\n", - ")\n", - "with run_output:\n", - " display(HTML(\n", - " '

'\n", - " \"No calculation run yet. PySCF output and any errors will appear here.\"\n", - " \"

\"\n", - " ))\n", - "result_output = widgets.Output()\n", - "comparison_output = widgets.Output()\n", - "notes_output = widgets.Output()\n", - "perf_estimate_html = widgets.HTML()\n", - "\n", - "# ── Step indicator ────────────────────────────────────────────────────────────\n", - "step_progress = StepProgress([\"Choose molecule\", \"Set method\", \"Run\", \"Results\"])\n", - "step_progress.start(0)\n", - "\n", - "# ── Calculation setup (defined here so _set_molecule can update them) ────────\n", - "method_dd = widgets.Dropdown(\n", - " options=SUPPORTED_METHODS, value=DEFAULT_METHOD,\n", - " description=\"Method:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"260px\"),\n", - ")\n", - "basis_dd = widgets.Dropdown(\n", - " options=SUPPORTED_BASIS_SETS, value=DEFAULT_BASIS,\n", - " description=\"Basis Set:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"260px\"),\n", - ")\n", - "charge_si = widgets.BoundedIntText(\n", - " value=DEFAULT_CHARGE, min=-10, max=10,\n", - " description=\"Charge:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "mult_si = widgets.BoundedIntText(\n", - " value=DEFAULT_MULTIPLICITY, min=1, max=10,\n", - " description=\"Multiplicity:\", style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "preopt_cb = widgets.Checkbox(\n", - " value=False,\n", - " description=\"Pre-optimize geometry (fast LJ force-field)\",\n", - " disabled=not PREOPT_AVAILABLE,\n", - " layout=widgets.Layout(width=\"400px\"),\n", - ")\n", - "\n", - "# ── Calculation type + extra options ──────────────────────────────────────────\n", - "calc_type_dd = widgets.Dropdown(\n", - " options=[\"Single Point\", \"Geometry Opt\", \"Frequency\", \"UV-Vis (TD-DFT)\"],\n", - " value=\"Single Point\",\n", - " description=\"Calc. Type:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"310px\"),\n", - ")\n", - "fmax_fi = widgets.BoundedFloatText(\n", - " value=0.05, min=0.001, max=1.0, step=0.005,\n", - " description=\"Force thr. (eV/Å):\",\n", - " style={\"description_width\": \"130px\"},\n", - " layout=widgets.Layout(width=\"270px\"),\n", - ")\n", - "max_steps_si = widgets.BoundedIntText(\n", - " value=200, min=10, max=1000,\n", - " description=\"Max steps:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"200px\"),\n", - ")\n", - "nstates_si = widgets.BoundedIntText(\n", - " value=10, min=1, max=50,\n", - " description=\"# states:\",\n", - " style={\"description_width\": \"100px\"},\n", - " layout=widgets.Layout(width=\"180px\"),\n", - ")\n", - "calc_extra_opts = widgets.VBox([])\n", - "\n", - "\n", - "def _on_calc_type_change(change):\n", - " ct = change[\"new\"]\n", - " if ct == \"Geometry Opt\":\n", - " calc_extra_opts.children = [\n", - " widgets.HBox(\n", - " [fmax_fi, max_steps_si],\n", - " layout=widgets.Layout(gap=\"8px\"),\n", - " ),\n", - " ]\n", - " elif ct == \"UV-Vis (TD-DFT)\":\n", - " calc_extra_opts.children = [\n", - " nstates_si,\n", - " widgets.HTML(\n", - " '⚠ Requires a DFT '\n", - " \"functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) \"\n", - " \"instead.\"\n", - " ),\n", - " ]\n", - " else:\n", - " calc_extra_opts.children = []\n", - "\n", - "\n", - "calc_type_dd.observe(_on_calc_type_change, names=\"value\")\n", - "\n", - "# ── Context-help buttons (next to Method and Basis dropdowns) ────────────────\n", - "method_help_btn = widgets.Button(\n", - " description=\"?\", button_style=\"\",\n", - " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", - " tooltip=\"RHF vs UHF — opens Help tab\",\n", - ")\n", - "basis_help_btn = widgets.Button(\n", - " description=\"?\", button_style=\"\",\n", - " layout=widgets.Layout(width=\"28px\", height=\"28px\"),\n", - " tooltip=\"Choosing a basis set — opens Help tab\",\n", - ")\n", - "\n", - "# ── Run widgets ──────────────────────────────────────────────────────────────\n", - "run_btn = widgets.Button(\n", - " description=\"Run Calculation\", button_style=\"success\", icon=\"play\",\n", - " disabled=True, layout=widgets.Layout(width=\"200px\", height=\"36px\"),\n", - ")\n", - "run_status = widgets.Label()\n", - "\n", - "# ── Log clear button ─────────────────────────────────────────────────────────\n", - "log_clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"\", icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\", height=\"26px\"),\n", - " tooltip=\"Clear calculation output\",\n", - ")\n", - "\n", - "def _clear_log(btn):\n", - " run_output.clear_output()\n", - "\n", - "log_clear_btn.on_click(_clear_log)\n", - "\n", - "# ── Comparison / export widgets ───────────────────────────────────────────────\n", - "accumulate_btn = widgets.Button(\n", - " description=\"Add to Comparison\", button_style=\"info\", icon=\"plus\",\n", - " disabled=True, layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"warning\", icon=\"trash\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "export_btn = widgets.Button(\n", - " description=\"Export Script\", button_style=\"\", icon=\"download\",\n", - " disabled=True, layout=widgets.Layout(width=\"160px\"),\n", - ")\n", - "export_status = widgets.Label()\n", - "\n", - "\n", - "# ── Thread-safe log capture ───────────────────────────────────────────────────\n", - "_RE_CYCLE = re.compile(\n", - " r\"cycle=\\s*(\\d+)\\s+E=\\s*([\\-\\d\\.]+)\\s+delta_E=\\s*([\\-\\d\\.Ee+\\-]+)\"\n", - ")\n", - "_RE_CONV = re.compile(r\"converged SCF energy\\s*=\\s*([\\-\\d\\.]+)\")\n", - "\n", - "\n", - "class _LogCapture:\n", - " '''Write PySCF output to an Output widget and capture it to a buffer.'''\n", - " def __init__(self, output_widget):\n", - " self._w = output_widget\n", - " self._buf = io.StringIO()\n", - " self._line_buf = \"\"\n", - "\n", - " def write(self, text: str) -> None:\n", - " if not text:\n", - " return\n", - " self._w.append_stdout(text)\n", - " self._buf.write(text)\n", - " # Scan complete lines for SCF progress and update the status label.\n", - " self._line_buf += text\n", - " while \"\\n\" in self._line_buf:\n", - " line, self._line_buf = self._line_buf.split(\"\\n\", 1)\n", - " m = _RE_CYCLE.search(line)\n", - " if m:\n", - " n, delta = m.group(1), m.group(3)\n", - " try:\n", - " run_status.value = f\"SCF cycle {n} · ΔE = {float(delta):.4g} Ha\"\n", - " except Exception:\n", - " run_status.value = f\"SCF cycle {n}\"\n", - " continue\n", - " m = _RE_CONV.search(line)\n", - " if m:\n", - " run_status.value = \"SCF converged ✓\"\n", - "\n", - " def flush(self) -> None:\n", - " pass\n", - "\n", - " def getvalue(self) -> str:\n", - " return self._buf.getvalue()\n", - "\n", - "\n", - "# Placeholders — overwritten by later cells once they execute.\n", - "_refresh_results_browser = lambda: None # noqa: E731\n", - "_show_help_topic = lambda topic: None # noqa: E731\n", - "_populate_compare_list = lambda: None # noqa: E731\n", - "_update_log_panel = lambda log_text, label=\"\": None # noqa: E731\n", - "_goto_output_tab = lambda: None # noqa: E731\n", - "\n", - "\n", - "# ── Callbacks ─────────────────────────────────────────────────────────────────\n", - "\n", - "def _set_molecule(mol, label=\"\"):\n", - " '''Update shared state and refresh dependent widgets.'''\n", - " _state[\"molecule\"] = mol\n", - " run_btn.disabled = False\n", - " export_btn.disabled = False\n", - "\n", - " try:\n", - " n_e = mol.get_electron_count()\n", - " e_str = f\"{n_e} electrons\"\n", - " except Exception:\n", - " e_str = \"\"\n", - "\n", - " _lbl = f'
{label}' if label else \"\"\n", - " _summary = (\n", - " f'{mol.get_formula()}'\n", - " f' '\n", - " f\"{len(mol.atoms)} atoms\"\n", - " + (f\" • {e_str}\" if e_str else \"\")\n", - " + f\" • charge {mol.charge} • mult {mol.multiplicity}\"\n", - " + f\"{_lbl}\"\n", - " )\n", - " mol_info_html.value = _summary\n", - " mol_summary_compact.value = (\n", - " f'
'\n", - " f\"{_summary}
\"\n", - " )\n", - "\n", - " charge_si.value = mol.charge\n", - " mult_si.value = mol.multiplicity\n", - " if mol.multiplicity > 1 and method_dd.value == \"RHF\":\n", - " method_dd.value = \"UHF\"\n", - "\n", - " viz_output.clear_output(wait=True)\n", - " if display_molecule is not None:\n", - " with viz_output:\n", - " display_molecule(mol)\n", - "\n", - " _update_notes()\n", - "\n", - " # Advance step indicator — but not during a running calculation (e.g. preopt)\n", - " if step_progress._states[2] != \"active\":\n", - " if step_progress._states[2] in (\"done\", \"fail\"):\n", - " # Fresh molecule after a completed run — reset indicator\n", - " step_progress.reset()\n", - " step_progress.complete(0)\n", - " step_progress.start(1)\n", - "\n", - " # Update time estimate for the newly loaded molecule\n", - " _update_estimate()\n", - "\n", - " # Collapse the molecule input panel to the compact summary + 3D viewer\n", - " mol_input_container.children = [mol_input_collapsed, viz_output]\n", - "\n", - "\n", - "def _format_result(r):\n", - " _conv = \"Yes\" if r.converged else \"No (treat results with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " _gap = (\n", - " f\"{r.homo_lumo_gap_ev:.4f} eV\"\n", - " if r.homo_lumo_gap_ev is not None else \"N/A\"\n", - " )\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Total energy\",\n", - " f\"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)\", \"#000\"),\n", - " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", - " (\"SCF converged\", _conv, _cc),\n", - " (\"SCF iterations\", str(r.n_iterations), \"#000\"),\n", - " ]\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"{r.formula} — {r.method}/{r.basis}\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_opt_result(r):\n", - " '''Format an OptimizationResult as an HTML result card.'''\n", - " _conv = \"Yes\" if r.converged else \"No (max steps reached)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Final energy\", f\"{r.energy_hartree:.8f} Ha\", \"#000\"),\n", - " (\"Energy change\", f\"{r.energy_change_hartree:+.6f} Ha\", \"#000\"),\n", - " (\"Opt converged\", _conv, _cc),\n", - " (\"Steps taken\", str(r.n_steps), \"#000\"),\n", - " (\"Geometry RMSD\", f\"{r.rmsd_angstrom:.4f} Å\", \"#000\"),\n", - " ]\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_freq_result(r):\n", - " '''Format a FreqResult as an HTML result card.'''\n", - " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " n_real = r.n_real_modes()\n", - " n_imag = r.n_imaginary_modes()\n", - " real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]\n", - " freq_str = \" \".join(f\"{f:.1f}\" for f in real_freqs)\n", - " if len([f for f in r.frequencies_cm1 if f > 0]) > 6:\n", - " freq_str += \" …\"\n", - " imag_note = \"\"\n", - " if n_imag > 0:\n", - " imag_note = (\n", - " f'Imaginary modes'\n", - " f'{n_imag} — geometry may not be a minimum'\n", - " )\n", - " _rows = (\n", - " f'SCF energy'\n", - " f'{r.energy_hartree:.8f} Ha'\n", - " f'SCF converged'\n", - " f'{_conv}'\n", - " f'Real modes'\n", - " f'{n_real}'\n", - " + imag_note\n", - " + (f'Frequencies (cm⁻¹)'\n", - " f'{freq_str or \"none\"}'\n", - " if real_freqs else \"\")\n", - " + f'ZPVE'\n", - " f'{r.zpve_hartree:.6f} Ha '\n", - " f'({r.zpve_hartree * 27.211386245988:.4f} eV)'\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"Frequency Analysis — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _format_tddft_result(r):\n", - " '''Format a TDDFTResult as an HTML result card with excitation table.'''\n", - " _conv = \"Yes\" if r.converged else \"No (treat with caution)\"\n", - " _cc = \"green\" if r.converged else \"#c00\"\n", - " header_rows = (\n", - " f'Ground-state energy'\n", - " f'{r.energy_hartree:.8f} Ha'\n", - " f'SCF converged'\n", - " f'{_conv}'\n", - " f'States computed'\n", - " f'{len(r.excitation_energies_ev)}'\n", - " )\n", - " exc_table = \"\"\n", - " if r.excitation_energies_ev:\n", - " wl = r.wavelengths_nm()\n", - " exc_rows = []\n", - " for i, (e_ev, f_osc) in enumerate(\n", - " zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1\n", - " ):\n", - " bold = \"font-weight:bold\" if f_osc > 0.05 else \"\"\n", - " exc_rows.append(\n", - " f''\n", - " f'S{i}'\n", - " f'{e_ev:.3f} eV'\n", - " f'{wl[i - 1]:.1f} nm'\n", - " f'f = {f_osc:.4f}'\n", - " f\"\"\n", - " )\n", - " if len(r.excitation_energies_ev) > 8:\n", - " exc_rows.append(\n", - " f'… '\n", - " f\"and {len(r.excitation_energies_ev) - 8} more states\"\n", - " )\n", - " exc_table = (\n", - " ''\n", - " \"Vertical excitations:\"\n", - " ''\n", - " 'State'\n", - " 'Energy'\n", - " 'λ'\n", - " 'Osc. str.'\n", - " + \"\".join(exc_rows)\n", - " )\n", - " return (\n", - " f'
'\n", - " f\"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})\"\n", - " f''\n", - " f\"{header_rows}{exc_table}
\"\n", - " )\n", - "\n", - "\n", - "def _do_run(btn):\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " run_status.value = \"Load a molecule first.\"\n", - " return\n", - " run_btn.disabled = True\n", - " run_status.value = \"Starting...\"\n", - " run_output.clear_output()\n", - " result_output.clear_output()\n", - "\n", - " # Advance step indicator: method confirmed → running\n", - " step_progress.complete(1)\n", - " step_progress.start(2)\n", - "\n", - " _calc_log.log_event(\n", - " \"calc_start\",\n", - " f\"{method_dd.value}/{basis_dd.value} on {mol.get_formula()}\",\n", - " n_atoms=len(mol.atoms),\n", - " )\n", - " _run_wall_t = time.perf_counter()\n", - "\n", - " def _thread():\n", - " log = _LogCapture(run_output)\n", - " try:\n", - " calc_mol = mol\n", - " if preopt_cb.value and PREOPT_AVAILABLE:\n", - " run_status.value = \"Pre-optimizing...\"\n", - " calc_mol, _rmsd = preoptimize(mol)\n", - " _set_molecule(calc_mol, f\"Geometry pre-optimized (LJ, RMSD={_rmsd:.3f} Å)\")\n", - "\n", - " # ── Dispatch to the right backend based on Calc. Type ─────────────\n", - " ct = calc_type_dd.value\n", - " if ct == \"Geometry Opt\":\n", - " run_status.value = \"Optimizing geometry...\"\n", - " from quantui import optimize_geometry\n", - " result = optimize_geometry(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " fmax=fmax_fi.value,\n", - " steps=max_steps_si.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_opt_result(result)\n", - " save_spectra, save_type = {}, \"geometry_opt\"\n", - " elif ct == \"Frequency\":\n", - " run_status.value = \"Computing frequencies (SCF + Hessian)...\"\n", - " from quantui.freq_calc import run_freq_calc\n", - " result = run_freq_calc(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_freq_result(result)\n", - " save_spectra = {\"ir\": {\n", - " \"frequencies_cm1\": result.frequencies_cm1,\n", - " \"ir_intensities\": result.ir_intensities,\n", - " \"zpve_hartree\": result.zpve_hartree,\n", - " }}\n", - " save_type = \"frequency\"\n", - " elif ct == \"UV-Vis (TD-DFT)\":\n", - " run_status.value = \"Running TD-DFT excited states...\"\n", - " from quantui.tddft_calc import run_tddft_calc\n", - " result = run_tddft_calc(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " nstates=nstates_si.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_tddft_result(result)\n", - " save_spectra = {\"uv_vis\": {\n", - " \"excitation_energies_ev\": result.excitation_energies_ev,\n", - " \"oscillator_strengths\": result.oscillator_strengths,\n", - " \"wavelengths_nm\": result.wavelengths_nm(),\n", - " }}\n", - " save_type = \"tddft\"\n", - " else: # Single Point\n", - " run_status.value = \"Calculating...\"\n", - " result = run_in_session(\n", - " molecule=calc_mol,\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " progress_stream=log,\n", - " )\n", - " result_html = _format_result(result)\n", - " save_spectra, save_type = {}, \"single_point\"\n", - " _elapsed = time.perf_counter() - _run_wall_t\n", - "\n", - " _state[\"last_result\"] = result\n", - " accumulate_btn.disabled = False\n", - "\n", - " result_output.append_display_data(HTML(result_html))\n", - " run_status.value = f\"Done in {_elapsed:.1f} s.\"\n", - "\n", - " # Advance step indicator: run complete → results ready\n", - " step_progress.complete(2)\n", - " step_progress.complete(3)\n", - "\n", - " # Persist result to disk\n", - " try:\n", - " from quantui import save_result\n", - " save_result(\n", - " result, pyscf_log=log.getvalue(),\n", - " calc_type=save_type, spectra=save_spectra,\n", - " )\n", - " _refresh_results_browser()\n", - " _populate_compare_list()\n", - " _update_log_panel(\n", - " log.getvalue(),\n", - " f\"{result.formula} {method_dd.value}/{basis_dd.value}\",\n", - " )\n", - " except Exception:\n", - " pass\n", - "\n", - " # Log performance record and refresh the estimate widget\n", - " try:\n", - " _calc_log.log_calculation(\n", - " formula=result.formula,\n", - " n_atoms=len(calc_mol.atoms),\n", - " n_electrons=calc_mol.get_electron_count(),\n", - " method=result.method,\n", - " basis=result.basis,\n", - " n_iterations=getattr(result, \"n_iterations\", -1),\n", - " elapsed_s=_elapsed,\n", - " converged=result.converged,\n", - " )\n", - " _calc_log.log_event(\n", - " \"calc_done\",\n", - " f\"{result.method}/{result.basis} on {result.formula}\",\n", - " elapsed_s=round(_elapsed, 2),\n", - " converged=result.converged,\n", - " )\n", - " _update_estimate()\n", - " except Exception:\n", - " pass\n", - "\n", - " except ImportError as _import_err:\n", - " _err_detail = str(_import_err)\n", - " log.write(\n", - " f\"Import error: {_err_detail}\\n\\n\"\n", - " \"A required calculation dependency could not be loaded.\\n\"\n", - " \"On Windows: use the Apptainer container.\\n\"\n", - " \" apptainer run quantui-local.sif\\n\"\n", - " )\n", - " run_status.value = \"Import error — see output.\"\n", - " step_progress.fail(2, _err_detail[:60])\n", - " _calc_log.log_event(\"calc_error\", _err_detail[:200])\n", - "\n", - " except Exception as exc:\n", - " import traceback\n", - " _elapsed = time.perf_counter() - _run_wall_t\n", - " log.write(f\"Error: {exc}\\n\\n{traceback.format_exc()}\")\n", - " run_status.value = \"Error — see Calculation Output below.\"\n", - " step_progress.fail(2, str(exc)[:60])\n", - " _calc_log.log_event(\"calc_error\", str(exc)[:200], elapsed_s=round(_elapsed, 2))\n", - "\n", - " finally:\n", - " run_btn.disabled = False\n", - "\n", - " threading.Thread(target=_thread, daemon=True).start()\n", - "\n", - "run_btn.on_click(_do_run)\n", - "\n", - "\n", - "def _update_notes(change=None):\n", - " notes_output.clear_output(wait=True)\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " return\n", - " try:\n", - " from quantui import PySCFCalculation\n", - " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", - " notes = calc.get_educational_notes()\n", - " if notes:\n", - " safe = (\n", - " notes\n", - " .replace(\"**\", \"\", 1)\n", - " .replace(\"**\", \"\", 1)\n", - " .replace(\"\\n\\n\", \"

\")\n", - " )\n", - " with notes_output:\n", - " display(HTML(\n", - " '
'\n", - " + safe + \"
\"\n", - " ))\n", - " except Exception:\n", - " pass\n", - "\n", - "method_dd.observe(_update_notes, names=\"value\")\n", - "basis_dd.observe(_update_notes, names=\"value\")\n", - "\n", - "\n", - "def _update_estimate(change=None):\n", - " '''Refresh the time-estimate label based on current molecule + method/basis.'''\n", - " mol = _state.get(\"molecule\")\n", - " if mol is None:\n", - " perf_estimate_html.value = \"\"\n", - " return\n", - " try:\n", - " est = _calc_log.estimate_time(\n", - " n_atoms=len(mol.atoms),\n", - " n_electrons=mol.get_electron_count(),\n", - " method=method_dd.value,\n", - " basis=basis_dd.value,\n", - " )\n", - " perf_estimate_html.value = _calc_log.format_estimate(est)\n", - " except Exception:\n", - " perf_estimate_html.value = \"\"\n", - "\n", - "method_dd.observe(_update_estimate, names=\"value\")\n", - "basis_dd.observe(_update_estimate, names=\"value\")\n", - "\n", - "# Log startup event\n", - "_calc_log.log_event(\"startup\", f\"QuantUI-local {quantui.__version__} started\")\n", - "\n", - "\n", - "def _do_accumulate(btn):\n", - " r = _state[\"last_result\"]\n", - " if r is None:\n", - " return\n", - " _state[\"results\"].append(r)\n", - " _refresh_comparison()\n", - "\n", - "accumulate_btn.on_click(_do_accumulate)\n", - "\n", - "\n", - "def _refresh_comparison():\n", - " from quantui import summary_from_session_result, comparison_table_html\n", - " comparison_output.clear_output(wait=True)\n", - " results = _state[\"results\"]\n", - " if not results:\n", - " return\n", - " summaries = [summary_from_session_result(r) for r in results]\n", - " with comparison_output:\n", - " display(HTML(comparison_table_html(summaries)))\n", - " if len(summaries) > 1:\n", - " try:\n", - " from quantui import plot_comparison\n", - " plot_comparison(summaries)\n", - " except Exception:\n", - " pass\n", - "\n", - "\n", - "def _do_clear(btn):\n", - " _state[\"results\"].clear()\n", - " comparison_output.clear_output()\n", - "\n", - "clear_btn.on_click(_do_clear)\n", - "\n", - "\n", - "def _do_export(btn):\n", - " mol = _state[\"molecule\"]\n", - " if mol is None:\n", - " export_status.value = \"Load a molecule first.\"\n", - " return\n", - " try:\n", - " from quantui import PySCFCalculation\n", - " from pathlib import Path\n", - " calc = PySCFCalculation(mol, method=method_dd.value, basis=basis_dd.value)\n", - " fname = f\"{mol.get_formula()}_{method_dd.value}_{basis_dd.value}.py\"\n", - " calc.generate_calculation_script(Path(fname))\n", - " export_status.value = f\"Saved: {fname}\"\n", - " except Exception as exc:\n", - " export_status.value = f\"Error: {exc}\"\n", - "\n", - "export_btn.on_click(_do_export)\n", - "\n", - "\n", - "def _on_method_help(btn):\n", - " _show_help_topic(\"method\")\n", - "\n", - "def _on_basis_help(btn):\n", - " _show_help_topic(\"basis_set\")\n", - "\n", - "method_help_btn.on_click(_on_method_help)\n", - "basis_help_btn.on_click(_on_basis_help)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# ── Preset Library ─────────────────────────────────────────────────────────\n", - "_preset_opts = [\"(select a molecule)\"] + list(MOLECULE_LIBRARY.keys())\n", - "preset_dd = widgets.Dropdown(\n", - " options=_preset_opts, value=\"(select a molecule)\",\n", - " description=\"Molecule:\", style={\"description_width\": \"90px\"},\n", - " layout=widgets.Layout(width=\"320px\"),\n", - ")\n", - "\n", - "def _load_preset(change):\n", - " name = change[\"new\"]\n", - " if name.startswith(\"(\"):\n", - " return\n", - " d = MOLECULE_LIBRARY[name]\n", - " _set_molecule(\n", - " Molecule(\n", - " atoms=d[\"atoms\"], coordinates=d[\"coordinates\"],\n", - " charge=d[\"charge\"], multiplicity=d[\"multiplicity\"],\n", - " ),\n", - " d[\"description\"],\n", - " )\n", - "\n", - "preset_dd.observe(_load_preset, names=\"value\")\n", - "\n", - "# ── XYZ Input ──────────────────────────────────────────────────────────────\n", - "xyz_area = widgets.Textarea(\n", - " placeholder=(\n", - " \"Paste XYZ coordinates (symbol x y z):\\n\"\n", - " \"O 0.000 0.000 0.000\\n\"\n", - " \"H 0.757 0.587 0.000\\n\"\n", - " \"H -0.757 0.587 0.000\"\n", - " ),\n", - " layout=widgets.Layout(width=\"440px\", height=\"130px\"),\n", - ")\n", - "xyz_btn = widgets.Button(description=\"Load XYZ\", button_style=\"info\", icon=\"upload\")\n", - "xyz_msg = widgets.Label()\n", - "\n", - "def _load_xyz(btn):\n", - " try:\n", - " mol = parse_xyz_input(xyz_area.value.strip())\n", - " _set_molecule(mol, \"Loaded from XYZ input\")\n", - " xyz_msg.value = \"\"\n", - " except Exception as exc:\n", - " xyz_msg.value = f\"Parse error: {exc}\"\n", - "\n", - "xyz_btn.on_click(_load_xyz)\n", - "\n", - "# ── PubChem Search ─────────────────────────────────────────────────────────\n", - "pubchem_txt = widgets.Text(\n", - " placeholder=\"name or SMILES (e.g. aspirin, caffeine, CC(=O)O)\",\n", - " layout=widgets.Layout(width=\"380px\"),\n", - ")\n", - "pubchem_btn = widgets.Button(\n", - " description=\"Search\", button_style=\"info\", icon=\"search\",\n", - " disabled=not PUBCHEM_AVAILABLE,\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "pubchem_msg = widgets.Label(\n", - " value=\"\" if PUBCHEM_AVAILABLE else \"PubChem unavailable — check internet connection\"\n", - ")\n", - "\n", - "def _search_pubchem(btn):\n", - " query = pubchem_txt.value.strip()\n", - " if not query:\n", - " pubchem_msg.value = \"Enter a molecule name or SMILES.\"\n", - " return\n", - " if student_friendly_fetch is None:\n", - " pubchem_msg.value = \"PubChem module not available.\"\n", - " return\n", - " pubchem_msg.value = f'Searching for \"{query}\"...'\n", - " pubchem_btn.disabled = True\n", - "\n", - " def _do():\n", - " try:\n", - " mol = student_friendly_fetch(query)\n", - " _set_molecule(mol, f\"PubChem: {query}\")\n", - " pubchem_msg.value = f\"Loaded {mol.get_formula()} from PubChem.\"\n", - " except Exception as exc:\n", - " pubchem_msg.value = f\"Not found: {exc}\"\n", - " finally:\n", - " pubchem_btn.disabled = False\n", - "\n", - " threading.Thread(target=_do, daemon=True).start()\n", - "\n", - "pubchem_btn.on_click(_search_pubchem)\n", - "\n", - "# ── Assemble input tab ─────────────────────────────────────────────────────\n", - "_hint = '

'\n", - "tab_preset = widgets.VBox([\n", - " widgets.HTML(_hint + \"Choose from 20+ curated educational molecules.

\"),\n", - " preset_dd,\n", - "])\n", - "tab_xyz = widgets.VBox([\n", - " widgets.HTML(_hint + \"Paste XYZ coordinates (element x y z, one atom per line).

\"),\n", - " xyz_area,\n", - " widgets.HBox([xyz_btn, xyz_msg]),\n", - "])\n", - "tab_pubchem = widgets.VBox([\n", - " widgets.HTML(_hint + \"Search by name or SMILES. Requires internet connection.

\"),\n", - " widgets.HBox([pubchem_txt, pubchem_btn]),\n", - " pubchem_msg,\n", - "])\n", - "\n", - "input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])\n", - "for _i, _t in enumerate([\"Preset Library\", \"XYZ Input\", \"PubChem Search\"]):\n", - " input_tab.set_title(_i, _t)\n", - "\n", - "# ── Collapsible molecule input container ──────────────────────────────────\n", - "mol_input_expanded = widgets.VBox([\n", - " widgets.HTML('

Molecule Input

'),\n", - " input_tab,\n", - "])\n", - "\n", - "# Change button — re-expands the input panel\n", - "change_mol_btn = widgets.Button(\n", - " description=\"Change\", button_style=\"\", icon=\"pencil\",\n", - " layout=widgets.Layout(width=\"100px\", height=\"32px\"),\n", - " tooltip=\"Re-expand the molecule input panel\",\n", - ")\n", - "\n", - "# Collapsed view: compact summary pill + Change button\n", - "mol_input_collapsed = widgets.HBox(\n", - " [mol_summary_compact, change_mol_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"12px\", padding=\"6px 0\"),\n", - ")\n", - "\n", - "# Container starts expanded; _set_molecule() collapses it after first load.\n", - "mol_input_container = widgets.VBox(\n", - " [mol_input_expanded, mol_info_html, viz_output],\n", - " layout=widgets.Layout(margin=\"0 0 4px 0\"),\n", - ")\n", - "\n", - "def _expand_mol_input(btn):\n", - " mol_input_container.children = [mol_input_expanded, mol_info_html, viz_output]\n", - "\n", - "change_mol_btn.on_click(_expand_mol_input)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "calc_setup_panel = widgets.VBox([\n", - " widgets.HTML('

Calculation Setup

'),\n", - " widgets.HBox([\n", - " widgets.VBox([\n", - " widgets.HBox(\n", - " [method_dd, method_help_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", - " ),\n", - " widgets.HBox(\n", - " [basis_dd, basis_help_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"4px\"),\n", - " ),\n", - " ]),\n", - " widgets.HTML(\"  \"),\n", - " widgets.VBox([charge_si, mult_si]),\n", - " ]),\n", - " calc_type_dd,\n", - " calc_extra_opts,\n", - " preopt_cb,\n", - " notes_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "run_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

Run Calculation

'\n", - " '

PySCF runs in this kernel. '\n", - " \"Output appears live below. Large molecules or high-accuracy basis sets may take \"\n", - " \"several minutes on a laptop.

\"\n", - " ),\n", - " perf_estimate_html,\n", - " widgets.HBox([run_btn, run_status]),\n", - " widgets.HBox(\n", - " [\n", - " widgets.HTML(\n", - " ''\n", - " \"Calculation Output\"\n", - " ),\n", - " log_clear_btn,\n", - " ],\n", - " layout=widgets.Layout(align_items=\"center\", justify_content=\"space-between\",\n", - " margin=\"10px 0 4px\", max_width=\"460px\"),\n", - " ),\n", - " run_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "results_panel = widgets.VBox([\n", - " widgets.HTML('

Results

'),\n", - " result_output,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "\n", - "# ── Past Results browser ──────────────────────────────────────────────────────\n", - "past_dd = widgets.Dropdown(\n", - " description=\"Load:\",\n", - " options=[(\"(no saved results)\", \"\")],\n", - " style={\"description_width\": \"50px\"},\n", - " layout=widgets.Layout(width=\"500px\"),\n", - ")\n", - "past_refresh_btn = widgets.Button(\n", - " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - " tooltip=\"Rescan the results directory\",\n", - ")\n", - "copy_path_btn = widgets.Button(\n", - " description=\"Copy path\", button_style=\"\", icon=\"clipboard\",\n", - " layout=widgets.Layout(width=\"120px\"),\n", - " tooltip=\"Copy the results directory path to clipboard\",\n", - ")\n", - "results_path_lbl = widgets.HTML()\n", - "past_output = widgets.Output()\n", - "\n", - "\n", - "def _get_results_dir() -> Path:\n", - " from quantui.results_storage import _default_results_dir\n", - " return _default_results_dir().resolve()\n", - "\n", - "\n", - "def _format_past_result(data: dict) -> str:\n", - " _conv = \"Yes\" if data.get(\"converged\") else \"No (treat results with caution)\"\n", - " _cc = \"green\" if data.get(\"converged\") else \"#c00\"\n", - " _gap = (\n", - " f\"{data['homo_lumo_gap_ev']:.4f} eV\"\n", - " if data.get(\"homo_lumo_gap_ev\") is not None else \"N/A\"\n", - " )\n", - " _rows = \"\".join(\n", - " f''\n", - " f'{k}'\n", - " f'{v}'\n", - " f\"\"\n", - " for k, v, vc in [\n", - " (\"Total energy\",\n", - " f\"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)\", \"#000\"),\n", - " (\"HOMO-LUMO gap\", _gap, \"#000\"),\n", - " (\"SCF converged\", _conv, _cc),\n", - " (\"SCF iterations\", str(data.get(\"n_iterations\", \"?\")), \"#000\"),\n", - " ]\n", - " )\n", - " ts = data.get(\"timestamp\", \"\")\n", - " return (\n", - " f'
'\n", - " f'{data[\"formula\"]} — {data[\"method\"]}/{data[\"basis\"]}'\n", - " f' {ts}'\n", - " f''\n", - " f\"{_rows}
\"\n", - " )\n", - "\n", - "\n", - "def _refresh_results_browser():\n", - " '''Repopulate the past-results dropdown. Called on startup and after each run.'''\n", - " try:\n", - " from quantui import list_results, load_result\n", - " except ImportError:\n", - " return\n", - " results_path_lbl.value = f'{_get_results_dir()}'\n", - " dirs = list_results()\n", - " if not dirs:\n", - " past_dd.options = [(\"(no saved results)\", \"\")]\n", - " return\n", - " options = []\n", - " for d in dirs:\n", - " try:\n", - " data = load_result(d)\n", - " ts = data.get(\"timestamp\", d.name)\n", - " label = f\"{ts} · {data['formula']} {data['method']}/{data['basis']}\"\n", - " options.append((label, str(d)))\n", - " except Exception:\n", - " pass\n", - " past_dd.options = options if options else [(\"(no saved results)\", \"\")]\n", - "\n", - "\n", - "def _load_past_result(change):\n", - " path_str = change[\"new\"]\n", - " if not path_str:\n", - " past_output.clear_output()\n", - " return\n", - " past_output.clear_output(wait=True)\n", - " try:\n", - " from quantui import load_result\n", - " data = load_result(Path(path_str))\n", - " past_output.append_display_data(HTML(_format_past_result(data)))\n", - " except Exception as exc:\n", - " past_output.append_stdout(f\"Could not load result: {exc}\\n\")\n", - "\n", - "\n", - "def _copy_results_path(btn):\n", - " '''Copy the results directory path to clipboard via browser JS.'''\n", - " p = _get_results_dir()\n", - " p.mkdir(parents=True, exist_ok=True)\n", - " path_str = str(p).replace(\"\\\\\", \"\\\\\\\\\").replace(\"'\", \"\\\\'\")\n", - " from IPython.display import Javascript\n", - " display(Javascript(f\"navigator.clipboard.writeText(\\'{path_str}\\')\"))\n", - " # Brief confirmation in the label\n", - " import threading\n", - " results_path_lbl.value = (\n", - " f'Copied: {p}'\n", - " )\n", - " def _reset():\n", - " import time; time.sleep(3)\n", - " results_path_lbl.value = f'{p}'\n", - " threading.Thread(target=_reset, daemon=True).start()\n", - "\n", - "\n", - "view_log_btn = widgets.Button(\n", - " description=\"View log\",\n", - " button_style=\"\",\n", - " icon=\"file-text-o\",\n", - " layout=widgets.Layout(width=\"110px\"),\n", - " tooltip=\"Open the full PySCF output log for this result in the Output tab\",\n", - ")\n", - "\n", - "\n", - "def _view_log(btn):\n", - " path_str = past_dd.value\n", - " if not path_str:\n", - " return\n", - " log_path = Path(path_str) / \"pyscf.log\"\n", - " if log_path.exists():\n", - " text = log_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n", - " label = Path(path_str).name\n", - " else:\n", - " text = \"(No pyscf.log found for this result.)\"\n", - " label = \"\"\n", - " _update_log_panel(text, label)\n", - " _goto_output_tab()\n", - "\n", - "\n", - "past_dd.observe(_load_past_result, names=\"value\")\n", - "past_refresh_btn.on_click(lambda _: _refresh_results_browser())\n", - "copy_path_btn.on_click(_copy_results_path)\n", - "view_log_btn.on_click(_view_log)\n", - "\n", - "# Populate on startup and make the real function visible to _do_run in Cell 3.\n", - "_refresh_results_browser()\n", - "\n", - "# ── Performance stats widgets ───────────────────────────────────────────────────────────────────\n", - "_perf_stats_html = widgets.HTML()\n", - "_perf_events_html = widgets.HTML()\n", - "\n", - "_reset_btn = widgets.Button(\n", - " description=\"Reset performance database\",\n", - " button_style=\"danger\",\n", - " icon=\"trash\",\n", - " layout=widgets.Layout(width=\"230px\"),\n", - ")\n", - "_reset_confirm_html = widgets.HTML(\n", - " ''\n", - " \"Warning: This will permanently delete all performance records. \"\n", - " \"Time estimates will reset to “no data”.\"\n", - ")\n", - "_reset_confirm_yes = widgets.Button(\n", - " description=\"Yes, delete all records\",\n", - " button_style=\"danger\",\n", - " icon=\"check\",\n", - " layout=widgets.Layout(width=\"190px\"),\n", - ")\n", - "_reset_confirm_no = widgets.Button(\n", - " description=\"Cancel\",\n", - " button_style=\"\",\n", - " icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\"),\n", - ")\n", - "_reset_confirm_box = widgets.VBox(\n", - " [\n", - " _reset_confirm_html,\n", - " widgets.HBox(\n", - " [_reset_confirm_yes, _reset_confirm_no],\n", - " layout=widgets.Layout(gap=\"8px\", margin=\"4px 0 0\"),\n", - " ),\n", - " ],\n", - " layout=widgets.Layout(\n", - " display=\"none\",\n", - " border=\"1px solid #fca5a5\",\n", - " padding=\"8px 10px\",\n", - " margin=\"6px 0 0\",\n", - " ),\n", - ")\n", - "\n", - "\n", - "def _build_perf_stats_html() -> str:\n", - " from quantui.calc_log import get_perf_history\n", - " records = get_perf_history()\n", - " if not records:\n", - " return (\n", - " ''\n", - " \"No performance data recorded yet.\"\n", - " )\n", - " groups: dict = {}\n", - " for r in records:\n", - " key = (r.get(\"method\", \"?\"), r.get(\"basis\", \"?\"))\n", - " groups.setdefault(key, []).append(r)\n", - " rows = \"\"\n", - " for (meth, bas), recs in sorted(groups.items()):\n", - " times = [r[\"elapsed_s\"] for r in recs if \"elapsed_s\" in r]\n", - " n = len(recs)\n", - " if times:\n", - " avg = sum(times) / len(times)\n", - " rows += (\n", - " \"\"\n", - " f'{meth}'\n", - " f'{bas}'\n", - " f'{n}'\n", - " f'{avg:.1f} s'\n", - " f'{min(times):.1f} s'\n", - " f'{max(times):.1f} s'\n", - " \"\"\n", - " )\n", - " header = (\n", - " \"\"\n", - " 'Method'\n", - " 'Basis'\n", - " 'Runs'\n", - " 'Avg'\n", - " 'Min'\n", - " 'Max'\n", - " \"\"\n", - " )\n", - " return (\n", - " ''\n", - " f\"{header}{rows}
\"\n", - " )\n", - "\n", - "\n", - "def _build_events_html() -> str:\n", - " from quantui.calc_log import get_recent_events\n", - " events = get_recent_events(20)\n", - " if not events:\n", - " return (\n", - " ''\n", - " \"No events recorded yet.\"\n", - " )\n", - " rows = \"\"\n", - " for e in reversed(events):\n", - " ts = e.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n", - " evt = e.get(\"event\", \"\")\n", - " msg = e.get(\"message\", \"\")\n", - " rows += (\n", - " \"\"\n", - " f'{ts}'\n", - " f'{evt}'\n", - " f'{msg}'\n", - " \"\"\n", - " )\n", - " return (\n", - " ''\n", - " f\"{rows}
\"\n", - " )\n", - "\n", - "\n", - "def _refresh_perf_stats():\n", - " _perf_stats_html.value = _build_perf_stats_html()\n", - " _perf_events_html.value = _build_events_html()\n", - "\n", - "\n", - "def _on_reset_click(btn):\n", - " _reset_confirm_box.layout.display = \"\"\n", - "\n", - "\n", - "def _on_confirm_yes(btn):\n", - " from quantui.calc_log import reset_perf_log\n", - " reset_perf_log()\n", - " _reset_confirm_box.layout.display = \"none\"\n", - " _refresh_perf_stats()\n", - "\n", - "\n", - "def _on_confirm_no(btn):\n", - " _reset_confirm_box.layout.display = \"none\"\n", - "\n", - "\n", - "_reset_btn.on_click(_on_reset_click)\n", - "_reset_confirm_yes.on_click(_on_confirm_yes)\n", - "_reset_confirm_no.on_click(_on_confirm_no)\n", - "_refresh_perf_stats()\n", - "\n", - "_perf_stats_panel = widgets.VBox([\n", - " _perf_stats_html,\n", - " widgets.HTML(\n", - " '

'\n", - " \"Recent events (last 20)

\"\n", - " ),\n", - " _perf_events_html,\n", - " widgets.HBox(\n", - " [_reset_btn],\n", - " layout=widgets.Layout(margin=\"14px 0 4px\"),\n", - " ),\n", - " _reset_confirm_box,\n", - "])\n", - "\n", - "_perf_accordion = widgets.Accordion(children=[_perf_stats_panel], selected_index=None)\n", - "_perf_accordion.set_title(0, \"Performance stats\")\n", - "\n", - "# ── History panel (assembled widget for the History tab) ─────────────────\n", - "history_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

'\n", - " \"Calculations are saved automatically. Select one below to view its results.

\"\n", - " ),\n", - " widgets.HBox(\n", - " [past_dd, past_refresh_btn, copy_path_btn, view_log_btn],\n", - " layout=widgets.Layout(align_items=\"center\", gap=\"8px\"),\n", - " ),\n", - " results_path_lbl,\n", - " past_output,\n", - " _perf_accordion,\n", - "])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "# ── Compare tab widgets ────────────────────────────────────────────────────────\n", - "compare_select = widgets.SelectMultiple(\n", - " options=[(\"(no saved results)\", \"\")],\n", - " rows=8,\n", - " description=\"\",\n", - " layout=widgets.Layout(width=\"100%\"),\n", - ")\n", - "compare_refresh_btn = widgets.Button(\n", - " description=\"Refresh\", button_style=\"\", icon=\"refresh\",\n", - " layout=widgets.Layout(width=\"100px\"),\n", - ")\n", - "compare_btn = widgets.Button(\n", - " description=\"Compare selected\", button_style=\"primary\", icon=\"bar-chart\",\n", - " disabled=True,\n", - " layout=widgets.Layout(width=\"180px\"),\n", - ")\n", - "compare_clear_btn = widgets.Button(\n", - " description=\"Clear\", button_style=\"warning\", icon=\"times\",\n", - " layout=widgets.Layout(width=\"90px\"),\n", - ")\n", - "compare_output = widgets.Output()\n", - "\n", - "\n", - "def _populate_compare_list():\n", - " '''Repopulate compare_select with all saved results.'''\n", - " from quantui.results_storage import list_results, load_result\n", - " dirs = list_results()\n", - " if not dirs:\n", - " compare_select.options = [(\"(no saved results)\", \"\")]\n", - " compare_btn.disabled = True\n", - " return\n", - " options = []\n", - " for d in dirs:\n", - " try:\n", - " data = load_result(d)\n", - " ts = data.get(\"timestamp\", d.name[:19])\n", - " label = f\"{ts} {data['formula']} {data['method']}/{data['basis']}\"\n", - " options.append((label, str(d)))\n", - " except Exception:\n", - " options.append((d.name, str(d)))\n", - " compare_select.options = options\n", - " compare_btn.disabled = False\n", - "\n", - "\n", - "def _on_compare_refresh(btn):\n", - " _populate_compare_list()\n", - "\n", - "\n", - "def _on_compare(btn):\n", - " selected = compare_select.value # tuple of selected path strings\n", - " if not selected or selected == (\"\",):\n", - " return\n", - " compare_output.clear_output(wait=True)\n", - " from quantui.results_storage import load_result\n", - " from quantui import summary_from_saved_result, comparison_table_html, plot_comparison\n", - " summaries = []\n", - " for path_str in selected:\n", - " if not path_str:\n", - " continue\n", - " try:\n", - " data = load_result(Path(path_str))\n", - " summaries.append(summary_from_saved_result(data))\n", - " except Exception as exc:\n", - " with compare_output:\n", - " display(HTML(f'

Error loading result: {exc}

'))\n", - " if not summaries:\n", - " return\n", - " with compare_output:\n", - " display(HTML(comparison_table_html(summaries)))\n", - " if len(summaries) > 1:\n", - " try:\n", - " import matplotlib.pyplot as plt\n", - " fig = plot_comparison(summaries)\n", - " display(fig)\n", - " plt.close(fig)\n", - " except Exception:\n", - " pass\n", - "\n", - "\n", - "def _on_compare_clear(btn):\n", - " compare_select.value = ()\n", - " compare_output.clear_output()\n", - "\n", - "\n", - "compare_refresh_btn.on_click(_on_compare_refresh)\n", - "compare_btn.on_click(_on_compare)\n", - "compare_clear_btn.on_click(_on_compare_clear)\n", - "\n", - "# Populate on load; expose to Cell-3 placeholder so _do_run refreshes it.\n", - "_populate_compare_list()\n", - "globals()[\"_populate_compare_list\"] = _populate_compare_list\n", - "\n", - "# ── Compare panel (assembled widget for the Compare tab) ──────────────────────\n", - "compare_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

Compare Calculations

'\n", - " '

'\n", - " \"Select two or more saved calculations to compare side-by-side. \"\n", - " \"Hold Ctrl (or ⌘) to select multiple entries.

\"\n", - " ),\n", - " widgets.HBox([compare_refresh_btn]),\n", - " compare_select,\n", - " widgets.HBox(\n", - " [compare_btn, compare_clear_btn],\n", - " layout=widgets.Layout(gap=\"8px\", margin=\"6px 0\"),\n", - " ),\n", - " compare_output,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Advanced accordion (Export only) ──────────────────────────────────────────\n", - "_export_content = widgets.VBox([\n", - " widgets.HTML(\n", - " '

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

\"\n", - " ),\n", - " widgets.HBox([export_btn, export_status]),\n", - "])\n", - "advanced_accordion = widgets.Accordion(children=[_export_content])\n", - "advanced_accordion.set_title(0, \"Export Script\")\n", - "advanced_accordion.selected_index = None # collapsed by default\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "from quantui.help_content import HELP_TOPICS\n", - "\n", - "# ── Help tab content ──────────────────────────────────────────────────────────\n", - "_help_keys = list(HELP_TOPICS.keys())\n", - "_help_labels = [HELP_TOPICS[k][\"title\"] for k in _help_keys]\n", - "\n", - "help_topic_dd = widgets.Dropdown(\n", - " options=list(zip(_help_labels, _help_keys)),\n", - " description=\"Topic:\",\n", - " style={\"description_width\": \"60px\"},\n", - " layout=widgets.Layout(width=\"460px\"),\n", - ")\n", - "help_content_html = widgets.HTML()\n", - "\n", - "def _render_help_topic(change=None):\n", - " key = help_topic_dd.value\n", - " if key and key in HELP_TOPICS:\n", - " entry = HELP_TOPICS[key]\n", - " help_content_html.value = (\n", - " f'
'\n", - " f'

'\n", - " f'{entry[\"title\"]}

'\n", - " f'
'\n", - " f'{entry[\"body\"]}
'\n", - " f'
'\n", - " )\n", - "\n", - "help_topic_dd.observe(_render_help_topic, names=\"value\")\n", - "_render_help_topic() # render first topic on load\n", - "\n", - "help_tab_panel = widgets.VBox([\n", - " widgets.HTML(\n", - " '

'\n", - " \"Browse help topics below. Click ? next to the Method or Basis Set \"\n", - " \"dropdown in the Calculate tab to jump directly to a relevant topic.

\"\n", - " ),\n", - " help_topic_dd,\n", - " help_content_html,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Assemble root tab ─────────────────────────────────────────────────────────\n", - "_calculate_content = widgets.VBox([\n", - " step_progress.widget,\n", - " mol_input_container,\n", - " calc_setup_panel,\n", - " run_panel,\n", - " results_panel,\n", - " advanced_accordion,\n", - "], layout=widgets.Layout(padding=\"8px 0\"))\n", - "\n", - "# ── Output log tab ────────────────────────────────────────────────────────────────\n", - "_log_output_html = widgets.HTML(\n", - " ''\n", - " \"No log yet — run a calculation first, or use \"\n", - " \"View log in the History tab.\"\n", - ")\n", - "_log_source_lbl = widgets.HTML()\n", - "_log_clear_btn = widgets.Button(\n", - " description=\"Clear\",\n", - " button_style=\"\",\n", - " icon=\"times\",\n", - " layout=widgets.Layout(width=\"80px\"),\n", - ")\n", - "\n", - "\n", - "def _render_log(text: str, source_label: str = \"\") -> None:\n", - " # Render text as syntax-highlighted HTML in the log panel.\n", - " import html as _html_mod\n", - " lines = text.splitlines()\n", - " rows = []\n", - " for line in lines:\n", - " esc = _html_mod.escape(line)\n", - " if \"converged SCF energy\" in line or \"SCF converged\" in line:\n", - " style = \"color:#16a34a;font-weight:600\"\n", - " elif \"cycle=\" in line and \"E=\" in line:\n", - " style = \"color:#475569\"\n", - " elif \"HOMO\" in line or \"LUMO\" in line:\n", - " style = \"color:#2563eb\"\n", - " elif \"Warning\" in line or \"warning\" in line:\n", - " style = \"color:#d97706\"\n", - " elif \"Error\" in line or \"error\" in line or \"failed\" in line:\n", - " style = \"color:#dc2626\"\n", - " else:\n", - " style = \"color:#1e293b\"\n", - " rows.append(f'
{esc}
')\n", - " _log_output_html.value = (\n", - " '
'\n", - " + \"\".join(rows)\n", - " + \"
\"\n", - " )\n", - " _log_source_lbl.value = (\n", - " f'Source: {source_label}'\n", - " if source_label\n", - " else \"\"\n", - " )\n", - "\n", - "\n", - "def _update_log_panel(log_text: str, label: str = \"\") -> None:\n", - " _render_log(log_text, label)\n", - "\n", - "\n", - "def _goto_output_tab() -> None:\n", - " root_tab.selected_index = 3\n", - "\n", - "\n", - "def _clear_log(_):\n", - " _log_output_html.value = (\n", - " 'Log cleared.'\n", - " )\n", - " _log_source_lbl.value = \"\"\n", - "\n", - "\n", - "_log_clear_btn.on_click(_clear_log)\n", - "\n", - "log_tab_panel = widgets.VBox(\n", - " [\n", - " widgets.HTML(\n", - " '

'\n", - " \"Full PySCF output for the most recent calculation. \"\n", - " \"Use View log in the History tab to load a saved result's log.

\"\n", - " ),\n", - " widgets.HBox(\n", - " [_log_clear_btn],\n", - " layout=widgets.Layout(margin=\"0 0 8px\"),\n", - " ),\n", - " _log_source_lbl,\n", - " _log_output_html,\n", - " ],\n", - " layout=widgets.Layout(padding=\"8px 0\"),\n", - ")\n", - "\n", - "# Overwrite Cell-3 placeholders so later widgets can be reached.\n", - "globals()[\"_update_log_panel\"] = _update_log_panel\n", - "globals()[\"_goto_output_tab\"] = _goto_output_tab\n", - "\n", - "root_tab = widgets.Tab(children=[\n", - " _calculate_content,\n", - " history_panel,\n", - " compare_panel,\n", - " log_tab_panel,\n", - " help_tab_panel,\n", - "])\n", - "root_tab.set_title(0, \"Calculate\")\n", - "root_tab.set_title(1, \"History\")\n", - "root_tab.set_title(2, \"Compare\")\n", - "root_tab.set_title(3, \"Output\")\n", - "root_tab.set_title(4, \"Help\")\n", - "\n", - "# ── \"?\" button callbacks: jump to Help tab with specific topic ────────────────\n", - "def _show_help_topic(topic: str) -> None:\n", - " if topic in HELP_TOPICS:\n", - " help_topic_dd.value = topic\n", - " root_tab.selected_index = 4\n", - "\n", - "# Overwrite Cell-3 placeholder so \"?\" buttons find the real function.\n", - "globals()[\"_show_help_topic\"] = _show_help_topic\n", - "\n", - "# ── Single root display call ──────────────────────────────────────────────────\n", - "display(root_tab)\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/quantui/app.py b/quantui/app.py index 64f8484..2347b59 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -82,7 +82,7 @@ _PREOPT_AVAILABLE = False # ── Module-level constants ──────────────────────────────────────────────────── -_THEME_HUE: dict = {"Dark": 180, "Dark Blue": 200, "Dark Maroon": 160} +_THEME_HUE: dict = {"Dark": 180} _APP_CSS: str = """