From 451fe67a4a01cb5bffed235b421251f38d37d827 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 11:53:50 -0400
Subject: [PATCH 01/22] Extract result formatters into new module
Move HTML result-card formatting helpers out of quantui/app.py into a new quantui/app_formatters.py and update app.py to import and delegate to these functions. Add unit tests (tests/test_app_formatters.py) to verify each formatter. This separates presentation logic for easier testing and maintenance without changing formatter behaviour.
---
quantui/app.py | 383 +++-------------------------------
quantui/app_formatters.py | 386 +++++++++++++++++++++++++++++++++++
tests/test_app_formatters.py | 163 +++++++++++++++
3 files changed, 577 insertions(+), 355 deletions(-)
create mode 100644 quantui/app_formatters.py
create mode 100644 tests/test_app_formatters.py
diff --git a/quantui/app.py b/quantui/app.py
index f6aafd6..4a48848 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -33,6 +33,27 @@
import quantui
import quantui.calc_log as _calc_log
import quantui.issue_tracker as _issue_tracker
+from quantui.app_formatters import (
+ format_freq_result as _fmt_freq_result,
+)
+from quantui.app_formatters import (
+ format_nmr_result as _fmt_nmr_result,
+)
+from quantui.app_formatters import (
+ format_opt_result as _fmt_opt_result,
+)
+from quantui.app_formatters import (
+ format_past_result as _fmt_past_result,
+)
+from quantui.app_formatters import (
+ format_pes_scan_result as _fmt_pes_scan_result,
+)
+from quantui.app_formatters import (
+ format_result as _fmt_result,
+)
+from quantui.app_formatters import (
+ format_tddft_result as _fmt_tddft_result,
+)
# Import directly from submodules to avoid circular-import issues.
# quantui/__init__.py imports this module (app.py), so using
@@ -5717,297 +5738,22 @@ def _build_events_html(self) -> str:
# ══ RESULT FORMATTERS ════════════════════════════════════════════════════
def _format_result(self, r) -> str:
- _conv = "Yes" if r.converged else "No (treat results with caution)"
- _cc = "green" if r.converged else "#c00"
- _gap = (
- f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A"
- )
- _rows = "".join(
- f""
- f'{k} '
- f'{v} '
- f" "
- for k, v, vc in [
- (
- "Total energy",
- f"{r.energy_hartree:.8f} Ha ({r.energy_ev:.4f} eV)",
- "#000",
- ),
- ("HOMO-LUMO gap", _gap, "#000"),
- ("SCF converged", _conv, _cc),
- (
- "SCF iterations",
- (
- "—"
- if getattr(r, "n_iterations", None) in (None, -1)
- else str(r.n_iterations)
- ),
- "#000",
- ),
- ]
- )
- _extra = ""
- # MP2: show HF reference energy separately
- _mp2_corr = getattr(r, "mp2_correlation_hartree", None)
- if _mp2_corr is not None:
- _hf_e = r.energy_hartree - _mp2_corr
- _extra += (
- f'HF reference '
- f'{_hf_e:.8f} Ha '
- f'MP2 correlation '
- f'{_mp2_corr:.8f} Ha '
- )
- _solvent = getattr(r, "solvent", None)
- if _solvent is not None:
- _extra += (
- f'Solvent (PCM) '
- f'{_solvent} '
- )
- _dip = getattr(r, "dipole_moment_debye", None)
- if _dip is not None:
- _extra += (
- f'Dipole moment '
- f'{_dip:.4f} D '
- )
- _chg = getattr(r, "mulliken_charges", None)
- _syms = getattr(r, "atom_symbols", None)
- if _chg is not None and _syms is not None:
- _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg))
- _extra += (
- f''
- f"Mulliken charges "
- f'{_charge_str} '
- )
- return (
- f''
- f"
{r.formula} — {r.method}/{r.basis} "
- f'
"
- )
+ return _fmt_result(r)
def _format_opt_result(self, r) -> str:
- _conv = "Yes" if r.converged else "No (max steps reached)"
- _cc = "green" if r.converged else "#c00"
- _rows = "".join(
- f""
- f'{k} '
- f'{v} '
- f" "
- for k, v, vc in [
- ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"),
- ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"),
- ("Opt converged", _conv, _cc),
- ("Steps taken", str(r.n_steps), "#000"),
- ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"),
- ]
- )
- return (
- f''
- f"
Geometry Optimisation — {r.formula} ({r.method}/{r.basis}) "
- f'
"
- )
+ return _fmt_opt_result(r)
def _format_freq_result(self, r) -> str:
- _conv = "Yes" if r.converged else "No (treat with caution)"
- _cc = "green" if r.converged else "#c00"
- n_real = r.n_real_modes()
- n_imag = r.n_imaginary_modes()
- real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]
- freq_str = " ".join(f"{f:.1f}" for f in real_freqs)
- if len([f for f in r.frequencies_cm1 if f > 0]) > 6:
- freq_str += " …"
- imag_note = ""
- if n_imag > 0:
- imag_note = (
- f'Imaginary modes '
- f'{n_imag} — geometry may not be a minimum '
- )
- _rows = (
- f'SCF energy '
- f'{r.energy_hartree:.8f} Ha '
- f'SCF converged '
- f'{_conv} '
- f'Real modes '
- f'{n_real} '
- + imag_note
- + (
- f'Frequencies (cm⁻¹) '
- f'{freq_str or "none"} '
- if real_freqs
- else ""
- )
- + f'ZPVE '
- f'{r.zpve_hartree:.6f} Ha '
- f"({r.zpve_hartree * 27.211386245988:.4f} eV) "
- )
- _thermo_rows = ""
- _thermo = getattr(r, "thermo", None)
- if _thermo is not None:
- _kj = 2625.5 # kJ/mol per Hartree
- _thermo_rows = (
- f''
- f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —"
- f" "
- f'H (298 K) '
- f'{_thermo.H_hartree:.6f} Ha '
- f'S (298 K) '
- f'{_thermo.S_jmol:.2f} J/(mol·K) '
- f'G (298 K) '
- f'{_thermo.G_hartree:.6f} Ha'
- f" ({_thermo.G_hartree * _kj:.2f} kJ/mol) "
- )
- return (
- f''
- f"
Frequency Analysis — {r.formula} ({r.method}/{r.basis}) "
- f'
'
- f"{_rows}{_thermo_rows}
"
- )
+ return _fmt_freq_result(r)
def _format_tddft_result(self, r) -> str:
- _conv = "Yes" if r.converged else "No (treat with caution)"
- _cc = "green" if r.converged else "#c00"
- header_rows = (
- f'Ground-state energy '
- f'{r.energy_hartree:.8f} Ha '
- f'SCF converged '
- f'{_conv} '
- f'States computed '
- f'{len(r.excitation_energies_ev)} '
- )
- exc_table = ""
- if r.excitation_energies_ev:
- wl = r.wavelengths_nm()
- exc_rows = []
- for i, (e_ev, f_osc) in enumerate(
- zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1
- ):
- bold = "font-weight:bold" if f_osc > 0.05 else ""
- exc_rows.append(
- f''
- f'S{i} '
- f'{e_ev:.3f} eV '
- f'{wl[i - 1]:.1f} nm '
- f'f = {f_osc:.4f} '
- f" "
- )
- if len(r.excitation_energies_ev) > 8:
- exc_rows.append(
- f'… '
- f"and {len(r.excitation_energies_ev) - 8} more states "
- )
- exc_table = (
- ''
- "Vertical excitations: "
- ""
- 'State '
- 'Energy '
- 'λ '
- 'Osc. str. '
- + "".join(exc_rows)
- )
- return (
- f''
- f"
TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis}) "
- f'
'
- f"{header_rows}{exc_table}
"
- )
+ return _fmt_tddft_result(r)
def _format_nmr_result(self, r) -> str:
- _conv = "Yes" if r.converged else "No (treat with caution)"
- _cc = "green" if r.converged else "#c00"
- header_rows = (
- f'SCF converged '
- f'{_conv} '
- f'Reference '
- f'{r.reference_compound} ({r.method}/{r.basis}) '
- )
-
- def _nmr_table(label: str, shifts: list, sym: str) -> str:
- if not shifts:
- return ""
- rows = "".join(
- f""
- f'{sym}-{n} '
- f'{d:.2f} ppm '
- f" "
- for n, (_i, d) in enumerate(shifts, 1)
- )
- return (
- f''
- f"{label} shifts (vs. TMS): "
- f""
- f'Atom '
- f'δ (ppm) '
- + rows
- )
-
- h_table = _nmr_table("¹H", r.h_shifts(), "H")
- c_table = _nmr_table("¹³C", r.c_shifts(), "C")
-
- _basis_warn = ""
- if r.basis.upper() in ("STO-3G", "3-21G"):
- _basis_warn = (
- ''
- ''
- f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better. "
- " "
- )
-
- _empty = ""
- if not r.h_shifts() and not r.c_shifts():
- _empty = (
- ''
- "No ¹H or ¹³C atoms found in this molecule. "
- )
-
- return (
- f''
- f"
NMR Shielding — {r.formula} ({r.method}/{r.basis}) "
- f'
'
- f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}
"
- )
+ return _fmt_nmr_result(r)
def _format_pes_scan_result(self, r) -> str:
- """Format a PESScanResult as an HTML result card."""
- _conv = "Yes" if r.converged_all else "No (some points did not converge)"
- _cc = "green" if r.converged_all else "#c00"
- if r.energies_hartree:
- e_min = min(r.energies_hartree)
- e_max = max(r.energies_hartree)
- barrier_kcal = (e_max - e_min) * 627.509474
- _e_row = (
- f'Min energy '
- f'{e_min:.8f} Ha '
- f'Energy range '
- f'{barrier_kcal:.2f} kcal/mol '
- )
- else:
- _e_row = ""
- _idx_str = "–".join(str(i + 1) for i in r.atom_indices)
- return (
- f''
- f"
PES Scan — {r.formula} ({r.method}/{r.basis}) "
- f'
'
- f'Scan type '
- f'{r.scan_type.capitalize()} ({_idx_str}) '
- f'Range '
- f'{r.scan_parameter_values[0]:.3f} → '
- f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} "
- f"({r.n_steps} points) "
- f"{_e_row}"
- f'All converged '
- f'{_conv} '
- f"
"
- )
+ return _fmt_pes_scan_result(r)
def _show_pes_scan_result(self, result) -> bool:
"""Render the PES energy profile chart.
@@ -6070,80 +5816,7 @@ def _show_pes_scan_result(self, result) -> bool:
return True
def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> str:
- import base64 as _b64
-
- _ct_labels = {
- "single_point": ("Single Point", "#2563eb", "#dbeafe"),
- "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"),
- "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"),
- "tddft": ("TD-DFT", "#b45309", "#fef3c7"),
- "nmr": ("NMR", "#0d9488", "#ccfbf1"),
- "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"),
- }
- ct = data.get("calc_type", "")
- _ct_label, _ct_fg, _ct_bg = _ct_labels.get(
- ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6")
- )
- _ct_badge = (
- f'{_ct_label} '
- )
- _conv = "Yes" if data.get("converged") else "No (treat results with caution)"
- _cc = "green" if data.get("converged") else "#c00"
- _gap = (
- f"{data['homo_lumo_gap_ev']:.4f} eV"
- if data.get("homo_lumo_gap_ev") is not None
- else "N/A"
- )
- _rows = "".join(
- f""
- f'{k} '
- f'{v} '
- f" "
- for k, v, vc in [
- (
- "Total energy",
- f"{data['energy_hartree']:.8f} Ha ({data['energy_ev']:.4f} eV)",
- "#000",
- ),
- ("HOMO-LUMO gap", _gap, "#000"),
- ("SCF converged", _conv, _cc),
- (
- "SCF iterations",
- (
- "—"
- if data.get("n_iterations") in (None, -1)
- else str(data.get("n_iterations"))
- ),
- "#000",
- ),
- ]
- )
- ts = data.get("timestamp", "")
-
- # Embed thumbnail if saved
- _thumb_html = ""
- if result_dir is not None:
- _thumb_path = Path(result_dir) / "thumbnail.png"
- if _thumb_path.exists():
- _img_b64 = _b64.b64encode(_thumb_path.read_bytes()).decode()
- _thumb_html = (
- f' '
- )
-
- return (
- f''
- f"{_thumb_html}"
- f"{_ct_badge}
"
- f'
{data["formula"]} — {data["method"]}/{data["basis"]} '
- f'
{ts} '
- f'
"
- )
+ return _fmt_past_result(data, result_dir=result_dir)
# ══ HELPERS ══════════════════════════════════════════════════════════════
diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py
new file mode 100644
index 0000000..586cbed
--- /dev/null
+++ b/quantui/app_formatters.py
@@ -0,0 +1,386 @@
+"""Result-card HTML formatters used by QuantUIApp."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Optional
+
+
+def format_result(r: Any) -> str:
+ """Format a single-point-style result card."""
+ _conv = "Yes" if r.converged else "No (treat results with caution)"
+ _cc = "green" if r.converged else "#c00"
+ _gap = f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A"
+ _rows = "".join(
+ f""
+ f'{k} '
+ f'{v} '
+ f" "
+ for k, v, vc in [
+ (
+ "Total energy",
+ f"{r.energy_hartree:.8f} Ha ({r.energy_ev:.4f} eV)",
+ "#000",
+ ),
+ ("HOMO-LUMO gap", _gap, "#000"),
+ ("SCF converged", _conv, _cc),
+ (
+ "SCF iterations",
+ (
+ "—"
+ if getattr(r, "n_iterations", None) in (None, -1)
+ else str(r.n_iterations)
+ ),
+ "#000",
+ ),
+ ]
+ )
+ _extra = ""
+ # MP2: show HF reference energy separately
+ _mp2_corr = getattr(r, "mp2_correlation_hartree", None)
+ if _mp2_corr is not None:
+ _hf_e = r.energy_hartree - _mp2_corr
+ _extra += (
+ f'HF reference '
+ f'{_hf_e:.8f} Ha '
+ f'MP2 correlation '
+ f'{_mp2_corr:.8f} Ha '
+ )
+ _solvent = getattr(r, "solvent", None)
+ if _solvent is not None:
+ _extra += (
+ f'Solvent (PCM) '
+ f'{_solvent} '
+ )
+ _dip = getattr(r, "dipole_moment_debye", None)
+ if _dip is not None:
+ _extra += (
+ f'Dipole moment '
+ f'{_dip:.4f} D '
+ )
+ _chg = getattr(r, "mulliken_charges", None)
+ _syms = getattr(r, "atom_symbols", None)
+ if _chg is not None and _syms is not None:
+ _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg))
+ _extra += (
+ f''
+ f"Mulliken charges "
+ f'{_charge_str} '
+ )
+ return (
+ f''
+ f"
{r.formula} — {r.method}/{r.basis} "
+ f'
"
+ )
+
+
+def format_opt_result(r: Any) -> str:
+ """Format a geometry-optimization result card."""
+ _conv = "Yes" if r.converged else "No (max steps reached)"
+ _cc = "green" if r.converged else "#c00"
+ _rows = "".join(
+ f""
+ f'{k} '
+ f'{v} '
+ f" "
+ for k, v, vc in [
+ ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"),
+ ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"),
+ ("Opt converged", _conv, _cc),
+ ("Steps taken", str(r.n_steps), "#000"),
+ ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"),
+ ]
+ )
+ return (
+ f''
+ f"
Geometry Optimisation — {r.formula} ({r.method}/{r.basis}) "
+ f'
"
+ )
+
+
+def format_freq_result(r: Any) -> str:
+ """Format a frequency-analysis result card."""
+ _conv = "Yes" if r.converged else "No (treat with caution)"
+ _cc = "green" if r.converged else "#c00"
+ n_real = r.n_real_modes()
+ n_imag = r.n_imaginary_modes()
+ real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]
+ freq_str = " ".join(f"{f:.1f}" for f in real_freqs)
+ if len([f for f in r.frequencies_cm1 if f > 0]) > 6:
+ freq_str += " …"
+ imag_note = ""
+ if n_imag > 0:
+ imag_note = (
+ f'Imaginary modes '
+ f'{n_imag} — geometry may not be a minimum '
+ )
+ _rows = (
+ f'SCF energy '
+ f'{r.energy_hartree:.8f} Ha '
+ f'SCF converged '
+ f'{_conv} '
+ f'Real modes '
+ f'{n_real} '
+ + imag_note
+ + (
+ f'Frequencies (cm⁻¹) '
+ f'{freq_str or "none"} '
+ if real_freqs
+ else ""
+ )
+ + f'ZPVE '
+ f'{r.zpve_hartree:.6f} Ha '
+ f"({r.zpve_hartree * 27.211386245988:.4f} eV) "
+ )
+ _thermo_rows = ""
+ _thermo = getattr(r, "thermo", None)
+ if _thermo is not None:
+ _kj = 2625.5 # kJ/mol per Hartree
+ _thermo_rows = (
+ f''
+ f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —"
+ f" "
+ f'H (298 K) '
+ f'{_thermo.H_hartree:.6f} Ha '
+ f'S (298 K) '
+ f'{_thermo.S_jmol:.2f} J/(mol·K) '
+ f'G (298 K) '
+ f'{_thermo.G_hartree:.6f} Ha'
+ f" ({_thermo.G_hartree * _kj:.2f} kJ/mol) "
+ )
+ return (
+ f''
+ f"
Frequency Analysis — {r.formula} ({r.method}/{r.basis}) "
+ f'
'
+ f"{_rows}{_thermo_rows}
"
+ )
+
+
+def format_tddft_result(r: Any) -> str:
+ """Format a TD-DFT / UV-Vis result card."""
+ _conv = "Yes" if r.converged else "No (treat with caution)"
+ _cc = "green" if r.converged else "#c00"
+ header_rows = (
+ f'Ground-state energy '
+ f'{r.energy_hartree:.8f} Ha '
+ f'SCF converged '
+ f'{_conv} '
+ f'States computed '
+ f'{len(r.excitation_energies_ev)} '
+ )
+ exc_table = ""
+ if r.excitation_energies_ev:
+ wl = r.wavelengths_nm()
+ exc_rows = []
+ for i, (e_ev, f_osc) in enumerate(
+ zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1
+ ):
+ bold = "font-weight:bold" if f_osc > 0.05 else ""
+ exc_rows.append(
+ f''
+ f'S{i} '
+ f'{e_ev:.3f} eV '
+ f'{wl[i - 1]:.1f} nm '
+ f'f = {f_osc:.4f} '
+ f" "
+ )
+ if len(r.excitation_energies_ev) > 8:
+ exc_rows.append(
+ f'… '
+ f"and {len(r.excitation_energies_ev) - 8} more states "
+ )
+ exc_table = (
+ ''
+ "Vertical excitations: "
+ ""
+ 'State '
+ 'Energy '
+ 'λ '
+ 'Osc. str. '
+ + "".join(exc_rows)
+ )
+ return (
+ f''
+ f"
TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis}) "
+ f'
'
+ f"{header_rows}{exc_table}
"
+ )
+
+
+def format_nmr_result(r: Any) -> str:
+ """Format an NMR shielding result card."""
+ _conv = "Yes" if r.converged else "No (treat with caution)"
+ _cc = "green" if r.converged else "#c00"
+ header_rows = (
+ f'SCF converged '
+ f'{_conv} '
+ f'Reference '
+ f'{r.reference_compound} ({r.method}/{r.basis}) '
+ )
+
+ def _nmr_table(label: str, shifts: list, sym: str) -> str:
+ if not shifts:
+ return ""
+ rows = "".join(
+ f""
+ f'{sym}-{n} '
+ f'{d:.2f} ppm '
+ f" "
+ for n, (_i, d) in enumerate(shifts, 1)
+ )
+ return (
+ f''
+ f"{label} shifts (vs. TMS): "
+ f""
+ f'Atom '
+ f'δ (ppm) '
+ + rows
+ )
+
+ h_table = _nmr_table("¹H", r.h_shifts(), "H")
+ c_table = _nmr_table("¹³C", r.c_shifts(), "C")
+
+ _basis_warn = ""
+ if r.basis.upper() in ("STO-3G", "3-21G"):
+ _basis_warn = (
+ ''
+ ''
+ f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better. "
+ " "
+ )
+
+ _empty = ""
+ if not r.h_shifts() and not r.c_shifts():
+ _empty = (
+ ''
+ "No ¹H or ¹³C atoms found in this molecule. "
+ )
+
+ return (
+ f''
+ f"
NMR Shielding — {r.formula} ({r.method}/{r.basis}) "
+ f'
'
+ f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}
"
+ )
+
+
+def format_pes_scan_result(r: Any) -> str:
+ """Format a PESScanResult as an HTML result card."""
+ _conv = "Yes" if r.converged_all else "No (some points did not converge)"
+ _cc = "green" if r.converged_all else "#c00"
+ if r.energies_hartree:
+ e_min = min(r.energies_hartree)
+ e_max = max(r.energies_hartree)
+ barrier_kcal = (e_max - e_min) * 627.509474
+ _e_row = (
+ f'Min energy '
+ f'{e_min:.8f} Ha '
+ f'Energy range '
+ f'{barrier_kcal:.2f} kcal/mol '
+ )
+ else:
+ _e_row = ""
+ _idx_str = "–".join(str(i + 1) for i in r.atom_indices)
+ return (
+ f''
+ f"
PES Scan — {r.formula} ({r.method}/{r.basis}) "
+ f'
'
+ f'Scan type '
+ f'{r.scan_type.capitalize()} ({_idx_str}) '
+ f'Range '
+ f'{r.scan_parameter_values[0]:.3f} → '
+ f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} "
+ f"({r.n_steps} points) "
+ f"{_e_row}"
+ f'All converged '
+ f'{_conv} '
+ f"
"
+ )
+
+
+def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) -> str:
+ """Format a saved result.json payload as an HTML result card."""
+ import base64 as _b64
+
+ _ct_labels = {
+ "single_point": ("Single Point", "#2563eb", "#dbeafe"),
+ "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"),
+ "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"),
+ "tddft": ("TD-DFT", "#b45309", "#fef3c7"),
+ "nmr": ("NMR", "#0d9488", "#ccfbf1"),
+ "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"),
+ }
+ ct = data.get("calc_type", "")
+ _ct_label, _ct_fg, _ct_bg = _ct_labels.get(
+ ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6")
+ )
+ _ct_badge = (
+ f'{_ct_label} '
+ )
+ _conv = "Yes" if data.get("converged") else "No (treat results with caution)"
+ _cc = "green" if data.get("converged") else "#c00"
+ _gap = (
+ f"{data['homo_lumo_gap_ev']:.4f} eV"
+ if data.get("homo_lumo_gap_ev") is not None
+ else "N/A"
+ )
+ _rows = "".join(
+ f""
+ f'{k} '
+ f'{v} '
+ f" "
+ for k, v, vc in [
+ (
+ "Total energy",
+ f"{data['energy_hartree']:.8f} Ha ({data['energy_ev']:.4f} eV)",
+ "#000",
+ ),
+ ("HOMO-LUMO gap", _gap, "#000"),
+ ("SCF converged", _conv, _cc),
+ (
+ "SCF iterations",
+ (
+ "—"
+ if data.get("n_iterations") in (None, -1)
+ else str(data.get("n_iterations"))
+ ),
+ "#000",
+ ),
+ ]
+ )
+ ts = data.get("timestamp", "")
+
+ # Embed thumbnail if saved
+ _thumb_html = ""
+ if result_dir is not None:
+ _thumb_path = Path(result_dir) / "thumbnail.png"
+ if _thumb_path.exists():
+ _img_b64 = _b64.b64encode(_thumb_path.read_bytes()).decode()
+ _thumb_html = (
+ f' '
+ )
+
+ return (
+ f''
+ f"{_thumb_html}"
+ f"{_ct_badge}
"
+ f'
{data["formula"]} — {data["method"]}/{data["basis"]} '
+ f'
{ts} '
+ f'
"
+ )
diff --git a/tests/test_app_formatters.py b/tests/test_app_formatters.py
new file mode 100644
index 0000000..3a47871
--- /dev/null
+++ b/tests/test_app_formatters.py
@@ -0,0 +1,163 @@
+"""Unit tests for extracted app formatter helpers."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from quantui.app_formatters import (
+ format_freq_result,
+ format_nmr_result,
+ format_opt_result,
+ format_past_result,
+ format_pes_scan_result,
+ format_result,
+ format_tddft_result,
+)
+
+
+class _FreqStub(SimpleNamespace):
+ def n_real_modes(self) -> int:
+ return sum(1 for f in self.frequencies_cm1 if f > 0)
+
+ def n_imaginary_modes(self) -> int:
+ return sum(1 for f in self.frequencies_cm1 if f < 0)
+
+
+class _NMRStub(SimpleNamespace):
+ def h_shifts(self) -> list[tuple[int, float]]:
+ return [
+ (i, d)
+ for i, d in self.chemical_shifts_ppm.items()
+ if self.atom_symbols[i] == "H"
+ ]
+
+ def c_shifts(self) -> list[tuple[int, float]]:
+ return [
+ (i, d)
+ for i, d in self.chemical_shifts_ppm.items()
+ if self.atom_symbols[i] == "C"
+ ]
+
+
+class _TDDFTStub(SimpleNamespace):
+ def wavelengths_nm(self) -> list[float]:
+ return [1239.841984 / e for e in self.excitation_energies_ev]
+
+
+def test_format_result_includes_mp2_rows():
+ result = SimpleNamespace(
+ converged=True,
+ homo_lumo_gap_ev=None,
+ energy_hartree=-76.3,
+ energy_ev=-2076.2,
+ n_iterations=12,
+ mp2_correlation_hartree=-0.3,
+ solvent="Water",
+ dipole_moment_debye=1.2,
+ mulliken_charges=[-0.5, 0.25, 0.25],
+ atom_symbols=["O", "H", "H"],
+ formula="H2O",
+ method="MP2",
+ basis="def2-SVP",
+ )
+ html = format_result(result)
+ assert "HF reference" in html
+ assert "MP2 correlation" in html
+ assert "Solvent (PCM)" in html
+
+
+def test_format_opt_result_contains_geometry_fields():
+ result = SimpleNamespace(
+ converged=True,
+ energy_hartree=-40.0,
+ energy_change_hartree=-0.1,
+ n_steps=7,
+ rmsd_angstrom=0.03,
+ formula="NH3",
+ method="RHF",
+ basis="6-31G",
+ )
+ html = format_opt_result(result)
+ assert "Geometry Optimisation" in html
+ assert "Steps taken" in html
+
+
+def test_format_freq_result_highlights_imaginary_modes():
+ result = _FreqStub(
+ converged=True,
+ frequencies_cm1=[-50.0, 1200.0, 1600.0],
+ energy_hartree=-75.0,
+ zpve_hartree=0.021,
+ formula="H2O",
+ method="B3LYP",
+ basis="def2-SVP",
+ thermo=None,
+ )
+ html = format_freq_result(result)
+ assert "Frequency Analysis" in html
+ assert "Imaginary modes" in html
+
+
+def test_format_tddft_result_lists_excitations():
+ result = _TDDFTStub(
+ converged=True,
+ energy_hartree=-100.0,
+ excitation_energies_ev=[2.0, 3.0],
+ oscillator_strengths=[0.1, 0.02],
+ formula="C2H4",
+ method="CAM-B3LYP",
+ basis="def2-SVP",
+ )
+ html = format_tddft_result(result)
+ assert "Vertical excitations" in html
+ assert "S1" in html
+
+
+def test_format_nmr_result_warns_on_small_basis():
+ result = _NMRStub(
+ converged=True,
+ reference_compound="TMS",
+ atom_symbols=["C", "H", "H", "H", "H"],
+ chemical_shifts_ppm={1: 0.2, 2: 0.2, 3: 0.2, 4: 0.2},
+ formula="CH4",
+ method="B3LYP",
+ basis="STO-3G",
+ )
+ html = format_nmr_result(result)
+ assert "qualitative NMR only" in html
+
+
+def test_format_pes_scan_result_reports_range_and_convergence():
+ result = SimpleNamespace(
+ converged_all=True,
+ energies_hartree=[-40.0, -39.95, -39.98],
+ atom_indices=[0, 1],
+ scan_type="bond",
+ scan_parameter_values=[1.0, 1.1, 1.2],
+ scan_unit="A",
+ n_steps=3,
+ formula="H2",
+ method="RHF",
+ basis="STO-3G",
+ )
+ html = format_pes_scan_result(result)
+ assert "PES Scan" in html
+ assert "All converged" in html
+
+
+def test_format_past_result_contains_calc_type_badge():
+ data = {
+ "calc_type": "single_point",
+ "converged": True,
+ "homo_lumo_gap_ev": 10.0,
+ "energy_hartree": -75.0,
+ "energy_ev": -2040.0,
+ "n_iterations": 10,
+ "timestamp": "2026-05-02_12-00-00-000001",
+ "formula": "H2O",
+ "method": "RHF",
+ "basis": "STO-3G",
+ }
+ html = format_past_result(data)
+ assert "Single Point" in html
+ assert "H2O" in html
From 7c3b7efdb72164526f76083e224d028c6c080461 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 11:59:03 -0400
Subject: [PATCH 02/22] Update .pre-commit-config.yaml
---
.pre-commit-config.yaml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4610248..ce04cbf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,3 +1,5 @@
+fail_fast: true
+
repos:
# ── Standard file hygiene ─────────────────────────────────────────────────
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -42,6 +44,7 @@ repos:
rev: v1.10.0
hooks:
- id: mypy
+ stages: [pre-push]
files: ^quantui/ # type-check the package only, not tests
args: ["--ignore-missing-imports", "--no-error-summary"]
additional_dependencies:
From de4e87081d3c375a44be5fb9432353f2296e4d8e Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 12:06:05 -0400
Subject: [PATCH 03/22] Move export helpers to app_exports module
Extract export-related logic from quantui/app.py into a new module quantui/app_exports.py and delegate calls from QuantUIApp. The new file implements on_export, on_export_xyz, on_export_mol, on_export_pdb, export_molecule_and_label, and molecule_to_rdkit. quantui/app.py now imports these helpers (aliased with _exp_*) and replaces the previous inline implementations with calls to the extracted functions. Refactor for separation of concerns; behavior should remain unchanged.
---
quantui/app.py | 149 +++++++----------------------------------
quantui/app_exports.py | 139 ++++++++++++++++++++++++++++++++++++++
2 files changed, 163 insertions(+), 125 deletions(-)
create mode 100644 quantui/app_exports.py
diff --git a/quantui/app.py b/quantui/app.py
index 4a48848..8eabe78 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -33,6 +33,24 @@
import quantui
import quantui.calc_log as _calc_log
import quantui.issue_tracker as _issue_tracker
+from quantui.app_exports import (
+ export_molecule_and_label as _exp_export_molecule_and_label,
+)
+from quantui.app_exports import (
+ molecule_to_rdkit as _exp_molecule_to_rdkit,
+)
+from quantui.app_exports import (
+ on_export as _exp_on_export,
+)
+from quantui.app_exports import (
+ on_export_mol as _exp_on_export_mol,
+)
+from quantui.app_exports import (
+ on_export_pdb as _exp_on_export_pdb,
+)
+from quantui.app_exports import (
+ on_export_xyz as _exp_on_export_xyz,
+)
from quantui.app_formatters import (
format_freq_result as _fmt_freq_result,
)
@@ -3001,142 +3019,23 @@ def _on_clear(self, btn) -> None:
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}"
+ _exp_on_export(self, btn)
def _on_export_xyz(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.xyz"
- xyz_body = mol.to_xyz_string()
- full_xyz = (
- f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n"
- )
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(full_xyz, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ _exp_on_export_xyz(self, btn)
def _on_export_mol(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- from rdkit import Chem
-
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.mol"
- rdmol = self._molecule_to_rdkit(mol)
- if rdmol is None:
- self.struct_export_status.value = "RDKit could not parse the structure."
- return
- mol_block = Chem.MolToMolBlock(rdmol)
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(mol_block, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ _exp_on_export_mol(self, btn)
def _on_export_pdb(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- from rdkit import Chem
-
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.pdb"
- rdmol = self._molecule_to_rdkit(mol)
- if rdmol is None:
- self.struct_export_status.value = "RDKit could not parse the structure."
- return
- pdb_block = Chem.MolToPDBBlock(rdmol)
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(pdb_block, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ _exp_on_export_pdb(self, btn)
def _export_molecule_and_label(self):
- """Return (molecule, method, basis) for structure export.
-
- For geo opt results, returns the final optimised geometry.
- Falls back to the currently loaded molecule for all other calc types.
- """
- from quantui.optimizer import OptimizationResult
-
- r = self._last_result
- if isinstance(r, OptimizationResult):
- mol = r.molecule
- else:
- assert self._molecule is not None
- mol = self._molecule
- method = (
- getattr(r, "method", self.method_dd.value)
- if r is not None
- else self.method_dd.value
- )
- basis = (
- getattr(r, "basis", self.basis_dd.value)
- if r is not None
- else self.basis_dd.value
- )
- return mol, method, basis
+ return _exp_export_molecule_and_label(self)
@staticmethod
def _molecule_to_rdkit(mol):
- """Convert a Molecule to an RDKit Mol with inferred bonds (best-effort)."""
- try:
- from rdkit import Chem
-
- xyz_block = (
- f"{len(mol.atoms)}\n{mol.get_formula()}\n{mol.to_xyz_string()}\n"
- )
- rdmol = Chem.MolFromXYZBlock(xyz_block)
- if rdmol is None:
- return None
- try:
- from rdkit.Chem import rdDetermineBonds
-
- rdDetermineBonds.DetermineBonds(rdmol, charge=mol.charge)
- except Exception:
- pass
- return rdmol
- except Exception:
- return None
+ return _exp_molecule_to_rdkit(mol)
# ── Compare ───────────────────────────────────────────────────────────
diff --git a/quantui/app_exports.py b/quantui/app_exports.py
new file mode 100644
index 0000000..801cff1
--- /dev/null
+++ b/quantui/app_exports.py
@@ -0,0 +1,139 @@
+"""Export helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+
+def on_export(app: Any, btn: Any) -> None:
+ """Export a standalone Python calculation script."""
+ if app._molecule is None:
+ app.export_status.value = "Load a molecule first."
+ return
+ try:
+ from quantui import PySCFCalculation
+
+ calc = PySCFCalculation(
+ app._molecule,
+ method=app.method_dd.value,
+ basis=app.basis_dd.value,
+ )
+ fname = (
+ f"{app._molecule.get_formula()}"
+ f"_{app.method_dd.value}_{app.basis_dd.value}.py"
+ )
+ calc.generate_calculation_script(Path(fname))
+ app.export_status.value = f"Saved: {fname}"
+ except Exception as exc:
+ app.export_status.value = f"Error: {exc}"
+
+
+def on_export_xyz(app: Any, btn: Any) -> None:
+ """Export molecule geometry to an XYZ file."""
+ if app._molecule is None:
+ app.struct_export_status.value = "Load a molecule first."
+ return
+ try:
+ mol, method, basis = export_molecule_and_label(app)
+ fname = f"{mol.get_formula()}_{method}_{basis}.xyz"
+ xyz_body = mol.to_xyz_string()
+ full_xyz = (
+ f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n"
+ )
+ dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname)
+ dest.write_text(full_xyz, encoding="utf-8")
+ app.struct_export_status.value = f"Saved: {dest}"
+ except Exception as exc:
+ app.struct_export_status.value = f"Error: {exc}"
+
+
+def on_export_mol(app: Any, btn: Any) -> None:
+ """Export molecule geometry to a MOL file via RDKit."""
+ if app._molecule is None:
+ app.struct_export_status.value = "Load a molecule first."
+ return
+ try:
+ from rdkit import Chem
+
+ mol, method, basis = export_molecule_and_label(app)
+ fname = f"{mol.get_formula()}_{method}_{basis}.mol"
+ rdmol = molecule_to_rdkit(mol)
+ if rdmol is None:
+ app.struct_export_status.value = "RDKit could not parse the structure."
+ return
+ mol_block = Chem.MolToMolBlock(rdmol)
+ dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname)
+ dest.write_text(mol_block, encoding="utf-8")
+ app.struct_export_status.value = f"Saved: {dest}"
+ except Exception as exc:
+ app.struct_export_status.value = f"Error: {exc}"
+
+
+def on_export_pdb(app: Any, btn: Any) -> None:
+ """Export molecule geometry to a PDB file via RDKit."""
+ if app._molecule is None:
+ app.struct_export_status.value = "Load a molecule first."
+ return
+ try:
+ from rdkit import Chem
+
+ mol, method, basis = export_molecule_and_label(app)
+ fname = f"{mol.get_formula()}_{method}_{basis}.pdb"
+ rdmol = molecule_to_rdkit(mol)
+ if rdmol is None:
+ app.struct_export_status.value = "RDKit could not parse the structure."
+ return
+ pdb_block = Chem.MolToPDBBlock(rdmol)
+ dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname)
+ dest.write_text(pdb_block, encoding="utf-8")
+ app.struct_export_status.value = f"Saved: {dest}"
+ except Exception as exc:
+ app.struct_export_status.value = f"Error: {exc}"
+
+
+def export_molecule_and_label(app: Any) -> tuple[Any, str, str]:
+ """Return (molecule, method, basis) for structure export.
+
+ For geometry optimization results, returns the final optimized geometry.
+ Falls back to the currently loaded molecule for all other calculation types.
+ """
+ from quantui.optimizer import OptimizationResult
+
+ result = app._last_result
+ if isinstance(result, OptimizationResult):
+ mol = result.molecule
+ else:
+ assert app._molecule is not None
+ mol = app._molecule
+ method = (
+ getattr(result, "method", app.method_dd.value)
+ if result is not None
+ else app.method_dd.value
+ )
+ basis = (
+ getattr(result, "basis", app.basis_dd.value)
+ if result is not None
+ else app.basis_dd.value
+ )
+ return mol, method, basis
+
+
+def molecule_to_rdkit(mol: Any) -> Any:
+ """Convert a Molecule to an RDKit Mol with inferred bonds (best-effort)."""
+ try:
+ from rdkit import Chem
+
+ xyz_block = f"{len(mol.atoms)}\n{mol.get_formula()}\n{mol.to_xyz_string()}\n"
+ rdmol = Chem.MolFromXYZBlock(xyz_block)
+ if rdmol is None:
+ return None
+ try:
+ from rdkit.Chem import rdDetermineBonds
+
+ rdDetermineBonds.DetermineBonds(rdmol, charge=mol.charge)
+ except Exception:
+ pass
+ return rdmol
+ except Exception:
+ return None
From 5fced0b3bb44d678390b6fb5b9a5106251618376 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 12:16:17 -0400
Subject: [PATCH 04/22] Extract history helpers to app_history.py
Move history-related logic out of quantui/app.py into a new module quantui/app_history.py and delegate calls to the new helpers. The following behaviors were relocated: past-dropdown handling, view-log flow, reconstructing Molecule from a result dir, loading results into the Results tab, loading analysis panels, and building the analysis context. app.py now imports these helpers (with _hist_ aliases) and forwards to them, keeping external behavior unchanged while cleaning up and modularizing the main app file.
---
quantui/app.py | 218 +++++-----------------------------------
quantui/app_history.py | 221 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 245 insertions(+), 194 deletions(-)
create mode 100644 quantui/app_history.py
diff --git a/quantui/app.py b/quantui/app.py
index 8eabe78..d34213c 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -72,6 +72,24 @@
from quantui.app_formatters import (
format_tddft_result as _fmt_tddft_result,
)
+from quantui.app_history import (
+ build_history_context as _hist_build_history_context,
+)
+from quantui.app_history import (
+ history_load_analysis as _hist_history_load_analysis,
+)
+from quantui.app_history import (
+ history_load_results as _hist_history_load_results,
+)
+from quantui.app_history import (
+ mol_from_result_dir as _hist_mol_from_result_dir,
+)
+from quantui.app_history import (
+ on_past_dd_changed as _hist_on_past_dd_changed,
+)
+from quantui.app_history import (
+ on_view_log as _hist_on_view_log,
+)
# Import directly from submodules to avoid circular-import issues.
# quantui/__init__.py imports this module (app.py), so using
@@ -3111,51 +3129,7 @@ def _on_compare_clear(self, btn) -> None:
# ── History ───────────────────────────────────────────────────────────
def _on_past_dd_changed(self, change) -> None:
- path_str = change["new"]
- # Hide result-specific panels whenever the selection changes so stale
- # content from a previous "View log" click doesn't persist.
- self._deactivate_all_ana_panels()
- self._pending_traj_result = None
- self._result_log_accordion.layout.display = "none"
- self._result_dir_label.layout.display = "none"
- self._iso_generate_btn.disabled = True
- if not path_str:
- self.past_output.clear_output()
- return
- self.past_output.clear_output()
- with self.past_output:
- try:
- from quantui import load_result
-
- _result_dir = Path(path_str)
- data = load_result(_result_dir)
- display(HTML(self._format_past_result(data, result_dir=_result_dir)))
- _btn_res = widgets.Button(
- description="→ View Results",
- button_style="success",
- layout=_layout(width="130px"),
- tooltip="Show this result in the Results tab",
- )
- _btn_ana = widgets.Button(
- description="→ View Analysis",
- button_style="info",
- layout=_layout(width="140px"),
- tooltip="Load analysis panels and navigate to the Analysis tab",
- )
- _btn_res.on_click(
- lambda _, d=data, rd=_result_dir: self._history_load_results(d, rd)
- )
- _btn_ana.on_click(
- lambda _, rd=_result_dir: self._history_load_analysis(rd)
- )
- display(
- widgets.HBox(
- [_btn_res, _btn_ana],
- layout=_layout(gap="8px", margin="6px 0 0"),
- )
- )
- except Exception as exc:
- print(f"Could not load result: {exc}")
+ _hist_on_past_dd_changed(self, change, layout_fn=_layout)
def _on_past_refresh(self, btn) -> None:
self._refresh_results_browser()
@@ -3178,163 +3152,19 @@ def _reset():
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
- result_dir = Path(path_str)
- try:
- _calc_log.log_event(
- "history_view",
- result_dir.name,
- result_dir=result_dir.name,
- session_id=self._session_id,
- )
- except Exception:
- pass
-
- # Read log text and populate log panel
- log_path = result_dir / "pyscf.log"
- if log_path.exists():
- text = log_path.read_text(encoding="utf-8", errors="replace")
- label = result_dir.name
- else:
- text = "(No pyscf.log found for this result.)"
- label = ""
- self._update_log_panel(text, label)
- self._show_result_log(result_dir, text)
-
- # Build analysis context from disk and apply via registry
- ctx = self._build_history_context(result_dir)
- if ctx is not None:
- _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
- try:
- _mol = self._mol_from_result_dir(result_dir, _data_stub)
- if _mol is not None:
- self._show_result_3d(_mol, extra_output=self._analysis_mol_output)
- else:
- self._analysis_mol_output.clear_output()
- except Exception:
- pass
- self._apply_analysis_context(ctx)
-
- self._goto_output_tab()
+ _hist_on_view_log(self, btn)
def _mol_from_result_dir(self, result_dir: Path, data: dict):
- """Try to reconstruct a displayable Molecule from a saved result directory.
-
- Returns a Molecule or None if geometry data is not available.
- Tries sources in order: frequency spectra → orbitals_meta → trajectory.
- """
- import json as _json
-
- from quantui.molecule import Molecule
-
- ct = data.get("calc_type", "")
-
- # Frequency: geometry stored inside spectra.molecule
- if ct == "frequency":
- mol_data = data.get("spectra", {}).get("molecule", {})
- if mol_data.get("atoms") and mol_data.get("coords"):
- try:
- return Molecule(
- atoms=mol_data["atoms"],
- coordinates=mol_data["coords"],
- charge=mol_data.get("charge", 0),
- multiplicity=mol_data.get("multiplicity", 1),
- )
- except Exception:
- pass
-
- # Single point / Geo opt: atom list from orbitals_meta.json
- meta_path = result_dir / "orbitals_meta.json"
- if meta_path.exists():
- try:
- meta = _json.loads(meta_path.read_text())
- mol_atom = meta.get("mol_atom")
- if mol_atom:
- atoms = [sym for sym, _ in mol_atom]
- coords = [c for _, c in mol_atom]
- return Molecule(atoms=atoms, coordinates=coords)
- except Exception:
- pass
-
- # Geo opt fallback: last step of trajectory.json
- if ct == "geometry_opt":
- traj_path = result_dir / "trajectory.json"
- if traj_path.exists():
- try:
- traj_data = _json.loads(traj_path.read_text())
- steps = traj_data.get("steps", [])
- if steps:
- return Molecule(
- atoms=traj_data["atoms"],
- coordinates=steps[-1]["coords"],
- charge=traj_data.get("charge", 0),
- multiplicity=traj_data.get("multiplicity", 1),
- )
- except Exception:
- pass
-
- return None
+ return _hist_mol_from_result_dir(result_dir, data)
def _history_load_results(self, data: dict, result_dir: Path) -> None:
- """Display a history result card in the Results tab and navigate there."""
- self.result_output.clear_output()
- with self.result_output:
- display(HTML(self._format_past_result(data, result_dir=result_dir)))
- self._result_dir_label.layout.display = "none"
- # Also show 3D structure if geometry is recoverable
- mol = self._mol_from_result_dir(result_dir, data)
- if mol is not None:
- self._show_result_3d(mol)
- self.root_tab.selected_index = 1
+ _hist_history_load_results(self, data, result_dir)
def _history_load_analysis(self, result_dir: Path) -> None:
- """Load analysis panels for a history result and navigate to Analysis tab."""
- log_path = result_dir / "pyscf.log"
- text = (
- log_path.read_text(encoding="utf-8", errors="replace")
- if log_path.exists()
- else "(No pyscf.log found for this result.)"
- )
- self._update_log_panel(result_dir.name if log_path.exists() else "", text)
- self._show_result_log(result_dir, text)
-
- ctx = self._build_history_context(result_dir)
- if ctx is not None:
- _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
- try:
- _mol = self._mol_from_result_dir(result_dir, _data_stub)
- if _mol is not None:
- self._show_result_3d(_mol, extra_output=self._analysis_mol_output)
- else:
- self._analysis_mol_output.clear_output()
- except Exception:
- pass
- self._apply_analysis_context(ctx)
-
- self.root_tab.selected_index = 2
+ _hist_history_load_analysis(self, result_dir)
def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]:
- """Load result.json from *result_dir* and return an ``_AnalysisContext``.
-
- Returns ``None`` if result.json cannot be read.
- """
- try:
- from quantui import load_result
-
- data = load_result(result_dir)
- except Exception:
- return None
- return _AnalysisContext(
- calc_type=data.get("calc_type", ""),
- formula=data.get("formula", result_dir.name),
- method=data.get("method", ""),
- basis=data.get("basis", ""),
- result_dir=result_dir,
- spectra_data=data.get("spectra", {}),
- source="history",
- )
+ return _hist_build_history_context(result_dir, context_cls=_AnalysisContext)
# ── Perf stats reset ──────────────────────────────────────────────────
diff --git a/quantui/app_history.py b/quantui/app_history.py
new file mode 100644
index 0000000..bc1a782
--- /dev/null
+++ b/quantui/app_history.py
@@ -0,0 +1,221 @@
+"""History-loading helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+import json as _json
+from pathlib import Path
+from typing import Any, Optional
+
+import ipywidgets as widgets
+from IPython.display import HTML, display
+
+
+def on_past_dd_changed(app: Any, change: dict[str, Any], *, layout_fn: Any) -> None:
+ """Handle history dropdown selection changes."""
+ path_str = change["new"]
+ # Hide result-specific panels whenever the selection changes so stale
+ # content from a previous "View log" click doesn't persist.
+ app._deactivate_all_ana_panels()
+ app._pending_traj_result = None
+ app._result_log_accordion.layout.display = "none"
+ app._result_dir_label.layout.display = "none"
+ app._iso_generate_btn.disabled = True
+ if not path_str:
+ app.past_output.clear_output()
+ return
+ app.past_output.clear_output()
+ with app.past_output:
+ try:
+ from quantui import load_result
+
+ result_dir = Path(path_str)
+ data = load_result(result_dir)
+ display(HTML(app._format_past_result(data, result_dir=result_dir)))
+ btn_results = widgets.Button(
+ description="-> View Results",
+ button_style="success",
+ layout=layout_fn(width="130px"),
+ tooltip="Show this result in the Results tab",
+ )
+ btn_analysis = widgets.Button(
+ description="-> View Analysis",
+ button_style="info",
+ layout=layout_fn(width="140px"),
+ tooltip="Load analysis panels and navigate to the Analysis tab",
+ )
+ btn_results.on_click(
+ lambda _, d=data, rd=result_dir: app._history_load_results(d, rd)
+ )
+ btn_analysis.on_click(
+ lambda _, rd=result_dir: app._history_load_analysis(rd)
+ )
+ display(
+ widgets.HBox(
+ [btn_results, btn_analysis],
+ layout=layout_fn(gap="8px", margin="6px 0 0"),
+ )
+ )
+ except Exception as exc:
+ print(f"Could not load result: {exc}")
+
+
+def on_view_log(app: Any, btn: Any) -> None:
+ """Handle View Log action for a selected history result."""
+ path_str = app.past_dd.value
+ if not path_str:
+ return
+ result_dir = Path(path_str)
+ try:
+ import quantui.calc_log as _calc_log
+
+ _calc_log.log_event(
+ "history_view",
+ result_dir.name,
+ result_dir=result_dir.name,
+ session_id=app._session_id,
+ )
+ except Exception:
+ pass
+
+ # Read log text and populate log panel
+ log_path = result_dir / "pyscf.log"
+ if log_path.exists():
+ text = log_path.read_text(encoding="utf-8", errors="replace")
+ label = result_dir.name
+ else:
+ text = "(No pyscf.log found for this result.)"
+ label = ""
+ app._update_log_panel(text, label)
+ app._show_result_log(result_dir, text)
+
+ # Build analysis context from disk and apply via registry
+ ctx = app._build_history_context(result_dir)
+ if ctx is not None:
+ data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
+ try:
+ mol = app._mol_from_result_dir(result_dir, data_stub)
+ if mol is not None:
+ app._show_result_3d(mol, extra_output=app._analysis_mol_output)
+ else:
+ app._analysis_mol_output.clear_output()
+ except Exception:
+ pass
+ app._apply_analysis_context(ctx)
+
+ app._goto_output_tab()
+
+
+def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any:
+ """Try to reconstruct a displayable Molecule from a saved result directory.
+
+ Returns a Molecule or None if geometry data is not available.
+ Tries sources in order: frequency spectra -> orbitals_meta -> trajectory.
+ """
+ from quantui.molecule import Molecule
+
+ calc_type = data.get("calc_type", "")
+
+ # Frequency: geometry stored inside spectra.molecule
+ if calc_type == "frequency":
+ mol_data = data.get("spectra", {}).get("molecule", {})
+ if mol_data.get("atoms") and mol_data.get("coords"):
+ try:
+ return Molecule(
+ atoms=mol_data["atoms"],
+ coordinates=mol_data["coords"],
+ charge=mol_data.get("charge", 0),
+ multiplicity=mol_data.get("multiplicity", 1),
+ )
+ except Exception:
+ pass
+
+ # Single point / Geo opt: atom list from orbitals_meta.json
+ meta_path = result_dir / "orbitals_meta.json"
+ if meta_path.exists():
+ try:
+ meta = _json.loads(meta_path.read_text())
+ mol_atom = meta.get("mol_atom")
+ if mol_atom:
+ atoms = [sym for sym, _ in mol_atom]
+ coords = [coords for _, coords in mol_atom]
+ return Molecule(atoms=atoms, coordinates=coords)
+ except Exception:
+ pass
+
+ # Geo opt fallback: last step of trajectory.json
+ if calc_type == "geometry_opt":
+ traj_path = result_dir / "trajectory.json"
+ if traj_path.exists():
+ try:
+ traj_data = _json.loads(traj_path.read_text())
+ steps = traj_data.get("steps", [])
+ if steps:
+ return Molecule(
+ atoms=traj_data["atoms"],
+ coordinates=steps[-1]["coords"],
+ charge=traj_data.get("charge", 0),
+ multiplicity=traj_data.get("multiplicity", 1),
+ )
+ except Exception:
+ pass
+
+ return None
+
+
+def history_load_results(app: Any, data: dict[str, Any], result_dir: Path) -> None:
+ """Display a history result card in the Results tab and navigate there."""
+ app.result_output.clear_output()
+ with app.result_output:
+ display(HTML(app._format_past_result(data, result_dir=result_dir)))
+ app._result_dir_label.layout.display = "none"
+ # Also show 3D structure if geometry is recoverable
+ mol = app._mol_from_result_dir(result_dir, data)
+ if mol is not None:
+ app._show_result_3d(mol)
+ app.root_tab.selected_index = 1
+
+
+def history_load_analysis(app: Any, result_dir: Path) -> None:
+ """Load analysis panels for a history result and navigate to Analysis tab."""
+ log_path = result_dir / "pyscf.log"
+ text = (
+ log_path.read_text(encoding="utf-8", errors="replace")
+ if log_path.exists()
+ else "(No pyscf.log found for this result.)"
+ )
+ app._update_log_panel(result_dir.name if log_path.exists() else "", text)
+ app._show_result_log(result_dir, text)
+
+ ctx = app._build_history_context(result_dir)
+ if ctx is not None:
+ data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
+ try:
+ mol = app._mol_from_result_dir(result_dir, data_stub)
+ if mol is not None:
+ app._show_result_3d(mol, extra_output=app._analysis_mol_output)
+ else:
+ app._analysis_mol_output.clear_output()
+ except Exception:
+ pass
+ app._apply_analysis_context(ctx)
+
+ app.root_tab.selected_index = 2
+
+
+def build_history_context(result_dir: Path, *, context_cls: Any) -> Optional[Any]:
+ """Load result.json from result_dir and return an analysis context."""
+ try:
+ from quantui import load_result
+
+ data = load_result(result_dir)
+ except Exception:
+ return None
+ return context_cls(
+ calc_type=data.get("calc_type", ""),
+ formula=data.get("formula", result_dir.name),
+ method=data.get("method", ""),
+ basis=data.get("basis", ""),
+ result_dir=result_dir,
+ spectra_data=data.get("spectra", {}),
+ source="history",
+ )
From fc69df31c34d309598b8b85f134df019b10947c1 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 12:29:11 -0400
Subject: [PATCH 05/22] Extract analysis panel logic to module
Move Analysis panel state and population helpers out of quantui/app.py into a new quantui/app_analysis.py module. Replace in-file implementations with imports and delegating calls (build_ana_switcher, select_ana_panel, activate_ana_panel, deactivate_all_ana_panels, apply_analysis_context and pop_* helpers) so QuantUIApp now uses the extracted functions. Remove a redundant local types import from app.py and wire the new module to preserve existing behaviour (including layout handling and plot re-render observers). This refactor isolates analysis concerns and reduces file size/complexity in app.py.
---
quantui/app.py | 466 ++++++----------------------------------
quantui/app_analysis.py | 450 ++++++++++++++++++++++++++++++++++++++
2 files changed, 511 insertions(+), 405 deletions(-)
create mode 100644 quantui/app_analysis.py
diff --git a/quantui/app.py b/quantui/app.py
index d34213c..6cb9c7b 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -20,7 +20,6 @@
import sys
import threading
import time
-import types as _types_mod
import uuid as _uuid
from dataclasses import dataclass, field
from pathlib import Path
@@ -33,6 +32,51 @@
import quantui
import quantui.calc_log as _calc_log
import quantui.issue_tracker as _issue_tracker
+from quantui.app_analysis import (
+ activate_ana_panel as _ana_activate_ana_panel,
+)
+from quantui.app_analysis import (
+ apply_analysis_context as _ana_apply_analysis_context,
+)
+from quantui.app_analysis import (
+ build_ana_switcher as _ana_build_ana_switcher,
+)
+from quantui.app_analysis import (
+ deactivate_all_ana_panels as _ana_deactivate_all_ana_panels,
+)
+from quantui.app_analysis import (
+ pop_energies as _ana_pop_energies,
+)
+from quantui.app_analysis import (
+ pop_geo_trajectory as _ana_pop_geo_trajectory,
+)
+from quantui.app_analysis import (
+ pop_ir_spectrum as _ana_pop_ir_spectrum,
+)
+from quantui.app_analysis import (
+ pop_isosurface as _ana_pop_isosurface,
+)
+from quantui.app_analysis import (
+ pop_nmr_shielding as _ana_pop_nmr_shielding,
+)
+from quantui.app_analysis import (
+ pop_pes_plot as _ana_pop_pes_plot,
+)
+from quantui.app_analysis import (
+ pop_pes_trajectory as _ana_pop_pes_trajectory,
+)
+from quantui.app_analysis import (
+ pop_preopt_trajectory as _ana_pop_preopt_trajectory,
+)
+from quantui.app_analysis import (
+ pop_uv_vis as _ana_pop_uv_vis,
+)
+from quantui.app_analysis import (
+ pop_vibrational as _ana_pop_vibrational,
+)
+from quantui.app_analysis import (
+ select_ana_panel as _ana_select_ana_panel,
+)
from quantui.app_exports import (
export_molecule_and_label as _exp_export_molecule_and_label,
)
@@ -1498,6 +1542,7 @@ def _build_results_section(self) -> None:
),
layout=_layout(display="none"),
)
+ self._ana_unavail_html = widgets.HTML(value="", layout=_layout(display="none"))
self._build_ana_switcher()
self.analysis_tab_panel = widgets.VBox(
[
@@ -1522,49 +1567,7 @@ def _build_results_section(self) -> None:
# ── Analysis panel switcher ───────────────────────────────────────────
def _build_ana_switcher(self) -> None:
- """Initialise analysis panel state; wire accordion re-render observers."""
- panel_meta = [
- (name, getattr(self, attr), when) for name, attr, when in self._PANEL_META
- ]
- self._ana_panel_names: list = [m[0] for m in panel_meta]
- self._ana_accordions: list = [m[1] for m in panel_meta]
- self._ana_available: set = set()
- self._ana_active: str = ""
- self._ana_unavail_html = widgets.HTML(
- value="",
- layout=_layout(display="none", margin="4px 0 8px"),
- )
-
- # Wrap each accordion's child so it holds both an "unavailable" message
- # and the real content. Real content starts hidden; the unavailable
- # message is shown until _activate_ana_panel() is called.
- self._ana_unavail_msgs: dict = {}
- self._ana_content_boxes: dict = {}
- for name, acc, when in panel_meta:
- unavail = widgets.HTML(
- value=(
- f'Not available — run a {when} '
- f"calculation first.
"
- ),
- layout=_layout(display=""),
- )
- content = acc.children[0]
- self._ana_unavail_msgs[name] = unavail
- self._ana_content_boxes[name] = content
- content.layout.display = "none"
- acc.children = (widgets.VBox([unavail, content]),)
- acc.layout.display = "" # always in the DOM
- acc.selected_index = None # collapsed until activated
-
- # Re-render Plotly charts when their accordion is expanded by clicking
- # the header directly (charts rendered into a hidden container have 0 size).
- self._ir_accordion.observe(
- self._safe_cb(self._on_ir_accordion_show), names=["selected_index"]
- )
- self._orb_accordion.observe(
- self._safe_cb(self._on_orb_accordion_show), names=["selected_index"]
- )
+ _ana_build_ana_switcher(self, layout_fn=_layout)
def _on_ir_accordion_show(self, change) -> None:
if change["new"] == 0 and getattr(self, "_last_ir_freqs", None):
@@ -1577,33 +1580,13 @@ def _on_orb_accordion_show(self, change) -> None:
self._on_orb_range_changed()
def _select_ana_panel(self, name: str) -> None:
- """Expand the named panel and collapse all others."""
- self._ana_active = name
- self._ana_unavail_html.layout.display = "none"
- for pname, acc in zip(self._ana_panel_names, self._ana_accordions):
- acc.selected_index = 0 if pname == name else None
+ _ana_select_ana_panel(self, name)
def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None:
- """Mark a panel as available: reveal its content."""
- self._ana_available.add(name)
- # Swap unavailable placeholder for real content.
- if name in self._ana_unavail_msgs:
- self._ana_unavail_msgs[name].layout.display = "none"
- self._ana_content_boxes[name].layout.display = ""
- if auto_select:
- self._select_ana_panel(name)
+ _ana_activate_ana_panel(self, name, auto_select=auto_select)
def _deactivate_all_ana_panels(self) -> None:
- """Reset all panels to collapsed/unavailable; used at start of each new run."""
- self._ana_available.clear()
- self._ana_active = ""
- self._ana_unavail_html.layout.display = "none"
- for name, acc in zip(self._ana_panel_names, self._ana_accordions):
- # Show the "not available" placeholder; hide real content.
- if name in self._ana_unavail_msgs:
- self._ana_unavail_msgs[name].layout.display = ""
- self._ana_content_boxes[name].layout.display = "none"
- acc.selected_index = None
+ _ana_deactivate_all_ana_panels(self)
# ── Panel registry and unified applier ───────────────────────────────────
#
@@ -1662,367 +1645,40 @@ def _deactivate_all_ana_panels(self) -> None:
}
def _apply_analysis_context(self, ctx: _AnalysisContext) -> None:
- """Populate Analysis panels from *ctx* and activate those that have data.
-
- Uses ``_PANEL_REGISTRY`` so that live-run and history-replay follow the
- exact same code path. The first registry entry that succeeds and has
- ``auto_select=True`` becomes the visible panel; all others are activated
- (full opacity, clickable) but not auto-shown.
- """
- self._deactivate_all_ana_panels()
- self._pending_traj_result = None
- # Reset trajectory accordion title to default
- self.traj_accordion.set_title(0, "Trajectory Viewer")
-
- first_auto_selected = False
- for panel_name, method_name, want_auto in self._PANEL_REGISTRY.get(
- ctx.calc_type, []
- ):
- try:
- ok = bool(getattr(self, method_name)(ctx))
- except Exception as _panel_exc:
- ok = False
- try:
- from quantui import calc_log as _clog
-
- _clog.log_event(
- "ana_panel_error",
- f"{method_name}: {type(_panel_exc).__name__}: {_panel_exc}"[
- :300
- ],
- )
- except Exception:
- pass
- if ok:
- do_auto = want_auto and not first_auto_selected
- self._activate_ana_panel(panel_name, auto_select=do_auto)
- if do_auto:
- first_auto_selected = True
-
- _src = " (from History)" if ctx.source == "history" else ""
- self._analysis_context_lbl.value = (
- f''
- f"Analysing: {ctx.label}{_src}
"
- )
- _has = bool(self._ana_available)
- self._to_analysis_btn.layout.display = "" if _has else "none"
- self._analysis_empty_html.layout.display = "none" if _has else ""
+ _ana_apply_analysis_context(self, ctx)
# ── Panel populate methods ────────────────────────────────────────────────
# Each receives an _AnalysisContext and returns True if data was rendered.
def _pop_energies(self, ctx: _AnalysisContext) -> bool:
- result = ctx.live_result
- if result is None and ctx.result_dir is not None:
- try:
- from quantui.results_storage import load_orbitals
-
- orb = load_orbitals(ctx.result_dir)
- orb.formula = ctx.formula
- result = orb
- except Exception:
- return False
- return self._show_orbital_diagram(result)
+ return _ana_pop_energies(self, ctx)
def _pop_isosurface(self, ctx: _AnalysisContext) -> bool:
- # Isosurface controls are enabled by _show_orbital_diagram when MO data
- # is present; just check whether that data was stashed.
- return (
- self._last_orb_mo_coeff is not None
- and self._last_orb_mol_atom is not None
- and self._last_orb_mol_basis is not None
- )
+ return _ana_pop_isosurface(self, ctx)
def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool:
- traj = None
- energies: list = []
- if ctx.live_result is not None:
- traj = getattr(ctx.live_result, "trajectory", None)
- energies = list(getattr(ctx.live_result, "energies_hartree", []))
- elif ctx.result_dir is not None:
- traj_file = ctx.result_dir / "trajectory.json"
- if traj_file.exists():
- try:
- from quantui.results_storage import load_trajectory
-
- traj, energies = load_trajectory(ctx.result_dir)
- except Exception:
- return False
- if not traj or len(traj) < 2:
- return False
- stub = _types_mod.SimpleNamespace(
- trajectory=traj,
- energies_hartree=energies,
- formula=ctx.formula,
- )
- self._pending_traj_result = stub
- return True
+ return _ana_pop_geo_trajectory(self, ctx)
def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool:
- if ctx.source == "live":
- pre = ctx.preopt_result
- if pre is None:
- return False
- traj = getattr(pre, "trajectory", None)
- energies = list(getattr(pre, "energies_hartree", []))
- else:
- if ctx.result_dir is None:
- return False
- preopt_path = ctx.result_dir / "preopt_trajectory.json"
- if not preopt_path.exists():
- return False
- try:
- from quantui.results_storage import load_trajectory
-
- traj, energies = load_trajectory(
- ctx.result_dir, filename="preopt_trajectory.json"
- )
- except Exception as _exc:
- from quantui import calc_log as _clog
-
- _clog.log_event(
- "pop_preopt_trajectory_error",
- f"{type(_exc).__name__}: {_exc}"[:300],
- )
- return False
- if not traj or len(traj) < 2:
- return False
- stub = _types_mod.SimpleNamespace(
- trajectory=traj,
- energies_hartree=energies,
- formula=ctx.formula,
- )
- self._pending_traj_result = stub
- self.traj_accordion.set_title(0, "Pre-optimization Trajectory")
- return True
+ return _ana_pop_preopt_trajectory(self, ctx)
def _pop_vibrational(self, ctx: _AnalysisContext) -> bool:
- if ctx.live_result is not None:
- freq_stub = ctx.live_result
- mol = ctx.molecule
- else:
- ir = ctx.spectra_data.get("ir", {})
- mol_data = ctx.spectra_data.get("molecule", {})
- freqs = ir.get("frequencies_cm1")
- ints = ir.get("ir_intensities")
- disps = ir.get("displacements")
- if not (freqs and disps and mol_data.get("atoms")):
- return False
- from quantui.molecule import Molecule as _Mol
-
- mol = _Mol(
- atoms=mol_data["atoms"],
- coordinates=mol_data["coords"],
- charge=mol_data.get("charge", 0),
- multiplicity=mol_data.get("multiplicity", 1),
- )
- freq_stub = _types_mod.SimpleNamespace(
- frequencies_cm1=freqs,
- ir_intensities=ints,
- displacements=disps,
- )
- return self._show_vib_animation(freq_stub, mol)
+ return _ana_pop_vibrational(self, ctx)
def _pop_ir_spectrum(self, ctx: _AnalysisContext) -> bool:
- if ctx.live_result is not None:
- freq_stub = ctx.live_result
- else:
- ir = ctx.spectra_data.get("ir", {})
- freqs = ir.get("frequencies_cm1")
- if not freqs:
- return False
- freq_stub = _types_mod.SimpleNamespace(
- frequencies_cm1=freqs,
- ir_intensities=ir.get("ir_intensities") or [],
- )
- return self._show_ir_spectrum(freq_stub)
+ return _ana_pop_ir_spectrum(self, ctx)
def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool:
- if ctx.live_result is not None:
- energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", []))
- osc = list(getattr(ctx.live_result, "oscillator_strengths", []))
- try:
- wl = list(ctx.live_result.wavelengths_nm())
- except Exception:
- wl = [1240.0 / e for e in energies_ev if e > 0]
- else:
- uv = ctx.spectra_data.get("uv_vis", {})
- energies_ev = uv.get("excitation_energies_ev", [])
- osc = uv.get("oscillator_strengths", [])
- wl = uv.get("wavelengths_nm", [])
- if not energies_ev or not osc:
- return False
- try:
- import plotly.graph_objects as _go
- import plotly.io as _pio
-
- _fig = _go.Figure()
- _fig.add_trace(
- _go.Bar(
- x=wl,
- y=osc,
- name="Osc. strength",
- marker_color="#2563eb",
- width=[4.0] * len(wl),
- )
- )
- tc = self._plotly_theme_colors()
- _fig.update_layout(
- xaxis_title="Wavelength (nm)",
- yaxis_title="Oscillator strength",
- height=320,
- margin=dict(l=60, r=20, t=30, b=50),
- plot_bgcolor=tc["plot_bgcolor"],
- paper_bgcolor=tc["paper_bgcolor"],
- font=dict(color=tc["font_color"]),
- xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- )
- self._apply_plotly_theme(_fig)
- self._set_html_output(
- self._tddft_fig,
- _pio.to_html(
- _fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- return True
- except Exception:
- return False
+ return _ana_pop_uv_vis(self, ctx)
def _pop_nmr_shielding(self, ctx: _AnalysisContext) -> bool:
- if ctx.live_result is not None:
- r = ctx.live_result
- atom_symbols = list(getattr(r, "atom_symbols", []))
- shielding = list(getattr(r, "shielding_iso_ppm", []))
- try:
- h_shifts = r.h_shifts()
- c_shifts = r.c_shifts()
- except Exception:
- h_shifts, c_shifts = [], []
- ref = getattr(r, "reference_compound", "TMS")
- else:
- nmr = ctx.spectra_data.get("nmr", {})
- atom_symbols = nmr.get("atom_symbols", [])
- shielding = nmr.get("shielding_iso_ppm", [])
- chem = nmr.get("chemical_shifts_ppm", {})
- ref = nmr.get("reference_compound", "TMS")
- # Reconstruct h/c shifts from stored chemical_shifts_ppm dict
- h_shifts = [
- (int(i), d)
- for i, d in chem.items()
- if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H"
- ]
- c_shifts = [
- (int(i), d)
- for i, d in chem.items()
- if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C"
- ]
- if not atom_symbols:
- return False
-
- def _shift_table(label: str, shifts: list, sym: str) -> str:
- if not shifts:
- return ""
- rows = "".join(
- f'{sym}-{n} '
- f'{d:.2f} ppm '
- for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1)
- )
- return (
- f''
- f"{label} shifts (vs. {ref}): "
- f'Atom '
- f'δ (ppm) '
- + rows
- )
-
- shielding_rows = "".join(
- f'{sym}{i + 1} '
- f'{s:.2f} '
- for i, (sym, s) in enumerate(zip(atom_symbols, shielding))
- )
- html = (
- f''
- f'
'
- f'Atom '
- f'σ (ppm) '
- f"{shielding_rows}
"
- f'
'
- f"{_shift_table('¹H', h_shifts, 'H')}"
- f"{_shift_table('¹³C', c_shifts, 'C')}"
- f"
"
- )
- self._nmr_output.value = html
- return True
+ return _ana_pop_nmr_shielding(self, ctx)
def _pop_pes_plot(self, ctx: _AnalysisContext) -> bool:
- result = ctx.live_result
- if result is None:
- scan = ctx.spectra_data.get("pes_scan", {})
- if not scan or not scan.get("energies_hartree"):
- return False
- energies_ha = scan["energies_hartree"]
- atom_indices = scan.get("atom_indices", [])
- scan_type = scan.get("scan_type", "bond")
- x_vals = scan.get("scan_parameter_values", [])
- e_min = min(energies_ha)
- _HARTREE_TO_KCAL = 627.5094740631
- e_rel = [(e - e_min) * _HARTREE_TO_KCAL for e in energies_ha]
- idx = [i + 1 for i in atom_indices]
- if scan_type == "bond":
- label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å"
- elif scan_type == "angle":
- label = (
- f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °"
- if len(idx) >= 3
- else "Angle / °"
- )
- else:
- label = (
- f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °"
- if len(idx) >= 4
- else "Dihedral / °"
- )
- result = _types_mod.SimpleNamespace(
- scan_type=scan_type,
- atom_indices=atom_indices,
- scan_parameter_values=x_vals,
- energies_hartree=energies_ha,
- energies_relative_kcal=e_rel,
- scan_coordinate_label=label,
- converged_all=True,
- )
- return self._show_pes_scan_result(result)
+ return _ana_pop_pes_plot(self, ctx)
def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool:
- traj: list = []
- energies: list = []
- if ctx.live_result is not None:
- traj = list(getattr(ctx.live_result, "coordinates_list", []))
- energies = list(getattr(ctx.live_result, "energies_hartree", []))
- elif ctx.result_dir is not None:
- traj_file = ctx.result_dir / "trajectory.json"
- if traj_file.exists():
- try:
- from quantui.results_storage import load_trajectory
-
- traj, energies = load_trajectory(ctx.result_dir)
- except Exception:
- return False
- if not traj or len(traj) < 2:
- return False
- stub = _types_mod.SimpleNamespace(
- coordinates_list=traj,
- energies_hartree=energies,
- trajectory=None,
- formula=ctx.formula,
- )
- self._pending_traj_result = stub
- self.traj_accordion.set_title(0, "Geometry at Each Scan Point")
- return True
+ return _ana_pop_pes_trajectory(self, ctx)
# ── History panel (Cell 8) ────────────────────────────────────────────
diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py
new file mode 100644
index 0000000..4905243
--- /dev/null
+++ b/quantui/app_analysis.py
@@ -0,0 +1,450 @@
+"""Analysis panel state and population helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+import types as _types_mod
+from typing import Any
+
+import ipywidgets as widgets
+
+
+def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
+ """Initialise analysis panel state and wire accordion re-render observers."""
+ panel_meta = [
+ (name, getattr(app, attr), when) for name, attr, when in app._PANEL_META
+ ]
+ app._ana_panel_names = [m[0] for m in panel_meta]
+ app._ana_accordions = [m[1] for m in panel_meta]
+ app._ana_available = set()
+ app._ana_active = ""
+ app._ana_unavail_html = widgets.HTML(
+ value="",
+ layout=layout_fn(display="none", margin="4px 0 8px"),
+ )
+
+ # Wrap each accordion child with both an unavailable message and real content.
+ app._ana_unavail_msgs = {}
+ app._ana_content_boxes = {}
+ for name, acc, when in panel_meta:
+ unavail = widgets.HTML(
+ value=(
+ f'Not available — run a {when} '
+ f"calculation first.
"
+ ),
+ layout=layout_fn(display=""),
+ )
+ content = acc.children[0]
+ app._ana_unavail_msgs[name] = unavail
+ app._ana_content_boxes[name] = content
+ content.layout.display = "none"
+ acc.children = (widgets.VBox([unavail, content]),)
+ acc.layout.display = "" # always in the DOM
+ acc.selected_index = None # collapsed until activated
+
+ # Re-render Plotly charts when their accordion is expanded by header click.
+ app._ir_accordion.observe(
+ app._safe_cb(app._on_ir_accordion_show), names=["selected_index"]
+ )
+ app._orb_accordion.observe(
+ app._safe_cb(app._on_orb_accordion_show), names=["selected_index"]
+ )
+
+
+def select_ana_panel(app: Any, name: str) -> None:
+ """Expand the named panel and collapse all others."""
+ app._ana_active = name
+ app._ana_unavail_html.layout.display = "none"
+ for panel_name, acc in zip(app._ana_panel_names, app._ana_accordions):
+ acc.selected_index = 0 if panel_name == name else None
+
+
+def activate_ana_panel(app: Any, name: str, auto_select: bool = True) -> None:
+ """Mark a panel as available and reveal its content."""
+ app._ana_available.add(name)
+ if name in app._ana_unavail_msgs:
+ app._ana_unavail_msgs[name].layout.display = "none"
+ app._ana_content_boxes[name].layout.display = ""
+ if auto_select:
+ app._select_ana_panel(name)
+
+
+def deactivate_all_ana_panels(app: Any) -> None:
+ """Reset all panels to collapsed/unavailable for a new run/context."""
+ app._ana_available.clear()
+ app._ana_active = ""
+ app._ana_unavail_html.layout.display = "none"
+ for name, acc in zip(app._ana_panel_names, app._ana_accordions):
+ if name in app._ana_unavail_msgs:
+ app._ana_unavail_msgs[name].layout.display = ""
+ app._ana_content_boxes[name].layout.display = "none"
+ acc.selected_index = None
+
+
+def apply_analysis_context(app: Any, ctx: Any) -> None:
+ """Populate Analysis panels from context and activate panels with data."""
+ app._deactivate_all_ana_panels()
+ app._pending_traj_result = None
+ app.traj_accordion.set_title(0, "Trajectory Viewer")
+
+ first_auto_selected = False
+ for panel_name, method_name, want_auto in app._PANEL_REGISTRY.get(
+ ctx.calc_type, []
+ ):
+ try:
+ ok = bool(getattr(app, method_name)(ctx))
+ except Exception as panel_exc:
+ ok = False
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event(
+ "ana_panel_error",
+ f"{method_name}: {type(panel_exc).__name__}: {panel_exc}"[:300],
+ )
+ except Exception:
+ pass
+ if ok:
+ do_auto = want_auto and not first_auto_selected
+ app._activate_ana_panel(panel_name, auto_select=do_auto)
+ if do_auto:
+ first_auto_selected = True
+
+ source_suffix = " (from History)" if ctx.source == "history" else ""
+ app._analysis_context_lbl.value = (
+ f''
+ f"Analysing: {ctx.label}{source_suffix}
"
+ )
+ has_any = bool(app._ana_available)
+ app._to_analysis_btn.layout.display = "" if has_any else "none"
+ app._analysis_empty_html.layout.display = "none" if has_any else ""
+
+
+def pop_energies(app: Any, ctx: Any) -> bool:
+ """Populate Energies panel from live result or history orbitals."""
+ result = ctx.live_result
+ if result is None and ctx.result_dir is not None:
+ try:
+ from quantui.results_storage import load_orbitals
+
+ orb = load_orbitals(ctx.result_dir)
+ orb.formula = ctx.formula
+ result = orb
+ except Exception:
+ return False
+ return bool(app._show_orbital_diagram(result))
+
+
+def pop_isosurface(app: Any, ctx: Any) -> bool:
+ """Populate Isosurface availability from orbital state."""
+ return (
+ app._last_orb_mo_coeff is not None
+ and app._last_orb_mol_atom is not None
+ and app._last_orb_mol_basis is not None
+ )
+
+
+def pop_geo_trajectory(app: Any, ctx: Any) -> bool:
+ """Populate Trajectory panel for geometry optimization contexts."""
+ traj = None
+ energies: list = []
+ if ctx.live_result is not None:
+ traj = getattr(ctx.live_result, "trajectory", None)
+ energies = list(getattr(ctx.live_result, "energies_hartree", []))
+ elif ctx.result_dir is not None:
+ traj_file = ctx.result_dir / "trajectory.json"
+ if traj_file.exists():
+ try:
+ from quantui.results_storage import load_trajectory
+
+ traj, energies = load_trajectory(ctx.result_dir)
+ except Exception:
+ return False
+ if not traj or len(traj) < 2:
+ return False
+ stub = _types_mod.SimpleNamespace(
+ trajectory=traj,
+ energies_hartree=energies,
+ formula=ctx.formula,
+ )
+ app._pending_traj_result = stub
+ return True
+
+
+def pop_preopt_trajectory(app: Any, ctx: Any) -> bool:
+ """Populate Trajectory panel for frequency pre-optimization contexts."""
+ if ctx.source == "live":
+ pre = ctx.preopt_result
+ if pre is None:
+ return False
+ traj = getattr(pre, "trajectory", None)
+ energies = list(getattr(pre, "energies_hartree", []))
+ else:
+ if ctx.result_dir is None:
+ return False
+ preopt_path = ctx.result_dir / "preopt_trajectory.json"
+ if not preopt_path.exists():
+ return False
+ try:
+ from quantui.results_storage import load_trajectory
+
+ traj, energies = load_trajectory(
+ ctx.result_dir, filename="preopt_trajectory.json"
+ )
+ except Exception as exc:
+ from quantui import calc_log as _clog
+
+ _clog.log_event(
+ "pop_preopt_trajectory_error",
+ f"{type(exc).__name__}: {exc}"[:300],
+ )
+ return False
+ if not traj or len(traj) < 2:
+ return False
+ stub = _types_mod.SimpleNamespace(
+ trajectory=traj,
+ energies_hartree=energies,
+ formula=ctx.formula,
+ )
+ app._pending_traj_result = stub
+ app.traj_accordion.set_title(0, "Pre-optimization Trajectory")
+ return True
+
+
+def pop_vibrational(app: Any, ctx: Any) -> bool:
+ """Populate Vibrational panel from live or history frequency data."""
+ if ctx.live_result is not None:
+ freq_stub = ctx.live_result
+ mol = ctx.molecule
+ else:
+ ir = ctx.spectra_data.get("ir", {})
+ mol_data = ctx.spectra_data.get("molecule", {})
+ freqs = ir.get("frequencies_cm1")
+ ints = ir.get("ir_intensities")
+ disps = ir.get("displacements")
+ if not (freqs and disps and mol_data.get("atoms")):
+ return False
+ from quantui.molecule import Molecule as _Mol
+
+ mol = _Mol(
+ atoms=mol_data["atoms"],
+ coordinates=mol_data["coords"],
+ charge=mol_data.get("charge", 0),
+ multiplicity=mol_data.get("multiplicity", 1),
+ )
+ freq_stub = _types_mod.SimpleNamespace(
+ frequencies_cm1=freqs,
+ ir_intensities=ints,
+ displacements=disps,
+ )
+ return bool(app._show_vib_animation(freq_stub, mol))
+
+
+def pop_ir_spectrum(app: Any, ctx: Any) -> bool:
+ """Populate IR panel from live or history frequency data."""
+ if ctx.live_result is not None:
+ freq_stub = ctx.live_result
+ else:
+ ir = ctx.spectra_data.get("ir", {})
+ freqs = ir.get("frequencies_cm1")
+ if not freqs:
+ return False
+ freq_stub = _types_mod.SimpleNamespace(
+ frequencies_cm1=freqs,
+ ir_intensities=ir.get("ir_intensities") or [],
+ )
+ return bool(app._show_ir_spectrum(freq_stub))
+
+
+def pop_uv_vis(app: Any, ctx: Any) -> bool:
+ """Populate UV-Vis panel from live or history TDDFT data."""
+ if ctx.live_result is not None:
+ energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", []))
+ osc = list(getattr(ctx.live_result, "oscillator_strengths", []))
+ try:
+ wl = list(ctx.live_result.wavelengths_nm())
+ except Exception:
+ wl = [1240.0 / e for e in energies_ev if e > 0]
+ else:
+ uv = ctx.spectra_data.get("uv_vis", {})
+ energies_ev = uv.get("excitation_energies_ev", [])
+ osc = uv.get("oscillator_strengths", [])
+ wl = uv.get("wavelengths_nm", [])
+ if not energies_ev or not osc:
+ return False
+ try:
+ import plotly.graph_objects as _go
+ import plotly.io as _pio
+
+ fig = _go.Figure()
+ fig.add_trace(
+ _go.Bar(
+ x=wl,
+ y=osc,
+ name="Osc. strength",
+ marker_color="#2563eb",
+ width=[4.0] * len(wl),
+ )
+ )
+ tc = app._plotly_theme_colors()
+ fig.update_layout(
+ xaxis_title="Wavelength (nm)",
+ yaxis_title="Oscillator strength",
+ height=320,
+ margin=dict(l=60, r=20, t=30, b=50),
+ plot_bgcolor=tc["plot_bgcolor"],
+ paper_bgcolor=tc["paper_bgcolor"],
+ font=dict(color=tc["font_color"]),
+ xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
+ yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
+ )
+ app._apply_plotly_theme(fig)
+ app._set_html_output(
+ app._tddft_fig,
+ _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ ),
+ )
+ return True
+ except Exception:
+ return False
+
+
+def pop_nmr_shielding(app: Any, ctx: Any) -> bool:
+ """Populate NMR panel from live or history shielding data."""
+ if ctx.live_result is not None:
+ result = ctx.live_result
+ atom_symbols = list(getattr(result, "atom_symbols", []))
+ shielding = list(getattr(result, "shielding_iso_ppm", []))
+ try:
+ h_shifts = result.h_shifts()
+ c_shifts = result.c_shifts()
+ except Exception:
+ h_shifts, c_shifts = [], []
+ ref = getattr(result, "reference_compound", "TMS")
+ else:
+ nmr = ctx.spectra_data.get("nmr", {})
+ atom_symbols = nmr.get("atom_symbols", [])
+ shielding = nmr.get("shielding_iso_ppm", [])
+ chem = nmr.get("chemical_shifts_ppm", {})
+ ref = nmr.get("reference_compound", "TMS")
+ h_shifts = [
+ (int(i), d)
+ for i, d in chem.items()
+ if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H"
+ ]
+ c_shifts = [
+ (int(i), d)
+ for i, d in chem.items()
+ if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C"
+ ]
+ if not atom_symbols:
+ return False
+
+ def _shift_table(label: str, shifts: list, sym: str) -> str:
+ if not shifts:
+ return ""
+ rows = "".join(
+ f'{sym}-{n} '
+ f'{d:.2f} ppm '
+ for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1)
+ )
+ return (
+ f''
+ f"{label} shifts (vs. {ref}): "
+ f'Atom '
+ f'δ (ppm) '
+ + rows
+ )
+
+ shielding_rows = "".join(
+ f'{sym}{i + 1} '
+ f'{s:.2f} '
+ for i, (sym, s) in enumerate(zip(atom_symbols, shielding))
+ )
+ html = (
+ f''
+ f'
'
+ f'Atom '
+ f'σ (ppm) '
+ f"{shielding_rows}
"
+ f'
'
+ f"{_shift_table('¹H', h_shifts, 'H')}"
+ f"{_shift_table('¹³C', c_shifts, 'C')}"
+ f"
"
+ )
+ app._nmr_output.value = html
+ return True
+
+
+def pop_pes_plot(app: Any, ctx: Any) -> bool:
+ """Populate PES plot panel from live or history scan data."""
+ result = ctx.live_result
+ if result is None:
+ scan = ctx.spectra_data.get("pes_scan", {})
+ if not scan or not scan.get("energies_hartree"):
+ return False
+ energies_ha = scan["energies_hartree"]
+ atom_indices = scan.get("atom_indices", [])
+ scan_type = scan.get("scan_type", "bond")
+ x_vals = scan.get("scan_parameter_values", [])
+ e_min = min(energies_ha)
+ hartree_to_kcal = 627.5094740631
+ e_rel = [(e - e_min) * hartree_to_kcal for e in energies_ha]
+ idx = [i + 1 for i in atom_indices]
+ if scan_type == "bond":
+ label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å"
+ elif scan_type == "angle":
+ label = (
+ f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °"
+ if len(idx) >= 3
+ else "Angle / °"
+ )
+ else:
+ label = (
+ f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °"
+ if len(idx) >= 4
+ else "Dihedral / °"
+ )
+ result = _types_mod.SimpleNamespace(
+ scan_type=scan_type,
+ atom_indices=atom_indices,
+ scan_parameter_values=x_vals,
+ energies_hartree=energies_ha,
+ energies_relative_kcal=e_rel,
+ scan_coordinate_label=label,
+ converged_all=True,
+ )
+ return bool(app._show_pes_scan_result(result))
+
+
+def pop_pes_trajectory(app: Any, ctx: Any) -> bool:
+ """Populate Trajectory panel from live or history PES scan data."""
+ traj: list = []
+ energies: list = []
+ if ctx.live_result is not None:
+ traj = list(getattr(ctx.live_result, "coordinates_list", []))
+ energies = list(getattr(ctx.live_result, "energies_hartree", []))
+ elif ctx.result_dir is not None:
+ traj_file = ctx.result_dir / "trajectory.json"
+ if traj_file.exists():
+ try:
+ from quantui.results_storage import load_trajectory
+
+ traj, energies = load_trajectory(ctx.result_dir)
+ except Exception:
+ return False
+ if not traj or len(traj) < 2:
+ return False
+ stub = _types_mod.SimpleNamespace(
+ coordinates_list=traj,
+ energies_hartree=energies,
+ trajectory=None,
+ formula=ctx.formula,
+ )
+ app._pending_traj_result = stub
+ app.traj_accordion.set_title(0, "Geometry at Each Scan Point")
+ return True
From 4171fe06d3eda1cc3a1c1535ca4fde941107b412 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 13:29:24 -0400
Subject: [PATCH 06/22] Extract visualization helpers to module
Move visualization and rendering logic out of QuantUIApp into a new quantui/app_visualization.py. app.py now imports the visualization functions (prefixed _viz_) and delegates the former inlined methods (trajectory, vib/IR plotting, orbital isosurfaces, 3D rendering, PES plotting, etc.) to the new module, slimming app.py and avoiding circular-import issues. Behavior is preserved; the new module contains the full implementations (trajectory carousel, vib animation, IR/ORB plotting, export handlers, etc.). Also includes small call-site cleanups (e.g. local _last_pes handling and passing layout_fn into trajectory helpers).
---
quantui/app.py | 1144 +++-------------------------------
quantui/app_visualization.py | 1061 +++++++++++++++++++++++++++++++
2 files changed, 1150 insertions(+), 1055 deletions(-)
create mode 100644 quantui/app_visualization.py
diff --git a/quantui/app.py b/quantui/app.py
index 6cb9c7b..9d5fc9a 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -134,6 +134,66 @@
from quantui.app_history import (
on_view_log as _hist_on_view_log,
)
+from quantui.app_visualization import (
+ build_vib_data_from_freq_result as _viz_build_vib_data_from_freq_result,
+)
+from quantui.app_visualization import (
+ build_vib_data_inner as _viz_build_vib_data_inner,
+)
+from quantui.app_visualization import (
+ on_ir_fwhm_changed as _viz_on_ir_fwhm_changed,
+)
+from quantui.app_visualization import (
+ on_ir_mode_changed as _viz_on_ir_mode_changed,
+)
+from quantui.app_visualization import (
+ on_iso_generate as _viz_on_iso_generate,
+)
+from quantui.app_visualization import (
+ on_orb_range_changed as _viz_on_orb_range_changed,
+)
+from quantui.app_visualization import (
+ on_traj_expand as _viz_on_traj_expand,
+)
+from quantui.app_visualization import (
+ on_vib_mode_changed as _viz_on_vib_mode_changed,
+)
+from quantui.app_visualization import (
+ render_orbital_isosurface as _viz_render_orbital_isosurface,
+)
+from quantui.app_visualization import (
+ render_traj_frame as _viz_render_traj_frame,
+)
+from quantui.app_visualization import (
+ render_vib_mode as _viz_render_vib_mode,
+)
+from quantui.app_visualization import (
+ show_ir_spectrum as _viz_show_ir_spectrum,
+)
+from quantui.app_visualization import (
+ show_opt_trajectory as _viz_show_opt_trajectory,
+)
+from quantui.app_visualization import (
+ show_orbital_diagram as _viz_show_orbital_diagram,
+)
+from quantui.app_visualization import (
+ show_pes_scan_result as _viz_show_pes_scan_result,
+)
+from quantui.app_visualization import (
+ show_result_3d as _viz_show_result_3d,
+)
+from quantui.app_visualization import (
+ show_vib_animation as _viz_show_vib_animation,
+)
+from quantui.app_visualization import (
+ traj_step_html as _viz_traj_step_html,
+)
+from quantui.app_visualization import (
+ update_ir_figure as _viz_update_ir_figure,
+)
+from quantui.app_visualization import (
+ wire_ir_controls as _viz_wire_ir_controls,
+)
# Import directly from submodules to avoid circular-import issues.
# quantui/__init__.py imports this module (app.py), so using
@@ -2367,8 +2427,9 @@ def _rerender_plotly_theme(self) -> None:
self._ir_mode_toggle.value,
self._ir_fwhm_slider.value,
)
- if getattr(self, "_last_pes_result", None) is not None:
- self._show_pes_scan_result(self._last_pes_result)
+ _last_pes = getattr(self, "_last_pes_result", None)
+ if _last_pes is not None:
+ self._show_pes_scan_result(_last_pes)
# Re-render 3D molecule viewer so scene_bgcolor updates immediately.
if self._molecule is not None and _display_molecule is not None:
self.viz_output.clear_output()
@@ -3204,26 +3265,12 @@ def _set_molecule_threadsafe(self, mol, status_message: str) -> None:
self._queue_main_thread_callback(self._set_molecule, mol, status_message)
def _show_result_3d(self, molecule, extra_output=None) -> None:
- """Render molecule 3D structure in the result visualization panel.
-
- Renders into ``result_viz_output`` and, if supplied, into *extra_output*
- as well (used to mirror the structure into the Analysis tab viewer).
- Safe to call from a background thread — uses ``with output:`` context.
- """
- if _display_molecule is None or molecule is None:
- return
- for _out in [self.result_viz_output, extra_output]:
- if _out is None:
- continue
- _out.clear_output()
- with _out:
- _display_molecule(
- molecule,
- backend=self._viz_backend,
- style=self._viz_style,
- lighting=self._viz_lighting,
- bgcolor=self._plotly_theme_colors()["scene_bgcolor"],
- )
+ _viz_show_result_3d(
+ self,
+ molecule,
+ extra_output,
+ display_molecule_fn=_display_molecule,
+ )
def _show_result_log(self, saved_dir: Path, log_text: str) -> None:
"""Populate the result-directory label and output-log accordion.
@@ -3261,1018 +3308,62 @@ def _show_result_log(self, saved_dir: Path, log_text: str) -> None:
self._result_log_accordion.layout.display = ""
def _on_traj_expand(self, change) -> None:
- """Lazily generate the trajectory animation when the accordion is first opened."""
- if change["new"] != 0:
- return
- result = self._pending_traj_result
- if result is None:
- return
- self._pending_traj_result = None
-
- from IPython.display import HTML as _H
- from IPython.display import display as _d
-
- self.traj_output.clear_output()
- with self.traj_output:
- _d(
- _H(
- 'Loading trajectory viewer…
'
- )
- )
-
- def _render():
- try:
- self._show_opt_trajectory(result)
- except Exception as exc:
- from IPython.display import HTML as _H2
- from IPython.display import display as _d2
-
- self.traj_output.clear_output()
- with self.traj_output:
- _d2(
- _H2(
- f'⚠ Trajectory rendering failed: {exc}
'
- )
- )
-
- threading.Thread(target=_render, daemon=True).start()
+ _viz_on_traj_expand(self, change)
def _show_opt_trajectory(self, opt_result) -> None:
- """Build the trajectory carousel and energy chart in the trajectory panel.
-
- Shows a step slider for flipping through frames and an energy-convergence
- chart. An Export button generates a standalone HTML animation file on demand.
- Safe to call from a background thread.
-
- When plotlymol is available:
- - Bond perception runs once on frame 0 (RDKit DetermineConnectivity is slow).
- - All remaining frames are pre-rendered in a background thread pool so
- slider navigation is instant after a few seconds.
- """
- import concurrent.futures
-
- from IPython.display import display as _ipy_display
-
- # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list)
- traj = getattr(opt_result, "trajectory", None) or getattr(
- opt_result, "coordinates_list", []
- )
- energies = opt_result.energies_hartree
- n = len(traj)
- if n < 2:
- self.traj_output.clear_output()
- with self.traj_output:
- _ipy_display(
- HTML(
- ''
- "No trajectory data available (single-frame result).
"
- )
- )
- return
-
- _HARTREE_TO_KCAL = 627.5094740631
- e0 = energies[0] if energies else 0.0
- rel_e = [(e - e0) * _HARTREE_TO_KCAL for e in energies] if energies else []
-
- # --- Energy convergence chart ---
- _has_plotly = False
- try:
- import plotly.graph_objects as go
-
- energy_fig = go.Figure(
- go.Scatter(
- x=list(range(n)),
- y=rel_e,
- mode="lines+markers",
- name="ΔE",
- line=dict(color="#2563eb", width=2),
- marker=dict(size=6),
- )
- )
- energy_fig.update_layout(
- title="Energy Convergence",
- xaxis_title="Step",
- yaxis_title="ΔE (kcal/mol)",
- height=220,
- margin=dict(l=60, r=20, t=40, b=40),
- )
- _has_plotly = True
- except ImportError:
- pass
-
- # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) ---
- _charge = traj[0].charge
- _xyzblocks = [
- f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj
- ]
- _FRAME_W, _FRAME_H, _FRAME_RES = 460, 340, 8
-
- # --- Attempt to set up fast-path: bond perception once on frame 0 ---
- # draw_3D_mol accepts a pre-parsed RDKit mol and skips bond perception,
- # so we only pay that cost for the first frame instead of every frame.
- _ref_mol = None
- _plotlymol_fast = False
- try:
- from plotlymol3d import (
- draw_3D_mol as _draw_3D_mol,
- )
- from plotlymol3d import (
- format_figure as _fmt_fig,
- )
- from plotlymol3d import (
- format_lighting as _fmt_light,
- )
- from plotlymol3d import (
- make_subplots as _make_subplots,
- )
- from plotlymol3d import (
- xyzblock_to_rdkitmol as _xyz_to_rdkit,
- )
- from rdkit import Chem as _Chem
-
- from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP
-
- _ref_mol = _xyz_to_rdkit(_xyzblocks[0], charge=_charge)
- _plotlymol_fast = _ref_mol is not None
- except Exception:
- pass
-
- def _build_fig_fast(idx: int):
- """Reuse frame-0 bond topology; only swap in new atom positions."""
- mol_xyz = _Chem.MolFromXYZBlock(_xyzblocks[idx] + "\n")
- if mol_xyz is None:
- return None
- rw = _Chem.RWMol(_ref_mol)
- conf_src = mol_xyz.GetConformer()
- conf_dst = rw.GetConformer()
- for atom_idx in range(rw.GetNumAtoms()):
- conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx))
- fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]])
- _draw_3D_mol(fig, rw.GetMol(), _FRAME_RES, "ball+stick")
- fig = _fmt_fig(fig)
- fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"]))
- _scene_bg = self._plotly_theme_colors()["scene_bgcolor"]
- fig.update_layout(
- width=_FRAME_W,
- height=_FRAME_H,
- paper_bgcolor="white",
- scene=dict(bgcolor=_scene_bg),
- margin=dict(l=0, r=0, t=0, b=0),
- )
- return fig
-
- def _build_fig(idx: int):
- """Return (kind, obj) for frame idx; fast path when bonds are cached."""
- if _plotlymol_fast:
- try:
- fig = _build_fig_fast(idx)
- if fig is not None:
- return ("plotly", fig)
- except Exception:
- pass
- # Slow fallback: full plotlymol pipeline
- try:
- from quantui.visualization_py3dmol import visualize_molecule_plotlymol
-
- fig = visualize_molecule_plotlymol(
- traj[idx],
- mode="ball+stick",
- resolution=_FRAME_RES,
- width=_FRAME_W,
- height=_FRAME_H,
- )
- _scene_bg = self._plotly_theme_colors()["scene_bgcolor"]
- fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=_scene_bg))
- return ("plotly", fig)
- except ImportError:
- pass
- # Last resort: py3Dmol
- try:
- import py3Dmol as _p3d
-
- view = _p3d.view(width=_FRAME_W, height=_FRAME_H)
- view.addModel(_xyzblocks[idx], "xyz")
- view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
- view.setBackgroundColor(
- "white" if self.theme_btn.value == "Light" else "#1e1e1e"
- )
- view.zoomTo()
- return ("py3dmol", view)
- except Exception as exc:
- return ("error", str(exc))
-
- _frame_cache: dict = {}
-
- # --- Carousel controls ---
- _step_slider = widgets.IntSlider(
- value=0,
- min=0,
- max=n - 1,
- description="Step:",
- continuous_update=False,
- style={"description_width": "40px"},
- layout=_layout(width="360px"),
- )
- _step_info = widgets.HTML(value=self._traj_step_html(0, traj, energies, rel_e))
- _frame_out = widgets.Output(layout=_layout(min_height="340px"))
- _cache_label = widgets.HTML(
- value=f''
- f"Pre-rendering frames… 0 / {n} "
- )
-
- def _display_frame(idx: int) -> None:
- kind, obj = _frame_cache[idx]
- _frame_out.clear_output()
- with _frame_out:
- if kind == "error":
- _ipy_display(
- HTML(
- f'Frame render failed: {obj}
'
- )
- )
- else:
- _ipy_display(obj)
-
- def _update_frame(change) -> None:
- idx = change["new"]
- _step_info.value = self._traj_step_html(idx, traj, energies, rel_e)
- if idx in _frame_cache:
- _display_frame(idx)
- return
- _frame_out.clear_output()
- with _frame_out:
- _ipy_display(
- HTML(
- 'Rendering…
'
- )
- )
-
- def _on_demand():
- try:
- _frame_cache[idx] = _build_fig(idx)
- _display_frame(idx)
- except Exception as exc:
- _frame_out.clear_output()
- with _frame_out:
- _ipy_display(
- HTML(
- f'Frame render failed: {exc}
'
- )
- )
-
- threading.Thread(target=_on_demand, daemon=True).start()
-
- _step_slider.observe(self._safe_cb(_update_frame), names="value")
-
- # --- Export button ---
- _export_btn = widgets.Button(
- description="Export Animation",
- icon="download",
- layout=_layout(width="160px", margin="0 0 0 12px"),
- tooltip="Generate a standalone HTML animation file (may take a minute)",
- )
- _export_status = widgets.HTML()
-
- def _on_export(_btn):
- _btn.disabled = True
- _export_status.value = (
- f''
- f"Generating {n}-frame animation, please wait… "
- )
-
- def _do_export():
- try:
- from plotlymol3d import create_trajectory_animation
-
- anim_fig = create_trajectory_animation(
- xyzblocks=_xyzblocks,
- energies_hartree=energies if energies else None,
- charge=_charge,
- mode="ball+stick",
- resolution=12,
- title=f"Geo Opt: {opt_result.formula}",
- )
- _result_dir = getattr(self, "_last_result_dir", None)
- out_path = (
- _result_dir / "trajectory_animation.html"
- if _result_dir is not None
- else Path.home() / f"{opt_result.formula}_trajectory.html"
- )
- anim_fig.write_html(str(out_path))
- _export_status.value = (
- f''
- f"✓ Saved: {out_path} "
- )
- except Exception as exc:
- _export_status.value = (
- f'Export failed: {exc} '
- )
- finally:
- _btn.disabled = False
-
- threading.Thread(target=_do_export, daemon=True).start()
-
- _export_btn.on_click(_on_export)
-
- # --- Assemble layout ---
- _header = widgets.HBox(
- [_step_slider, _export_btn],
- layout=_layout(align_items="center", margin="4px 0"),
- )
- _panel = widgets.VBox(
- [_header, _step_info, _cache_label, _frame_out, _export_status]
- )
-
- # Display panel immediately — clears the “Loading…” message right away.
- self.traj_output.clear_output()
- with self.traj_output:
- if _has_plotly and rel_e:
- _ipy_display(energy_fig)
- _ipy_display(_panel)
-
- # Show placeholder while frame 0 renders in the background.
- _frame_out.clear_output()
- with _frame_out:
- _ipy_display(
- HTML(
- ''
- "Rendering frame 0…
"
- )
- )
-
- # Render all frames (0 first, then 1+) in a background thread.
- def _prerender_all() -> None:
- try:
- _frame_cache[0] = _build_fig(0)
- _display_frame(0)
- _cache_label.value = (
- f''
- f"Pre-rendering frames… 1 / {n} "
- )
- if n > 1:
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
- futures = {pool.submit(_build_fig, i): i for i in range(1, n)}
- done = 1
- for fut in concurrent.futures.as_completed(futures):
- i = futures[fut]
- try:
- _frame_cache[i] = fut.result()
- except Exception:
- pass
- done += 1
- _cache_label.value = (
- f''
- f"Pre-rendering frames… {done} / {n} "
- )
- except Exception:
- pass
- _cache_label.value = (
- f''
- f"✓ All {n} frames ready "
- )
-
- threading.Thread(target=_prerender_all, daemon=True).start()
+ _viz_show_opt_trajectory(self, opt_result, layout_fn=_layout)
def _traj_step_html(self, step: int, traj, energies, rel_e) -> str:
- """One-line info label for the given trajectory step index."""
- n = len(traj)
- mol = traj[step]
- e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—"
- delta = (
- f" · ΔE = {rel_e[step]:+.3f} kcal/mol"
- if rel_e and step < len(rel_e)
- else ""
- )
- return (
- f''
- f"Step {step} / {n - 1} · {mol.get_formula()}"
- f" · E = {e_abs}{delta} "
- )
+ return _viz_traj_step_html(self, step, traj, energies, rel_e)
def _render_traj_frame(self, molecule, output_widget) -> None:
- """Render a single trajectory frame into output_widget (thread-safe).
-
- Tries plotlymol first, falls back to py3Dmol.
- """
- try:
- from quantui.visualization_py3dmol import visualize_molecule_plotlymol
-
- fig = visualize_molecule_plotlymol(
- molecule, mode="ball+stick", resolution=8, width=460, height=340
- )
- _scene_bg = self._plotly_theme_colors()["scene_bgcolor"]
- fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=_scene_bg))
- output_widget.clear_output()
- with output_widget:
- display(fig)
- return
- except ImportError:
- pass
-
- # Fallback: py3Dmol
- try:
- import py3Dmol as _p3d
-
- xyz = (
- f"{len(molecule.atoms)}\n"
- f"{molecule.get_formula()}\n"
- f"{molecule.to_xyz_string()}"
- )
- view = _p3d.view(width=460, height=340)
- view.addModel(xyz, "xyz")
- view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
- view.setBackgroundColor("white")
- view.zoomTo()
- output_widget.clear_output()
- with output_widget:
- display(view)
- except Exception as exc:
- output_widget.clear_output()
- with output_widget:
- display(
- HTML(
- f'Frame render failed: {exc}
'
- )
- )
+ _viz_render_traj_frame(self, molecule, output_widget)
def _build_vib_data_from_freq_result(self, freq_result, molecule):
- """Construct a ``plotlymol3d.VibrationalData`` from a FreqResult.
-
- Args:
- freq_result: ``FreqResult`` with ``displacements`` populated.
- molecule: The ``Molecule`` used for the frequency calculation.
-
- Returns:
- ``VibrationalData`` or ``None`` if prerequisites are missing.
- """
- try:
- import numpy as np
- from plotlymol3d import VibrationalData, VibrationalMode
- except ImportError:
- return None
-
- try:
- return self._build_vib_data_inner(
- freq_result, molecule, np, VibrationalData, VibrationalMode
- )
- except Exception as _e:
- try:
- from quantui import calc_log as _clog
-
- _clog.log_event("vib_data_error", f"{type(_e).__name__}: {_e}"[:300])
- except Exception:
- pass
- return None
+ return _viz_build_vib_data_from_freq_result(self, freq_result, molecule)
def _build_vib_data_inner(
self, freq_result, molecule, np, VibrationalData, VibrationalMode
):
- displacements = getattr(freq_result, "displacements", None)
- if displacements is None:
- return None
-
- freqs = freq_result.frequencies_cm1
- intensities = freq_result.ir_intensities
- n_modes = len(freqs)
-
- coords = np.array(molecule.coordinates, dtype=float)
-
- # Map element symbols to atomic numbers using a common-elements table.
- # ASE is not required — this covers all elements students will encounter.
- _Z = {
- "H": 1,
- "He": 2,
- "Li": 3,
- "Be": 4,
- "B": 5,
- "C": 6,
- "N": 7,
- "O": 8,
- "F": 9,
- "Ne": 10,
- "Na": 11,
- "Mg": 12,
- "Al": 13,
- "Si": 14,
- "P": 15,
- "S": 16,
- "Cl": 17,
- "Ar": 18,
- "K": 19,
- "Ca": 20,
- "Br": 35,
- "I": 53,
- }
- atomic_numbers: List[int] = [_Z.get(sym, 0) for sym in molecule.atoms]
-
- modes = []
- for i in range(n_modes):
- freq = freqs[i]
- ir_inten = intensities[i] if i < len(intensities) else None
- displ = np.array(displacements[i], dtype=float)
- modes.append(
- VibrationalMode(
- mode_number=i + 1,
- frequency=float(freq),
- ir_intensity=ir_inten,
- displacement_vectors=displ,
- is_imaginary=freq < 0,
- )
- )
-
- return VibrationalData(
- coordinates=coords,
- atomic_numbers=atomic_numbers,
- modes=modes,
- source_file="quantui_freq_calc",
- program="pyscf",
+ return _viz_build_vib_data_inner(
+ self, freq_result, molecule, np, VibrationalData, VibrationalMode
)
def _show_vib_animation(self, freq_result, molecule) -> bool:
- """Populate the vibrational animation accordion after a Frequency result.
-
- Builds a ``VibrationalData`` from the result, populates the mode selector
- dropdown, and renders the animation for the first non-trivial mode.
- Returns True if populated, False if data is missing or plotlyMol unavailable.
- Does NOT call ``_activate_ana_panel``; that is handled by the registry.
- """
- vib_data = self._build_vib_data_from_freq_result(freq_result, molecule)
- if vib_data is None:
- return False
-
- freqs = freq_result.frequencies_cm1
- if not freqs:
- return False
-
- # Build dropdown options: one entry per mode with frequency label.
- # Skip near-zero translation/rotation modes (|ν| < 10 cm⁻¹).
- options = []
- for m in vib_data.modes:
- freq_val = m.frequency
- if abs(freq_val) < 10:
- continue
- label = (
- f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹"
- if freq_val >= 0
- else f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)"
- )
- options.append((label, m.mode_number))
-
- if not options:
- return False
-
- self.vib_mode_dd.options = options
- self.vib_mode_dd.value = options[0][1]
-
- # Store vib_data for callback use.
- self._last_vib_data = vib_data
- self._last_vib_molecule = molecule
-
- # Show loading indicator and render in a background thread so _do_run
- # is not blocked while the animation is generated (can take several seconds).
- # append_display_data is used instead of display() because this method is
- # called from the _do_run background thread; display(HTML(...)) is not
- # thread-safe for plain HTML but append_display_data is.
- _first_label, _first_mode = options[0]
- self.vib_output.clear_output()
- self.vib_output.append_display_data(
- HTML(
- f''
- f"⏳ Rendering vibrational animation ({_first_label})…
"
- )
- )
- threading.Thread(
- target=self._render_vib_mode,
- args=(vib_data, molecule, _first_mode),
- daemon=True,
- ).start()
-
- return True
+ return _viz_show_vib_animation(self, freq_result, molecule)
def _show_ir_spectrum(self, freq_result) -> bool:
- """Populate the IR Spectrum accordion after a Frequency result.
-
- Returns True if populated, False if no frequency data at all.
- When IR intensities are unavailable, falls back to unit weights so the
- panel still activates showing frequency positions.
- Does NOT call ``_activate_ana_panel``; that is handled by the registry.
- """
- freqs = list(freq_result.frequencies_cm1 or [])
- ints = list(getattr(freq_result, "ir_intensities", None) or [])
- if not freqs:
- return False
-
- # When intensities are missing, substitute unit weights so the stick
- # plot still shows frequency positions; accordion title reflects this.
- self._ir_intensities_real = bool(ints)
- if not ints:
- ints = [1.0] * len(freqs)
- self._ir_accordion.set_title(
- 0,
- (
- "IR Spectrum"
- if self._ir_intensities_real
- else "IR Spectrum (positions only — intensities unavailable)"
- ),
- )
-
- # Store for callbacks
- self._last_ir_freqs = freqs
- self._last_ir_ints = ints
-
- self._update_ir_figure("Stick", 20.0)
-
- # _show_ir_spectrum may run from the _do_run background thread.
- # Wire observers and set widget state on the main thread.
- self._queue_main_thread_callback(self._wire_ir_controls)
-
- return True
+ return _viz_show_ir_spectrum(self, freq_result)
def _wire_ir_controls(self) -> None:
- """(Re)bind IR controls and reset defaults on the main thread."""
- self._ir_mode_toggle.unobserve_all()
- self._ir_fwhm_slider.unobserve_all()
- self._ir_mode_toggle.observe(
- self._safe_cb(self._on_ir_mode_changed), names="value"
- )
- self._ir_fwhm_slider.observe(
- self._safe_cb(self._on_ir_fwhm_changed), names="value"
- )
-
- # Reset toggle/slider to defaults
- self._ir_mode_toggle.value = "Stick"
- self._ir_fwhm_slider.value = 20.0
- self._ir_fwhm_slider.layout.display = "none"
+ _viz_wire_ir_controls(self)
def _on_ir_mode_changed(self, change) -> None:
- """Handle Stick/Broadened mode changes for IR panel."""
- mode = change["new"]
- try:
- _calc_log.log_event(
- "ir_mode_change",
- mode,
- mode=mode,
- session_id=self._session_id,
- )
- except Exception:
- pass
- self._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none"
- self._update_ir_figure(mode, self._ir_fwhm_slider.value)
+ _viz_on_ir_mode_changed(self, change)
def _on_ir_fwhm_changed(self, change) -> None:
- """Re-render broadened IR trace when line width slider changes."""
- if self._ir_mode_toggle.value == "Broadened":
- self._update_ir_figure("Broadened", change["new"])
+ _viz_on_ir_fwhm_changed(self, change)
def _update_ir_figure(self, mode: str, fwhm: float) -> None:
- """Re-render the IR spectrum chart for the given mode and FWHM."""
- try:
- import plotly.io as _pio
-
- from quantui.ir_plot import plot_ir_spectrum
-
- _ytitle = (
- "IR Intensity (km/mol)"
- if getattr(self, "_ir_intensities_real", True)
- else "Relative intensity (a.u.)"
- )
- fig = plot_ir_spectrum(
- self._last_ir_freqs,
- self._last_ir_ints,
- mode=mode.lower(),
- fwhm=fwhm,
- yaxis_title=_ytitle,
- )
- self._apply_plotly_theme(fig)
- self._set_html_output(
- self._ir_fig,
- _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- except Exception as _e:
- try:
- from quantui import calc_log as _clog
-
- _clog.log_event("ir_fig_error", f"{type(_e).__name__}: {_e}"[:300])
- except Exception:
- pass
+ _viz_update_ir_figure(self, mode, fwhm)
def _show_orbital_diagram(self, result) -> bool:
- """Build and reveal the interactive orbital diagram accordion.
-
- Returns True if the diagram was populated, False if data is missing.
- Does NOT call ``_activate_ana_panel``; that is handled by the registry.
- """
- mo_energy = getattr(result, "mo_energy_hartree", None)
- mo_occ = getattr(result, "mo_occ", None)
- if mo_energy is None or mo_occ is None:
- return False
-
- try:
- from quantui.orbital_visualization import orbital_info_from_arrays
-
- info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula)
- except Exception:
- return False
-
- self._last_orb_info = info
- self._last_orb_mo_coeff = getattr(result, "mo_coeff", None)
- self._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None)
- self._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None)
-
- _plotly_rendered = False
- try:
- import plotly.io as _pio
-
- from quantui.orbital_visualization import plot_orbital_diagram_plotly
-
- fig = plot_orbital_diagram_plotly(
- info, max_orbitals=self._orb_n_orb_input.value
- )
- # Sync axis limit controls to auto-computed range
- yr = fig.layout.yaxis.range
- if yr is not None:
- self._orb_ymin_input.value = round(float(yr[0]), 2)
- self._orb_ymax_input.value = round(float(yr[1]), 2)
- self._apply_plotly_theme(fig)
- html_str = _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- )
- self._set_html_output(self._orb_diagram_html, html_str)
- _plotly_rendered = True
- except Exception:
- pass
-
- if not _plotly_rendered:
- # Fallback: static matplotlib PNG (plotly not installed)
- import base64
- import io as _io
-
- try:
- from matplotlib.backends.backend_agg import (
- FigureCanvasAgg as _AggCanvas,
- )
-
- from quantui.orbital_visualization import plot_orbital_diagram
-
- mpl_fig = plot_orbital_diagram(info)
- _AggCanvas(mpl_fig)
- buf = _io.BytesIO()
- mpl_fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
- buf.seek(0)
- img_b64 = base64.b64encode(buf.read()).decode()
- self._set_html_output(
- self._orb_diagram_html,
- (
- f' '
- ),
- )
- except Exception:
- pass
-
- if (
- self._last_orb_mo_coeff is not None
- and self._last_orb_mol_atom is not None
- and self._last_orb_mol_basis is not None
- ):
- self._orb_iso_output.clear_output()
- self._orb_toggle.value = "HOMO"
- self._orb_iso_controls.layout.display = ""
- self._iso_generate_btn.disabled = False
- else:
- self._orb_iso_controls.layout.display = "none"
- self._iso_generate_btn.disabled = True
-
- return True
+ return _viz_show_orbital_diagram(self, result)
def _on_iso_generate(self, btn) -> None:
- """Generate an orbital isosurface for the currently selected orbital."""
- orbital_label = self._orb_toggle.value
- btn.disabled = True
- btn.description = "Generating…"
- self._orb_iso_output.clear_output()
- with self._orb_iso_output:
- display(
- HTML(
- f''
- f"⏳ Generating {orbital_label} cube file and rendering isosurface"
- f" — this may take 15–30 s…
"
- )
- )
-
- def _run():
- try:
- self._render_orbital_isosurface(orbital_label)
- finally:
- btn.disabled = False
- btn.description = "Generate Isosurface"
-
- threading.Thread(target=_run, daemon=True).start()
+ _viz_on_iso_generate(self, btn)
def _on_orb_range_changed(self, _change=None) -> None:
- """Live-update the orbital diagram when axis limits or orbital count changes."""
- info = getattr(self, "_last_orb_info", None)
- if info is None:
- return
- ymin = self._orb_ymin_input.value
- ymax = self._orb_ymax_input.value
- if ymin >= ymax:
- return
- try:
- import plotly.io as _pio
-
- from quantui.orbital_visualization import plot_orbital_diagram_plotly
-
- fig = plot_orbital_diagram_plotly(
- info,
- max_orbitals=self._orb_n_orb_input.value,
- yrange=(ymin, ymax),
- )
- self._apply_plotly_theme(fig)
- self._set_html_output(
- self._orb_diagram_html,
- _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- except Exception:
- pass
+ _viz_on_orb_range_changed(self, _change)
def _render_orbital_isosurface(self, orbital_label: str) -> None:
- """Generate a cube file and render an orbital isosurface (Linux/WSL only)."""
- import tempfile
-
- orb_info = getattr(self, "_last_orb_info", None)
- if orb_info is None:
- return
-
- n_occ = orb_info.n_occupied
- n_total = len(orb_info.mo_energies_ev)
- _idx_map = {
- "HOMO-1": n_occ - 2,
- "HOMO": n_occ - 1,
- "LUMO": n_occ,
- "LUMO+1": n_occ + 1,
- }
- orb_idx = _idx_map.get(orbital_label)
- if orb_idx is None or orb_idx < 0 or orb_idx >= n_total:
- return
-
- mo_coeff = getattr(self, "_last_orb_mo_coeff", None)
- mol_atom = getattr(self, "_last_orb_mol_atom", None)
- mol_basis = getattr(self, "_last_orb_mol_basis", None)
- if mo_coeff is None or mol_atom is None or mol_basis is None:
- return
-
- try:
- from quantui.orbital_visualization import (
- generate_cube_from_arrays,
- plot_cube_isosurface,
- )
-
- with tempfile.TemporaryDirectory() as tmpdir:
- cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube"
- generate_cube_from_arrays(
- mol_atom, mol_basis, mo_coeff, orb_idx, cube_path
- )
- fig = plot_cube_isosurface(
- cube_path, title=f"{orbital_label} Isosurface"
- )
- except Exception as _exc:
- from IPython.display import HTML as _H
- from IPython.display import display as _d
-
- self._orb_iso_output.clear_output()
- with self._orb_iso_output:
- _d(
- _H(
- f'⚠ Orbital isosurface failed: {_exc}
'
- )
- )
- return
-
- from IPython.display import display as _ipy_display
-
- self._orb_iso_output.clear_output()
- with self._orb_iso_output:
- _ipy_display(fig)
+ _viz_render_orbital_isosurface(self, orbital_label)
def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None:
- """Render vibrational animation for the given mode into ``vib_output``.
-
- Safe to call from background thread via ``with output:`` context.
- """
- from IPython.display import HTML as _H
-
- def _err(msg: str) -> None:
- self.vib_output.clear_output()
- self.vib_output.append_display_data(
- _H(f'⚠ {msg}
')
- )
-
- try:
- from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol
- except ImportError as exc:
- _err(
- f"Vibrational animation requires plotlymol3d "
- f"(pip install plotlymol3d): {exc}"
- )
- return
-
- # Build an RDKit mol for bond connectivity (required by animation function).
- xyzblock = (
- f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
- f"{molecule.to_xyz_string()}"
- )
- try:
- rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
- except Exception as exc:
- _err(f"Could not parse molecule for bond connectivity: {exc}")
- return
-
- try:
- from quantui import calc_log as _clog_anim
-
- _clog_anim.log_event("vib_render_start", f"mode {mode_number}")
- except Exception:
- pass
- try:
- anim_fig = create_vibration_animation(
- vib_data=vib_data,
- mode_number=mode_number,
- mol=rdmol,
- amplitude=0.4,
- n_frames=20,
- mode="ball+stick",
- resolution=12,
- )
- anim_fig.update_layout(height=420)
- except Exception as exc:
- try:
- from quantui import calc_log as _clog_anim
-
- _clog_anim.log_event(
- "vib_render_error",
- f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300],
- )
- except Exception:
- pass
- _err(f"Animation generation failed: {exc}")
- return
- try:
- from quantui import calc_log as _clog_anim
-
- _clog_anim.log_event("vib_render_done", f"mode {mode_number}")
- except Exception:
- pass
-
- import plotly.io as _pio
-
- _anim_html = _pio.to_html(
- anim_fig,
- full_html=False,
- include_plotlyjs="require",
- config={"responsive": True},
- )
- self.vib_output.clear_output()
- self.vib_output.append_display_data(_H(_anim_html))
+ _viz_render_vib_mode(self, vib_data, molecule, mode_number)
def _on_vib_mode_changed(self, change) -> None:
- """Re-render vib animation when the mode dropdown changes."""
- mode_number = change["new"]
- vib_data = getattr(self, "_last_vib_data", None)
- molecule = getattr(self, "_last_vib_molecule", None)
- if vib_data is None or molecule is None:
- return
- # Show a loading indicator immediately so the user gets feedback while
- # the animation generates in the background.
- _label = next(
- (lbl for lbl, num in self.vib_mode_dd.options if num == mode_number),
- f"mode {mode_number}",
- )
- self.vib_output.clear_output()
- self.vib_output.append_display_data(
- HTML(
- f''
- f"⏳ Rendering vibrational animation ({_label})…
"
- )
- )
- threading.Thread(
- target=self._render_vib_mode,
- args=(vib_data, molecule, mode_number),
- daemon=True,
- ).start()
+ _viz_on_vib_mode_changed(self, change)
def _do_run(self) -> None:
"""Main calculation dispatch — runs in a background thread."""
@@ -5141,64 +4232,7 @@ def _format_pes_scan_result(self, r) -> str:
return _fmt_pes_scan_result(r)
def _show_pes_scan_result(self, result) -> bool:
- """Render the PES energy profile chart.
-
- Returns True if the chart was rendered, False if plotly is unavailable.
- Does NOT call ``_activate_ana_panel`` or set up trajectory; those are
- handled by ``_pop_pes_plot`` and ``_pop_pes_trajectory`` in the registry.
- """
- self._last_pes_result = result
- try:
- import plotly.graph_objects as go
- import plotly.io as pio
-
- e_rel = result.energies_relative_kcal
- x_vals = result.scan_parameter_values
-
- hover_text = [
- f"{result.scan_coordinate_label}: {x:.4f} "
- f"ΔE = {de:.3f} kcal/mol "
- f"E = {e:.8f} Ha"
- for x, de, e in zip(x_vals, e_rel, result.energies_hartree)
- ]
-
- fig = go.Figure(
- go.Scatter(
- x=x_vals,
- y=e_rel,
- mode="lines+markers",
- line=dict(color="#2563eb", width=2),
- marker=dict(size=8, color="#2563eb"),
- hovertext=hover_text,
- hoverinfo="text",
- )
- )
- tc = self._plotly_theme_colors()
- fig.update_layout(
- xaxis_title=result.scan_coordinate_label,
- yaxis_title="Relative energy / kcal mol⁻¹",
- height=380,
- margin=dict(l=60, r=20, t=30, b=50),
- plot_bgcolor=tc["plot_bgcolor"],
- paper_bgcolor=tc["paper_bgcolor"],
- font=dict(color=tc["font_color"]),
- xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- hovermode="closest",
- )
- self._set_html_output(
- self._pes_plot_html,
- pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- except Exception:
- pass
-
- return True
+ return _viz_show_pes_scan_result(self, result)
def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> str:
return _fmt_past_result(data, result_dir=result_dir)
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
new file mode 100644
index 0000000..b096d21
--- /dev/null
+++ b/quantui/app_visualization.py
@@ -0,0 +1,1061 @@
+"""Visualization and rendering helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+import threading
+from pathlib import Path
+from typing import Any, List
+
+import ipywidgets as widgets
+from IPython.display import HTML, display
+
+
+def show_result_3d(
+ app: Any,
+ molecule: Any,
+ extra_output: Any = None,
+ *,
+ display_molecule_fn: Any,
+) -> None:
+ """Render molecule 3D structure in result and optional extra output panels."""
+ if display_molecule_fn is None or molecule is None:
+ return
+ for out_widget in [app.result_viz_output, extra_output]:
+ if out_widget is None:
+ continue
+ out_widget.clear_output()
+ with out_widget:
+ display_molecule_fn(
+ molecule,
+ backend=app._viz_backend,
+ style=app._viz_style,
+ lighting=app._viz_lighting,
+ bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
+ )
+
+
+def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
+ """Lazily generate trajectory animation when accordion first opens."""
+ if change["new"] != 0:
+ return
+ result = app._pending_traj_result
+ if result is None:
+ return
+ app._pending_traj_result = None
+
+ from IPython.display import HTML as _H
+ from IPython.display import display as _d
+
+ app.traj_output.clear_output()
+ with app.traj_output:
+ _d(
+ _H(
+ 'Loading trajectory viewer…
'
+ )
+ )
+
+ def _render() -> None:
+ try:
+ app._show_opt_trajectory(result)
+ except Exception as exc:
+ from IPython.display import HTML as _H2
+ from IPython.display import display as _d2
+
+ app.traj_output.clear_output()
+ with app.traj_output:
+ _d2(
+ _H2(
+ f'⚠ Trajectory rendering failed: {exc}
'
+ )
+ )
+
+ threading.Thread(target=_render, daemon=True).start()
+
+
+def show_opt_trajectory(app: Any, opt_result: Any, *, layout_fn: Any) -> None:
+ """Build trajectory carousel and energy chart in trajectory panel."""
+ import concurrent.futures
+
+ from IPython.display import display as _ipy_display
+
+ # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list)
+ traj = getattr(opt_result, "trajectory", None) or getattr(
+ opt_result, "coordinates_list", []
+ )
+ energies = opt_result.energies_hartree
+ n = len(traj)
+ if n < 2:
+ app.traj_output.clear_output()
+ with app.traj_output:
+ _ipy_display(
+ HTML(
+ ''
+ "No trajectory data available (single-frame result).
"
+ )
+ )
+ return
+
+ hartree_to_kcal = 627.5094740631
+ e0 = energies[0] if energies else 0.0
+ rel_e = [(e - e0) * hartree_to_kcal for e in energies] if energies else []
+
+ # --- Energy convergence chart ---
+ has_plotly = False
+ try:
+ import plotly.graph_objects as go
+
+ energy_fig = go.Figure(
+ go.Scatter(
+ x=list(range(n)),
+ y=rel_e,
+ mode="lines+markers",
+ name="ΔE",
+ line=dict(color="#2563eb", width=2),
+ marker=dict(size=6),
+ )
+ )
+ energy_fig.update_layout(
+ title="Energy Convergence",
+ xaxis_title="Step",
+ yaxis_title="ΔE (kcal/mol)",
+ height=220,
+ margin=dict(l=60, r=20, t=40, b=40),
+ )
+ has_plotly = True
+ except ImportError:
+ pass
+
+ # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) ---
+ charge = traj[0].charge
+ xyzblocks = [
+ f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj
+ ]
+ frame_w, frame_h, frame_res = 460, 340, 8
+
+ # --- Attempt fast-path: bond perception once on frame 0 ---
+ ref_mol = None
+ plotlymol_fast = False
+ try:
+ from plotlymol3d import (
+ draw_3D_mol as _draw_3D_mol,
+ )
+ from plotlymol3d import (
+ format_figure as _fmt_fig,
+ )
+ from plotlymol3d import (
+ format_lighting as _fmt_light,
+ )
+ from plotlymol3d import (
+ make_subplots as _make_subplots,
+ )
+ from plotlymol3d import (
+ xyzblock_to_rdkitmol as _xyz_to_rdkit,
+ )
+ from rdkit import Chem as _Chem
+
+ from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP
+
+ ref_mol = _xyz_to_rdkit(xyzblocks[0], charge=charge)
+ plotlymol_fast = ref_mol is not None
+ except Exception:
+ pass
+
+ def _build_fig_fast(idx: int):
+ """Reuse frame-0 bond topology; only swap in new atom positions."""
+ mol_xyz = _Chem.MolFromXYZBlock(xyzblocks[idx] + "\n")
+ if mol_xyz is None:
+ return None
+ rw = _Chem.RWMol(ref_mol)
+ conf_src = mol_xyz.GetConformer()
+ conf_dst = rw.GetConformer()
+ for atom_idx in range(rw.GetNumAtoms()):
+ conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx))
+ fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]])
+ _draw_3D_mol(fig, rw.GetMol(), frame_res, "ball+stick")
+ fig = _fmt_fig(fig)
+ fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"]))
+ scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
+ fig.update_layout(
+ width=frame_w,
+ height=frame_h,
+ paper_bgcolor="white",
+ scene=dict(bgcolor=scene_bg),
+ margin=dict(l=0, r=0, t=0, b=0),
+ )
+ return fig
+
+ def _build_fig(idx: int):
+ """Return (kind, obj) for frame idx; fast path when bonds are cached."""
+ if plotlymol_fast:
+ try:
+ fig = _build_fig_fast(idx)
+ if fig is not None:
+ return ("plotly", fig)
+ except Exception:
+ pass
+ # Slow fallback: full plotlymol pipeline
+ try:
+ from quantui.visualization_py3dmol import visualize_molecule_plotlymol
+
+ fig = visualize_molecule_plotlymol(
+ traj[idx],
+ mode="ball+stick",
+ resolution=frame_res,
+ width=frame_w,
+ height=frame_h,
+ )
+ scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
+ fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg))
+ return ("plotly", fig)
+ except ImportError:
+ pass
+ # Last resort: py3Dmol
+ try:
+ import py3Dmol as _p3d
+
+ view = _p3d.view(width=frame_w, height=frame_h)
+ view.addModel(xyzblocks[idx], "xyz")
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
+ view.setBackgroundColor(
+ "white" if app.theme_btn.value == "Light" else "#1e1e1e"
+ )
+ view.zoomTo()
+ return ("py3dmol", view)
+ except Exception as exc:
+ return ("error", str(exc))
+
+ frame_cache: dict[int, Any] = {}
+
+ # --- Carousel controls ---
+ step_slider = widgets.IntSlider(
+ value=0,
+ min=0,
+ max=n - 1,
+ description="Step:",
+ continuous_update=False,
+ style={"description_width": "40px"},
+ layout=layout_fn(width="360px"),
+ )
+ step_info = widgets.HTML(value=app._traj_step_html(0, traj, energies, rel_e))
+ frame_out = widgets.Output(layout=layout_fn(min_height="340px"))
+ cache_label = widgets.HTML(
+ value=f''
+ f"Pre-rendering frames… 0 / {n} "
+ )
+
+ def _display_frame(idx: int) -> None:
+ kind, obj = frame_cache[idx]
+ frame_out.clear_output()
+ with frame_out:
+ if kind == "error":
+ _ipy_display(
+ HTML(
+ f'Frame render failed: {obj}
'
+ )
+ )
+ else:
+ _ipy_display(obj)
+
+ def _update_frame(change: dict[str, Any]) -> None:
+ idx = change["new"]
+ step_info.value = app._traj_step_html(idx, traj, energies, rel_e)
+ if idx in frame_cache:
+ _display_frame(idx)
+ return
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(
+ HTML(
+ 'Rendering…
'
+ )
+ )
+
+ def _on_demand() -> None:
+ try:
+ frame_cache[idx] = _build_fig(idx)
+ _display_frame(idx)
+ except Exception as exc:
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(
+ HTML(
+ f'Frame render failed: {exc}
'
+ )
+ )
+
+ threading.Thread(target=_on_demand, daemon=True).start()
+
+ step_slider.observe(app._safe_cb(_update_frame), names="value")
+
+ # --- Export button ---
+ export_btn = widgets.Button(
+ description="Export Animation",
+ icon="download",
+ layout=layout_fn(width="160px", margin="0 0 0 12px"),
+ tooltip="Generate a standalone HTML animation file (may take a minute)",
+ )
+ export_status = widgets.HTML()
+
+ def _on_export(_btn) -> None:
+ _btn.disabled = True
+ export_status.value = (
+ f''
+ f"Generating {n}-frame animation, please wait… "
+ )
+
+ def _do_export() -> None:
+ try:
+ from plotlymol3d import create_trajectory_animation
+
+ anim_fig = create_trajectory_animation(
+ xyzblocks=xyzblocks,
+ energies_hartree=energies if energies else None,
+ charge=charge,
+ mode="ball+stick",
+ resolution=12,
+ title=f"Geo Opt: {opt_result.formula}",
+ )
+ result_dir = getattr(app, "_last_result_dir", None)
+ out_path = (
+ result_dir / "trajectory_animation.html"
+ if result_dir is not None
+ else Path.home() / f"{opt_result.formula}_trajectory.html"
+ )
+ anim_fig.write_html(str(out_path))
+ export_status.value = (
+ f''
+ f"✓ Saved: {out_path} "
+ )
+ except Exception as exc:
+ export_status.value = (
+ f'Export failed: {exc} '
+ )
+ finally:
+ _btn.disabled = False
+
+ threading.Thread(target=_do_export, daemon=True).start()
+
+ export_btn.on_click(_on_export)
+
+ # --- Assemble layout ---
+ header = widgets.HBox(
+ [step_slider, export_btn],
+ layout=layout_fn(align_items="center", margin="4px 0"),
+ )
+ panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status])
+
+ # Display panel immediately.
+ app.traj_output.clear_output()
+ with app.traj_output:
+ if has_plotly and rel_e:
+ _ipy_display(energy_fig)
+ _ipy_display(panel)
+
+ # Show placeholder while frame 0 renders in the background.
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(
+ HTML(
+ ''
+ "Rendering frame 0…
"
+ )
+ )
+
+ def _prerender_all() -> None:
+ """Render all frames (0 first, then 1+) in a background thread."""
+ try:
+ frame_cache[0] = _build_fig(0)
+ _display_frame(0)
+ cache_label.value = (
+ f''
+ f"Pre-rendering frames… 1 / {n} "
+ )
+ if n > 1:
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
+ futures = {pool.submit(_build_fig, i): i for i in range(1, n)}
+ done = 1
+ for fut in concurrent.futures.as_completed(futures):
+ i = futures[fut]
+ try:
+ frame_cache[i] = fut.result()
+ except Exception:
+ pass
+ done += 1
+ cache_label.value = (
+ f''
+ f"Pre-rendering frames… {done} / {n} "
+ )
+ except Exception:
+ pass
+ cache_label.value = (
+ f''
+ f"✓ All {n} frames ready "
+ )
+
+ threading.Thread(target=_prerender_all, daemon=True).start()
+
+
+def traj_step_html(
+ app: Any, step: int, traj: list[Any], energies: list[Any], rel_e: list[Any]
+) -> str:
+ """One-line info label for a trajectory step index."""
+ n = len(traj)
+ mol = traj[step]
+ e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—"
+ delta = (
+ f" · ΔE = {rel_e[step]:+.3f} kcal/mol"
+ if rel_e and step < len(rel_e)
+ else ""
+ )
+ return (
+ f''
+ f"Step {step} / {n - 1} · {mol.get_formula()}"
+ f" · E = {e_abs}{delta} "
+ )
+
+
+def render_traj_frame(app: Any, molecule: Any, output_widget: Any) -> None:
+ """Render one trajectory frame into output widget."""
+ try:
+ from quantui.visualization_py3dmol import visualize_molecule_plotlymol
+
+ fig = visualize_molecule_plotlymol(
+ molecule, mode="ball+stick", resolution=8, width=460, height=340
+ )
+ scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
+ fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg))
+ output_widget.clear_output()
+ with output_widget:
+ display(fig)
+ return
+ except ImportError:
+ pass
+
+ # Fallback: py3Dmol
+ try:
+ import py3Dmol as _p3d
+
+ xyz = (
+ f"{len(molecule.atoms)}\n"
+ f"{molecule.get_formula()}\n"
+ f"{molecule.to_xyz_string()}"
+ )
+ view = _p3d.view(width=460, height=340)
+ view.addModel(xyz, "xyz")
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
+ view.setBackgroundColor("white")
+ view.zoomTo()
+ output_widget.clear_output()
+ with output_widget:
+ display(view)
+ except Exception as exc:
+ output_widget.clear_output()
+ with output_widget:
+ display(
+ HTML(
+ f'Frame render failed: {exc}
'
+ )
+ )
+
+
+def build_vib_data_from_freq_result(app: Any, freq_result: Any, molecule: Any) -> Any:
+ """Construct plotlymol3d VibrationalData from a frequency result."""
+ try:
+ import numpy as np
+ from plotlymol3d import VibrationalData, VibrationalMode
+ except ImportError:
+ return None
+
+ try:
+ return app._build_vib_data_inner(
+ freq_result, molecule, np, VibrationalData, VibrationalMode
+ )
+ except Exception as exc:
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event("vib_data_error", f"{type(exc).__name__}: {exc}"[:300])
+ except Exception:
+ pass
+ return None
+
+
+def build_vib_data_inner(
+ app: Any,
+ freq_result: Any,
+ molecule: Any,
+ np: Any,
+ VibrationalData: Any,
+ VibrationalMode: Any,
+) -> Any:
+ """Internal constructor for VibrationalData with dependency injection."""
+ displacements = getattr(freq_result, "displacements", None)
+ if displacements is None:
+ return None
+
+ freqs = freq_result.frequencies_cm1
+ intensities = freq_result.ir_intensities
+ n_modes = len(freqs)
+
+ coords = np.array(molecule.coordinates, dtype=float)
+
+ # Map element symbols to atomic numbers using a common-elements table.
+ z_map = {
+ "H": 1,
+ "He": 2,
+ "Li": 3,
+ "Be": 4,
+ "B": 5,
+ "C": 6,
+ "N": 7,
+ "O": 8,
+ "F": 9,
+ "Ne": 10,
+ "Na": 11,
+ "Mg": 12,
+ "Al": 13,
+ "Si": 14,
+ "P": 15,
+ "S": 16,
+ "Cl": 17,
+ "Ar": 18,
+ "K": 19,
+ "Ca": 20,
+ "Br": 35,
+ "I": 53,
+ }
+ atomic_numbers: List[int] = [z_map.get(sym, 0) for sym in molecule.atoms]
+
+ modes = []
+ for i in range(n_modes):
+ freq = freqs[i]
+ ir_inten = intensities[i] if i < len(intensities) else None
+ displ = np.array(displacements[i], dtype=float)
+ modes.append(
+ VibrationalMode(
+ mode_number=i + 1,
+ frequency=float(freq),
+ ir_intensity=ir_inten,
+ displacement_vectors=displ,
+ is_imaginary=freq < 0,
+ )
+ )
+
+ return VibrationalData(
+ coordinates=coords,
+ atomic_numbers=atomic_numbers,
+ modes=modes,
+ source_file="quantui_freq_calc",
+ program="pyscf",
+ )
+
+
+def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool:
+ """Populate vibrational animation accordion after a Frequency result."""
+ vib_data = app._build_vib_data_from_freq_result(freq_result, molecule)
+ if vib_data is None:
+ return False
+
+ freqs = freq_result.frequencies_cm1
+ if not freqs:
+ return False
+
+ # Build dropdown options; skip near-zero translation/rotation modes.
+ options = []
+ for mode in vib_data.modes:
+ freq_val = mode.frequency
+ if abs(freq_val) < 10:
+ continue
+ label = (
+ f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹"
+ if freq_val >= 0
+ else f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)"
+ )
+ options.append((label, mode.mode_number))
+
+ if not options:
+ return False
+
+ app.vib_mode_dd.options = options
+ app.vib_mode_dd.value = options[0][1]
+
+ app._last_vib_data = vib_data
+ app._last_vib_molecule = molecule
+
+ first_label, first_mode = options[0]
+ app.vib_output.clear_output()
+ app.vib_output.append_display_data(
+ HTML(
+ f''
+ f"⏳ Rendering vibrational animation ({first_label})…
"
+ )
+ )
+ threading.Thread(
+ target=app._render_vib_mode,
+ args=(vib_data, molecule, first_mode),
+ daemon=True,
+ ).start()
+
+ return True
+
+
+def show_ir_spectrum(app: Any, freq_result: Any) -> bool:
+ """Populate IR Spectrum accordion after a Frequency result."""
+ freqs = list(freq_result.frequencies_cm1 or [])
+ ints = list(getattr(freq_result, "ir_intensities", None) or [])
+ if not freqs:
+ return False
+
+ app._ir_intensities_real = bool(ints)
+ if not ints:
+ ints = [1.0] * len(freqs)
+ app._ir_accordion.set_title(
+ 0,
+ (
+ "IR Spectrum"
+ if app._ir_intensities_real
+ else "IR Spectrum (positions only — intensities unavailable)"
+ ),
+ )
+
+ app._last_ir_freqs = freqs
+ app._last_ir_ints = ints
+
+ app._update_ir_figure("Stick", 20.0)
+
+ # _show_ir_spectrum may run from _do_run background thread.
+ app._queue_main_thread_callback(app._wire_ir_controls)
+
+ return True
+
+
+def wire_ir_controls(app: Any) -> None:
+ """Rebind IR controls and reset defaults on the main thread."""
+ app._ir_mode_toggle.unobserve_all()
+ app._ir_fwhm_slider.unobserve_all()
+ app._ir_mode_toggle.observe(app._safe_cb(app._on_ir_mode_changed), names="value")
+ app._ir_fwhm_slider.observe(app._safe_cb(app._on_ir_fwhm_changed), names="value")
+
+ app._ir_mode_toggle.value = "Stick"
+ app._ir_fwhm_slider.value = 20.0
+ app._ir_fwhm_slider.layout.display = "none"
+
+
+def on_ir_mode_changed(app: Any, change: dict[str, Any]) -> None:
+ """Handle Stick/Broadened mode changes for IR panel."""
+ mode = change["new"]
+ try:
+ import quantui.calc_log as _calc_log
+
+ _calc_log.log_event(
+ "ir_mode_change",
+ mode,
+ mode=mode,
+ session_id=app._session_id,
+ )
+ except Exception:
+ pass
+ app._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none"
+ app._update_ir_figure(mode, app._ir_fwhm_slider.value)
+
+
+def on_ir_fwhm_changed(app: Any, change: dict[str, Any]) -> None:
+ """Re-render broadened IR trace when line width slider changes."""
+ if app._ir_mode_toggle.value == "Broadened":
+ app._update_ir_figure("Broadened", change["new"])
+
+
+def update_ir_figure(app: Any, mode: str, fwhm: float) -> None:
+ """Re-render IR spectrum chart for mode and FWHM settings."""
+ try:
+ import plotly.io as _pio
+
+ from quantui.ir_plot import plot_ir_spectrum
+
+ y_title = (
+ "IR Intensity (km/mol)"
+ if getattr(app, "_ir_intensities_real", True)
+ else "Relative intensity (a.u.)"
+ )
+ fig = plot_ir_spectrum(
+ app._last_ir_freqs,
+ app._last_ir_ints,
+ mode=mode.lower(),
+ fwhm=fwhm,
+ yaxis_title=y_title,
+ )
+ app._apply_plotly_theme(fig)
+ app._set_html_output(
+ app._ir_fig,
+ _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ ),
+ )
+ except Exception as exc:
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event("ir_fig_error", f"{type(exc).__name__}: {exc}"[:300])
+ except Exception:
+ pass
+
+
+def show_orbital_diagram(app: Any, result: Any) -> bool:
+ """Build and reveal interactive orbital diagram accordion."""
+ mo_energy = getattr(result, "mo_energy_hartree", None)
+ mo_occ = getattr(result, "mo_occ", None)
+ if mo_energy is None or mo_occ is None:
+ return False
+
+ try:
+ from quantui.orbital_visualization import orbital_info_from_arrays
+
+ info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula)
+ except Exception:
+ return False
+
+ app._last_orb_info = info
+ app._last_orb_mo_coeff = getattr(result, "mo_coeff", None)
+ app._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None)
+ app._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None)
+
+ plotly_rendered = False
+ try:
+ import plotly.io as _pio
+
+ from quantui.orbital_visualization import plot_orbital_diagram_plotly
+
+ fig = plot_orbital_diagram_plotly(info, max_orbitals=app._orb_n_orb_input.value)
+ yr = fig.layout.yaxis.range
+ if yr is not None:
+ app._orb_ymin_input.value = round(float(yr[0]), 2)
+ app._orb_ymax_input.value = round(float(yr[1]), 2)
+ app._apply_plotly_theme(fig)
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ )
+ app._set_html_output(app._orb_diagram_html, html_str)
+ plotly_rendered = True
+ except Exception:
+ pass
+
+ if not plotly_rendered:
+ import base64
+ import io as _io
+
+ try:
+ from matplotlib.backends.backend_agg import (
+ FigureCanvasAgg as _AggCanvas,
+ )
+
+ from quantui.orbital_visualization import plot_orbital_diagram
+
+ mpl_fig = plot_orbital_diagram(info)
+ _AggCanvas(mpl_fig)
+ buf = _io.BytesIO()
+ mpl_fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
+ buf.seek(0)
+ img_b64 = base64.b64encode(buf.read()).decode()
+ app._set_html_output(
+ app._orb_diagram_html,
+ (
+ f' '
+ ),
+ )
+ except Exception:
+ pass
+
+ if (
+ app._last_orb_mo_coeff is not None
+ and app._last_orb_mol_atom is not None
+ and app._last_orb_mol_basis is not None
+ ):
+ app._orb_iso_output.clear_output()
+ app._orb_toggle.value = "HOMO"
+ app._orb_iso_controls.layout.display = ""
+ app._iso_generate_btn.disabled = False
+ else:
+ app._orb_iso_controls.layout.display = "none"
+ app._iso_generate_btn.disabled = True
+
+ return True
+
+
+def on_iso_generate(app: Any, btn: Any) -> None:
+ """Generate orbital isosurface for currently selected orbital."""
+ orbital_label = app._orb_toggle.value
+ btn.disabled = True
+ btn.description = "Generating…"
+ app._orb_iso_output.clear_output()
+ with app._orb_iso_output:
+ display(
+ HTML(
+ f''
+ f"⏳ Generating {orbital_label} cube file and rendering isosurface"
+ f" — this may take 15–30 s…
"
+ )
+ )
+
+ def _run() -> None:
+ try:
+ app._render_orbital_isosurface(orbital_label)
+ finally:
+ btn.disabled = False
+ btn.description = "Generate Isosurface"
+
+ threading.Thread(target=_run, daemon=True).start()
+
+
+def on_orb_range_changed(app: Any, _change: Any = None) -> None:
+ """Live-update orbital diagram for axis limits or orbital count changes."""
+ info = getattr(app, "_last_orb_info", None)
+ if info is None:
+ return
+ ymin = app._orb_ymin_input.value
+ ymax = app._orb_ymax_input.value
+ if ymin >= ymax:
+ return
+ try:
+ import plotly.io as _pio
+
+ from quantui.orbital_visualization import plot_orbital_diagram_plotly
+
+ fig = plot_orbital_diagram_plotly(
+ info,
+ max_orbitals=app._orb_n_orb_input.value,
+ yrange=(ymin, ymax),
+ )
+ app._apply_plotly_theme(fig)
+ app._set_html_output(
+ app._orb_diagram_html,
+ _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ ),
+ )
+ except Exception:
+ pass
+
+
+def render_orbital_isosurface(app: Any, orbital_label: str) -> None:
+ """Generate cube file and render orbital isosurface (Linux/WSL only)."""
+ import tempfile
+
+ orb_info = getattr(app, "_last_orb_info", None)
+ if orb_info is None:
+ return
+
+ n_occ = orb_info.n_occupied
+ n_total = len(orb_info.mo_energies_ev)
+ idx_map = {
+ "HOMO-1": n_occ - 2,
+ "HOMO": n_occ - 1,
+ "LUMO": n_occ,
+ "LUMO+1": n_occ + 1,
+ }
+ orb_idx = idx_map.get(orbital_label)
+ if orb_idx is None or orb_idx < 0 or orb_idx >= n_total:
+ return
+
+ mo_coeff = getattr(app, "_last_orb_mo_coeff", None)
+ mol_atom = getattr(app, "_last_orb_mol_atom", None)
+ mol_basis = getattr(app, "_last_orb_mol_basis", None)
+ if mo_coeff is None or mol_atom is None or mol_basis is None:
+ return
+
+ try:
+ from quantui.orbital_visualization import (
+ generate_cube_from_arrays,
+ plot_cube_isosurface,
+ )
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube"
+ generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
+ fig = plot_cube_isosurface(cube_path, title=f"{orbital_label} Isosurface")
+ except Exception as exc:
+ from IPython.display import HTML as _H
+ from IPython.display import display as _d
+
+ app._orb_iso_output.clear_output()
+ with app._orb_iso_output:
+ _d(
+ _H(
+ f'⚠ Orbital isosurface failed: {exc}
'
+ )
+ )
+ return
+
+ from IPython.display import display as _ipy_display
+
+ app._orb_iso_output.clear_output()
+ with app._orb_iso_output:
+ _ipy_display(fig)
+
+
+def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None:
+ """Render vibrational animation for mode number into vib output."""
+ from IPython.display import HTML as _H
+
+ def _err(msg: str) -> None:
+ app.vib_output.clear_output()
+ app.vib_output.append_display_data(
+ _H(f'⚠ {msg}
')
+ )
+
+ try:
+ from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol
+ except ImportError as exc:
+ _err(
+ f"Vibrational animation requires plotlymol3d "
+ f"(pip install plotlymol3d): {exc}"
+ )
+ return
+
+ xyzblock = (
+ f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
+ f"{molecule.to_xyz_string()}"
+ )
+ try:
+ rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
+ except Exception as exc:
+ _err(f"Could not parse molecule for bond connectivity: {exc}")
+ return
+
+ try:
+ from quantui import calc_log as _clog_anim
+
+ _clog_anim.log_event("vib_render_start", f"mode {mode_number}")
+ except Exception:
+ pass
+ try:
+ anim_fig = create_vibration_animation(
+ vib_data=vib_data,
+ mode_number=mode_number,
+ mol=rdmol,
+ amplitude=0.4,
+ n_frames=20,
+ mode="ball+stick",
+ resolution=12,
+ )
+ anim_fig.update_layout(height=420)
+ except Exception as exc:
+ try:
+ from quantui import calc_log as _clog_anim
+
+ _clog_anim.log_event(
+ "vib_render_error",
+ f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300],
+ )
+ except Exception:
+ pass
+ _err(f"Animation generation failed: {exc}")
+ return
+ try:
+ from quantui import calc_log as _clog_anim
+
+ _clog_anim.log_event("vib_render_done", f"mode {mode_number}")
+ except Exception:
+ pass
+
+ import plotly.io as _pio
+
+ anim_html = _pio.to_html(
+ anim_fig,
+ full_html=False,
+ include_plotlyjs="require",
+ config={"responsive": True},
+ )
+ app.vib_output.clear_output()
+ app.vib_output.append_display_data(_H(anim_html))
+
+
+def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None:
+ """Re-render vibrational animation when mode dropdown changes."""
+ mode_number = change["new"]
+ vib_data = getattr(app, "_last_vib_data", None)
+ molecule = getattr(app, "_last_vib_molecule", None)
+ if vib_data is None or molecule is None:
+ return
+
+ label = next(
+ (lbl for lbl, num in app.vib_mode_dd.options if num == mode_number),
+ f"mode {mode_number}",
+ )
+ app.vib_output.clear_output()
+ app.vib_output.append_display_data(
+ HTML(
+ f''
+ f"⏳ Rendering vibrational animation ({label})…
"
+ )
+ )
+ threading.Thread(
+ target=app._render_vib_mode,
+ args=(vib_data, molecule, mode_number),
+ daemon=True,
+ ).start()
+
+
+def show_pes_scan_result(app: Any, result: Any) -> bool:
+ """Render PES energy profile chart and stash latest PES result."""
+ app._last_pes_result = result
+ try:
+ import plotly.graph_objects as go
+ import plotly.io as pio
+
+ e_rel = result.energies_relative_kcal
+ x_vals = result.scan_parameter_values
+
+ hover_text = [
+ f"{result.scan_coordinate_label}: {x:.4f} "
+ f"ΔE = {de:.3f} kcal/mol "
+ f"E = {e:.8f} Ha"
+ for x, de, e in zip(x_vals, e_rel, result.energies_hartree)
+ ]
+
+ fig = go.Figure(
+ go.Scatter(
+ x=x_vals,
+ y=e_rel,
+ mode="lines+markers",
+ line=dict(color="#2563eb", width=2),
+ marker=dict(size=8, color="#2563eb"),
+ hovertext=hover_text,
+ hoverinfo="text",
+ )
+ )
+ tc = app._plotly_theme_colors()
+ fig.update_layout(
+ xaxis_title=result.scan_coordinate_label,
+ yaxis_title="Relative energy / kcal mol⁻¹",
+ height=380,
+ margin=dict(l=60, r=20, t=30, b=50),
+ plot_bgcolor=tc["plot_bgcolor"],
+ paper_bgcolor=tc["paper_bgcolor"],
+ font=dict(color=tc["font_color"]),
+ xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
+ yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
+ hovermode="closest",
+ )
+ app._set_html_output(
+ app._pes_plot_html,
+ pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ ),
+ )
+ except Exception:
+ pass
+
+ return True
From 96d0d21f601902269b8073796430685e744b793a Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 13:52:43 -0400
Subject: [PATCH 07/22] Extract UI builders into app_builders.py
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move UI construction logic out of quantui/app.py into a new quantui/app_builders.py module. Replace in-file widget construction with calls to builder helpers (build_theme_selector, build_welcome_header, build_molecule_section, build_calc_setup, build_run_section, build_compare_section, build_output_tab, build_help_section, build_issue_widgets) and update imports. Add a TYPE_CHECKING block in QuantUIApp to declare attributes initialized by the new builders to avoid attr churn. This is a refactor only — no behavioral changes to the UI are intended.
---
quantui/app.py | 566 +++++++---------------------------------
quantui/app_builders.py | 513 ++++++++++++++++++++++++++++++++++++
2 files changed, 603 insertions(+), 476 deletions(-)
create mode 100644 quantui/app_builders.py
diff --git a/quantui/app.py b/quantui/app.py
index 9d5fc9a..c1e007b 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -23,7 +23,7 @@
import uuid as _uuid
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any, ClassVar, List, Literal, Optional
+from typing import TYPE_CHECKING, Any, ClassVar, List, Literal, Optional
import ipywidgets as widgets
from IPython import get_ipython
@@ -77,6 +77,33 @@
from quantui.app_analysis import (
select_ana_panel as _ana_select_ana_panel,
)
+from quantui.app_builders import (
+ build_calc_setup as _bld_build_calc_setup,
+)
+from quantui.app_builders import (
+ build_compare_section as _bld_build_compare_section,
+)
+from quantui.app_builders import (
+ build_help_section as _bld_build_help_section,
+)
+from quantui.app_builders import (
+ build_issue_widgets as _bld_build_issue_widgets,
+)
+from quantui.app_builders import (
+ build_molecule_section as _bld_build_molecule_section,
+)
+from quantui.app_builders import (
+ build_output_tab as _bld_build_output_tab,
+)
+from quantui.app_builders import (
+ build_run_section as _bld_build_run_section,
+)
+from quantui.app_builders import (
+ build_theme_selector as _bld_build_theme_selector,
+)
+from quantui.app_builders import (
+ build_welcome_header as _bld_build_welcome_header,
+)
from quantui.app_exports import (
export_molecule_and_label as _exp_export_molecule_and_label,
)
@@ -487,6 +514,51 @@ class QuantUIApp:
app.display()
"""
+ if TYPE_CHECKING:
+ # Attributes initialized in companion builder modules. Keeping these
+ # declarations here avoids attr-defined churn during phased extraction.
+ _clear_log_cache_btn: Any
+ _clear_log_cache_confirm_btn: Any
+ _exit_btn: Any
+ _exit_output: Any
+ _help_btn: Any
+ _issue_btn: Any
+ _issue_cancel_btn: Any
+ _issue_overlay: Any
+ _issue_status_html: Any
+ _issue_submit_btn: Any
+ _issue_textarea: Any
+ _log_clear_btn: Any
+ _log_output_html: Any
+ _log_source_lbl: Any
+ _theme_style: Any
+ _welcome_html: Any
+ advanced_accordion: Any
+ calc_setup_panel: Any
+ change_mol_btn: Any
+ compare_btn: Any
+ compare_clear_btn: Any
+ compare_output: Any
+ compare_panel: Any
+ compare_refresh_btn: Any
+ compare_select: Any
+ help_content_html: Any
+ help_tab_panel: Any
+ help_topic_dd: Any
+ log_tab_panel: Any
+ mol_input_collapsed: Any
+ mol_input_container: Any
+ mol_input_expanded: Any
+ preset_dd: Any
+ pubchem_btn: Any
+ pubchem_msg: Any
+ pubchem_txt: Any
+ run_panel: Any
+ theme_btn: Any
+ xyz_area: Any
+ xyz_btn: Any
+ xyz_msg: Any
+
def __init__(self) -> None:
# ── Instance state ────────────────────────────────────────────────
self._molecule: Optional[Molecule] = None
@@ -562,19 +634,7 @@ def _build_widgets(self) -> None:
# ── Theme selector ────────────────────────────────────────────────────
def _build_theme_selector(self) -> None:
- self._theme_style = widgets.Output(
- layout=_layout(height="0px", overflow="hidden", margin="0", padding="0")
- )
- self.theme_btn = widgets.ToggleButtons(
- options=["Light", "Dark"],
- value="Dark",
- description="Theme:",
- style={"description_width": "48px", "button_width": "90px"},
- layout=_layout(margin="0"),
- )
- # Apply Dark theme immediately
- with self._theme_style:
- display(HTML(self._theme_css("Dark")))
+ _bld_build_theme_selector(self, layout_fn=_layout)
def _theme_css(self, theme: str) -> str:
"""Return the CSS filter block for *theme*, or '' for Light."""
@@ -678,64 +738,7 @@ def _ok(flag: bool, extra: str = "") -> str:
# ── Welcome header ────────────────────────────────────────────────────
def _build_welcome_header(self) -> None:
- _logo_svg = (
- ''
- ""
- ''
- ' '
- ""
- ' '
- " "
- ''
- ' '
- ""
- ' '
- " "
- " "
- ' '
- ''
- ' '
- ' '
- " "
- ''
- ' '
- ' '
- " "
- ''
- ' '
- ' '
- " "
- ' '
- ' '
- ' '
- ' '
- " "
- )
- _html = (
- f''
- f"{_logo_svg}"
- f"
"
- f'
QuantUI
'
- f'
'
- f"Quantum chemistry calculations, right on your device
"
- f'
'
- f"v{quantui.__version__} · "
- f"Help tab for instructions · "
- f"Status tab for system info
"
- f"
"
- f"
"
- )
- self._welcome_html = widgets.HTML(value=_html)
+ _bld_build_welcome_header(self)
# ── Shared widgets (Cell 3) ───────────────────────────────────────────
@@ -1097,183 +1100,23 @@ def _build_shared_widgets(self) -> None:
# ── Molecule section (Cell 4) ─────────────────────────────────────────
def _build_molecule_section(self) -> None:
- # Preset dropdown
- _preset_opts = ["(select a molecule)"] + list(MOLECULE_LIBRARY.keys())
- self.preset_dd = widgets.Dropdown(
- options=_preset_opts,
- value="(select a molecule)",
- description="Molecule:",
- style={"description_width": "90px"},
- layout=_layout(width="320px"),
- )
-
- # XYZ input
- self.xyz_area = widgets.Textarea(
- placeholder=(
- "Paste XYZ coordinates (symbol x y z):\n"
- "O 0.000 0.000 0.000\n"
- "H 0.757 0.587 0.000\n"
- "H -0.757 0.587 0.000"
- ),
- layout=_layout(width="440px", height="130px"),
- )
- self.xyz_btn = widgets.Button(
- description="Load XYZ", button_style="info", icon="upload"
- )
- self.xyz_msg = widgets.Label()
-
- # PubChem search
- self.pubchem_txt = widgets.Text(
- placeholder="name or SMILES (e.g. aspirin, caffeine, CC(=O)O)",
- layout=_layout(width="380px"),
- )
- self.pubchem_btn = widgets.Button(
- description="Search",
- button_style="info",
- icon="search",
- disabled=not PUBCHEM_AVAILABLE,
- layout=_layout(width="100px"),
- )
- self.pubchem_msg = widgets.Label(
- value=(
- ""
- if PUBCHEM_AVAILABLE
- else "PubChem unavailable — check internet connection"
- )
- )
-
- # Assemble input tab
- _hint = ''
- tab_preset = widgets.VBox(
- [
- widgets.HTML(
- _hint + "Choose from 20+ curated educational molecules.
"
- ),
- self.preset_dd,
- ]
- )
- tab_xyz = widgets.VBox(
- [
- widgets.HTML(
- _hint
- + "Paste XYZ coordinates (element x y z, one atom per line).
"
- ),
- self.xyz_area,
- widgets.HBox([self.xyz_btn, self.xyz_msg]),
- ]
- )
- tab_pubchem = widgets.VBox(
- [
- widgets.HTML(
- _hint
- + "Search by name or SMILES. Requires internet connection."
- ),
- widgets.HBox([self.pubchem_txt, self.pubchem_btn]),
- self.pubchem_msg,
- ]
- )
- input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])
- for _i, _t in enumerate(["Preset Library", "XYZ Input", "PubChem Search"]):
- input_tab.set_title(_i, _t)
-
- # Collapsible container
- self.mol_input_expanded = widgets.VBox(
- [
- widgets.HTML('Molecule Input '),
- input_tab,
- ]
- )
- self.change_mol_btn = widgets.Button(
- description="Change",
- button_style="",
- icon="pencil",
- layout=_layout(width="100px", height="32px"),
- tooltip="Re-expand the molecule input panel",
- )
- self.mol_input_collapsed = widgets.HBox(
- [self.mol_summary_compact, self.change_mol_btn],
- layout=_layout(align_items="center", gap="12px", padding="6px 0"),
- )
- _mol_container_children = [
- self.mol_input_expanded,
- self.mol_info_html,
- self.viz_output,
- ]
- if self.viz_backend_toggle is not None:
- _mol_container_children.append(self.viz_backend_toggle)
- if VISUALIZATION_AVAILABLE:
- _mol_container_children.append(self.viz_controls_box)
- self.mol_input_container = widgets.VBox(
- _mol_container_children,
- layout=_layout(margin="0 0 4px 0"),
+ _bld_build_molecule_section(
+ self,
+ layout_fn=_layout,
+ molecule_library=MOLECULE_LIBRARY,
+ pubchem_available=PUBCHEM_AVAILABLE,
+ visualization_available=VISUALIZATION_AVAILABLE,
)
# ── Calculation setup panel (Cell 5) ──────────────────────────────────
def _build_calc_setup(self) -> None:
- self.calc_setup_panel = widgets.VBox(
- [
- widgets.HTML('Calculation Setup '),
- widgets.HBox(
- [
- widgets.VBox(
- [
- widgets.HBox(
- [self.method_dd, self.method_help_btn],
- layout=_layout(align_items="center", gap="4px"),
- ),
- widgets.HBox(
- [self.basis_dd, self.basis_help_btn],
- layout=_layout(align_items="center", gap="4px"),
- ),
- ]
- ),
- widgets.HTML(" "),
- widgets.VBox([self.charge_si, self.mult_si]),
- ]
- ),
- self.calc_type_dd,
- self.calc_extra_opts,
- self.preopt_cb,
- widgets.HBox(
- [self.solvent_cb, self.solvent_dd],
- layout=_layout(align_items="center", gap="4px"),
- ),
- self.notes_output,
- ]
- )
+ _bld_build_calc_setup(self, layout_fn=_layout)
# ── Run panel (Cell 6) ────────────────────────────────────────────────
def _build_run_section(self) -> None:
- self.run_panel = widgets.VBox(
- [
- widgets.HTML(
- 'Run Calculation '
- 'PySCF runs in this '
- "kernel. Output appears live below. Large molecules or high-accuracy basis "
- "sets may take several minutes on a laptop.
"
- ),
- self.perf_estimate_html,
- widgets.HBox([self.run_btn, self.run_status]),
- widgets.HBox(
- [
- widgets.HTML(
- ''
- "Calculation Output "
- ),
- self.log_clear_btn,
- ],
- layout=_layout(
- align_items="center",
- justify_content="space-between",
- margin="10px 0 4px",
- max_width="460px",
- ),
- ),
- self.run_output,
- ]
- )
+ _bld_build_run_section(self, layout_fn=_layout)
# ── Results panel (Cell 7) ────────────────────────────────────────────
@@ -1939,253 +1782,24 @@ def _build_history_section(self) -> None:
# ── Compare panel (Cell 9) ────────────────────────────────────────────
def _build_compare_section(self) -> None:
- self.compare_select = widgets.SelectMultiple(
- options=[("(no saved results)", "")],
- rows=8,
- description="",
- layout=_layout(width="100%"),
- )
- self.compare_refresh_btn = widgets.Button(
- description="Refresh",
- button_style="",
- icon="refresh",
- layout=_layout(width="100px"),
- )
- self.compare_btn = widgets.Button(
- description="Compare selected",
- button_style="primary",
- icon="bar-chart",
- disabled=True,
- layout=_layout(width="180px"),
- )
- self.compare_clear_btn = widgets.Button(
- description="Clear",
- button_style="warning",
- icon="times",
- layout=_layout(width="90px"),
- )
- self.compare_output = widgets.Output()
-
- self.compare_panel = widgets.VBox(
- [
- widgets.HTML(
- 'Compare Calculations '
- ''
- "Select two or more saved calculations to compare side-by-side. "
- "Hold Ctrl (or ⌘) to select multiple entries.
"
- ),
- widgets.HBox([self.compare_refresh_btn]),
- self.compare_select,
- widgets.HBox(
- [self.compare_btn, self.compare_clear_btn],
- layout=_layout(gap="8px", margin="6px 0"),
- ),
- self.compare_output,
- ],
- layout=_layout(padding="8px 0"),
- )
-
- # Export accordion (Advanced)
- _rdkit_note = (
- ""
- if _RDKIT_AVAILABLE
- else 'MOL/PDB export requires RDKit '
- "(conda install -c conda-forge rdkit).
"
- )
- _export_content = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Download a self-contained PySCF script you can study or run outside the notebook.
"
- ),
- widgets.HBox([self.export_btn, self.export_status]),
- widgets.HTML(' '),
- widgets.HTML(
- ''
- "Download the molecular structure in a standard chemistry file format.
"
- + _rdkit_note
- ),
- widgets.HBox(
- [self.export_xyz_btn, self.export_mol_btn, self.export_pdb_btn],
- layout=_layout(flex_flow="row wrap", gap="6px"),
- ),
- self.struct_export_status,
- ]
+ _bld_build_compare_section(
+ self,
+ layout_fn=_layout,
+ rdkit_available=_RDKIT_AVAILABLE,
)
- self.advanced_accordion = widgets.Accordion(children=[_export_content])
- self.advanced_accordion.set_title(0, "Export")
- self.advanced_accordion.selected_index = None
-
- # Populate on startup
- self._populate_compare_list()
# ── Output log tab (Cell 10) ──────────────────────────────────────────
def _build_output_tab(self) -> None:
- self._log_output_html = widgets.HTML(
- ''
- "No log yet — run a calculation first, or use "
- "View log in the History tab. "
- )
- self._log_source_lbl = widgets.HTML()
- self._log_clear_btn = widgets.Button(
- description="Clear",
- button_style="",
- icon="times",
- layout=_layout(width="80px"),
- )
- self._clear_log_cache_btn = widgets.Button(
- description="Clear Log Cache",
- button_style="",
- icon="trash",
- tooltip=(
- "Delete the session event log (event_log.jsonl). "
- "Calculation performance data is preserved."
- ),
- layout=_layout(width="160px"),
- )
- self._clear_log_cache_confirm_btn = widgets.Button(
- description="Confirm clear?",
- button_style="danger",
- layout=_layout(width="140px", display="none"),
- )
- self.log_tab_panel = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Raw PySCF output for the most recent calculation. "
- "Use View log in the History tab to load a saved result's log. "
- "Orbital diagrams, trajectories, and spectra are in the "
- "Analysis tab.
"
- ),
- widgets.HBox(
- [self._log_clear_btn],
- layout=_layout(margin="0 0 8px"),
- ),
- self._log_source_lbl,
- self._log_output_html,
- self._result_log_accordion,
- widgets.HTML(
- ' '
- ''
- "Session event log — records molecule loads, calculations, "
- "and issue reports across this session.
"
- ),
- widgets.HBox(
- [self._clear_log_cache_btn, self._clear_log_cache_confirm_btn],
- layout=_layout(align_items="center", gap="8px"),
- ),
- ],
- layout=_layout(padding="8px 0"),
- )
+ _bld_build_output_tab(self, layout_fn=_layout)
# ── Help section (Cell 10) ────────────────────────────────────────────
def _build_help_section(self) -> None:
- _help_keys = list(HELP_TOPICS.keys())
- _help_labels = [HELP_TOPICS[k]["title"] for k in _help_keys]
- self.help_topic_dd = widgets.Dropdown(
- options=list(zip(_help_labels, _help_keys)),
- description="Topic:",
- style={"description_width": "60px"},
- layout=_layout(width="460px"),
- )
- self.help_content_html = widgets.HTML()
- self._render_help_topic() # render first topic immediately
-
- # [?] toggle button shown in the top bar
- self._help_btn = widgets.Button(
- description="?",
- button_style="",
- tooltip="Help topics",
- layout=_layout(width="34px", margin="0 0 0 8px"),
- )
-
- # Exit button shown in the top bar
- self._exit_btn = widgets.Button(
- description="Exit",
- button_style="danger",
- tooltip="Shut down the QuantUI server and close this session",
- layout=_layout(width="64px", margin="0 0 0 8px"),
- )
- self._exit_output = widgets.Output(
- layout=_layout(height="0px", overflow="hidden")
- )
-
- self.help_tab_panel = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Browse help topics below. Click ? next to the Method or Basis Set "
- "dropdown in the Calculate tab to jump directly to a relevant topic.
"
- ),
- self.help_topic_dd,
- self.help_content_html,
- ],
- layout=_layout(
- display="none",
- padding="8px 0",
- border="1px solid #e2e8f0",
- border_radius="6px",
- padding_left="12px",
- margin="0 0 8px",
- ),
- )
+ _bld_build_help_section(self, layout_fn=_layout)
def _build_issue_widgets(self) -> None:
- """Build the Issue report button, overlay, and related widgets."""
- # ── Issue button (shown in the top bar) ───────────────────────────
- self._issue_btn = widgets.Button(
- description="Report Issue",
- button_style="warning",
- icon="flag",
- tooltip="Report a bug or unexpected behaviour observed in this session",
- layout=_layout(width="140px", margin="0 0 0 8px"),
- )
- # ── Issue overlay (hidden until button is clicked) ────────────────
- self._issue_textarea = widgets.Textarea(
- placeholder=(
- "Describe what you observed — what you did, what you expected, "
- "and what actually happened."
- ),
- layout=_layout(width="100%", height="90px"),
- )
- self._issue_submit_btn = widgets.Button(
- description="Submit",
- button_style="success",
- layout=_layout(width="90px"),
- )
- self._issue_cancel_btn = widgets.Button(
- description="Cancel",
- button_style="",
- layout=_layout(width="80px"),
- )
- self._issue_status_html = widgets.HTML()
- self._issue_overlay = widgets.VBox(
- [
- widgets.HTML(
- ''
- "⚐ Report Issue
"
- ''
- "Your report (and a snapshot of the current session state) will be "
- "saved to issues.db and the session event log.
"
- ),
- self._issue_textarea,
- widgets.HBox(
- [self._issue_submit_btn, self._issue_cancel_btn],
- layout=_layout(margin="6px 0 0", gap="8px"),
- ),
- self._issue_status_html,
- ],
- layout=_layout(
- display="none",
- border="1px solid #f59e0b",
- border_radius="6px",
- padding="12px 14px",
- margin="0 0 6px",
- background_color="#fffbeb",
- ),
- )
+ _bld_build_issue_widgets(self, layout_fn=_layout)
# ── Tab assembly (Cell 10) ────────────────────────────────────────────
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
new file mode 100644
index 0000000..8974cae
--- /dev/null
+++ b/quantui/app_builders.py
@@ -0,0 +1,513 @@
+"""UI builder helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import ipywidgets as widgets
+from IPython.display import HTML, display
+
+import quantui
+from quantui.help_content import HELP_TOPICS
+
+
+def build_theme_selector(app: Any, *, layout_fn: Any) -> None:
+ """Build the theme selector widgets and apply default theme CSS."""
+ app._theme_style = widgets.Output(
+ layout=layout_fn(height="0px", overflow="hidden", margin="0", padding="0")
+ )
+ app.theme_btn = widgets.ToggleButtons(
+ options=["Light", "Dark"],
+ value="Dark",
+ description="Theme:",
+ style={"description_width": "48px", "button_width": "90px"},
+ layout=layout_fn(margin="0"),
+ )
+ with app._theme_style:
+ display(HTML(app._theme_css("Dark")))
+
+
+def build_welcome_header(app: Any) -> None:
+ """Build the static QuantUI welcome banner."""
+ logo_svg = (
+ ''
+ ""
+ ''
+ ' '
+ ""
+ ' '
+ " "
+ ''
+ ' '
+ ""
+ ' '
+ " "
+ " "
+ ' '
+ ''
+ ' '
+ ' '
+ " "
+ ''
+ ' '
+ ' '
+ " "
+ ''
+ ' '
+ ' '
+ " "
+ ' '
+ ' '
+ ' '
+ ' '
+ " "
+ )
+ html = (
+ f'"
+ f"{logo_svg}"
+ f"
"
+ f'
QuantUI
'
+ f'
'
+ f"Quantum chemistry calculations, right on your device
"
+ f'
'
+ f"v{quantui.__version__} · "
+ f"Help tab for instructions · "
+ f"Status tab for system info
"
+ f"
"
+ f"
"
+ )
+ app._welcome_html = widgets.HTML(value=html)
+
+
+def build_molecule_section(
+ app: Any,
+ *,
+ layout_fn: Any,
+ molecule_library: dict[str, Any],
+ pubchem_available: bool,
+ visualization_available: bool,
+) -> None:
+ """Build molecule input widgets and collapsed summary container."""
+ preset_opts = ["(select a molecule)"] + list(molecule_library.keys())
+ app.preset_dd = widgets.Dropdown(
+ options=preset_opts,
+ value="(select a molecule)",
+ description="Molecule:",
+ style={"description_width": "90px"},
+ layout=layout_fn(width="320px"),
+ )
+
+ app.xyz_area = widgets.Textarea(
+ placeholder=(
+ "Paste XYZ coordinates (symbol x y z):\n"
+ "O 0.000 0.000 0.000\n"
+ "H 0.757 0.587 0.000\n"
+ "H -0.757 0.587 0.000"
+ ),
+ layout=layout_fn(width="440px", height="130px"),
+ )
+ app.xyz_btn = widgets.Button(
+ description="Load XYZ", button_style="info", icon="upload"
+ )
+ app.xyz_msg = widgets.Label()
+
+ app.pubchem_txt = widgets.Text(
+ placeholder="name or SMILES (e.g. aspirin, caffeine, CC(=O)O)",
+ layout=layout_fn(width="380px"),
+ )
+ app.pubchem_btn = widgets.Button(
+ description="Search",
+ button_style="info",
+ icon="search",
+ disabled=not pubchem_available,
+ layout=layout_fn(width="100px"),
+ )
+ app.pubchem_msg = widgets.Label(
+ value=(
+ ""
+ if pubchem_available
+ else "PubChem unavailable — check internet connection"
+ )
+ )
+
+ hint = ''
+ tab_preset = widgets.VBox(
+ [
+ widgets.HTML(hint + "Choose from 20+ curated educational molecules.
"),
+ app.preset_dd,
+ ]
+ )
+ tab_xyz = widgets.VBox(
+ [
+ widgets.HTML(
+ hint + "Paste XYZ coordinates (element x y z, one atom per line)."
+ ),
+ app.xyz_area,
+ widgets.HBox([app.xyz_btn, app.xyz_msg]),
+ ]
+ )
+ tab_pubchem = widgets.VBox(
+ [
+ widgets.HTML(
+ hint + "Search by name or SMILES. Requires internet connection."
+ ),
+ widgets.HBox([app.pubchem_txt, app.pubchem_btn]),
+ app.pubchem_msg,
+ ]
+ )
+ input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])
+ for i, title in enumerate(["Preset Library", "XYZ Input", "PubChem Search"]):
+ input_tab.set_title(i, title)
+
+ app.mol_input_expanded = widgets.VBox(
+ [
+ widgets.HTML('Molecule Input '),
+ input_tab,
+ ]
+ )
+ app.change_mol_btn = widgets.Button(
+ description="Change",
+ button_style="",
+ icon="pencil",
+ layout=layout_fn(width="100px", height="32px"),
+ tooltip="Re-expand the molecule input panel",
+ )
+ app.mol_input_collapsed = widgets.HBox(
+ [app.mol_summary_compact, app.change_mol_btn],
+ layout=layout_fn(align_items="center", gap="12px", padding="6px 0"),
+ )
+ mol_container_children = [
+ app.mol_input_expanded,
+ app.mol_info_html,
+ app.viz_output,
+ ]
+ if app.viz_backend_toggle is not None:
+ mol_container_children.append(app.viz_backend_toggle)
+ if visualization_available:
+ mol_container_children.append(app.viz_controls_box)
+ app.mol_input_container = widgets.VBox(
+ mol_container_children,
+ layout=layout_fn(margin="0 0 4px 0"),
+ )
+
+
+def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
+ """Build the calculation setup panel."""
+ app.calc_setup_panel = widgets.VBox(
+ [
+ widgets.HTML('Calculation Setup '),
+ widgets.HBox(
+ [
+ widgets.VBox(
+ [
+ widgets.HBox(
+ [app.method_dd, app.method_help_btn],
+ layout=layout_fn(align_items="center", gap="4px"),
+ ),
+ widgets.HBox(
+ [app.basis_dd, app.basis_help_btn],
+ layout=layout_fn(align_items="center", gap="4px"),
+ ),
+ ]
+ ),
+ widgets.HTML(" "),
+ widgets.VBox([app.charge_si, app.mult_si]),
+ ]
+ ),
+ app.calc_type_dd,
+ app.calc_extra_opts,
+ app.preopt_cb,
+ widgets.HBox(
+ [app.solvent_cb, app.solvent_dd],
+ layout=layout_fn(align_items="center", gap="4px"),
+ ),
+ app.notes_output,
+ ]
+ )
+
+
+def build_run_section(app: Any, *, layout_fn: Any) -> None:
+ """Build the run panel shown in the Calculate tab."""
+ app.run_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ 'Run Calculation '
+ 'PySCF runs in this '
+ "kernel. Output appears live below. Large molecules or high-accuracy basis "
+ "sets may take several minutes on a laptop.
"
+ ),
+ app.perf_estimate_html,
+ widgets.HBox([app.run_btn, app.run_status]),
+ widgets.HBox(
+ [
+ widgets.HTML(
+ ''
+ "Calculation Output "
+ ),
+ app.log_clear_btn,
+ ],
+ layout=layout_fn(
+ align_items="center",
+ justify_content="space-between",
+ margin="10px 0 4px",
+ max_width="460px",
+ ),
+ ),
+ app.run_output,
+ ]
+ )
+
+
+def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> None:
+ """Build compare tab widgets and export accordion."""
+ app.compare_select = widgets.SelectMultiple(
+ options=[("(no saved results)", "")],
+ rows=8,
+ description="",
+ layout=layout_fn(width="100%"),
+ )
+ app.compare_refresh_btn = widgets.Button(
+ description="Refresh",
+ button_style="",
+ icon="refresh",
+ layout=layout_fn(width="100px"),
+ )
+ app.compare_btn = widgets.Button(
+ description="Compare selected",
+ button_style="primary",
+ icon="bar-chart",
+ disabled=True,
+ layout=layout_fn(width="180px"),
+ )
+ app.compare_clear_btn = widgets.Button(
+ description="Clear",
+ button_style="warning",
+ icon="times",
+ layout=layout_fn(width="90px"),
+ )
+ app.compare_output = widgets.Output()
+
+ app.compare_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ 'Compare Calculations '
+ ''
+ "Select two or more saved calculations to compare side-by-side. "
+ "Hold Ctrl (or ⌘) to select multiple entries.
"
+ ),
+ widgets.HBox([app.compare_refresh_btn]),
+ app.compare_select,
+ widgets.HBox(
+ [app.compare_btn, app.compare_clear_btn],
+ layout=layout_fn(gap="8px", margin="6px 0"),
+ ),
+ app.compare_output,
+ ],
+ layout=layout_fn(padding="8px 0"),
+ )
+
+ rdkit_note = (
+ ""
+ if rdkit_available
+ else 'MOL/PDB export requires RDKit '
+ "(conda install -c conda-forge rdkit).
"
+ )
+ export_content = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Download a self-contained PySCF script you can study or run outside the notebook.
"
+ ),
+ widgets.HBox([app.export_btn, app.export_status]),
+ widgets.HTML(' '),
+ widgets.HTML(
+ ''
+ "Download the molecular structure in a standard chemistry file format.
"
+ + rdkit_note
+ ),
+ widgets.HBox(
+ [app.export_xyz_btn, app.export_mol_btn, app.export_pdb_btn],
+ layout=layout_fn(flex_wrap="wrap", gap="6px"),
+ ),
+ app.struct_export_status,
+ ]
+ )
+ app.advanced_accordion = widgets.Accordion(children=[export_content])
+ app.advanced_accordion.set_title(0, "Export")
+ app.advanced_accordion.selected_index = None
+
+ app._populate_compare_list()
+
+
+def build_output_tab(app: Any, *, layout_fn: Any) -> None:
+ """Build the Output tab panel widgets."""
+ app._log_output_html = widgets.HTML(
+ ''
+ "No log yet — run a calculation first, or use "
+ "View log in the History tab. "
+ )
+ app._log_source_lbl = widgets.HTML()
+ app._log_clear_btn = widgets.Button(
+ description="Clear",
+ button_style="",
+ icon="times",
+ layout=layout_fn(width="80px"),
+ )
+ app._clear_log_cache_btn = widgets.Button(
+ description="Clear Log Cache",
+ button_style="",
+ icon="trash",
+ tooltip=(
+ "Delete the session event log (event_log.jsonl). "
+ "Calculation performance data is preserved."
+ ),
+ layout=layout_fn(width="160px"),
+ )
+ app._clear_log_cache_confirm_btn = widgets.Button(
+ description="Confirm clear?",
+ button_style="danger",
+ layout=layout_fn(width="140px", display="none"),
+ )
+ app.log_tab_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Raw PySCF output for the most recent calculation. "
+ "Use View log in the History tab to load a saved result's log. "
+ "Orbital diagrams, trajectories, and spectra are in the "
+ "Analysis tab.
"
+ ),
+ widgets.HBox(
+ [app._log_clear_btn],
+ layout=layout_fn(margin="0 0 8px"),
+ ),
+ app._log_source_lbl,
+ app._log_output_html,
+ app._result_log_accordion,
+ widgets.HTML(
+ ' '
+ ''
+ "Session event log — records molecule loads, calculations, "
+ "and issue reports across this session.
"
+ ),
+ widgets.HBox(
+ [app._clear_log_cache_btn, app._clear_log_cache_confirm_btn],
+ layout=layout_fn(align_items="center", gap="8px"),
+ ),
+ ],
+ layout=layout_fn(padding="8px 0"),
+ )
+
+
+def build_help_section(app: Any, *, layout_fn: Any) -> None:
+ """Build the floating help panel and top-bar help/exit buttons."""
+ help_keys = list(HELP_TOPICS.keys())
+ help_labels = [HELP_TOPICS[k]["title"] for k in help_keys]
+ app.help_topic_dd = widgets.Dropdown(
+ options=list(zip(help_labels, help_keys)),
+ description="Topic:",
+ style={"description_width": "60px"},
+ layout=layout_fn(width="460px"),
+ )
+ app.help_content_html = widgets.HTML()
+ app._render_help_topic()
+
+ app._help_btn = widgets.Button(
+ description="?",
+ button_style="",
+ tooltip="Help topics",
+ layout=layout_fn(width="34px", margin="0 0 0 8px"),
+ )
+
+ app._exit_btn = widgets.Button(
+ description="Exit",
+ button_style="danger",
+ tooltip="Shut down the QuantUI server and close this session",
+ layout=layout_fn(width="64px", margin="0 0 0 8px"),
+ )
+ app._exit_output = widgets.Output(layout=layout_fn(height="0px", overflow="hidden"))
+
+ app.help_tab_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Browse help topics below. Click ? next to the Method or Basis Set "
+ "dropdown in the Calculate tab to jump directly to a relevant topic.
"
+ ),
+ app.help_topic_dd,
+ app.help_content_html,
+ ],
+ layout=layout_fn(
+ display="none",
+ padding="8px 0",
+ border="1px solid #e2e8f0",
+ border_radius="6px",
+ padding_left="12px",
+ margin="0 0 8px",
+ ),
+ )
+
+
+def build_issue_widgets(app: Any, *, layout_fn: Any) -> None:
+ """Build issue-report widgets shown from the top toolbar."""
+ app._issue_btn = widgets.Button(
+ description="Report Issue",
+ button_style="warning",
+ icon="flag",
+ tooltip="Report a bug or unexpected behaviour observed in this session",
+ layout=layout_fn(width="140px", margin="0 0 0 8px"),
+ )
+ app._issue_textarea = widgets.Textarea(
+ placeholder=(
+ "Describe what you observed — what you did, what you expected, "
+ "and what actually happened."
+ ),
+ layout=layout_fn(width="100%", height="90px"),
+ )
+ app._issue_submit_btn = widgets.Button(
+ description="Submit",
+ button_style="success",
+ layout=layout_fn(width="90px"),
+ )
+ app._issue_cancel_btn = widgets.Button(
+ description="Cancel",
+ button_style="",
+ layout=layout_fn(width="80px"),
+ )
+ app._issue_status_html = widgets.HTML()
+ app._issue_overlay = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "⚐ Report Issue
"
+ ''
+ "Your report (and a snapshot of the current session state) will be "
+ "saved to issues.db and the session event log.
"
+ ),
+ app._issue_textarea,
+ widgets.HBox(
+ [app._issue_submit_btn, app._issue_cancel_btn],
+ layout=layout_fn(margin="6px 0 0", gap="8px"),
+ ),
+ app._issue_status_html,
+ ],
+ layout=layout_fn(
+ display="none",
+ border="1px solid #f59e0b",
+ border_radius="6px",
+ padding="12px 14px",
+ margin="0 0 6px",
+ background_color="#fffbeb",
+ ),
+ )
From f0541e71a21c9d52aa6741f184576dc4bf656af2 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 14:02:47 -0400
Subject: [PATCH 08/22] Refactor status/history panels into builders
Extract the Status and History tab construction from quantui/app.py into new builder functions in quantui/app_builders.py. Introduces build_status_panel and build_history_section which receive needed dependencies (layout, session resource and calibration loaders, feature flags, and benchmark lists) and create the corresponding widgets on the app instance. App.py imports and calls these builders and adds several new widget attributes to QuantUIApp. This modularizes UI construction, reduces code duplication in app.py, and keeps panel-specific logic in app_builders.py.
---
quantui/app.py | 319 ++++++----------------------------------
quantui/app_builders.py | 297 +++++++++++++++++++++++++++++++++++++
2 files changed, 343 insertions(+), 273 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index c1e007b..af52502 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -17,7 +17,6 @@
import io
import os
import re
-import sys
import threading
import time
import uuid as _uuid
@@ -86,6 +85,9 @@
from quantui.app_builders import (
build_help_section as _bld_build_help_section,
)
+from quantui.app_builders import (
+ build_history_section as _bld_build_history_section,
+)
from quantui.app_builders import (
build_issue_widgets as _bld_build_issue_widgets,
)
@@ -98,6 +100,9 @@
from quantui.app_builders import (
build_run_section as _bld_build_run_section,
)
+from quantui.app_builders import (
+ build_status_panel as _bld_build_status_panel,
+)
from quantui.app_builders import (
build_theme_selector as _bld_build_theme_selector,
)
@@ -528,14 +533,32 @@ class QuantUIApp:
_issue_status_html: Any
_issue_submit_btn: Any
_issue_textarea: Any
+ _cal_accordion: Any
+ _cal_mode_toggle: Any
+ _cal_progress: Any
+ _cal_results_html: Any
+ _cal_run_btn: Any
+ _cal_step_label: Any
+ _cal_stop_btn: Any
_log_clear_btn: Any
_log_output_html: Any
_log_source_lbl: Any
+ _perf_accordion: Any
+ _perf_events_html: Any
+ _perf_stats_html: Any
+ _reset_btn: Any
+ _reset_confirm_box: Any
+ _reset_confirm_html: Any
+ _reset_confirm_no: Any
+ _reset_confirm_yes: Any
+ _status_html: Any
+ _status_tab_panel: Any
_theme_style: Any
_welcome_html: Any
advanced_accordion: Any
calc_setup_panel: Any
change_mol_btn: Any
+ copy_path_btn: Any
compare_btn: Any
compare_clear_btn: Any
compare_output: Any
@@ -545,16 +568,22 @@ class QuantUIApp:
help_content_html: Any
help_tab_panel: Any
help_topic_dd: Any
+ history_panel: Any
log_tab_panel: Any
mol_input_collapsed: Any
mol_input_container: Any
mol_input_expanded: Any
+ past_dd: Any
+ past_output: Any
+ past_refresh_btn: Any
preset_dd: Any
pubchem_btn: Any
pubchem_msg: Any
pubchem_txt: Any
+ results_path_lbl: Any
run_panel: Any
theme_btn: Any
+ view_log_btn: Any
xyz_area: Any
xyz_btn: Any
xyz_msg: Any
@@ -652,87 +681,15 @@ def _theme_css(self, theme: str) -> str:
# ── 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"
- _py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
- _env = os.environ.get("CONDA_DEFAULT_ENV", "") or os.path.basename(
- os.environ.get("VIRTUAL_ENV", "")
- )
- _cal_label = _load_last_calibration_label()
-
- def _ok(flag: bool, extra: str = "") -> str:
- tick = '✓ '
- cross = '✗ '
- return (tick if flag else cross) + (" " + extra if extra else "")
-
- _items = [
- (
- "PySCF (calculations)",
- _ok(
- _PYSCF_AVAILABLE,
- "" if _PYSCF_AVAILABLE else "— Linux / macOS / WSL required",
- ),
- ),
- ("ASE (structure I/O, opt.)", _ok(ASE_AVAILABLE)),
- ("PubChem search", _ok(PUBCHEM_AVAILABLE)),
- ("3D viewer (py3Dmol)", _ok(VISUALIZATION_AVAILABLE)),
- ("CPU cores / Memory", f"{_cores} cores / {_mem} "),
- ]
- _rows = "".join(
- f'{k} '
- f'{v} '
- for k, v in _items
- )
-
- _env_badge = (
- f' {_env}'
- if _env and _env not in ("base", "")
- else ""
- )
- _cal_line = (
- f''
- f"Timing calibration: {_cal_label}
"
- if _cal_label
- else ''
- "Timing calibration: not yet run — use the Calibrate panel in History
"
- )
-
- self._status_html = widgets.HTML(
- f''
- f'
'
- f"QuantUI {quantui.__version__}"
- f''
- f"Python {_py_ver}{_env_badge}
"
- f'
'
- f"{_cal_line}"
- f"
"
- )
-
- _steps = [
- "Select a molecule — library dropdown, XYZ paste, or PubChem search",
- "Choose a method (RHF / DFT / MP2) and basis set in the Calculate tab",
- "Click Run Calculation — SCF progress appears in real time",
- "Explore results in the Results and Analysis tabs",
- "Browse past calculations in History ; compare them in Compare ",
- ]
- _steps_html = "".join(
- f'{s} '
- for s in _steps
- )
- _guide_html = widgets.HTML(
- f''
- f'
'
- f"Quick start
"
- f'
{_steps_html} '
- f"
"
- )
-
- self._status_tab_panel = widgets.VBox(
- [self._status_html, _guide_html],
- layout=_layout(padding="8px 0"),
+ _bld_build_status_panel(
+ self,
+ layout_fn=_layout,
+ get_session_resources_fn=get_session_resources,
+ load_last_calibration_label_fn=_load_last_calibration_label,
+ pyscf_available=_PYSCF_AVAILABLE,
+ ase_available=ASE_AVAILABLE,
+ pubchem_available=PUBCHEM_AVAILABLE,
+ visualization_available=VISUALIZATION_AVAILABLE,
)
# ── Welcome header ────────────────────────────────────────────────────
@@ -1586,199 +1543,15 @@ def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool:
# ── History panel (Cell 8) ────────────────────────────────────────────
def _build_history_section(self) -> None:
- self.past_dd = widgets.Dropdown(
- description="Load:",
- options=[("(no saved results)", "")],
- style={"description_width": "50px"},
- layout=_layout(width="500px"),
- )
- self.past_refresh_btn = widgets.Button(
- description="Refresh",
- button_style="",
- icon="refresh",
- layout=_layout(width="100px"),
- tooltip="Rescan the results directory",
- )
- self.copy_path_btn = widgets.Button(
- description="Copy path",
- button_style="",
- icon="clipboard",
- layout=_layout(width="120px"),
- tooltip="Copy the results directory path to clipboard",
- )
- self.results_path_lbl = widgets.HTML()
- self.past_output = widgets.Output()
- self.view_log_btn = widgets.Button(
- description="View log",
- button_style="",
- icon="file-text-o",
- layout=_layout(width="110px"),
- tooltip="Open the full PySCF output log in the Output tab",
- )
-
- # Calibration widgets
- self._cal_mode_toggle = widgets.ToggleButtons(
- options=[("Quick (~10 s)", "short"), ("Full (~5 min)", "long")],
- value="short",
- description="",
- button_style="",
- style={"description_width": "0px", "button_width": "140px"},
- layout=_layout(margin="0 0 8px"),
- )
- self._cal_run_btn = widgets.Button(
- description="Run Calibration",
- button_style="primary",
- icon="play",
- disabled=not _PYSCF_AVAILABLE,
- tooltip=(
- "Run the benchmark suite to calibrate time estimates"
- if _PYSCF_AVAILABLE
- else "PySCF required (Linux / macOS / WSL)"
- ),
- layout=_layout(width="180px"),
- )
- self._cal_stop_btn = widgets.Button(
- description="Stop",
- button_style="warning",
- icon="stop",
- layout=_layout(width="90px", display="none"),
- )
- self._cal_progress = widgets.IntProgress(
- min=0,
- max=len(_BENCHMARK_SUITE),
- value=0,
- description="",
- bar_style="info",
- layout=_layout(width="300px", display="none"),
- )
- self._cal_step_label = widgets.HTML(
- value="",
- layout=_layout(display="none"),
- )
- self._cal_results_html = widgets.HTML(value="")
-
- # Performance stats widgets
- self._perf_stats_html = widgets.HTML()
- self._perf_events_html = widgets.HTML()
- self._reset_btn = widgets.Button(
- description="Reset performance database",
- button_style="danger",
- icon="trash",
- layout=_layout(width="230px"),
- )
- self._reset_confirm_html = widgets.HTML(
- ''
- "Warning: This will permanently delete all performance records. "
- "Time estimates will reset to “no data”. "
- )
- self._reset_confirm_yes = widgets.Button(
- description="Yes, delete all records",
- button_style="danger",
- icon="check",
- layout=_layout(width="190px"),
- )
- self._reset_confirm_no = widgets.Button(
- description="Cancel",
- button_style="",
- icon="times",
- layout=_layout(width="90px"),
- )
- self._reset_confirm_box = widgets.VBox(
- [
- self._reset_confirm_html,
- widgets.HBox(
- [self._reset_confirm_yes, self._reset_confirm_no],
- layout=_layout(gap="8px", margin="4px 0 0"),
- ),
- ],
- layout=_layout(
- display="none",
- border="1px solid #fca5a5",
- padding="8px 10px",
- margin="6px 0 0",
- ),
- )
-
- _perf_stats_panel = widgets.VBox(
- [
- self._perf_stats_html,
- widgets.HTML(
- ''
- "Recent events (last 20)
"
- ),
- self._perf_events_html,
- widgets.HBox(
- [self._reset_btn],
- layout=_layout(margin="14px 0 4px"),
- ),
- self._reset_confirm_box,
- ]
- )
- self._perf_accordion = widgets.Accordion(
- children=[_perf_stats_panel], selected_index=None
- )
- self._perf_accordion.set_title(0, "Performance stats")
-
- # Calibration accordion
- _cal_last = _load_last_calibration_label()
- _cal_note = (
- f''
- f"Last run: {_cal_last}
"
- if _cal_last
- else ""
- )
- _cal_panel = widgets.VBox(
- [
- widgets.HTML(
- f''
- f"Benchmark this machine so the time estimator uses basis-function "
- f"scaling (Nβ ) rather than generic defaults. "
- f"Quick runs {len(_BENCHMARK_SUITE)} small calculations (~10 s). "
- f"Full runs {len(_BENCHMARK_SUITE_LONG)} calculations spanning "
- f"all common molecule sizes and methods (~5 min).
" + _cal_note
- ),
- self._cal_mode_toggle,
- widgets.HBox(
- [self._cal_run_btn, self._cal_stop_btn],
- layout=_layout(gap="6px", align_items="center"),
- ),
- self._cal_progress,
- self._cal_step_label,
- self._cal_results_html,
- ],
- layout=_layout(padding="4px 0"),
- )
- self._cal_accordion = widgets.Accordion(
- children=[_cal_panel], selected_index=None
- )
- self._cal_accordion.set_title(0, "Calibrate time estimates")
-
- self.history_panel = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Calculations are saved automatically. Select one below to view its results.
"
- ),
- widgets.HBox(
- [
- self.past_dd,
- self.past_refresh_btn,
- self.copy_path_btn,
- self.view_log_btn,
- ],
- layout=_layout(align_items="center", gap="8px"),
- ),
- self.results_path_lbl,
- self.past_output,
- self._perf_accordion,
- self._cal_accordion,
- ]
+ _bld_build_history_section(
+ self,
+ layout_fn=_layout,
+ pyscf_available=_PYSCF_AVAILABLE,
+ benchmark_suite=_BENCHMARK_SUITE,
+ benchmark_suite_long=_BENCHMARK_SUITE_LONG,
+ load_last_calibration_label_fn=_load_last_calibration_label,
)
- # Populate on startup
- self._refresh_results_browser()
- self._refresh_perf_stats()
-
# ── Compare panel (Cell 9) ────────────────────────────────────────────
def _build_compare_section(self) -> None:
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 8974cae..c24549f 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import os
+import sys
from typing import Any
import ipywidgets as widgets
@@ -11,6 +13,301 @@
from quantui.help_content import HELP_TOPICS
+def build_status_panel(
+ app: Any,
+ *,
+ layout_fn: Any,
+ get_session_resources_fn: Any,
+ load_last_calibration_label_fn: Any,
+ pyscf_available: bool,
+ ase_available: bool,
+ pubchem_available: bool,
+ visualization_available: bool,
+) -> None:
+ """Build the Status tab panel."""
+ cores, mem_gb = get_session_resources_fn()
+ mem = f"{mem_gb} GB" if mem_gb is not None else "unknown"
+ py_ver = (
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
+ )
+ env = os.environ.get("CONDA_DEFAULT_ENV", "") or os.path.basename(
+ os.environ.get("VIRTUAL_ENV", "")
+ )
+ cal_label = load_last_calibration_label_fn()
+
+ def _ok(flag: bool, extra: str = "") -> str:
+ tick = '✓ '
+ cross = '✗ '
+ return (tick if flag else cross) + (" " + extra if extra else "")
+
+ items = [
+ (
+ "PySCF (calculations)",
+ _ok(
+ pyscf_available,
+ "" if pyscf_available else "— Linux / macOS / WSL required",
+ ),
+ ),
+ ("ASE (structure I/O, opt.)", _ok(ase_available)),
+ ("PubChem search", _ok(pubchem_available)),
+ ("3D viewer (py3Dmol)", _ok(visualization_available)),
+ ("CPU cores / Memory", f"{cores} cores / {mem} "),
+ ]
+ rows = "".join(
+ f'{k} '
+ f'{v} '
+ for k, v in items
+ )
+
+ env_badge = (
+ f' {env}'
+ if env and env not in ("base", "")
+ else ""
+ )
+ cal_line = (
+ f''
+ f"Timing calibration: {cal_label}
"
+ if cal_label
+ else ''
+ "Timing calibration: not yet run — use the Calibrate panel in History
"
+ )
+
+ app._status_html = widgets.HTML(
+ f''
+ f'
'
+ f"QuantUI {quantui.__version__}"
+ f''
+ f"Python {py_ver}{env_badge}
"
+ f'
'
+ f"{cal_line}"
+ f"
"
+ )
+
+ steps = [
+ "Select a molecule — library dropdown, XYZ paste, or PubChem search",
+ "Choose a method (RHF / DFT / MP2) and basis set in the Calculate tab",
+ "Click Run Calculation — SCF progress appears in real time",
+ "Explore results in the Results and Analysis tabs",
+ "Browse past calculations in History ; compare them in Compare ",
+ ]
+ steps_html = "".join(
+ f'{s} ' for s in steps
+ )
+ guide_html = widgets.HTML(
+ f''
+ f'
'
+ f"Quick start
"
+ f'
{steps_html} '
+ f"
"
+ )
+
+ app._status_tab_panel = widgets.VBox(
+ [app._status_html, guide_html],
+ layout=layout_fn(padding="8px 0"),
+ )
+
+
+def build_history_section(
+ app: Any,
+ *,
+ layout_fn: Any,
+ pyscf_available: bool,
+ benchmark_suite: list[Any],
+ benchmark_suite_long: list[Any],
+ load_last_calibration_label_fn: Any,
+) -> None:
+ """Build the History tab panel including calibration and perf widgets."""
+ app.past_dd = widgets.Dropdown(
+ description="Load:",
+ options=[("(no saved results)", "")],
+ style={"description_width": "50px"},
+ layout=layout_fn(width="500px"),
+ )
+ app.past_refresh_btn = widgets.Button(
+ description="Refresh",
+ button_style="",
+ icon="refresh",
+ layout=layout_fn(width="100px"),
+ tooltip="Rescan the results directory",
+ )
+ app.copy_path_btn = widgets.Button(
+ description="Copy path",
+ button_style="",
+ icon="clipboard",
+ layout=layout_fn(width="120px"),
+ tooltip="Copy the results directory path to clipboard",
+ )
+ app.results_path_lbl = widgets.HTML()
+ app.past_output = widgets.Output()
+ app.view_log_btn = widgets.Button(
+ description="View log",
+ button_style="",
+ icon="file-text-o",
+ layout=layout_fn(width="110px"),
+ tooltip="Open the full PySCF output log in the Output tab",
+ )
+
+ app._cal_mode_toggle = widgets.ToggleButtons(
+ options=[("Quick (~10 s)", "short"), ("Full (~5 min)", "long")],
+ value="short",
+ description="",
+ button_style="",
+ style={"description_width": "0px", "button_width": "140px"},
+ layout=layout_fn(margin="0 0 8px"),
+ )
+ app._cal_run_btn = widgets.Button(
+ description="Run Calibration",
+ button_style="primary",
+ icon="play",
+ disabled=not pyscf_available,
+ tooltip=(
+ "Run the benchmark suite to calibrate time estimates"
+ if pyscf_available
+ else "PySCF required (Linux / macOS / WSL)"
+ ),
+ layout=layout_fn(width="180px"),
+ )
+ app._cal_stop_btn = widgets.Button(
+ description="Stop",
+ button_style="warning",
+ icon="stop",
+ layout=layout_fn(width="90px", display="none"),
+ )
+ app._cal_progress = widgets.IntProgress(
+ min=0,
+ max=len(benchmark_suite),
+ value=0,
+ description="",
+ bar_style="info",
+ layout=layout_fn(width="300px", display="none"),
+ )
+ app._cal_step_label = widgets.HTML(
+ value="",
+ layout=layout_fn(display="none"),
+ )
+ app._cal_results_html = widgets.HTML(value="")
+
+ app._perf_stats_html = widgets.HTML()
+ app._perf_events_html = widgets.HTML()
+ app._reset_btn = widgets.Button(
+ description="Reset performance database",
+ button_style="danger",
+ icon="trash",
+ layout=layout_fn(width="230px"),
+ )
+ app._reset_confirm_html = widgets.HTML(
+ ''
+ "Warning: This will permanently delete all performance records. "
+ "Time estimates will reset to “no data”. "
+ )
+ app._reset_confirm_yes = widgets.Button(
+ description="Yes, delete all records",
+ button_style="danger",
+ icon="check",
+ layout=layout_fn(width="190px"),
+ )
+ app._reset_confirm_no = widgets.Button(
+ description="Cancel",
+ button_style="",
+ icon="times",
+ layout=layout_fn(width="90px"),
+ )
+ app._reset_confirm_box = widgets.VBox(
+ [
+ app._reset_confirm_html,
+ widgets.HBox(
+ [app._reset_confirm_yes, app._reset_confirm_no],
+ layout=layout_fn(gap="8px", margin="4px 0 0"),
+ ),
+ ],
+ layout=layout_fn(
+ display="none",
+ border="1px solid #fca5a5",
+ padding="8px 10px",
+ margin="6px 0 0",
+ ),
+ )
+
+ perf_stats_panel = widgets.VBox(
+ [
+ app._perf_stats_html,
+ widgets.HTML(
+ ''
+ "Recent events (last 20)
"
+ ),
+ app._perf_events_html,
+ widgets.HBox(
+ [app._reset_btn],
+ layout=layout_fn(margin="14px 0 4px"),
+ ),
+ app._reset_confirm_box,
+ ]
+ )
+ app._perf_accordion = widgets.Accordion(
+ children=[perf_stats_panel], selected_index=None
+ )
+ app._perf_accordion.set_title(0, "Performance stats")
+
+ cal_last = load_last_calibration_label_fn()
+ cal_note = (
+ f''
+ f"Last run: {cal_last}
"
+ if cal_last
+ else ""
+ )
+ cal_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ f''
+ f"Benchmark this machine so the time estimator uses basis-function "
+ f"scaling (Nβ ) rather than generic defaults. "
+ f"Quick runs {len(benchmark_suite)} small calculations (~10 s). "
+ f"Full runs {len(benchmark_suite_long)} calculations spanning "
+ f"all common molecule sizes and methods (~5 min).
" + cal_note
+ ),
+ app._cal_mode_toggle,
+ widgets.HBox(
+ [app._cal_run_btn, app._cal_stop_btn],
+ layout=layout_fn(gap="6px", align_items="center"),
+ ),
+ app._cal_progress,
+ app._cal_step_label,
+ app._cal_results_html,
+ ],
+ layout=layout_fn(padding="4px 0"),
+ )
+ app._cal_accordion = widgets.Accordion(children=[cal_panel], selected_index=None)
+ app._cal_accordion.set_title(0, "Calibrate time estimates")
+
+ app.history_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Calculations are saved automatically. Select one below to view its results.
"
+ ),
+ widgets.HBox(
+ [
+ app.past_dd,
+ app.past_refresh_btn,
+ app.copy_path_btn,
+ app.view_log_btn,
+ ],
+ layout=layout_fn(align_items="center", gap="8px"),
+ ),
+ app.results_path_lbl,
+ app.past_output,
+ app._perf_accordion,
+ app._cal_accordion,
+ ]
+ )
+
+ app._refresh_results_browser()
+ app._refresh_perf_stats()
+
+
def build_theme_selector(app: Any, *, layout_fn: Any) -> None:
"""Build the theme selector widgets and apply default theme CSS."""
app._theme_style = widgets.Output(
From 8a4e4de277e9f980c0fa5381b6a25ba40c4361da Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 18:34:19 -0400
Subject: [PATCH 09/22] Extract UI builders and runflow logic
Move large widget-construction and run/flow handlers out of quantui/app.py into dedicated modules for better modularity. Added build_shared_widgets and build_results_section to quantui/app_builders.py and introduced quantui/app_runflow.py (new) with on_run_clicked, update_estimate, and update_notes. Updated imports and calls in app.py to delegate to these helpers and adjusted attribute declarations accordingly, significantly reducing app.py size and centralising UI construction and run logic.
---
quantui/app.py | 901 +++++++---------------------------------
quantui/app_builders.py | 691 ++++++++++++++++++++++++++++++
quantui/app_runflow.py | 83 ++++
3 files changed, 915 insertions(+), 760 deletions(-)
create mode 100644 quantui/app_runflow.py
diff --git a/quantui/app.py b/quantui/app.py
index af52502..bc7c419 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -97,9 +97,15 @@
from quantui.app_builders import (
build_output_tab as _bld_build_output_tab,
)
+from quantui.app_builders import (
+ build_results_section as _bld_build_results_section,
+)
from quantui.app_builders import (
build_run_section as _bld_build_run_section,
)
+from quantui.app_builders import (
+ build_shared_widgets as _bld_build_shared_widgets,
+)
from quantui.app_builders import (
build_status_panel as _bld_build_status_panel,
)
@@ -166,6 +172,15 @@
from quantui.app_history import (
on_view_log as _hist_on_view_log,
)
+from quantui.app_runflow import (
+ on_run_clicked as _run_on_run_clicked,
+)
+from quantui.app_runflow import (
+ update_estimate as _run_update_estimate,
+)
+from quantui.app_runflow import (
+ update_notes as _run_update_notes,
+)
from quantui.app_visualization import (
build_vib_data_from_freq_result as _viz_build_vib_data_from_freq_result,
)
@@ -580,13 +595,113 @@ class QuantUIApp:
pubchem_btn: Any
pubchem_msg: Any
pubchem_txt: Any
+ result_output: Any
+ result_viz_output: Any
results_path_lbl: Any
+ run_btn: Any
+ run_output: Any
run_panel: Any
+ run_status: Any
+ solvent_cb: Any
+ solvent_dd: Any
+ step_progress: Any
theme_btn: Any
+ viz_backend_toggle: Any
+ viz_controls_box: Any
+ viz_lighting_dd: Any
+ viz_output: Any
+ viz_style_dd: Any
view_log_btn: Any
xyz_area: Any
xyz_btn: Any
xyz_msg: Any
+ _freq_preopt_cb: Any
+ _freq_seed_dd: Any
+ _freq_seed_note: Any
+ _freq_seed_refresh_btn: Any
+ _go_analysis_btn: Any
+ _go_results_btn: Any
+ _ir_fig: Any
+ _ir_fwhm_slider: Any
+ _ir_mode_toggle: Any
+ _ir_accordion: Any
+ _iso_accordion: Any
+ _iso_generate_btn: Any
+ _last_result_dir: Any
+ _nmr_accordion: Any
+ _nmr_output: Any
+ _orb_accordion: Any
+ _orb_diagram_box: Any
+ _orb_diagram_html: Any
+ _orb_iso_controls: Any
+ _orb_iso_output: Any
+ _orb_n_orb_input: Any
+ _orb_toggle: Any
+ _orb_ymax_input: Any
+ _orb_ymin_input: Any
+ _pes_plot_html: Any
+ _pes_scan_accordion: Any
+ _result_dir_label: Any
+ _result_log_accordion: Any
+ _result_log_output: Any
+ _scan_atom1: Any
+ _scan_atom2: Any
+ _scan_atom3: Any
+ _scan_atom34_box: Any
+ _scan_atom4: Any
+ _scan_start: Any
+ _scan_steps: Any
+ _scan_stop: Any
+ _scan_type_dd: Any
+ _scan_unit_lbl: Any
+ _tddft_accordion: Any
+ _tddft_fig: Any
+ _to_analysis_btn: Any
+ _viz_backend: Any
+ _viz_label: Any
+ _viz_lighting: Any
+ _viz_style: Any
+ _analysis_context_lbl: Any
+ _analysis_empty_html: Any
+ _analysis_mol_output: Any
+ _ana_unavail_html: Any
+ accumulate_btn: Any
+ analysis_tab_panel: Any
+ basis_dd: Any
+ basis_help_btn: Any
+ calc_extra_opts: Any
+ calc_type_dd: Any
+ charge_si: Any
+ clear_btn: Any
+ _completion_banner: Any
+ _completion_mol_lbl: Any
+ comparison_output: Any
+ export_btn: Any
+ export_mol_btn: Any
+ export_pdb_btn: Any
+ export_status: Any
+ export_xyz_btn: Any
+ fmax_fi: Any
+ log_clear_btn: Any
+ max_steps_si: Any
+ method_dd: Any
+ method_help_btn: Any
+ mol_info_html: Any
+ mol_summary_compact: Any
+ mult_si: Any
+ notes_output: Any
+ nstates_si: Any
+ perf_estimate_html: Any
+ post_calc_panel: Any
+ preopt_cb: Any
+ results_panel: Any
+ results_tab_panel: Any
+ struct_export_status: Any
+ traj_accordion: Any
+ traj_output: Any
+ vib_accordion: Any
+ vib_mode_dd: Any
+ vib_output: Any
def __init__(self) -> None:
# ── Instance state ────────────────────────────────────────────────
@@ -700,359 +815,29 @@ def _build_welcome_header(self) -> None:
# ── Shared widgets (Cell 3) ───────────────────────────────────────────
def _build_shared_widgets(self) -> None:
- # Output widgets
- self.mol_info_html = widgets.HTML(
- value='No molecule loaded yet. '
- )
- self.mol_summary_compact = widgets.HTML(value="")
- self.viz_output = widgets.Output(layout=_layout(min_height="50px"))
- self.run_output = widgets.Output(
- layout=_layout(
- border="1px solid #c0ccd8",
- min_height="80px",
- max_height="400px",
- padding="8px",
- overflow_y="auto",
- )
- )
- with self.run_output:
- display(
- HTML(
- ''
- "No calculation run yet. PySCF output and any errors will appear here."
- "
"
- )
- )
- self.result_output = widgets.Output()
- self.result_viz_output = widgets.Output()
- self.comparison_output = widgets.Output()
- self._last_result_dir: Optional[Path] = None
-
- # 3D viewer backend selector — shown only when both backends are installed
- self._viz_backend: _VizBackend = _DEFAULT_VIZ_BACKEND
- if _BOTH_VIZ_AVAILABLE:
- self.viz_backend_toggle = widgets.ToggleButtons(
- options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")],
- value=_DEFAULT_VIZ_BACKEND,
- tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"],
- style={"button_width": "90px"},
- layout=_layout(margin="2px 0 0 0"),
- )
- else:
- self.viz_backend_toggle = None # type: ignore[assignment]
-
- # 3D viewer style and lighting controls
- self._viz_style: str = _DEFAULT_VIZ_STYLE
- self._viz_lighting: str = _DEFAULT_LIGHTING
- self.viz_style_dd = widgets.Dropdown(
- options=_VIZ_STYLE_OPTIONS,
- value=_DEFAULT_VIZ_STYLE,
- description="Style:",
- style={"description_width": "40px"},
- layout=_layout(width="180px"),
- disabled=not VISUALIZATION_AVAILABLE,
- )
- # Lighting only applies to the PlotlyMol backend
- _lighting_available = VISUALIZATION_AVAILABLE and _PLOTLYMOL_VIZ
- self.viz_lighting_dd = widgets.Dropdown(
- options=_LIGHTING_OPTIONS,
- value=_DEFAULT_LIGHTING,
- description="Lighting:",
- style={"description_width": "58px"},
- layout=_layout(width="170px"),
- disabled=not _lighting_available,
- )
- if not _lighting_available:
- self.viz_lighting_dd.layout.visibility = "hidden"
- self.viz_controls_box = widgets.HBox(
- [self.viz_style_dd, self.viz_lighting_dd],
- layout=_layout(gap="8px", margin="2px 0 0 0", align_items="center"),
- )
- self.notes_output = widgets.Output()
- self.perf_estimate_html = widgets.HTML()
-
- # Step indicator
- self.step_progress = StepProgress(
- ["Choose molecule", "Set method", "Run", "Results"]
- )
- self.step_progress.start(0)
-
- # Calculation setup dropdowns
- self.method_dd = widgets.Dropdown(
- options=SUPPORTED_METHODS,
- value=DEFAULT_METHOD,
- description="Method:",
- style={"description_width": "100px"},
- layout=_layout(width="260px"),
- )
- self.basis_dd = widgets.Dropdown(
- options=SUPPORTED_BASIS_SETS,
- value=DEFAULT_BASIS,
- description="Basis Set:",
- style={"description_width": "100px"},
- layout=_layout(width="260px"),
- )
- self.charge_si = widgets.BoundedIntText(
- value=DEFAULT_CHARGE,
- min=-10,
- max=10,
- description="Charge:",
- style={"description_width": "100px"},
- layout=_layout(width="190px"),
- )
- self.mult_si = widgets.BoundedIntText(
- value=DEFAULT_MULTIPLICITY,
- min=1,
- max=10,
- description="Multiplicity:",
- style={"description_width": "100px"},
- layout=_layout(width="190px"),
- )
- self.preopt_cb = widgets.Checkbox(
- value=False,
- description="Pre-optimize geometry (for a crude starting point)",
- disabled=not _PREOPT_AVAILABLE,
- layout=_layout(width="400px"),
- )
-
- # Implicit solvent (PCM)
- from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS
-
- self.solvent_cb = widgets.Checkbox(
- value=False,
- description="Implicit solvent (PCM)",
- layout=_layout(width="240px"),
- )
- self.solvent_dd = widgets.Dropdown(
- options=list(_SOLVENT_OPTS.keys()),
- value="Water",
- description="Solvent:",
- style={"description_width": "70px"},
- layout=_layout(width="200px", display="none"),
- )
-
- # Calculation type + extra options
- self.calc_type_dd = widgets.Dropdown(
- options=[
- "Single Point",
- "Geometry Opt",
- "Frequency",
- "UV-Vis (TD-DFT)",
- "NMR Shielding",
- "PES Scan",
- ],
- value="Single Point",
- description="Calc. Type:",
- style={"description_width": "100px"},
- layout=_layout(width="310px"),
- )
- self.fmax_fi = widgets.BoundedFloatText(
- value=DEFAULT_FMAX,
- min=0.001,
- max=1.0,
- step=0.005,
- description="Force thr. (eV/Å):",
- style={"description_width": "130px"},
- layout=_layout(width="270px"),
- )
- self.max_steps_si = widgets.BoundedIntText(
- value=DEFAULT_OPT_STEPS,
- min=10,
- max=1000,
- description="Max steps:",
- style={"description_width": "100px"},
- layout=_layout(width="200px"),
- )
- self.nstates_si = widgets.BoundedIntText(
- value=10,
- min=1,
- max=50,
- description="# states:",
- style={"description_width": "100px"},
- layout=_layout(width="180px"),
- )
-
- # ── Frequency calc extra widgets ──────────────────────────────────────
- self._freq_seed_dd = widgets.Dropdown(
- options=[("(use current molecule)", "")],
- description="Seed geometry:",
- style={"description_width": "110px"},
- layout=_layout(width="420px"),
- tooltip="Optionally load the final optimised geometry from a previous Geo Opt result",
- )
- self._freq_seed_refresh_btn = widgets.Button(
- description="",
- icon="refresh",
- layout=_layout(width="32px", height="32px"),
- tooltip="Refresh the list of saved geometry optimisations",
- )
- self._freq_preopt_cb = widgets.Checkbox(
- value=False,
- description="Geometry optimization (recommended for unoptimized inputs)",
- style={"description_width": "initial"},
- layout=_layout(width="100%"),
- )
- self._freq_seed_note = widgets.HTML("")
-
- # ── PES scan extra widgets ────────────────────────────────────────────
- self._scan_type_dd = widgets.Dropdown(
- options=["Bond", "Angle", "Dihedral"],
- value="Bond",
- description="Scan type:",
- style={"description_width": "80px"},
- layout=_layout(width="220px"),
- )
- _atom_idx_layout = _layout(width="95px")
- _atom_idx_style = {"description_width": "50px"}
- self._scan_atom1 = widgets.BoundedIntText(
- value=1,
- min=1,
- max=999,
- description="Atom 1:",
- style=_atom_idx_style,
- layout=_atom_idx_layout,
- )
- self._scan_atom2 = widgets.BoundedIntText(
- value=2,
- min=1,
- max=999,
- description="Atom 2:",
- style=_atom_idx_style,
- layout=_atom_idx_layout,
- )
- self._scan_atom3 = widgets.BoundedIntText(
- value=3,
- min=1,
- max=999,
- description="Atom 3:",
- style=_atom_idx_style,
- layout=_atom_idx_layout,
- )
- self._scan_atom4 = widgets.BoundedIntText(
- value=4,
- min=1,
- max=999,
- description="Atom 4:",
- style=_atom_idx_style,
- layout=_atom_idx_layout,
- )
- self._scan_atom34_box = widgets.HBox(
- [self._scan_atom3, self._scan_atom4],
- layout=_layout(gap="4px"),
- )
- self._scan_start = widgets.BoundedFloatText(
- value=0.5,
- min=0.01,
- max=1000.0,
- step=0.1,
- description="Start:",
- style={"description_width": "40px"},
- layout=_layout(width="140px"),
- )
- self._scan_stop = widgets.BoundedFloatText(
- value=2.0,
- min=0.01,
- max=1000.0,
- step=0.1,
- description="Stop:",
- style={"description_width": "40px"},
- layout=_layout(width="140px"),
- )
- self._scan_steps = widgets.BoundedIntText(
- value=10,
- min=2,
- max=100,
- description="Points:",
- style={"description_width": "50px"},
- layout=_layout(width="120px"),
- )
- self._scan_unit_lbl = widgets.HTML(
- 'Å '
- )
-
- self.calc_extra_opts = widgets.VBox([])
-
- # Context-help buttons
- self.method_help_btn = widgets.Button(
- description="?",
- button_style="",
- layout=_layout(width="28px", height="28px"),
- tooltip="RHF vs UHF — opens Help tab",
- )
- self.basis_help_btn = widgets.Button(
- description="?",
- button_style="",
- layout=_layout(width="28px", height="28px"),
- tooltip="Choosing a basis set — opens Help tab",
- )
-
- # Run widgets
- self.run_btn = widgets.Button(
- description="Run Calculation",
- button_style="success",
- icon="play",
- disabled=True,
- layout=_layout(width="200px", height="36px"),
- )
- self.run_status = widgets.Label()
-
- # Log clear button (in run panel)
- self.log_clear_btn = widgets.Button(
- description="Clear",
- button_style="",
- icon="times",
- layout=_layout(width="90px", height="26px"),
- tooltip="Clear calculation output",
- )
-
- # Comparison / export widgets
- self.accumulate_btn = widgets.Button(
- description="Add to Comparison",
- button_style="info",
- icon="plus",
- disabled=True,
- layout=_layout(width="190px"),
- )
- self.clear_btn = widgets.Button(
- description="Clear",
- button_style="warning",
- icon="trash",
- layout=_layout(width="100px"),
- )
- self.export_btn = widgets.Button(
- description="Export Script",
- button_style="",
- icon="download",
- disabled=True,
- layout=_layout(width="160px"),
- )
- self.export_status = widgets.Label()
- _rdkit_tip = (
- ""
- if _RDKIT_AVAILABLE
- else "Requires RDKit (conda install -c conda-forge rdkit)"
- )
- self.export_xyz_btn = widgets.Button(
- description="Export XYZ",
- icon="download",
- disabled=True,
- layout=_layout(width="130px"),
- )
- self.export_mol_btn = widgets.Button(
- description="Export MOL",
- icon="download",
- disabled=True,
- tooltip=_rdkit_tip,
- layout=_layout(width="130px"),
- )
- self.export_pdb_btn = widgets.Button(
- description="Export PDB",
- icon="download",
- disabled=True,
- tooltip=_rdkit_tip,
- layout=_layout(width="130px"),
+ _bld_build_shared_widgets(
+ self,
+ layout_fn=_layout,
+ step_progress_cls=StepProgress,
+ supported_methods=SUPPORTED_METHODS,
+ supported_basis_sets=SUPPORTED_BASIS_SETS,
+ default_method=DEFAULT_METHOD,
+ default_basis=DEFAULT_BASIS,
+ default_charge=DEFAULT_CHARGE,
+ default_multiplicity=DEFAULT_MULTIPLICITY,
+ default_fmax=DEFAULT_FMAX,
+ default_opt_steps=DEFAULT_OPT_STEPS,
+ preopt_available=_PREOPT_AVAILABLE,
+ visualization_available=VISUALIZATION_AVAILABLE,
+ both_viz_available=_BOTH_VIZ_AVAILABLE,
+ default_viz_backend=_DEFAULT_VIZ_BACKEND,
+ default_viz_style=_DEFAULT_VIZ_STYLE,
+ default_lighting=_DEFAULT_LIGHTING,
+ viz_style_options=_VIZ_STYLE_OPTIONS,
+ plotlymol_viz=_PLOTLYMOL_VIZ,
+ lighting_options=_LIGHTING_OPTIONS,
+ rdkit_available=_RDKIT_AVAILABLE,
)
- self.struct_export_status = widgets.Label()
# ── Molecule section (Cell 4) ─────────────────────────────────────────
@@ -1078,351 +863,7 @@ def _build_run_section(self) -> None:
# ── Results panel (Cell 7) ────────────────────────────────────────────
def _build_results_section(self) -> None:
- # PES scan energy plot accordion (hidden until a PES Scan completes)
- self._pes_plot_html = widgets.Output(layout=_layout(width="100%"))
- self._pes_scan_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- [self._pes_plot_html],
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._pes_scan_accordion.set_title(0, "PES Energy Profile")
- self._pes_scan_accordion.selected_index = None
-
- # Trajectory accordion (Geo Opt / PES Scan — hidden until result completes)
- self.traj_output = widgets.Output()
- self.traj_accordion = widgets.Accordion(
- children=[self.traj_output],
- layout=_layout(display="none", margin="8px 0"),
- )
- self.traj_accordion.set_title(0, "Trajectory Viewer")
- self.traj_accordion.selected_index = None # collapsed by default
- self.traj_accordion.observe(
- self._safe_cb(self._on_traj_expand), names=["selected_index"]
- )
-
- # Vibrational animation accordion (Frequency only — hidden until Freq completes)
- self.vib_mode_dd = widgets.Dropdown(
- description="Mode:",
- options=[],
- style={"description_width": "50px"},
- layout=_layout(width="360px"),
- )
- self.vib_output = widgets.Output()
- self.vib_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- [self.vib_mode_dd, self.vib_output],
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self.vib_accordion.set_title(0, "Vibrational Mode Viewer")
- self.vib_accordion.selected_index = None # collapsed by default
-
- # IR Spectrum accordion (hidden until a Frequency result is available)
- self._ir_mode_toggle = widgets.ToggleButtons(
- options=["Stick", "Broadened"],
- value="Stick",
- style={"button_width": "80px"},
- layout=_layout(margin="0 8px 0 0"),
- )
- self._ir_fwhm_slider = widgets.FloatSlider(
- value=20.0,
- min=5.0,
- max=100.0,
- step=5.0,
- description="Line width:",
- style={"description_width": "80px"},
- layout=_layout(width="260px", display="none"),
- )
- self._ir_fig = widgets.Output(layout=_layout(width="100%"))
-
- _ir_controls = widgets.HBox(
- [self._ir_mode_toggle, self._ir_fwhm_slider],
- layout=_layout(align_items="center", margin="0 0 6px 0"),
- )
- _ir_body_children = [_ir_controls, self._ir_fig]
- self._ir_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- _ir_body_children,
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._ir_accordion.set_title(0, "IR Spectrum")
- self._ir_accordion.selected_index = None
-
- # Orbital energy diagram + isosurface accordion (Single Point / Geo Opt)
- # Use plotly.io.to_html so FigureWidget / anywidget dependency is not needed.
-
- self._orb_ymin_input = widgets.BoundedFloatText(
- value=-30.0,
- min=-500.0,
- max=200.0,
- step=1.0,
- description="Y min:",
- layout=_layout(width="140px"),
- style={"description_width": "45px"},
- )
- self._orb_ymax_input = widgets.BoundedFloatText(
- value=5.0,
- min=-500.0,
- max=500.0,
- step=1.0,
- description="Y max:",
- layout=_layout(width="140px"),
- style={"description_width": "45px"},
- )
- self._orb_n_orb_input = widgets.BoundedIntText(
- value=20,
- min=4,
- max=200,
- step=2,
- description="Show N:",
- layout=_layout(width="120px"),
- style={"description_width": "50px"},
- )
- _orb_controls_row = widgets.HBox(
- [
- widgets.HTML(
- 'Y range: '
- ),
- self._orb_ymin_input,
- self._orb_ymax_input,
- widgets.HTML(
- ''
- "Orbitals shown: "
- ),
- self._orb_n_orb_input,
- ],
- layout=_layout(
- align_items="center",
- flex_wrap="wrap",
- gap="4px",
- margin="0 0 6px 0",
- ),
- )
- self._orb_diagram_html = widgets.Output(layout=_layout(width="100%"))
- _orb_diagram_content: list = [_orb_controls_row, self._orb_diagram_html]
- self._orb_diagram_box = widgets.VBox(
- _orb_diagram_content,
- layout=_layout(width="100%"),
- )
- self._orb_toggle = widgets.ToggleButtons(
- options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"],
- value="HOMO",
- style={"button_width": "70px"},
- layout=_layout(margin="8px 0 4px 0"),
- )
- self._orb_iso_output = widgets.Output()
- self._orb_iso_controls = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Orbital isosurface: "
- ),
- self._orb_toggle,
- self._orb_iso_output,
- ],
- layout=_layout(display="none", margin="8px 0 0 0"),
- )
- self._orb_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- [self._orb_diagram_box],
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._orb_accordion.set_title(0, "Orbital Diagram")
- self._orb_accordion.selected_index = None
-
- # Post-calculate panel — isosurface and other heavy on-demand analyses
- self._iso_generate_btn = widgets.Button(
- description="Generate Isosurface",
- button_style="primary",
- icon="flask",
- disabled=True,
- tooltip=(
- "Generate a 3D orbital isosurface. "
- "Available after running or loading a Single Point or Geometry Optimization."
- ),
- layout=_layout(width="200px", margin="8px 0 4px 0"),
- )
- _iso_body = widgets.VBox(
- [
- widgets.HTML(
- ''
- "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — "
- "requires PySCF and RDKit). Run or load a Single Point or Geometry "
- "Optimization first, then click Generate .
"
- ),
- self._orb_iso_controls,
- self._iso_generate_btn,
- ],
- layout=_layout(padding="8px"),
- )
- self._iso_accordion = widgets.Accordion(
- children=[_iso_body],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._iso_accordion.set_title(0, "Orbital Isosurface")
- self._iso_accordion.selected_index = None
-
- # ── UV-Vis spectrum accordion (TD-DFT only — hidden until result) ──
- self._tddft_fig = widgets.Output(layout=_layout(width="100%"))
- self._tddft_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- [self._tddft_fig],
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum")
- self._tddft_accordion.selected_index = None
-
- # ── NMR shielding accordion (NMR only — hidden until result) ────────
- self._nmr_output = widgets.HTML(value="", layout=_layout(width="100%"))
- self._nmr_accordion = widgets.Accordion(
- children=[
- widgets.VBox(
- [self._nmr_output],
- layout=_layout(padding="8px"),
- )
- ],
- layout=_layout(display="none", margin="8px 0"),
- )
- self._nmr_accordion.set_title(0, "NMR Chemical Shifts")
- self._nmr_accordion.selected_index = None
-
- # ── Result directory path label (hidden until a calculation saves) ──
- self._result_dir_label = widgets.HTML(
- value="",
- layout=_layout(display="none", margin="4px 0 0 0"),
- )
-
- # ── Full output log accordion (hidden until a calculation saves) ────
- self._result_log_output = widgets.Output()
- self._result_log_accordion = widgets.Accordion(
- children=[self._result_log_output],
- layout=_layout(display="none", margin="8px 0 0 0"),
- )
- self._result_log_accordion.set_title(0, "Full output log (pyscf.log)")
- self._result_log_accordion.selected_index = None
-
- # ── Completion banner (Calculate tab — hidden until run finishes) ───
- self._go_results_btn = widgets.Button(
- description="→ View Results",
- button_style="success",
- layout=_layout(width="130px"),
- )
- self._go_analysis_btn = widgets.Button(
- description="→ View Analysis",
- button_style="info",
- layout=_layout(width="140px"),
- )
- self._completion_mol_lbl = widgets.HTML(value="")
- self._completion_banner = widgets.HBox(
- [
- widgets.HTML(
- ''
- "✓ Calculation complete — "
- ),
- self._completion_mol_lbl,
- self._go_results_btn,
- self._go_analysis_btn,
- ],
- layout=_layout(
- display="none",
- align_items="center",
- gap="8px",
- padding="10px 12px",
- border="1px solid #bbf7d0",
- border_radius="6px",
- background_color="#f0fdf4",
- margin="8px 0",
- ),
- )
-
- # ── Results tab panel (Tab 1) ─────────────────────────────────────
- self._to_analysis_btn = widgets.Button(
- description="→ View Analysis",
- button_style="",
- icon="bar-chart",
- layout=_layout(display="none", width="160px", margin="8px 0 0 0"),
- )
- # Label above the 3D viewer — updated by _do_run to say "Optimized geometry"
- # for Geometry Opt, or hidden for other calc types that don't change geometry.
- self._viz_label = widgets.HTML(
- value="",
- layout=_layout(display="none"),
- )
- self.results_tab_panel = widgets.VBox(
- [
- widgets.HTML('Results '),
- self.result_output,
- self._viz_label,
- self.result_viz_output,
- self._result_dir_label,
- # advanced_accordion appended in _assemble_tabs (built later in
- # _build_compare_section — must run before it can be referenced here)
- self._to_analysis_btn,
- ],
- layout=_layout(padding="8px 0"),
- )
- # Backward-compat alias — existing methods that reference results_panel still work
- self.results_panel = self.results_tab_panel
-
- # ── Analysis tab: molecule viewer (shown for all calc types) ─────
- self._analysis_mol_output = widgets.Output()
-
- # ── Analysis tab panel (Tab 2) ────────────────────────────────────
- self._analysis_context_lbl = widgets.HTML(
- value=(
- ''
- "No result loaded yet. Run a calculation or load one from History.
"
- )
- )
- self._analysis_empty_html = widgets.HTML(
- value=(
- ''
- "No interactive analysis is available for this calculation type. "
- "Run a Single Point, Geo Opt, or Frequency calculation to see "
- "orbital diagrams, trajectory animations, and spectra here.
"
- ),
- layout=_layout(display="none"),
- )
- self._ana_unavail_html = widgets.HTML(value="", layout=_layout(display="none"))
- self._build_ana_switcher()
- self.analysis_tab_panel = widgets.VBox(
- [
- self._analysis_context_lbl,
- self._analysis_mol_output,
- self._analysis_empty_html,
- self._ana_unavail_html,
- self._orb_accordion,
- self._pes_scan_accordion,
- self.traj_accordion,
- self.vib_accordion,
- self._ir_accordion,
- self._iso_accordion,
- self._tddft_accordion,
- self._nmr_accordion,
- ],
- layout=_layout(padding="8px 0"),
- )
- # Backward-compat alias for post_calc_panel references in tests
- self.post_calc_panel = self.analysis_tab_panel
+ _bld_build_results_section(self, layout_fn=_layout)
# ── Analysis panel switcher ───────────────────────────────────────────
@@ -2103,23 +1544,7 @@ def _on_basis_help(self, btn) -> None:
# ── Run ───────────────────────────────────────────────────────────────
def _on_run_clicked(self, btn) -> None:
- self.run_output.clear_output()
- self.result_output.clear_output()
- self.result_viz_output.clear_output()
- self._analysis_mol_output.clear_output()
- self._viz_label.layout.display = "none"
- self._viz_label.value = ""
- self._deactivate_all_ana_panels()
- self._clear_output_widget(self._pes_plot_html)
- self._result_dir_label.value = ""
- self._result_dir_label.layout.display = "none"
- self._result_log_accordion.layout.display = "none"
- self._result_log_accordion.selected_index = None
- self._result_log_output.clear_output()
- self._completion_banner.layout.display = "none"
- self._to_analysis_btn.layout.display = "none"
- self._analysis_empty_html.layout.display = "none"
- threading.Thread(target=self._do_run, daemon=True).start()
+ _run_on_run_clicked(self, btn)
def _on_solvent_cb_changed(self, change) -> None:
self.solvent_dd.layout.display = "" if change["new"] else "none"
@@ -3256,54 +2681,10 @@ def _do_run(self) -> None:
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
+ _run_update_notes(self, change)
def _update_estimate(self, change=None) -> None:
- if self._molecule is None:
- self.perf_estimate_html.value = ""
- return
- try:
- n_basis = _calc_log.count_basis_functions(
- self._molecule.atoms, self.basis_dd.value
- )
- est = _calc_log.estimate_time(
- n_atoms=len(self._molecule.atoms),
- n_electrons=self._molecule.get_electron_count(),
- method=self.method_dd.value,
- basis=self.basis_dd.value,
- n_basis=n_basis,
- )
- self.perf_estimate_html.value = _calc_log.format_estimate(est)
- except Exception:
- self.perf_estimate_html.value = ""
+ _run_update_estimate(self, calc_log_mod=_calc_log, change=change)
def _refresh_results_browser(self) -> None:
try:
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index c24549f..9b0a8ee 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -308,6 +308,370 @@ def build_history_section(
app._refresh_perf_stats()
+def build_shared_widgets(
+ app: Any,
+ *,
+ layout_fn: Any,
+ step_progress_cls: Any,
+ supported_methods: list[Any],
+ supported_basis_sets: list[Any],
+ default_method: str,
+ default_basis: str,
+ default_charge: int,
+ default_multiplicity: int,
+ default_fmax: float,
+ default_opt_steps: int,
+ preopt_available: bool,
+ visualization_available: bool,
+ both_viz_available: bool,
+ default_viz_backend: Any,
+ default_viz_style: str,
+ default_lighting: str,
+ viz_style_options: list[Any],
+ plotlymol_viz: bool,
+ lighting_options: list[Any],
+ rdkit_available: bool,
+) -> None:
+ """Build shared widgets used across tabs and callbacks."""
+ app.mol_info_html = widgets.HTML(
+ value='No molecule loaded yet. '
+ )
+ app.mol_summary_compact = widgets.HTML(value="")
+ app.viz_output = widgets.Output(layout=layout_fn(min_height="50px"))
+ app.run_output = widgets.Output(
+ layout=layout_fn(
+ border="1px solid #c0ccd8",
+ min_height="80px",
+ max_height="400px",
+ padding="8px",
+ overflow_y="auto",
+ )
+ )
+ with app.run_output:
+ display(
+ HTML(
+ ''
+ "No calculation run yet. PySCF output and any errors will appear here."
+ "
"
+ )
+ )
+ app.result_output = widgets.Output()
+ app.result_viz_output = widgets.Output()
+ app.comparison_output = widgets.Output()
+ app._last_result_dir = None
+
+ app._viz_backend = default_viz_backend
+ if both_viz_available:
+ app.viz_backend_toggle = widgets.ToggleButtons(
+ options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")],
+ value=default_viz_backend,
+ tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"],
+ style={"button_width": "90px"},
+ layout=layout_fn(margin="2px 0 0 0"),
+ )
+ else:
+ app.viz_backend_toggle = None # type: ignore[assignment]
+
+ app._viz_style = default_viz_style
+ app._viz_lighting = default_lighting
+ app.viz_style_dd = widgets.Dropdown(
+ options=viz_style_options,
+ value=default_viz_style,
+ description="Style:",
+ style={"description_width": "40px"},
+ layout=layout_fn(width="180px"),
+ disabled=not visualization_available,
+ )
+ lighting_available = visualization_available and plotlymol_viz
+ app.viz_lighting_dd = widgets.Dropdown(
+ options=lighting_options,
+ value=default_lighting,
+ description="Lighting:",
+ style={"description_width": "58px"},
+ layout=layout_fn(width="170px"),
+ disabled=not lighting_available,
+ )
+ if not lighting_available:
+ app.viz_lighting_dd.layout.visibility = "hidden"
+ app.viz_controls_box = widgets.HBox(
+ [app.viz_style_dd, app.viz_lighting_dd],
+ layout=layout_fn(gap="8px", margin="2px 0 0 0", align_items="center"),
+ )
+ app.notes_output = widgets.Output()
+ app.perf_estimate_html = widgets.HTML()
+
+ app.step_progress = step_progress_cls(
+ ["Choose molecule", "Set method", "Run", "Results"]
+ )
+ app.step_progress.start(0)
+
+ app.method_dd = widgets.Dropdown(
+ options=supported_methods,
+ value=default_method,
+ description="Method:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="260px"),
+ )
+ app.basis_dd = widgets.Dropdown(
+ options=supported_basis_sets,
+ value=default_basis,
+ description="Basis Set:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="260px"),
+ )
+ app.charge_si = widgets.BoundedIntText(
+ value=default_charge,
+ min=-10,
+ max=10,
+ description="Charge:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="190px"),
+ )
+ app.mult_si = widgets.BoundedIntText(
+ value=default_multiplicity,
+ min=1,
+ max=10,
+ description="Multiplicity:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="190px"),
+ )
+ app.preopt_cb = widgets.Checkbox(
+ value=False,
+ description="Pre-optimize geometry (for a crude starting point)",
+ disabled=not preopt_available,
+ layout=layout_fn(width="400px"),
+ )
+
+ from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS
+
+ app.solvent_cb = widgets.Checkbox(
+ value=False,
+ description="Implicit solvent (PCM)",
+ layout=layout_fn(width="240px"),
+ )
+ app.solvent_dd = widgets.Dropdown(
+ options=list(_SOLVENT_OPTS.keys()),
+ value="Water",
+ description="Solvent:",
+ style={"description_width": "70px"},
+ layout=layout_fn(width="200px", display="none"),
+ )
+
+ app.calc_type_dd = widgets.Dropdown(
+ options=[
+ "Single Point",
+ "Geometry Opt",
+ "Frequency",
+ "UV-Vis (TD-DFT)",
+ "NMR Shielding",
+ "PES Scan",
+ ],
+ value="Single Point",
+ description="Calc. Type:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="310px"),
+ )
+ app.fmax_fi = widgets.BoundedFloatText(
+ value=default_fmax,
+ min=0.001,
+ max=1.0,
+ step=0.005,
+ description="Force thr. (eV/Å):",
+ style={"description_width": "130px"},
+ layout=layout_fn(width="270px"),
+ )
+ app.max_steps_si = widgets.BoundedIntText(
+ value=default_opt_steps,
+ min=10,
+ max=1000,
+ description="Max steps:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="200px"),
+ )
+ app.nstates_si = widgets.BoundedIntText(
+ value=10,
+ min=1,
+ max=50,
+ description="# states:",
+ style={"description_width": "100px"},
+ layout=layout_fn(width="180px"),
+ )
+
+ app._freq_seed_dd = widgets.Dropdown(
+ options=[("(use current molecule)", "")],
+ description="Seed geometry:",
+ style={"description_width": "110px"},
+ layout=layout_fn(width="420px"),
+ tooltip="Optionally load the final optimised geometry from a previous Geo Opt result",
+ )
+ app._freq_seed_refresh_btn = widgets.Button(
+ description="",
+ icon="refresh",
+ layout=layout_fn(width="32px", height="32px"),
+ tooltip="Refresh the list of saved geometry optimisations",
+ )
+ app._freq_preopt_cb = widgets.Checkbox(
+ value=False,
+ description="Geometry optimization (recommended for unoptimized inputs)",
+ style={"description_width": "initial"},
+ layout=layout_fn(width="100%"),
+ )
+ app._freq_seed_note = widgets.HTML("")
+
+ app._scan_type_dd = widgets.Dropdown(
+ options=["Bond", "Angle", "Dihedral"],
+ value="Bond",
+ description="Scan type:",
+ style={"description_width": "80px"},
+ layout=layout_fn(width="220px"),
+ )
+ atom_idx_layout = layout_fn(width="95px")
+ atom_idx_style = {"description_width": "50px"}
+ app._scan_atom1 = widgets.BoundedIntText(
+ value=1,
+ min=1,
+ max=999,
+ description="Atom 1:",
+ style=atom_idx_style,
+ layout=atom_idx_layout,
+ )
+ app._scan_atom2 = widgets.BoundedIntText(
+ value=2,
+ min=1,
+ max=999,
+ description="Atom 2:",
+ style=atom_idx_style,
+ layout=atom_idx_layout,
+ )
+ app._scan_atom3 = widgets.BoundedIntText(
+ value=3,
+ min=1,
+ max=999,
+ description="Atom 3:",
+ style=atom_idx_style,
+ layout=atom_idx_layout,
+ )
+ app._scan_atom4 = widgets.BoundedIntText(
+ value=4,
+ min=1,
+ max=999,
+ description="Atom 4:",
+ style=atom_idx_style,
+ layout=atom_idx_layout,
+ )
+ app._scan_atom34_box = widgets.HBox(
+ [app._scan_atom3, app._scan_atom4],
+ layout=layout_fn(gap="4px"),
+ )
+ app._scan_start = widgets.BoundedFloatText(
+ value=0.5,
+ min=0.01,
+ max=1000.0,
+ step=0.1,
+ description="Start:",
+ style={"description_width": "40px"},
+ layout=layout_fn(width="140px"),
+ )
+ app._scan_stop = widgets.BoundedFloatText(
+ value=2.0,
+ min=0.01,
+ max=1000.0,
+ step=0.1,
+ description="Stop:",
+ style={"description_width": "40px"},
+ layout=layout_fn(width="140px"),
+ )
+ app._scan_steps = widgets.BoundedIntText(
+ value=10,
+ min=2,
+ max=100,
+ description="Points:",
+ style={"description_width": "50px"},
+ layout=layout_fn(width="120px"),
+ )
+ app._scan_unit_lbl = widgets.HTML(
+ 'Å '
+ )
+
+ app.calc_extra_opts = widgets.VBox([])
+
+ app.method_help_btn = widgets.Button(
+ description="?",
+ button_style="",
+ layout=layout_fn(width="28px", height="28px"),
+ tooltip="RHF vs UHF — opens Help tab",
+ )
+ app.basis_help_btn = widgets.Button(
+ description="?",
+ button_style="",
+ layout=layout_fn(width="28px", height="28px"),
+ tooltip="Choosing a basis set — opens Help tab",
+ )
+
+ app.run_btn = widgets.Button(
+ description="Run Calculation",
+ button_style="success",
+ icon="play",
+ disabled=True,
+ layout=layout_fn(width="200px", height="36px"),
+ )
+ app.run_status = widgets.Label()
+
+ app.log_clear_btn = widgets.Button(
+ description="Clear",
+ button_style="",
+ icon="times",
+ layout=layout_fn(width="90px", height="26px"),
+ tooltip="Clear calculation output",
+ )
+
+ app.accumulate_btn = widgets.Button(
+ description="Add to Comparison",
+ button_style="info",
+ icon="plus",
+ disabled=True,
+ layout=layout_fn(width="190px"),
+ )
+ app.clear_btn = widgets.Button(
+ description="Clear",
+ button_style="warning",
+ icon="trash",
+ layout=layout_fn(width="100px"),
+ )
+ app.export_btn = widgets.Button(
+ description="Export Script",
+ button_style="",
+ icon="download",
+ disabled=True,
+ layout=layout_fn(width="160px"),
+ )
+ app.export_status = widgets.Label()
+ rdkit_tip = (
+ "" if rdkit_available else "Requires RDKit (conda install -c conda-forge rdkit)"
+ )
+ app.export_xyz_btn = widgets.Button(
+ description="Export XYZ",
+ icon="download",
+ disabled=True,
+ layout=layout_fn(width="130px"),
+ )
+ app.export_mol_btn = widgets.Button(
+ description="Export MOL",
+ icon="download",
+ disabled=True,
+ tooltip=rdkit_tip,
+ layout=layout_fn(width="130px"),
+ )
+ app.export_pdb_btn = widgets.Button(
+ description="Export PDB",
+ icon="download",
+ disabled=True,
+ tooltip=rdkit_tip,
+ layout=layout_fn(width="130px"),
+ )
+ app.struct_export_status = widgets.Label()
+
+
def build_theme_selector(app: Any, *, layout_fn: Any) -> None:
"""Build the theme selector widgets and apply default theme CSS."""
app._theme_style = widgets.Output(
@@ -566,6 +930,333 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
)
+def build_results_section(app: Any, *, layout_fn: Any) -> None:
+ """Build results and analysis tab panels/widgets."""
+ app._pes_plot_html = widgets.Output(layout=layout_fn(width="100%"))
+ app._pes_scan_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ [app._pes_plot_html],
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._pes_scan_accordion.set_title(0, "PES Energy Profile")
+ app._pes_scan_accordion.selected_index = None
+
+ app.traj_output = widgets.Output()
+ app.traj_accordion = widgets.Accordion(
+ children=[app.traj_output],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app.traj_accordion.set_title(0, "Trajectory Viewer")
+ app.traj_accordion.selected_index = None
+ app.traj_accordion.observe(
+ app._safe_cb(app._on_traj_expand), names=["selected_index"]
+ )
+
+ app.vib_mode_dd = widgets.Dropdown(
+ description="Mode:",
+ options=[],
+ style={"description_width": "50px"},
+ layout=layout_fn(width="360px"),
+ )
+ app.vib_output = widgets.Output()
+ app.vib_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ [app.vib_mode_dd, app.vib_output],
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app.vib_accordion.set_title(0, "Vibrational Mode Viewer")
+ app.vib_accordion.selected_index = None
+
+ app._ir_mode_toggle = widgets.ToggleButtons(
+ options=["Stick", "Broadened"],
+ value="Stick",
+ style={"button_width": "80px"},
+ layout=layout_fn(margin="0 8px 0 0"),
+ )
+ app._ir_fwhm_slider = widgets.FloatSlider(
+ value=20.0,
+ min=5.0,
+ max=100.0,
+ step=5.0,
+ description="Line width:",
+ style={"description_width": "80px"},
+ layout=layout_fn(width="260px", display="none"),
+ )
+ app._ir_fig = widgets.Output(layout=layout_fn(width="100%"))
+
+ ir_controls = widgets.HBox(
+ [app._ir_mode_toggle, app._ir_fwhm_slider],
+ layout=layout_fn(align_items="center", margin="0 0 6px 0"),
+ )
+ ir_body_children = [ir_controls, app._ir_fig]
+ app._ir_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ ir_body_children,
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._ir_accordion.set_title(0, "IR Spectrum")
+ app._ir_accordion.selected_index = None
+
+ app._orb_ymin_input = widgets.BoundedFloatText(
+ value=-30.0,
+ min=-500.0,
+ max=200.0,
+ step=1.0,
+ description="Y min:",
+ layout=layout_fn(width="140px"),
+ style={"description_width": "45px"},
+ )
+ app._orb_ymax_input = widgets.BoundedFloatText(
+ value=5.0,
+ min=-500.0,
+ max=500.0,
+ step=1.0,
+ description="Y max:",
+ layout=layout_fn(width="140px"),
+ style={"description_width": "45px"},
+ )
+ app._orb_n_orb_input = widgets.BoundedIntText(
+ value=20,
+ min=4,
+ max=200,
+ step=2,
+ description="Show N:",
+ layout=layout_fn(width="120px"),
+ style={"description_width": "50px"},
+ )
+ orb_controls_row = widgets.HBox(
+ [
+ widgets.HTML(
+ 'Y range: '
+ ),
+ app._orb_ymin_input,
+ app._orb_ymax_input,
+ widgets.HTML(
+ ''
+ "Orbitals shown: "
+ ),
+ app._orb_n_orb_input,
+ ],
+ layout=layout_fn(
+ align_items="center",
+ flex_wrap="wrap",
+ gap="4px",
+ margin="0 0 6px 0",
+ ),
+ )
+ app._orb_diagram_html = widgets.Output(layout=layout_fn(width="100%"))
+ orb_diagram_content: list[Any] = [orb_controls_row, app._orb_diagram_html]
+ app._orb_diagram_box = widgets.VBox(
+ orb_diagram_content,
+ layout=layout_fn(width="100%"),
+ )
+ app._orb_toggle = widgets.ToggleButtons(
+ options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"],
+ value="HOMO",
+ style={"button_width": "70px"},
+ layout=layout_fn(margin="8px 0 4px 0"),
+ )
+ app._orb_iso_output = widgets.Output()
+ app._orb_iso_controls = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Orbital isosurface: "
+ ),
+ app._orb_toggle,
+ app._orb_iso_output,
+ ],
+ layout=layout_fn(display="none", margin="8px 0 0 0"),
+ )
+ app._orb_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ [app._orb_diagram_box],
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._orb_accordion.set_title(0, "Orbital Diagram")
+ app._orb_accordion.selected_index = None
+
+ app._iso_generate_btn = widgets.Button(
+ description="Generate Isosurface",
+ button_style="primary",
+ icon="flask",
+ disabled=True,
+ tooltip=(
+ "Generate a 3D orbital isosurface. "
+ "Available after running or loading a Single Point or Geometry Optimization."
+ ),
+ layout=layout_fn(width="200px", margin="8px 0 4px 0"),
+ )
+ iso_body = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — "
+ "requires PySCF and RDKit). Run or load a Single Point or Geometry "
+ "Optimization first, then click Generate .
"
+ ),
+ app._orb_iso_controls,
+ app._iso_generate_btn,
+ ],
+ layout=layout_fn(padding="8px"),
+ )
+ app._iso_accordion = widgets.Accordion(
+ children=[iso_body],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._iso_accordion.set_title(0, "Orbital Isosurface")
+ app._iso_accordion.selected_index = None
+
+ app._tddft_fig = widgets.Output(layout=layout_fn(width="100%"))
+ app._tddft_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ [app._tddft_fig],
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum")
+ app._tddft_accordion.selected_index = None
+
+ app._nmr_output = widgets.HTML(value="", layout=layout_fn(width="100%"))
+ app._nmr_accordion = widgets.Accordion(
+ children=[
+ widgets.VBox(
+ [app._nmr_output],
+ layout=layout_fn(padding="8px"),
+ )
+ ],
+ layout=layout_fn(display="none", margin="8px 0"),
+ )
+ app._nmr_accordion.set_title(0, "NMR Chemical Shifts")
+ app._nmr_accordion.selected_index = None
+
+ app._result_dir_label = widgets.HTML(
+ value="",
+ layout=layout_fn(display="none", margin="4px 0 0 0"),
+ )
+
+ app._result_log_output = widgets.Output()
+ app._result_log_accordion = widgets.Accordion(
+ children=[app._result_log_output],
+ layout=layout_fn(display="none", margin="8px 0 0 0"),
+ )
+ app._result_log_accordion.set_title(0, "Full output log (pyscf.log)")
+ app._result_log_accordion.selected_index = None
+
+ app._go_results_btn = widgets.Button(
+ description="→ View Results",
+ button_style="success",
+ layout=layout_fn(width="130px"),
+ )
+ app._go_analysis_btn = widgets.Button(
+ description="→ View Analysis",
+ button_style="info",
+ layout=layout_fn(width="140px"),
+ )
+ app._completion_mol_lbl = widgets.HTML(value="")
+ app._completion_banner = widgets.HBox(
+ [
+ widgets.HTML(
+ ''
+ "✓ Calculation complete — "
+ ),
+ app._completion_mol_lbl,
+ app._go_results_btn,
+ app._go_analysis_btn,
+ ],
+ layout=layout_fn(
+ display="none",
+ align_items="center",
+ gap="8px",
+ padding="10px 12px",
+ border="1px solid #bbf7d0",
+ border_radius="6px",
+ background_color="#f0fdf4",
+ margin="8px 0",
+ ),
+ )
+
+ app._to_analysis_btn = widgets.Button(
+ description="→ View Analysis",
+ button_style="",
+ icon="bar-chart",
+ layout=layout_fn(display="none", width="160px", margin="8px 0 0 0"),
+ )
+ app._viz_label = widgets.HTML(
+ value="",
+ layout=layout_fn(display="none"),
+ )
+ app.results_tab_panel = widgets.VBox(
+ [
+ widgets.HTML('Results '),
+ app.result_output,
+ app._viz_label,
+ app.result_viz_output,
+ app._result_dir_label,
+ app._to_analysis_btn,
+ ],
+ layout=layout_fn(padding="8px 0"),
+ )
+ app.results_panel = app.results_tab_panel
+
+ app._analysis_mol_output = widgets.Output()
+
+ app._analysis_context_lbl = widgets.HTML(
+ value=(
+ ''
+ "No result loaded yet. Run a calculation or load one from History.
"
+ )
+ )
+ app._analysis_empty_html = widgets.HTML(
+ value=(
+ ''
+ "No interactive analysis is available for this calculation type. "
+ "Run a Single Point, Geo Opt, or Frequency calculation to see "
+ "orbital diagrams, trajectory animations, and spectra here.
"
+ ),
+ layout=layout_fn(display="none"),
+ )
+ app._ana_unavail_html = widgets.HTML(value="", layout=layout_fn(display="none"))
+ app._build_ana_switcher()
+ app.analysis_tab_panel = widgets.VBox(
+ [
+ app._analysis_context_lbl,
+ app._analysis_mol_output,
+ app._analysis_empty_html,
+ app._ana_unavail_html,
+ app._orb_accordion,
+ app._pes_scan_accordion,
+ app.traj_accordion,
+ app.vib_accordion,
+ app._ir_accordion,
+ app._iso_accordion,
+ app._tddft_accordion,
+ app._nmr_accordion,
+ ],
+ layout=layout_fn(padding="8px 0"),
+ )
+ app.post_calc_panel = app.analysis_tab_panel
+
+
def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> None:
"""Build compare tab widgets and export accordion."""
app.compare_select = widgets.SelectMultiple(
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
new file mode 100644
index 0000000..1cde112
--- /dev/null
+++ b/quantui/app_runflow.py
@@ -0,0 +1,83 @@
+"""Runflow helpers used by QuantUIApp."""
+
+from __future__ import annotations
+
+import threading
+from typing import Any
+
+from IPython.display import HTML, display
+
+
+def on_run_clicked(app: Any, btn: Any) -> None:
+ """Reset result panes and start the background run thread."""
+ app.run_output.clear_output()
+ app.result_output.clear_output()
+ app.result_viz_output.clear_output()
+ app._analysis_mol_output.clear_output()
+ app._viz_label.layout.display = "none"
+ app._viz_label.value = ""
+ app._deactivate_all_ana_panels()
+ app._clear_output_widget(app._pes_plot_html)
+ app._result_dir_label.value = ""
+ app._result_dir_label.layout.display = "none"
+ app._result_log_accordion.layout.display = "none"
+ app._result_log_accordion.selected_index = None
+ app._result_log_output.clear_output()
+ app._completion_banner.layout.display = "none"
+ app._to_analysis_btn.layout.display = "none"
+ app._analysis_empty_html.layout.display = "none"
+ threading.Thread(target=app._do_run, daemon=True).start()
+
+
+def update_notes(app: Any, change: Any = None) -> None:
+ """Refresh educational method notes for the active molecule/method."""
+ app.notes_output.clear_output(wait=True)
+ if app._molecule is None:
+ return
+ try:
+ from quantui import PySCFCalculation
+
+ calc = PySCFCalculation(
+ app._molecule,
+ method=app.method_dd.value,
+ basis=app.basis_dd.value,
+ )
+ notes = calc.get_educational_notes()
+ if notes:
+ safe = (
+ notes.replace("**", "", 1)
+ .replace("**", " ", 1)
+ .replace("\n\n", " ")
+ )
+ with app.notes_output:
+ display(
+ HTML(
+ ''
+ + safe
+ + "
"
+ )
+ )
+ except Exception:
+ pass
+
+
+def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
+ """Refresh runtime estimate text from the performance model."""
+ if app._molecule is None:
+ app.perf_estimate_html.value = ""
+ return
+ try:
+ n_basis = calc_log_mod.count_basis_functions(
+ app._molecule.atoms, app.basis_dd.value
+ )
+ est = calc_log_mod.estimate_time(
+ n_atoms=len(app._molecule.atoms),
+ n_electrons=app._molecule.get_electron_count(),
+ method=app.method_dd.value,
+ basis=app.basis_dd.value,
+ n_basis=n_basis,
+ )
+ app.perf_estimate_html.value = calc_log_mod.format_estimate(est)
+ except Exception:
+ app.perf_estimate_html.value = ""
From fe713f6296935bd748595ead26db6b783de976fb Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 18:44:36 -0400
Subject: [PATCH 10/22] Extract calc/scan/freq UI handlers to runflow
Move calculation-type, PES scan and frequency-seed UI logic out of QuantUIApp into app_runflow.py as standalone functions (on_calc_type_changed, update_scan_widgets, refresh_freq_seed_options, on_freq_seed_changed). Update quantui/app.py to import and delegate to these functions (passing layout_fn where needed) and add corresponding import aliases. Also add ipywidgets import in app_runflow.py. This refactors UI behavior into the runflow module without changing functionality.
---
quantui/app.py | 131 +++++------------------------------------
quantui/app_runflow.py | 120 +++++++++++++++++++++++++++++++++++++
2 files changed, 136 insertions(+), 115 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index bc7c419..f812552 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -172,15 +172,27 @@
from quantui.app_history import (
on_view_log as _hist_on_view_log,
)
+from quantui.app_runflow import (
+ on_calc_type_changed as _run_on_calc_type_changed,
+)
+from quantui.app_runflow import (
+ on_freq_seed_changed as _run_on_freq_seed_changed,
+)
from quantui.app_runflow import (
on_run_clicked as _run_on_run_clicked,
)
+from quantui.app_runflow import (
+ refresh_freq_seed_options as _run_refresh_freq_seed_options,
+)
from quantui.app_runflow import (
update_estimate as _run_update_estimate,
)
from quantui.app_runflow import (
update_notes as _run_update_notes,
)
+from quantui.app_runflow import (
+ update_scan_widgets as _run_update_scan_widgets,
+)
from quantui.app_visualization import (
build_vib_data_from_freq_result as _viz_build_vib_data_from_freq_result,
)
@@ -1411,127 +1423,16 @@ def _on_expand_mol_input(self, btn) -> None:
# ── Calc type ─────────────────────────────────────────────────────────
def _on_calc_type_changed(self, change) -> None:
- ct = change["new"]
- if ct == "Geometry Opt":
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self.fmax_fi, self.max_steps_si],
- layout=_layout(gap="8px"),
- ),
- ]
- elif ct == "Frequency":
- self._refresh_freq_seed_options()
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self._freq_seed_dd, self._freq_seed_refresh_btn],
- layout=_layout(align_items="center", gap="6px"),
- ),
- self._freq_preopt_cb,
- self._freq_seed_note,
- ]
- elif ct == "UV-Vis (TD-DFT)":
- self.calc_extra_opts.children = [
- self.nstates_si,
- widgets.HTML(
- '⚠ Requires a DFT '
- "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) "
- "instead. "
- ),
- ]
- elif ct == "NMR Shielding":
- self.calc_extra_opts.children = [
- widgets.HTML(
- ''
- "⚠ Recommended: B3LYP/6-31G* or better. "
- "STO-3G and 3-21G give qualitative results only. "
- "Start from an optimised geometry for best accuracy. "
- ),
- ]
- elif ct == "PES Scan":
- self._update_scan_widgets()
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self._scan_type_dd],
- layout=_layout(margin="0 0 4px 0"),
- ),
- widgets.HBox(
- [self._scan_atom1, self._scan_atom2],
- layout=_layout(gap="4px"),
- ),
- self._scan_atom34_box,
- widgets.HBox(
- [
- self._scan_start,
- self._scan_stop,
- self._scan_steps,
- self._scan_unit_lbl,
- ],
- layout=_layout(gap="4px", align_items="center"),
- ),
- ]
- else:
- self.calc_extra_opts.children = []
+ _run_on_calc_type_changed(self, change, layout_fn=_layout)
def _update_scan_widgets(self, _change=None) -> None:
- """Show/hide atom3/4 inputs and update unit label based on scan type."""
- st = self._scan_type_dd.value
- if st == "Bond":
- self._scan_atom34_box.layout.display = "none"
- self._scan_unit_lbl.value = (
- 'Å '
- )
- elif st == "Angle":
- self._scan_atom4.layout.display = "none"
- self._scan_atom3.layout.display = ""
- self._scan_atom34_box.layout.display = ""
- self._scan_unit_lbl.value = (
- '° '
- )
- else: # Dihedral
- self._scan_atom3.layout.display = ""
- self._scan_atom4.layout.display = ""
- self._scan_atom34_box.layout.display = ""
- self._scan_unit_lbl.value = (
- '° '
- )
+ _run_update_scan_widgets(self, _change)
def _refresh_freq_seed_options(self) -> None:
- """Populate _freq_seed_dd with saved geometry-opt results."""
- from quantui.results_storage import list_results, load_result
-
- options = [("(use current molecule)", "")]
- for d in list_results():
- try:
- data = load_result(d)
- if data.get("calc_type") != "geometry_opt":
- continue
- traj_file = d / "trajectory.json"
- if not traj_file.exists():
- continue
- ts = data.get("timestamp", d.name[:19])
- label = (
- f"{data['formula']} {data['method']}/{data['basis']}" f" — {ts}"
- )
- options.append((label, str(d)))
- except Exception:
- continue
- self._freq_seed_dd.options = options
+ _run_refresh_freq_seed_options(self)
def _on_freq_seed_changed(self, change) -> None:
- """Enable/disable pre-opt checkbox and update the seed note."""
- path_str = change["new"]
- if path_str:
- # A history geometry is selected — pre-optimize makes no sense.
- self._freq_preopt_cb.value = False
- self._freq_preopt_cb.disabled = True
- self._freq_seed_note.value = (
- ''
- "✓ Final optimised geometry will be loaded from the selected result."
- " "
- )
- else:
- self._freq_preopt_cb.disabled = False
- self._freq_seed_note.value = ""
+ _run_on_freq_seed_changed(self, change)
# ── Help buttons ──────────────────────────────────────────────────────
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 1cde112..730a6c5 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -5,6 +5,7 @@
import threading
from typing import Any
+import ipywidgets as widgets
from IPython.display import HTML, display
@@ -29,6 +30,125 @@ def on_run_clicked(app: Any, btn: Any) -> None:
threading.Thread(target=app._do_run, daemon=True).start()
+def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None:
+ """Update extra options panel based on selected calculation type."""
+ ct = change["new"]
+ if ct == "Geometry Opt":
+ app.calc_extra_opts.children = [
+ widgets.HBox(
+ [app.fmax_fi, app.max_steps_si],
+ layout=layout_fn(gap="8px"),
+ ),
+ ]
+ elif ct == "Frequency":
+ app._refresh_freq_seed_options()
+ app.calc_extra_opts.children = [
+ widgets.HBox(
+ [app._freq_seed_dd, app._freq_seed_refresh_btn],
+ layout=layout_fn(align_items="center", gap="6px"),
+ ),
+ app._freq_preopt_cb,
+ app._freq_seed_note,
+ ]
+ elif ct == "UV-Vis (TD-DFT)":
+ app.calc_extra_opts.children = [
+ app.nstates_si,
+ widgets.HTML(
+ '⚠ Requires a DFT '
+ "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) "
+ "instead. "
+ ),
+ ]
+ elif ct == "NMR Shielding":
+ app.calc_extra_opts.children = [
+ widgets.HTML(
+ ''
+ "⚠ Recommended: B3LYP/6-31G* or better. "
+ "STO-3G and 3-21G give qualitative results only. "
+ "Start from an optimised geometry for best accuracy. "
+ ),
+ ]
+ elif ct == "PES Scan":
+ app._update_scan_widgets()
+ app.calc_extra_opts.children = [
+ widgets.HBox(
+ [app._scan_type_dd],
+ layout=layout_fn(margin="0 0 4px 0"),
+ ),
+ widgets.HBox(
+ [app._scan_atom1, app._scan_atom2],
+ layout=layout_fn(gap="4px"),
+ ),
+ app._scan_atom34_box,
+ widgets.HBox(
+ [
+ app._scan_start,
+ app._scan_stop,
+ app._scan_steps,
+ app._scan_unit_lbl,
+ ],
+ layout=layout_fn(gap="4px", align_items="center"),
+ ),
+ ]
+ else:
+ app.calc_extra_opts.children = []
+
+
+def update_scan_widgets(app: Any, _change: Any = None) -> None:
+ """Show/hide atom inputs and unit label based on scan type."""
+ st = app._scan_type_dd.value
+ if st == "Bond":
+ app._scan_atom34_box.layout.display = "none"
+ app._scan_unit_lbl.value = 'Å '
+ elif st == "Angle":
+ app._scan_atom4.layout.display = "none"
+ app._scan_atom3.layout.display = ""
+ app._scan_atom34_box.layout.display = ""
+ app._scan_unit_lbl.value = '° '
+ else: # Dihedral
+ app._scan_atom3.layout.display = ""
+ app._scan_atom4.layout.display = ""
+ app._scan_atom34_box.layout.display = ""
+ app._scan_unit_lbl.value = '° '
+
+
+def refresh_freq_seed_options(app: Any) -> None:
+ """Populate frequency seed dropdown with saved geometry optimisations."""
+ from quantui.results_storage import list_results, load_result
+
+ options = [("(use current molecule)", "")]
+ for d in list_results():
+ try:
+ data = load_result(d)
+ if data.get("calc_type") != "geometry_opt":
+ continue
+ traj_file = d / "trajectory.json"
+ if not traj_file.exists():
+ continue
+ ts = data.get("timestamp", d.name[:19])
+ label = f"{data['formula']} {data['method']}/{data['basis']}" f" — {ts}"
+ options.append((label, str(d)))
+ except Exception:
+ continue
+ app._freq_seed_dd.options = options
+
+
+def on_freq_seed_changed(app: Any, change: Any) -> None:
+ """Enable/disable pre-opt checkbox and update seed note message."""
+ path_str = change["new"]
+ if path_str:
+ app._freq_preopt_cb.value = False
+ app._freq_preopt_cb.disabled = True
+ app._freq_seed_note.value = (
+ ''
+ "✓ Final optimised geometry will be loaded from the selected result."
+ " "
+ )
+ else:
+ app._freq_preopt_cb.disabled = False
+ app._freq_seed_note.value = ""
+
+
def update_notes(app: Any, change: Any = None) -> None:
"""Refresh educational method notes for the active molecule/method."""
app.notes_output.clear_output(wait=True)
From b370950c05e8d04eb2466d154a1223c3df3a63dc Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 18:59:33 -0400
Subject: [PATCH 11/22] Extract runflow UI handlers to app_runflow
Move multiple runflow-related UI handler implementations out of QuantUIApp and into quantui.app_runflow, and update QuantUIApp to delegate to those functions. Handlers moved include solvent checkbox toggling, clearing the run log, accumulating/clearing in-session comparison results, compare list refresh/clear, and result/comparison browser refresh/population. This separates UI wiring from handler logic for better organization and testability, with no functional change to behavior.
---
quantui/app.py | 106 ++++++++++++++---------------------------
quantui/app_runflow.py | 105 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 141 insertions(+), 70 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index f812552..4d6b40a 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -172,18 +172,45 @@
from quantui.app_history import (
on_view_log as _hist_on_view_log,
)
+from quantui.app_runflow import (
+ on_accumulate as _run_on_accumulate,
+)
from quantui.app_runflow import (
on_calc_type_changed as _run_on_calc_type_changed,
)
+from quantui.app_runflow import (
+ on_clear as _run_on_clear,
+)
+from quantui.app_runflow import (
+ on_clear_log as _run_on_clear_log,
+)
+from quantui.app_runflow import (
+ on_compare_clear as _run_on_compare_clear,
+)
+from quantui.app_runflow import (
+ on_compare_refresh as _run_on_compare_refresh,
+)
from quantui.app_runflow import (
on_freq_seed_changed as _run_on_freq_seed_changed,
)
from quantui.app_runflow import (
on_run_clicked as _run_on_run_clicked,
)
+from quantui.app_runflow import (
+ on_solvent_cb_changed as _run_on_solvent_cb_changed,
+)
+from quantui.app_runflow import (
+ populate_compare_list as _run_populate_compare_list,
+)
+from quantui.app_runflow import (
+ refresh_comparison as _run_refresh_comparison,
+)
from quantui.app_runflow import (
refresh_freq_seed_options as _run_refresh_freq_seed_options,
)
+from quantui.app_runflow import (
+ refresh_results_browser as _run_refresh_results_browser,
+)
from quantui.app_runflow import (
update_estimate as _run_update_estimate,
)
@@ -1448,23 +1475,18 @@ def _on_run_clicked(self, btn) -> None:
_run_on_run_clicked(self, btn)
def _on_solvent_cb_changed(self, change) -> None:
- self.solvent_dd.layout.display = "" if change["new"] else "none"
+ _run_on_solvent_cb_changed(self, change)
def _on_clear_log(self, btn) -> None:
- self.run_output.clear_output()
+ _run_on_clear_log(self, btn)
# ── Accumulate / export ───────────────────────────────────────────────
def _on_accumulate(self, btn) -> None:
- r = self._last_result
- if r is None:
- return
- self._results.append(r)
- self._refresh_comparison()
+ _run_on_accumulate(self, btn)
def _on_clear(self, btn) -> None:
- self._results.clear()
- self.comparison_output.clear_output()
+ _run_on_clear(self, btn)
def _on_export(self, btn) -> None:
_exp_on_export(self, btn)
@@ -1488,7 +1510,7 @@ def _molecule_to_rdkit(mol):
# ── Compare ───────────────────────────────────────────────────────────
def _on_compare_refresh(self, btn) -> None:
- self._populate_compare_list()
+ _run_on_compare_refresh(self, btn)
def _on_compare(self, btn) -> None:
selected = self.compare_select.value
@@ -1553,8 +1575,7 @@ def _on_compare(self, btn) -> None:
display(widgets.VBox(_btns, layout=_layout(gap="4px")))
def _on_compare_clear(self, btn) -> None:
- self.compare_select.value = ()
- self.compare_output.clear_output()
+ _run_on_compare_clear(self, btn)
# ── History ───────────────────────────────────────────────────────────
@@ -2588,68 +2609,13 @@ def _update_estimate(self, change=None) -> None:
_run_update_estimate(self, calc_log_mod=_calc_log, change=change)
def _refresh_results_browser(self) -> None:
- try:
- from quantui import list_results, load_result
- except ImportError:
- return
- self.results_path_lbl.value = (
- f''
- f"{self._get_results_dir()} "
- )
- dirs = list_results()
- if not dirs:
- self.past_dd.options = [("(no saved results)", "")]
- return
- options = []
- for d in dirs:
- try:
- data = load_result(d)
- ts = data.get("timestamp", d.name)
- label = f"{ts} · {data['formula']} {data['method']}/{data['basis']}"
- options.append((label, str(d)))
- except Exception:
- pass
- self.past_dd.options = options if options else [("(no saved results)", "")]
- # Keep frequency seed dropdown in sync if it's currently visible.
- if self.calc_type_dd.value == "Frequency":
- self._refresh_freq_seed_options()
+ _run_refresh_results_browser(self)
def _refresh_comparison(self) -> None:
- from quantui import comparison_table_html, summary_from_session_result
-
- self.comparison_output.clear_output(wait=True)
- if not self._results:
- return
- summaries = [summary_from_session_result(r) for r in self._results]
- with self.comparison_output:
- display(HTML(comparison_table_html(summaries)))
- if len(summaries) > 1:
- try:
- from quantui import plot_comparison
-
- plot_comparison(summaries)
- except Exception:
- pass
+ _run_refresh_comparison(self)
def _populate_compare_list(self) -> None:
- from quantui.results_storage import list_results, load_result
-
- dirs = list_results()
- if not dirs:
- self.compare_select.options = [("(no saved results)", "")]
- self.compare_btn.disabled = True
- return
- options = []
- for d in dirs:
- try:
- data = load_result(d)
- ts = data.get("timestamp", d.name[:19])
- label = f"{ts} {data['formula']} {data['method']}/{data['basis']}"
- options.append((label, str(d)))
- except Exception:
- options.append((d.name, str(d)))
- self.compare_select.options = options
- self.compare_btn.disabled = False
+ _run_populate_compare_list(self)
def _show_help_topic(self, topic: str) -> None:
if topic in HELP_TOPICS:
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 730a6c5..063436a 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -149,6 +149,42 @@ def on_freq_seed_changed(app: Any, change: Any) -> None:
app._freq_seed_note.value = ""
+def on_solvent_cb_changed(app: Any, change: Any) -> None:
+ """Show or hide solvent dropdown based on checkbox state."""
+ app.solvent_dd.layout.display = "" if change["new"] else "none"
+
+
+def on_clear_log(app: Any, btn: Any) -> None:
+ """Clear the live run output panel."""
+ app.run_output.clear_output()
+
+
+def on_accumulate(app: Any, btn: Any) -> None:
+ """Add the latest result to the in-session comparison list."""
+ r = app._last_result
+ if r is None:
+ return
+ app._results.append(r)
+ app._refresh_comparison()
+
+
+def on_clear(app: Any, btn: Any) -> None:
+ """Clear in-session comparison results and rendered output."""
+ app._results.clear()
+ app.comparison_output.clear_output()
+
+
+def on_compare_refresh(app: Any, btn: Any) -> None:
+ """Refresh Compare selector options from saved results."""
+ app._populate_compare_list()
+
+
+def on_compare_clear(app: Any, btn: Any) -> None:
+ """Clear Compare tab selection and output area."""
+ app.compare_select.value = ()
+ app.compare_output.clear_output()
+
+
def update_notes(app: Any, change: Any = None) -> None:
"""Refresh educational method notes for the active molecule/method."""
app.notes_output.clear_output(wait=True)
@@ -201,3 +237,72 @@ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
app.perf_estimate_html.value = calc_log_mod.format_estimate(est)
except Exception:
app.perf_estimate_html.value = ""
+
+
+def refresh_results_browser(app: Any) -> None:
+ """Refresh the History dropdown with saved result directories."""
+ try:
+ from quantui import list_results, load_result
+ except ImportError:
+ return
+ app.results_path_lbl.value = (
+ f''
+ f"{app._get_results_dir()} "
+ )
+ dirs = list_results()
+ if not dirs:
+ app.past_dd.options = [("(no saved results)", "")]
+ return
+ options = []
+ for d in dirs:
+ try:
+ data = load_result(d)
+ ts = data.get("timestamp", d.name)
+ label = f"{ts} · {data['formula']} {data['method']}/{data['basis']}"
+ options.append((label, str(d)))
+ except Exception:
+ pass
+ app.past_dd.options = options if options else [("(no saved results)", "")]
+ if app.calc_type_dd.value == "Frequency":
+ app._refresh_freq_seed_options()
+
+
+def refresh_comparison(app: Any) -> None:
+ """Refresh in-session comparison output from accumulated results."""
+ from quantui import comparison_table_html, summary_from_session_result
+
+ app.comparison_output.clear_output(wait=True)
+ if not app._results:
+ return
+ summaries = [summary_from_session_result(r) for r in app._results]
+ with app.comparison_output:
+ display(HTML(comparison_table_html(summaries)))
+ if len(summaries) > 1:
+ try:
+ from quantui import plot_comparison
+
+ plot_comparison(summaries)
+ except Exception:
+ pass
+
+
+def populate_compare_list(app: Any) -> None:
+ """Populate the Compare tab selector with saved result entries."""
+ from quantui.results_storage import list_results, load_result
+
+ dirs = list_results()
+ if not dirs:
+ app.compare_select.options = [("(no saved results)", "")]
+ app.compare_btn.disabled = True
+ return
+ options = []
+ for d in dirs:
+ try:
+ data = load_result(d)
+ ts = data.get("timestamp", d.name[:19])
+ label = f"{ts} {data['formula']} {data['method']}/{data['basis']}"
+ options.append((label, str(d)))
+ except Exception:
+ options.append((d.name, str(d)))
+ app.compare_select.options = options
+ app.compare_btn.disabled = False
From b02cd20994b3b57c2a8cb49b3be76f055a27c06b Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 19:55:20 -0400
Subject: [PATCH 12/22] Move UI event handlers to app_runflow
Refactor: extract many QuantUI UI event handler implementations from quantui/app.py into standalone functions in quantui/app_runflow.py and update app.py to delegate to them. Handlers moved/rewired include Compare, History refresh, copy-results-path, perf-log reset/confirm, calibration run/stop/do_calibration, log clear, clear-log-cache (and confirm), exit, help toggle/topic, issue report (open/cancel/submit), expand molecule input, and method/basis help. The new functions accept injected dependencies (e.g. layout_fn, reset_perf_log_fn, calc_log_mod, issue_tracker_mod, benchmark suites, pyscf_available) to keep app.py thin and improve testability. Also adjusted imports (removed some direct os/Javascript usage from app.py; moved Javascript and time use into app_runflow; added time import) and updated threading usage where appropriate.
---
quantui/app.py | 353 +++++++++++------------------------------
quantui/app_runflow.py | 349 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 441 insertions(+), 261 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 4d6b40a..6553c1d 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -15,7 +15,6 @@
import asyncio
import io
-import os
import re
import threading
import time
@@ -26,7 +25,7 @@
import ipywidgets as widgets
from IPython import get_ipython
-from IPython.display import HTML, Javascript, display
+from IPython.display import HTML, display
import quantui
import quantui.calc_log as _calc_log
@@ -172,9 +171,21 @@
from quantui.app_history import (
on_view_log as _hist_on_view_log,
)
+from quantui.app_runflow import (
+ do_calibration as _run_do_calibration,
+)
from quantui.app_runflow import (
on_accumulate as _run_on_accumulate,
)
+from quantui.app_runflow import (
+ on_basis_help as _run_on_basis_help,
+)
+from quantui.app_runflow import (
+ on_cal_run as _run_on_cal_run,
+)
+from quantui.app_runflow import (
+ on_cal_stop as _run_on_cal_stop,
+)
from quantui.app_runflow import (
on_calc_type_changed as _run_on_calc_type_changed,
)
@@ -184,15 +195,66 @@
from quantui.app_runflow import (
on_clear_log as _run_on_clear_log,
)
+from quantui.app_runflow import (
+ on_clear_log_cache as _run_on_clear_log_cache,
+)
+from quantui.app_runflow import (
+ on_clear_log_cache_confirm as _run_on_clear_log_cache_confirm,
+)
+from quantui.app_runflow import (
+ on_compare as _run_on_compare,
+)
from quantui.app_runflow import (
on_compare_clear as _run_on_compare_clear,
)
from quantui.app_runflow import (
on_compare_refresh as _run_on_compare_refresh,
)
+from quantui.app_runflow import (
+ on_confirm_no as _run_on_confirm_no,
+)
+from quantui.app_runflow import (
+ on_confirm_yes as _run_on_confirm_yes,
+)
+from quantui.app_runflow import (
+ on_copy_results_path as _run_on_copy_results_path,
+)
+from quantui.app_runflow import (
+ on_exit_clicked as _run_on_exit_clicked,
+)
+from quantui.app_runflow import (
+ on_expand_mol_input as _run_on_expand_mol_input,
+)
from quantui.app_runflow import (
on_freq_seed_changed as _run_on_freq_seed_changed,
)
+from quantui.app_runflow import (
+ on_help_toggle as _run_on_help_toggle,
+)
+from quantui.app_runflow import (
+ on_help_topic_changed as _run_on_help_topic_changed,
+)
+from quantui.app_runflow import (
+ on_issue_btn as _run_on_issue_btn,
+)
+from quantui.app_runflow import (
+ on_issue_cancel as _run_on_issue_cancel,
+)
+from quantui.app_runflow import (
+ on_issue_submit as _run_on_issue_submit,
+)
+from quantui.app_runflow import (
+ on_log_clear as _run_on_log_clear,
+)
+from quantui.app_runflow import (
+ on_method_help as _run_on_method_help,
+)
+from quantui.app_runflow import (
+ on_past_refresh as _run_on_past_refresh,
+)
+from quantui.app_runflow import (
+ on_reset_click as _run_on_reset_click,
+)
from quantui.app_runflow import (
on_run_clicked as _run_on_run_clicked,
)
@@ -1440,12 +1502,11 @@ def _do():
threading.Thread(target=_do, daemon=True).start()
def _on_expand_mol_input(self, btn) -> None:
- _children = [self.mol_input_expanded, self.mol_info_html, self.viz_output]
- if self.viz_backend_toggle is not None:
- _children.append(self.viz_backend_toggle)
- if VISUALIZATION_AVAILABLE:
- _children.append(self.viz_controls_box)
- self.mol_input_container.children = _children
+ _run_on_expand_mol_input(
+ self,
+ btn,
+ visualization_available=VISUALIZATION_AVAILABLE,
+ )
# ── Calc type ─────────────────────────────────────────────────────────
@@ -1464,10 +1525,10 @@ def _on_freq_seed_changed(self, change) -> None:
# ── Help buttons ──────────────────────────────────────────────────────
def _on_method_help(self, btn) -> None:
- self._show_help_topic("method")
+ _run_on_method_help(self, btn)
def _on_basis_help(self, btn) -> None:
- self._show_help_topic("basis_set")
+ _run_on_basis_help(self, btn)
# ── Run ───────────────────────────────────────────────────────────────
@@ -1513,66 +1574,7 @@ def _on_compare_refresh(self, btn) -> None:
_run_on_compare_refresh(self, btn)
def _on_compare(self, btn) -> None:
- selected = self.compare_select.value
- if not selected or selected == ("",):
- return
- self.compare_output.clear_output(wait=True)
- from quantui import (
- comparison_table_html,
- plot_comparison,
- summary_from_saved_result,
- )
- from quantui.results_storage import load_result
-
- summaries = []
- valid_dirs: list = []
- for path_str in selected:
- if not path_str:
- continue
- try:
- data = load_result(Path(path_str))
- summaries.append(summary_from_saved_result(data))
- valid_dirs.append(Path(path_str))
- except Exception as exc:
- with self.compare_output:
- display(
- HTML(
- f'Error loading result: {exc}
'
- )
- )
- if not summaries:
- return
- with self.compare_output:
- display(HTML(comparison_table_html(summaries)))
- if len(summaries) > 1:
- try:
- import matplotlib.pyplot as plt
-
- fig = plot_comparison(summaries)
- display(fig)
- plt.close(fig)
- except Exception:
- pass
- # Per-row → Analyse buttons
- if valid_dirs:
- _btns = []
- for s, rdir in zip(summaries, valid_dirs):
- _short = f"{s.formula} {s.method}/{s.basis}"
- _btn = widgets.Button(
- description=f"→ Analyse {_short}"[:48],
- button_style="info",
- layout=_layout(width="auto", max_width="340px"),
- tooltip=f"Load {_short} into the Analysis tab",
- )
- _btn.on_click(lambda _, rd=rdir: self._history_load_analysis(rd))
- _btns.append(_btn)
- display(
- widgets.HTML(
- 'Analyse a result:
'
- )
- )
- display(widgets.VBox(_btns, layout=_layout(gap="4px")))
+ _run_on_compare(self, btn, layout_fn=_layout)
def _on_compare_clear(self, btn) -> None:
_run_on_compare_clear(self, btn)
@@ -1583,24 +1585,10 @@ def _on_past_dd_changed(self, change) -> None:
_hist_on_past_dd_changed(self, change, layout_fn=_layout)
def _on_past_refresh(self, btn) -> None:
- self._refresh_results_browser()
+ _run_on_past_refresh(self, btn)
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()
+ _run_on_copy_results_path(self, btn)
def _on_view_log(self, btn) -> None:
_hist_on_view_log(self, btn)
@@ -1620,158 +1608,45 @@ def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]
# ── Perf stats reset ──────────────────────────────────────────────────
def _on_reset_click(self, btn) -> None:
- self._reset_confirm_box.layout.display = ""
+ _run_on_reset_click(self, btn)
def _on_confirm_yes(self, btn) -> None:
- from quantui.calc_log import reset_perf_log
-
- reset_perf_log()
- self._reset_confirm_box.layout.display = "none"
- self._refresh_perf_stats()
+ _run_on_confirm_yes(self, btn, reset_perf_log_fn=_calc_log.reset_perf_log)
def _on_confirm_no(self, btn) -> None:
- self._reset_confirm_box.layout.display = "none"
+ _run_on_confirm_no(self, btn)
# ── Calibration ───────────────────────────────────────────────────────
def _on_cal_run(self, btn) -> None:
- import threading as _threading
-
- mode = self._cal_mode_toggle.value
- suite = _BENCHMARK_SUITE if mode == "short" else _BENCHMARK_SUITE_LONG
- self._cal_stop_event = _threading.Event()
- self._cal_run_btn.disabled = True
- self._cal_mode_toggle.disabled = True
- self._cal_stop_btn.layout.display = ""
- self._cal_progress.max = len(suite)
- self._cal_progress.value = 0
- self._cal_progress.layout.display = ""
- self._cal_step_label.layout.display = ""
- self._cal_step_label.value = (
- 'Starting… '
+ _run_on_cal_run(
+ self,
+ btn,
+ benchmark_suite=_BENCHMARK_SUITE,
+ benchmark_suite_long=_BENCHMARK_SUITE_LONG,
)
- self._cal_results_html.value = ""
-
- _threading.Thread(target=self._do_calibration, daemon=True).start()
def _on_cal_stop(self, btn) -> None:
- if hasattr(self, "_cal_stop_event"):
- self._cal_stop_event.set()
+ _run_on_cal_stop(self, btn)
def _do_calibration(self) -> None:
- from quantui.benchmarks import run_calibration
-
- mode = self._cal_mode_toggle.value
-
- def _progress(
- step_n: int, total: int, label: str, status: str, elapsed: float
- ) -> None:
- _icon = {"ok": "✓", "timed_out": "⏱", "stopped": "⛔", "error": "✗"}.get(
- status, "?"
- )
- self._cal_progress.value = step_n
- self._cal_step_label.value = (
- f''
- f"Step {step_n} / {total} — {label} "
- f"[{_icon} {elapsed:.1f} s] "
- )
-
- result = run_calibration(
- progress_cb=_progress,
- stop_event=self._cal_stop_event,
- timeout_per_step=300.0 if mode == "long" else 120.0,
- mode=mode,
- )
-
- # Render results table
- _rows = "".join(
- f""
- f'{s.label} '
- f''
- f"{s.n_electrons} "
- f''
- f"{s.n_basis if s.n_basis is not None else '—'} "
- f''
- f"{s.elapsed_s:.2f} s "
- f''
- f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}'
- f" "
- f" "
- for s in result.steps
- )
- _summary = f"Completed {result.n_completed} / {result.n_total} steps." + (
- " (stopped early)" if result.stopped_early else ""
- )
- self._cal_results_html.value = (
- f''
- f'
{_summary}
'
- f'
'
- f""
- f'Calculation '
- f'e⁻ '
- f'Basis fns '
- f'Wall time '
- f'Status '
- f" "
- f"{_rows}
"
- )
-
- self._cal_step_label.value = (
- 'Calibration complete. '
- "Time estimates are now active. "
- if result.n_completed > 0
- else 'No steps completed. '
- )
- self._cal_stop_btn.layout.display = "none"
- self._cal_run_btn.disabled = not _PYSCF_AVAILABLE
- self._cal_mode_toggle.disabled = False
- self._refresh_perf_stats()
+ _run_do_calibration(self, pyscf_available=_PYSCF_AVAILABLE)
# ── Output log ────────────────────────────────────────────────────────
def _on_log_clear(self, btn) -> None:
- self._log_output_html.value = (
- 'Log cleared. '
- )
- self._log_source_lbl.value = ""
+ _run_on_log_clear(self, btn)
# ── Issue reporting ───────────────────────────────────────────────────
def _on_issue_btn(self, _=None) -> None:
- """Show the issue report overlay."""
- self._issue_textarea.value = ""
- self._issue_status_html.value = ""
- self._issue_overlay.layout.display = ""
+ _run_on_issue_btn(self, _)
def _on_issue_cancel(self, _=None) -> None:
- self._issue_overlay.layout.display = "none"
+ _run_on_issue_cancel(self, _)
def _on_issue_submit(self, _=None) -> None:
- text = self._issue_textarea.value.strip()
- if not text:
- self._issue_status_html.value = (
- ''
- "Please describe the issue before submitting. "
- )
- return
- self._issue_submit_btn.disabled = True
- try:
- issue_id = _issue_tracker.log_issue(
- description=text,
- context=self._build_issue_context(),
- session_id=self._session_id,
- )
- self._issue_status_html.value = (
- f''
- f"✓ Issue #{issue_id} saved. Thank you! "
- )
- self._issue_overlay.layout.display = "none"
- except Exception as exc:
- self._issue_status_html.value = (
- f'Save failed: {exc} '
- )
- finally:
- self._issue_submit_btn.disabled = False
+ _run_on_issue_submit(self, issue_tracker_mod=_issue_tracker)
def _build_issue_context(self) -> dict:
"""Snapshot the current app state to attach to an issue report."""
@@ -1826,65 +1701,23 @@ def _build_issue_context(self) -> dict:
# ── Clear log cache ───────────────────────────────────────────────────
def _on_clear_log_cache(self, _=None) -> None:
- """First click: reveal the confirmation button."""
- self._clear_log_cache_confirm_btn.layout.display = ""
- self._clear_log_cache_btn.disabled = True
+ _run_on_clear_log_cache(self, _)
def _on_clear_log_cache_confirm(self, _=None) -> None:
- """Second click: clear event_log.jsonl and reset the UI."""
- try:
- _calc_log.log_event(
- "log_cleared",
- "Session event log cleared by user",
- session_id=self._session_id,
- )
- _calc_log.clear_event_log()
- except Exception:
- pass
- self._clear_log_cache_confirm_btn.layout.display = "none"
- self._clear_log_cache_btn.disabled = False
+ _run_on_clear_log_cache_confirm(self, calc_log_mod=_calc_log)
# ── Exit ──────────────────────────────────────────────────────────────
def _on_exit_clicked(self, _=None) -> None:
- self._exit_btn.description = "Exiting…"
- self._exit_btn.disabled = True
- self._welcome_html.value = (
- ''
- '
'
- ' '
- ' '
- ' '
- " "
- '
'
- "QuantUI has shut down. You may close this tab.
"
- "
"
- )
-
- def _do_exit() -> None:
- import signal
- import time
-
- time.sleep(0.6)
- try:
- # Signal the Voilà/Jupyter server process (our parent) to exit cleanly.
- os.kill(os.getppid(), signal.SIGTERM)
- except Exception:
- pass
- # Terminate the kernel process regardless.
- os._exit(0)
-
- threading.Thread(target=_do_exit, daemon=True).start()
+ _run_on_exit_clicked(self, _)
# ── Help ──────────────────────────────────────────────────────────────
def _on_help_toggle(self, _=None) -> None:
- visible = self.help_tab_panel.layout.display != "none"
- self.help_tab_panel.layout.display = "none" if visible else ""
+ _run_on_help_toggle(self, _)
def _on_help_topic_changed(self, change=None) -> None:
- self._render_help_topic()
+ _run_on_help_topic_changed(self, change)
# ══ LOGIC METHODS ════════════════════════════════════════════════════════
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 063436a..4645d8d 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -3,10 +3,11 @@
from __future__ import annotations
import threading
+import time
from typing import Any
import ipywidgets as widgets
-from IPython.display import HTML, display
+from IPython.display import HTML, Javascript, display
def on_run_clicked(app: Any, btn: Any) -> None:
@@ -179,12 +180,358 @@ def on_compare_refresh(app: Any, btn: Any) -> None:
app._populate_compare_list()
+def on_compare(app: Any, btn: Any, *, layout_fn: Any) -> None:
+ """Render selected saved results in the Compare tab."""
+ from pathlib import Path
+
+ selected = app.compare_select.value
+ if not selected or selected == ("",):
+ return
+ app.compare_output.clear_output(wait=True)
+ from quantui import (
+ comparison_table_html,
+ plot_comparison,
+ summary_from_saved_result,
+ )
+ from quantui.results_storage import load_result
+
+ summaries = []
+ valid_dirs: list[Any] = []
+ for path_str in selected:
+ if not path_str:
+ continue
+ try:
+ data = load_result(Path(path_str))
+ summaries.append(summary_from_saved_result(data))
+ valid_dirs.append(Path(path_str))
+ except Exception as exc:
+ with app.compare_output:
+ display(
+ HTML(f'Error loading result: {exc}
')
+ )
+ if not summaries:
+ return
+ with app.compare_output:
+ display(HTML(comparison_table_html(summaries)))
+ if len(summaries) > 1:
+ try:
+ import matplotlib.pyplot as plt
+
+ fig = plot_comparison(summaries)
+ display(fig)
+ plt.close(fig)
+ except Exception:
+ pass
+ if valid_dirs:
+ btns = []
+ for s, rdir in zip(summaries, valid_dirs):
+ short = f"{s.formula} {s.method}/{s.basis}"
+ button = widgets.Button(
+ description=f"→ Analyse {short}"[:48],
+ button_style="info",
+ layout=layout_fn(width="auto", max_width="340px"),
+ tooltip=f"Load {short} into the Analysis tab",
+ )
+ button.on_click(lambda _, rd=rdir: app._history_load_analysis(rd))
+ btns.append(button)
+ display(
+ widgets.HTML(
+ 'Analyse a result:
'
+ )
+ )
+ display(widgets.VBox(btns, layout=layout_fn(gap="4px")))
+
+
def on_compare_clear(app: Any, btn: Any) -> None:
"""Clear Compare tab selection and output area."""
app.compare_select.value = ()
app.compare_output.clear_output()
+def on_past_refresh(app: Any, btn: Any) -> None:
+ """Refresh History saved-results browser."""
+ app._refresh_results_browser()
+
+
+def on_copy_results_path(app: Any, btn: Any) -> None:
+ """Copy results directory path to clipboard and show transient status."""
+ p = app._get_results_dir()
+ p.mkdir(parents=True, exist_ok=True)
+ path_str = str(p).replace("\\", "\\\\").replace("'", "\\'")
+ display(Javascript(f"navigator.clipboard.writeText('{path_str}')"))
+ app.results_path_lbl.value = (
+ f'Copied: {p} '
+ )
+
+ def _reset() -> None:
+ time.sleep(3)
+ app.results_path_lbl.value = (
+ f'{p} '
+ )
+
+ threading.Thread(target=_reset, daemon=True).start()
+
+
+def on_reset_click(app: Any, btn: Any) -> None:
+ """Reveal the perf-log reset confirmation controls."""
+ app._reset_confirm_box.layout.display = ""
+
+
+def on_confirm_yes(app: Any, btn: Any, *, reset_perf_log_fn: Any) -> None:
+ """Reset performance log after confirmation and refresh summary stats."""
+ reset_perf_log_fn()
+ app._reset_confirm_box.layout.display = "none"
+ app._refresh_perf_stats()
+
+
+def on_confirm_no(app: Any, btn: Any) -> None:
+ """Cancel perf-log reset confirmation prompt."""
+ app._reset_confirm_box.layout.display = "none"
+
+
+def on_log_clear(app: Any, btn: Any) -> None:
+ """Clear rendered event-log output widgets in the Log tab."""
+ app._log_output_html.value = (
+ 'Log cleared. '
+ )
+ app._log_source_lbl.value = ""
+
+
+def on_clear_log_cache(app: Any, _unused: Any = None) -> None:
+ """First click handler for event-log cache clear workflow."""
+ app._clear_log_cache_confirm_btn.layout.display = ""
+ app._clear_log_cache_btn.disabled = True
+
+
+def on_clear_log_cache_confirm(app: Any, *, calc_log_mod: Any) -> None:
+ """Second click handler that clears persisted event log and restores UI."""
+ try:
+ calc_log_mod.log_event(
+ "log_cleared",
+ "Session event log cleared by user",
+ session_id=app._session_id,
+ )
+ calc_log_mod.clear_event_log()
+ except Exception:
+ pass
+ app._clear_log_cache_confirm_btn.layout.display = "none"
+ app._clear_log_cache_btn.disabled = False
+
+
+def on_help_toggle(app: Any, _unused: Any = None) -> None:
+ """Toggle visibility of the floating Help overlay panel."""
+ visible = app.help_tab_panel.layout.display != "none"
+ app.help_tab_panel.layout.display = "none" if visible else ""
+
+
+def on_help_topic_changed(app: Any, change: Any = None) -> None:
+ """Refresh help topic content after selector changes."""
+ _ = change
+ app._render_help_topic()
+
+
+def on_issue_btn(app: Any, _unused: Any = None) -> None:
+ """Open the issue-report overlay and reset transient form status."""
+ app._issue_textarea.value = ""
+ app._issue_status_html.value = ""
+ app._issue_overlay.layout.display = ""
+
+
+def on_issue_cancel(app: Any, _unused: Any = None) -> None:
+ """Dismiss the issue-report overlay without saving."""
+ app._issue_overlay.layout.display = "none"
+
+
+def on_issue_submit(app: Any, *, issue_tracker_mod: Any) -> None:
+ """Persist issue text and hide overlay on success."""
+ text = app._issue_textarea.value.strip()
+ if not text:
+ app._issue_status_html.value = (
+ ''
+ "Please describe the issue before submitting. "
+ )
+ return
+ app._issue_submit_btn.disabled = True
+ try:
+ issue_id = issue_tracker_mod.log_issue(
+ description=text,
+ context=app._build_issue_context(),
+ session_id=app._session_id,
+ )
+ app._issue_status_html.value = (
+ f''
+ f"✓ Issue #{issue_id} saved. Thank you! "
+ )
+ app._issue_overlay.layout.display = "none"
+ except Exception as exc:
+ app._issue_status_html.value = (
+ f'Save failed: {exc} '
+ )
+ finally:
+ app._issue_submit_btn.disabled = False
+
+
+def on_expand_mol_input(app: Any, btn: Any, *, visualization_available: bool) -> None:
+ """Expand molecule input section to show full editor and controls."""
+ _ = btn
+ children = [app.mol_input_expanded, app.mol_info_html, app.viz_output]
+ if app.viz_backend_toggle is not None:
+ children.append(app.viz_backend_toggle)
+ if visualization_available:
+ children.append(app.viz_controls_box)
+ app.mol_input_container.children = children
+
+
+def on_method_help(app: Any, btn: Any) -> None:
+ """Open help overlay focused on method guidance."""
+ _ = btn
+ app._show_help_topic("method")
+
+
+def on_basis_help(app: Any, btn: Any) -> None:
+ """Open help overlay focused on basis-set guidance."""
+ _ = btn
+ app._show_help_topic("basis_set")
+
+
+def on_exit_clicked(app: Any, _unused: Any = None) -> None:
+ """Update UI and request shutdown of Voilà/Jupyter parent and kernel."""
+ import os
+ import signal
+
+ app._exit_btn.description = "Exiting…"
+ app._exit_btn.disabled = True
+ app._welcome_html.value = (
+ ''
+ '
'
+ ' '
+ ' '
+ ' '
+ " "
+ '
'
+ "QuantUI has shut down. You may close this tab.
"
+ "
"
+ )
+
+ def _do_exit() -> None:
+ time.sleep(0.6)
+ try:
+ # Signal the Voilà/Jupyter server process (our parent) to exit cleanly.
+ os.kill(os.getppid(), signal.SIGTERM)
+ except Exception:
+ pass
+ # Terminate the kernel process regardless.
+ os._exit(0)
+
+ threading.Thread(target=_do_exit, daemon=True).start()
+
+
+def on_cal_run(
+ app: Any,
+ btn: Any,
+ *,
+ benchmark_suite: Any,
+ benchmark_suite_long: Any,
+) -> None:
+ """Start async calibration run and initialize calibration UI state."""
+ _ = btn
+ mode = app._cal_mode_toggle.value
+ suite = benchmark_suite if mode == "short" else benchmark_suite_long
+ app._cal_stop_event = threading.Event()
+ app._cal_run_btn.disabled = True
+ app._cal_mode_toggle.disabled = True
+ app._cal_stop_btn.layout.display = ""
+ app._cal_progress.max = len(suite)
+ app._cal_progress.value = 0
+ app._cal_progress.layout.display = ""
+ app._cal_step_label.layout.display = ""
+ app._cal_step_label.value = (
+ 'Starting… '
+ )
+ app._cal_results_html.value = ""
+
+ threading.Thread(target=app._do_calibration, daemon=True).start()
+
+
+def on_cal_stop(app: Any, btn: Any) -> None:
+ """Signal any active calibration run to stop at the next safe point."""
+ _ = btn
+ if hasattr(app, "_cal_stop_event"):
+ app._cal_stop_event.set()
+
+
+def do_calibration(app: Any, *, pyscf_available: bool) -> None:
+ """Run calibration suite and render calibration summary table."""
+ from quantui.benchmarks import run_calibration
+
+ mode = app._cal_mode_toggle.value
+
+ def _progress(
+ step_n: int, total: int, label: str, status: str, elapsed: float
+ ) -> None:
+ icon = {"ok": "✓", "timed_out": "⏱", "stopped": "⛔", "error": "✗"}.get(
+ status, "?"
+ )
+ app._cal_progress.value = step_n
+ app._cal_step_label.value = (
+ f''
+ f"Step {step_n} / {total} — {label} "
+ f"[{icon} {elapsed:.1f} s] "
+ )
+
+ result = run_calibration(
+ progress_cb=_progress,
+ stop_event=app._cal_stop_event,
+ timeout_per_step=300.0 if mode == "long" else 120.0,
+ mode=mode,
+ )
+
+ rows = "".join(
+ f""
+ f'{s.label} '
+ f''
+ f"{s.n_electrons} "
+ f''
+ f"{s.n_basis if s.n_basis is not None else '—'} "
+ f''
+ f"{s.elapsed_s:.2f} s "
+ f''
+ f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}'
+ f" "
+ f" "
+ for s in result.steps
+ )
+ summary = f"Completed {result.n_completed} / {result.n_total} steps." + (
+ " (stopped early)" if result.stopped_early else ""
+ )
+ app._cal_results_html.value = (
+ f''
+ f'
{summary}
'
+ f'
'
+ f""
+ f'Calculation '
+ f'e⁻ '
+ f'Basis fns '
+ f'Wall time '
+ f'Status '
+ f" "
+ f"{rows}
"
+ )
+
+ app._cal_step_label.value = (
+ 'Calibration complete. '
+ "Time estimates are now active. "
+ if result.n_completed > 0
+ else 'No steps completed. '
+ )
+ app._cal_stop_btn.layout.display = "none"
+ app._cal_run_btn.disabled = not pyscf_available
+ app._cal_mode_toggle.disabled = False
+ app._refresh_perf_stats()
+
+
def update_notes(app: Any, change: Any = None) -> None:
"""Refresh educational method notes for the active molecule/method."""
app.notes_output.clear_output(wait=True)
From 3273e35f963ef4d83696f219dd35bc16259479c1 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sat, 2 May 2026 20:37:48 -0400
Subject: [PATCH 13/22] Add calc_type scoping, status markers & tail timing
Introduce structured status markers and calc_type-aware performance logging and estimation.
- Log capture: recognize [QuantUI_STATUS] lines and update the UI status label; add an on_scf_converged callback (fired once) so callers can record SCF-convergence timing.
- App runflow: instrument run finalization with named timing marks, compute spans between stages, record a calc_tail_timing event (post-SCF and other checkpoints), and use a distinct elapsed_for_est for perf logging. Adjust run_status lifecycle text.
- calc_log: add optional calc_type to logged records and to estimate_time so estimates are scoped by calc_type (with a back-compat bridge allowing legacy untyped records only for single_point). Filtering logic updated across estimation strategies.
- runflow & benchmarks: propagate calc_type when logging/estimating; benchmarks default to "single_point".
- freq_calc: emit QuantUI_STATUS marker lines during SCF/Hessian/IR/thermochemistry steps so UI can display progress; emit status messages on failures as well.
- Tests: add unit tests for LogCapture status marker handling and SCF callback, and new tests verifying calc_log estimate_time scoping by calc_type and legacy record behavior.
These changes improve UI feedback during long workflows and make performance estimates and analytics more accurate by separating different calculation workflows.
---
quantui/app.py | 91 +++++++++++++++++++++++++++++++---
quantui/app_runflow.py | 9 ++++
quantui/benchmarks.py | 1 +
quantui/calc_log.py | 36 +++++++++++---
quantui/freq_calc.py | 42 ++++++++++++++++
tests/test_app.py | 23 +++++++++
tests/test_calc_log.py | 108 +++++++++++++++++++++++++++++++++++++++++
7 files changed, 296 insertions(+), 14 deletions(-)
create mode 100644 tests/test_calc_log.py
diff --git a/quantui/app.py b/quantui/app.py
index 6553c1d..2dc06b8 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -21,7 +21,7 @@
import uuid as _uuid
from dataclasses import dataclass, field
from pathlib import Path
-from typing import TYPE_CHECKING, Any, ClassVar, List, Literal, Optional
+from typing import TYPE_CHECKING, Any, Callable, ClassVar, List, Literal, Optional
import ipywidgets as widgets
from IPython import get_ipython
@@ -547,6 +547,7 @@ def _layout(**kwargs: Any) -> widgets.Layout:
r"cycle=\s*(\d+)\s+E=\s*([\-\d\.]+)\s+delta_E=\s*([\-\d\.Ee+\-]+)"
)
_RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)")
+_RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)")
# ══ LOG CAPTURE ══════════════════════════════════════════════════════════════
@@ -559,11 +560,14 @@ def __init__(
self,
output_widget: widgets.Output,
status_label: Optional[widgets.Label] = None,
+ on_scf_converged: Optional[Callable[[], None]] = None,
) -> None:
self._w = output_widget
self._buf = io.StringIO()
self._line_buf = ""
self._status = status_label
+ self._on_scf_converged = on_scf_converged
+ self._scf_converged_seen = False
def write(self, text: str) -> None:
if not text:
@@ -573,6 +577,10 @@ def write(self, text: str) -> None:
self._line_buf += text
while "\n" in self._line_buf:
line, self._line_buf = self._line_buf.split("\n", 1)
+ m = _RE_Q_STATUS.search(line)
+ if m and self._status is not None:
+ self._status.value = m.group(1).strip()
+ continue
m = _RE_CYCLE.search(line)
if m and self._status is not None:
n, delta = m.group(1), m.group(3)
@@ -582,8 +590,15 @@ def write(self, text: str) -> None:
self._status.value = f"SCF cycle {n}"
continue
m = _RE_CONV.search(line)
- if m and self._status is not None:
- self._status.value = "SCF converged ✓"
+ if m:
+ if self._status is not None:
+ self._status.value = "SCF converged ✓"
+ if not self._scf_converged_seen and self._on_scf_converged is not None:
+ self._scf_converged_seen = True
+ try:
+ self._on_scf_converged()
+ except Exception:
+ pass
def flush(self) -> None:
pass
@@ -1951,7 +1966,27 @@ def _do_run(self) -> None:
)
_run_wall_t = time.perf_counter()
_run_cpu_t = time.process_time()
- log = _LogCapture(self.run_output, self.run_status)
+ _scf_converged_t: Optional[float] = None
+ _tail_marks: dict[str, float] = {}
+
+ def _mark(stage: str) -> None:
+ _tail_marks[stage] = time.perf_counter()
+
+ def _span(stage_a: str, stage_b: str) -> Optional[float]:
+ if stage_a not in _tail_marks or stage_b not in _tail_marks:
+ return None
+ return round(_tail_marks[stage_b] - _tail_marks[stage_a], 3)
+
+ def _on_scf_converged() -> None:
+ nonlocal _scf_converged_t
+ if _scf_converged_t is None:
+ _scf_converged_t = time.perf_counter()
+
+ log = _LogCapture(
+ self.run_output,
+ self.run_status,
+ on_scf_converged=_on_scf_converged,
+ )
# Write structured log header immediately so it appears at the top of output
try:
@@ -2191,6 +2226,7 @@ def _do_run(self) -> None:
result_html = self._format_result(result)
save_spectra, save_type = {}, "single_point"
+ _mark("result_ready")
_elapsed = time.perf_counter() - _run_wall_t
_elapsed_cpu = time.process_time() - _run_cpu_t
self._last_result = result
@@ -2198,7 +2234,7 @@ def _do_run(self) -> None:
self.accumulate_btn.disabled = False
self.result_output.append_display_data(HTML(result_html))
- self.run_status.value = f"Done in {_elapsed:.1f} s."
+ self.run_status.value = "Finalizing results..."
# Show 3D structure in the result panel and mirrored in Analysis tab
_viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol
@@ -2209,6 +2245,7 @@ def _do_run(self) -> None:
)
self._viz_label.layout.display = ""
self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output)
+ _mark("viz_done")
# Populate Analysis panels via the unified registry
_ana_ctx = _AnalysisContext(
@@ -2233,6 +2270,7 @@ def _do_run(self) -> None:
f"{_mol_label}"
)
self._completion_banner.layout.display = ""
+ _mark("banner_ready")
# Write structured log footer
try:
@@ -2251,6 +2289,7 @@ def _do_run(self) -> None:
pass
# Persist to disk
+ _mark("persist_begin")
try:
from quantui import load_result, save_result
from quantui.results_storage import (
@@ -2308,16 +2347,21 @@ def _do_run(self) -> None:
)
except Exception:
pass
+ _mark("persist_done")
# Activate analysis panels AFTER saving/refreshing the results browser.
# _refresh_results_browser (above) sets past_dd.options, which fires its
# observer and calls _deactivate_all_ana_panels. Placing this call here
# means that observer has already run (harmlessly, panels not yet active)
# by the time we activate them.
+ _mark("analysis_begin")
self._apply_analysis_context(_ana_ctx)
+ _mark("analysis_done")
# Log performance
+ _mark("perf_begin")
try:
+ _elapsed_for_est = time.perf_counter() - _run_wall_t
_calc_log.log_calculation(
formula=result.formula,
n_atoms=len(calc_mol.atoms),
@@ -2325,22 +2369,55 @@ def _do_run(self) -> None:
method=result.method,
basis=result.basis,
n_iterations=getattr(result, "n_iterations", None),
- elapsed_s=_elapsed,
+ elapsed_s=_elapsed_for_est,
converged=result.converged,
n_basis=_calc_log.count_basis_functions(
calc_mol.atoms, result.basis
),
n_cores=1,
+ calc_type=save_type,
)
_calc_log.log_event(
"calc_done",
f"{result.method}/{result.basis} on {result.formula}",
- elapsed_s=round(_elapsed, 2),
+ elapsed_s=round(_elapsed_for_est, 2),
converged=result.converged,
)
self._update_estimate()
except Exception:
pass
+ _mark("perf_done")
+
+ _mark("success_done")
+ _elapsed_total = _tail_marks["success_done"] - _run_wall_t
+ self.run_status.value = f"Done in {_elapsed_total:.1f} s."
+
+ try:
+ _tail_end = _tail_marks.get("success_done")
+ _post_scf_to_done: Optional[float] = None
+ if _tail_end is not None and _scf_converged_t is not None:
+ _post_scf_to_done = round(_tail_end - _scf_converged_t, 3)
+ _post_result_to_done = _span("result_ready", "success_done")
+ _calc_log.log_event(
+ "calc_tail_timing",
+ "Post-SCF completion timing checkpoint",
+ session_id=self._session_id,
+ formula=result.formula,
+ method=result.method,
+ basis=result.basis,
+ calc_type=save_type,
+ scf_converged_seen=_scf_converged_t is not None,
+ post_scf_to_done_s=_post_scf_to_done,
+ post_result_to_done_s=_post_result_to_done,
+ result_to_viz_s=_span("result_ready", "viz_done"),
+ result_to_banner_s=_span("result_ready", "banner_ready"),
+ persist_block_s=_span("persist_begin", "persist_done"),
+ analysis_apply_s=_span("analysis_begin", "analysis_done"),
+ perf_block_s=_span("perf_begin", "perf_done"),
+ banner_to_done_s=_span("banner_ready", "success_done"),
+ )
+ except Exception:
+ pass
except ImportError as _import_err:
_err_detail = str(_import_err)
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 4645d8d..51e8e3f 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -571,6 +571,14 @@ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
app.perf_estimate_html.value = ""
return
try:
+ calc_type = {
+ "Single Point": "single_point",
+ "Geometry Opt": "geometry_opt",
+ "Frequency": "frequency",
+ "UV-Vis (TD-DFT)": "tddft",
+ "NMR Shielding": "nmr",
+ "PES Scan": "pes_scan",
+ }.get(app.calc_type_dd.value, "single_point")
n_basis = calc_log_mod.count_basis_functions(
app._molecule.atoms, app.basis_dd.value
)
@@ -580,6 +588,7 @@ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
method=app.method_dd.value,
basis=app.basis_dd.value,
n_basis=n_basis,
+ calc_type=calc_type,
)
app.perf_estimate_html.value = calc_log_mod.format_estimate(est)
except Exception:
diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py
index 9740710..c4ab8f3 100644
--- a/quantui/benchmarks.py
+++ b/quantui/benchmarks.py
@@ -470,6 +470,7 @@ def _run_step(
converged=res.converged,
n_basis=step.n_basis,
n_cores=1,
+ calc_type="single_point",
)
except concurrent.futures.TimeoutError:
step.status = _STATUS_TIMEOUT
diff --git a/quantui/calc_log.py b/quantui/calc_log.py
index 085ea4b..e3913e7 100644
--- a/quantui/calc_log.py
+++ b/quantui/calc_log.py
@@ -333,6 +333,7 @@ def log_calculation(
converged: bool,
n_basis: Optional[int] = None,
n_cores: Optional[int] = None,
+ calc_type: Optional[str] = None,
) -> None:
"""Append one performance record to ``perf_log.jsonl``."""
record: dict = {
@@ -350,6 +351,8 @@ def log_calculation(
record["n_basis"] = n_basis
if n_cores is not None:
record["n_cores"] = n_cores
+ if calc_type is not None:
+ record["calc_type"] = calc_type
_append(_perf_path(), record)
@@ -360,6 +363,7 @@ def estimate_time(
basis: str,
n_basis: Optional[int] = None,
n_cores: Optional[int] = None,
+ calc_type: Optional[str] = None,
) -> Optional[dict]:
"""
Return a time estimate dict, or ``None`` if there is insufficient data.
@@ -391,14 +395,34 @@ def estimate_time(
4. **Same basis, any method, electron-count fallback** (≥ 2 records):
Same as the original strategy 2. Confidence: low.
+ ``calc_type`` narrows the candidate pool so that expensive workflows
+ (for example, Frequency) are not predicted from cheap workflows
+ (for example, Single Point). Legacy records without ``calc_type`` are
+ only included when estimating ``single_point``.
+
Returns ``None`` when fewer than 2 converged records are available for
- any strategy.
+ the scoped candidate pool.
"""
records = _read_all(_perf_path())
converged = [r for r in records if r.get("converged")]
if not converged:
return None
+ if calc_type is None:
+ scoped = converged
+ elif calc_type == "single_point":
+ # Back-compat bridge: older records did not store calc_type.
+ scoped = [
+ r
+ for r in converged
+ if r.get("calc_type") == "single_point" or r.get("calc_type") is None
+ ]
+ else:
+ scoped = [r for r in converged if r.get("calc_type") == calc_type]
+
+ if len(scoped) < 2:
+ return None
+
beta_new = _METHOD_SCALE_EXP.get(method, 3.5)
n_cores_current = n_cores if n_cores is not None else 1
@@ -417,7 +441,7 @@ def _eff(r: dict) -> Optional[float]:
if n_basis is not None:
exact_nb = [
r
- for r in converged
+ for r in scoped
if r.get("method") == method
and r.get("basis") == basis
and r.get("n_basis") is not None
@@ -432,9 +456,7 @@ def _eff(r: dict) -> Optional[float]:
}
# ── Strategy 2: exact method + basis, electron-count fallback ────────────
- exact = [
- r for r in converged if r.get("method") == method and r.get("basis") == basis
- ]
+ exact = [r for r in scoped if r.get("method") == method and r.get("basis") == basis]
if len(exact) >= 2:
median_ne = statistics.median(r["n_electrons"] for r in exact)
median_t = statistics.median(r["elapsed_s"] for r in exact)
@@ -449,7 +471,7 @@ def _eff(r: dict) -> Optional[float]:
if n_basis is not None:
same_basis_nb = [
r
- for r in converged
+ for r in scoped
if r.get("basis") == basis and r.get("n_basis") is not None
]
effs = [e for r in same_basis_nb for e in [_eff(r)] if e is not None]
@@ -472,7 +494,7 @@ def _eff(r: dict) -> Optional[float]:
}
# ── Strategy 4: same basis, any method, electron-count fallback ───────────
- same_basis = [r for r in converged if r.get("basis") == basis]
+ same_basis = [r for r in scoped if r.get("basis") == basis]
if len(same_basis) >= 2:
median_ne = statistics.median(r["n_electrons"] for r in same_basis)
median_t = statistics.median(r["elapsed_s"] for r in same_basis)
diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py
index cc90f5f..526c349 100644
--- a/quantui/freq_calc.py
+++ b/quantui/freq_calc.py
@@ -169,6 +169,13 @@ def run_freq_calc(
stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
+ def _status(msg: str) -> None:
+ """Emit a status marker line consumable by QuantUI's log capture."""
+ try:
+ stream.write(f"\n[QuantUI_STATUS] {msg}\n")
+ except Exception:
+ pass
+
# ── Build Mole object ────────────────────────────────────────────────────
mol = gto.Mole()
mol.atom = molecule.to_pyscf_format()
@@ -196,6 +203,8 @@ def run_freq_calc(
f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}"
) from exc
+ _status("SCF converged. Computing analytical Hessian...")
+
converged = bool(getattr(mf, "converged", False))
n_iterations = int(getattr(mf, "cycles", -1))
@@ -251,6 +260,8 @@ def run_freq_calc(
h = hess_obj.kernel()
+ _status("Analytical Hessian complete. Running harmonic analysis...")
+
freq_info = pyscf_thermo.harmonic_analysis(mol, h)
# freq_wavenumber entries may be complex numbers when PySCF uses a
@@ -300,11 +311,19 @@ def run_freq_calc(
_KM_MOL_FAC = 42.255 # (D/Å)²/amu → km/mol
_n_ir = mol.natm
+ _ir_total_solves = _n_ir * 3 * 2
+ _ir_done_solves = 0
_coords0 = mol.atom_coords().copy()
_dm0 = mf.make_rdm1()
_dpdx = _np_ir.zeros((_n_ir * 3, 3))
_xc = getattr(mf, "xc", None)
+ _status(
+ "Numerical IR intensities: "
+ f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete "
+ f"({_ir_total_solves - _ir_done_solves} remaining)"
+ )
+
_mol_v = mol.verbose
mol.verbose = 0
try:
@@ -321,6 +340,12 @@ def run_freq_calc(
_mf_d.verbose = 0
_mf_d.stdout = stream
_mf_d.kernel(dm0=_dm0)
+ _ir_done_solves += 1
+ _status(
+ "Numerical IR intensities: "
+ f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete "
+ f"({_ir_total_solves - _ir_done_solves} remaining)"
+ )
_mu_p = _np_ir.array(_mf_d.dip_moment(verbose=0))
_cm = _coords0.copy()
@@ -334,6 +359,12 @@ def run_freq_calc(
_mf_d.verbose = 0
_mf_d.stdout = stream
_mf_d.kernel(dm0=_dm0)
+ _ir_done_solves += 1
+ _status(
+ "Numerical IR intensities: "
+ f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete "
+ f"({_ir_total_solves - _ir_done_solves} remaining)"
+ )
_mu_m = _np_ir.array(_mf_d.dip_moment(verbose=0))
_dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA)
@@ -347,13 +378,21 @@ def run_freq_calc(
_ir = (_KM_MOL_FAC * (_dpdQ**2).sum(axis=1)).tolist()
if len(_ir) == len(frequencies_cm1):
ir_intensities = _ir
+ _status(
+ "Numerical IR intensities complete. Computing thermochemistry..."
+ )
except Exception as _ir_exc:
logger.warning("Numerical IR intensities failed: %s", _ir_exc)
+ _status(
+ "Numerical IR intensities failed; continuing without IR intensities."
+ )
# Thermochemistry at 298.15 K / 1 atm — best-effort
try:
import numpy as _np
+ _status("Computing thermochemistry...")
+
_freq_au = freq_info.get("freq_au")
if _freq_au is None:
_freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE
@@ -407,11 +446,14 @@ def _tv(v):
S_jmol=_S,
G_hartree=_G,
)
+ _status("Frequency backend complete.")
except Exception as _exc:
logger.warning("Thermochemistry failed: %s", _exc)
+ _status("Thermochemistry failed; frequency backend complete.")
except Exception as exc:
logger.warning("Hessian/frequency computation failed: %s", exc)
+ _status("Hessian/frequency step failed.")
if progress_stream is not None:
try:
progress_stream.write(f"\n⚠ Hessian failed: {exc}\n")
diff --git a/tests/test_app.py b/tests/test_app.py
index 86d39b6..ecf647f 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -261,6 +261,29 @@ def test_status_label_updated_on_convergence(self):
cap.write("converged SCF energy = -76.031234\n")
assert "converged" in status.value.lower()
+ def test_status_marker_updates_status_label(self):
+ cap, status = self._make_capture()
+ cap.write(
+ "[QuantUI_STATUS] Numerical IR intensities: "
+ "4/24 extra SCF solves complete (20 remaining)\n"
+ )
+ assert "4/24" in status.value
+ assert "remaining" in status.value
+
+ def test_scf_converged_callback_fires_once(self):
+ out = widgets.Output()
+ status = widgets.Label()
+ called = 0
+
+ def _on_conv() -> None:
+ nonlocal called
+ called += 1
+
+ cap = _LogCapture(out, status, on_scf_converged=_on_conv)
+ cap.write("converged SCF energy = -76.031234\n")
+ cap.write("converged SCF energy = -76.031230\n")
+ assert called == 1
+
def test_flush_is_noop(self):
cap, _ = self._make_capture()
cap.flush() # Must not raise
diff --git a/tests/test_calc_log.py b/tests/test_calc_log.py
new file mode 100644
index 0000000..14a52c9
--- /dev/null
+++ b/tests/test_calc_log.py
@@ -0,0 +1,108 @@
+"""Tests for quantui.calc_log estimation behavior."""
+
+from __future__ import annotations
+
+import importlib
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def isolated_log_dir(tmp_path, monkeypatch):
+ """Point QUANTUI_LOG_DIR at a fresh temp directory for every test."""
+ monkeypatch.setenv("QUANTUI_LOG_DIR", str(tmp_path))
+ import quantui.calc_log as clog
+
+ importlib.reload(clog)
+ yield tmp_path
+
+
+def test_estimate_time_scopes_by_calc_type(isolated_log_dir):
+ import quantui.calc_log as clog
+
+ # Fast single-point history
+ for elapsed in (12.0, 14.0, 16.0):
+ clog.log_calculation(
+ formula="CH2O",
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_iterations=12,
+ elapsed_s=elapsed,
+ converged=True,
+ n_basis=44,
+ n_cores=1,
+ calc_type="single_point",
+ )
+
+ # Slow frequency history
+ for elapsed in (118.0, 122.0):
+ clog.log_calculation(
+ formula="CH2O",
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_iterations=12,
+ elapsed_s=elapsed,
+ converged=True,
+ n_basis=44,
+ n_cores=1,
+ calc_type="frequency",
+ )
+
+ est_freq = clog.estimate_time(
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_basis=44,
+ calc_type="frequency",
+ )
+ est_sp = clog.estimate_time(
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_basis=44,
+ calc_type="single_point",
+ )
+
+ assert est_freq is not None
+ assert est_sp is not None
+ assert est_freq["n_samples"] == 2
+ assert est_freq["seconds"] > 80
+ assert est_sp["seconds"] < 30
+
+
+def test_estimate_time_non_single_point_ignores_legacy_untyped_records(
+ isolated_log_dir,
+):
+ import quantui.calc_log as clog
+
+ # Legacy records with no calc_type should not be used for frequency estimates.
+ for elapsed in (10.0, 12.0, 15.0):
+ clog.log_calculation(
+ formula="CH2O",
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_iterations=12,
+ elapsed_s=elapsed,
+ converged=True,
+ n_basis=44,
+ n_cores=1,
+ )
+
+ est_freq = clog.estimate_time(
+ n_atoms=4,
+ n_electrons=16,
+ method="B3LYP",
+ basis="6-31G",
+ n_basis=44,
+ calc_type="frequency",
+ )
+
+ assert est_freq is None
From e30b25ec14497d7b4dae21c8943bb0bef4ce8a64 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Tue, 12 May 2026 23:00:05 -0400
Subject: [PATCH 14/22] Add native Jupyter launcher and async viz fixes
Add native Jupyter launch scripts (Windows .bat + WSL .sh) and include logs in .gitignore to support a local editable QuantUI JupyterLab workflow. Introduce render tokens and main-thread queuing for trajectory and orbital-isosurface rendering to avoid stale background renders and race conditions; add timeout/watchdog and richer error/logging for iso rendering. Improve Analysis panel UX with contextual "not available" messages, panel availability bookkeeping, and telemetry for missing expected panels. Add optional QM pre-optimization before non-Geometry-Opt calculations, tweak several UI labels/layouts (energy-level label, preopt descriptions, input widths), and show calculation type badges in history lists. Update notebook cell outputs/metadata and adjust tests to expect the new contextual missing-trajectory message.
---
.gitignore | 1 +
launch-native-jupyter.bat | 72 ++++++++
launch-native-jupyter.sh | 37 ++++
notebooks/molecule_computations.ipynb | 2 +-
quantui/app.py | 60 +++++-
quantui/app_analysis.py | 185 +++++++++++++++++--
quantui/app_builders.py | 11 +-
quantui/app_runflow.py | 35 +++-
quantui/app_visualization.py | 243 +++++++++++++++++++------
quantui/orbital_visualization.py | 163 ++++++++++++++++-
tests/test_geo_opt_analysis_history.py | 18 ++
tests/test_orbital_visualization.py | 10 +
12 files changed, 751 insertions(+), 86 deletions(-)
create mode 100644 launch-native-jupyter.bat
create mode 100644 launch-native-jupyter.sh
diff --git a/.gitignore b/.gitignore
index 1a4c3cf..353d06f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,4 @@ planning/
nul
/.claude/
/temp - untracked/
+/logs
diff --git a/launch-native-jupyter.bat b/launch-native-jupyter.bat
new file mode 100644
index 0000000..14b03f8
--- /dev/null
+++ b/launch-native-jupyter.bat
@@ -0,0 +1,72 @@
+@echo off
+echo QuantUI NATIVE JUPYTER MODE -- Local conda env in WSL, no container
+echo Use this when you have edited quantui/*.py and want JupyterLab.
+echo.
+
+REM Convert the Windows repo path to a WSL path for portability
+set "WIN_REPO=%~dp0."
+for /f "delims=" %%i in ('wsl wslpath -a "%WIN_REPO%"') do set WSLPATH=%%i
+if not defined WSLPATH (
+ echo ERROR: Could not resolve a WSL path for %WIN_REPO%
+ echo Try this command manually:
+ echo wsl wslpath -a "%WIN_REPO%"
+ echo.
+ pause
+ exit /b 1
+)
+set LOGFILE=%~dp0logs\native-jupyter.log
+
+echo Startup log: %LOGFILE%
+echo.
+
+REM Runs JupyterLab directly from the quantui conda env inside WSL.
+REM pip install -e . is skipped when pyproject.toml has not changed since the
+REM last install (.dev_install_stamp). quantui/*.py changes are always live in
+REM editable mode -- reinstall is only needed after pyproject.toml changes or on
+REM first use.
+REM Uses port 8868 to avoid conflict with container-based launchers on 8866 and
+REM native Voila launcher on 8867.
+REM Clears quantui/__pycache__ on every launch to prevent stale .pyc bytecode
+REM (WSL2 DrvFs does not reliably propagate Windows-side mtime changes, so Python
+REM may load pre-edit bytecode even after source changes -- see GOTCHAS.md).
+REM PYTHONDONTWRITEBYTECODE=1 prevents a new stale cache from accumulating.
+start "QuantUI [native-jupyter]" wsl -d Ubuntu --cd "%WSLPATH%" -- bash ./launch-native-jupyter.sh
+
+echo Waiting for JupyterLab to start on localhost:8868...
+set MAX_WAIT=45
+set waited=0
+set OPENED=0
+
+:wait_for_jupyter
+powershell -NoProfile -Command "$client = New-Object Net.Sockets.TcpClient; try { $client.Connect('127.0.0.1', 8868); $client.Close(); exit 0 } catch { exit 1 }" > nul 2>&1
+if %errorlevel%==0 goto open_browser
+if %waited% GEQ %MAX_WAIT% goto startup_timeout
+timeout /t 1 /nobreak > nul
+set /a waited=%waited%+1
+goto wait_for_jupyter
+
+:startup_timeout
+echo.
+echo JupyterLab did not open localhost:8868 within %MAX_WAIT% seconds.
+echo Check the QuantUI [native-jupyter] WSL window for startup errors.
+echo Review startup log: %LOGFILE%
+if exist "%LOGFILE%" start "" "%LOGFILE%"
+echo.
+goto done
+
+:open_browser
+set OPENED=1
+start http://127.0.0.1:8868/lab/tree/notebooks/molecule_computations.ipynb
+
+:done
+
+echo.
+if "%OPENED%"=="1" (
+ echo Native JupyterLab server running at http://127.0.0.1:8868/lab
+ echo All local quantui/*.py changes are live -- no rebuild needed.
+ echo Close the WSL window to stop.
+) else (
+ echo JupyterLab startup not confirmed yet.
+ echo Review the QuantUI [native-jupyter] WSL window for details.
+ echo Startup log: %LOGFILE%
+)
diff --git a/launch-native-jupyter.sh b/launch-native-jupyter.sh
new file mode 100644
index 0000000..c28d825
--- /dev/null
+++ b/launch-native-jupyter.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LOG_FILE="logs/native-jupyter.log"
+mkdir -p "$(dirname "$LOG_FILE")"
+
+{
+ echo
+ echo "=== QuantUI native Jupyter launch: $(date -Iseconds) ==="
+ echo "PWD: $(pwd)"
+} >> "$LOG_FILE"
+
+exec > >(tee -a "$LOG_FILE") 2>&1
+
+source ~/miniconda3/etc/profile.d/conda.sh
+conda activate quantui
+
+echo "Using Python: $(command -v python)"
+echo "Using Jupyter: $(command -v jupyter)"
+
+# Reinstall editable package only when pyproject metadata changed, or on first run.
+if [ ! -f .dev_install_stamp ] || [ pyproject.toml -nt .dev_install_stamp ]; then
+ pip install -e . -q
+ touch .dev_install_stamp
+fi
+
+# Prevent stale bytecode from WSL2 DrvFs mtime quirks.
+rm -rf quantui/__pycache__
+export PYTHONDONTWRITEBYTECODE=1
+
+exec jupyter lab notebooks/molecule_computations.ipynb \
+ --no-browser \
+ --port=8868 \
+ --ServerApp.port_retries=0 \
+ --ServerApp.root_dir="$(pwd)" \
+ --IdentityProvider.token='' \
+ --ServerApp.password=''
diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb
index 7c10d72..b9b8943 100644
--- a/notebooks/molecule_computations.ipynb
+++ b/notebooks/molecule_computations.ipynb
@@ -78,7 +78,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.15"
+ "version": "3.11.14"
}
},
"nbformat": 4,
diff --git a/quantui/app.py b/quantui/app.py
index 2dc06b8..51292c8 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -826,6 +826,8 @@ def __init__(self) -> None:
self._last_calc_type: Optional[str] = None # e.g. "frequency", "single_point"
self._results: List = []
self._pending_traj_result: Any = None
+ self._traj_render_token: int = 0
+ self._iso_render_token: int = 0
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -1334,6 +1336,12 @@ def _set_html_output(self, out: widgets.Output, html: str) -> None:
assigned to widgets.HTML.value (innerHTML path), which leads to blank
figure panels. Rendering through Output display_data executes the JS.
"""
+ if threading.current_thread() is not threading.main_thread():
+ ip = get_ipython()
+ io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None)
+ if io_loop is not None:
+ io_loop.add_callback(self._set_html_output, out, html)
+ return
self._clear_output_widget(out)
out.append_display_data(HTML(html))
@@ -1892,8 +1900,15 @@ def _show_result_log(self, saved_dir: Path, log_text: str) -> None:
def _on_traj_expand(self, change) -> None:
_viz_on_traj_expand(self, change)
- def _show_opt_trajectory(self, opt_result) -> None:
- _viz_show_opt_trajectory(self, opt_result, layout_fn=_layout)
+ def _show_opt_trajectory(
+ self, opt_result, render_token: Optional[int] = None
+ ) -> None:
+ _viz_show_opt_trajectory(
+ self,
+ opt_result,
+ layout_fn=_layout,
+ render_token=render_token,
+ )
def _traj_step_html(self, step: int, traj, energies, rel_e) -> str:
return _viz_traj_step_html(self, step, traj, energies, rel_e)
@@ -1938,8 +1953,14 @@ def _on_iso_generate(self, btn) -> None:
def _on_orb_range_changed(self, _change=None) -> None:
_viz_on_orb_range_changed(self, _change)
- def _render_orbital_isosurface(self, orbital_label: str) -> None:
- _viz_render_orbital_isosurface(self, orbital_label)
+ def _render_orbital_isosurface(
+ self, orbital_label: str, render_token: Optional[int] = None
+ ) -> None:
+ _viz_render_orbital_isosurface(
+ self,
+ orbital_label,
+ render_token=render_token,
+ )
def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None:
_viz_render_vib_mode(self, vib_data, molecule, mode_number)
@@ -2026,6 +2047,37 @@ def _on_scf_converged() -> None:
save_spectra: dict = {}
save_type: str = "single_point"
_pre_opt: Any = None # OptimizationResult from Frequency pre-opt step
+
+ # Optional QM geometry optimization before non-frequency workflows.
+ # Frequency has dedicated seed/pre-opt handling in its own branch.
+ if self._freq_preopt_cb.value and ct not in ("Geometry Opt", "Frequency"):
+ from quantui import optimize_geometry
+
+ self.run_status.value = f"Pre-optimizing geometry before {ct}…"
+ log.write(
+ f"\n── Pre-optimisation (before {ct}) "
+ f"────────────────────────────────────\n"
+ )
+ _pre_opt = optimize_geometry(
+ molecule=calc_mol,
+ method=self.method_dd.value,
+ basis=self.basis_dd.value,
+ progress_stream=log, # type: ignore[arg-type]
+ )
+ calc_mol = _pre_opt.molecule
+ _conv_str = (
+ "converged" if _pre_opt.converged else "did NOT fully converge"
+ )
+ log.write(
+ f"\nPre-optimisation {_conv_str} in {_pre_opt.n_steps} steps."
+ f" E = {_pre_opt.energies_hartree[-1]:.8f} Ha\n\n"
+ )
+ if not _pre_opt.converged:
+ log.write(
+ "⚠ Pre-optimisation did not fully converge — "
+ "proceeding with best available geometry.\n\n"
+ )
+
if ct == "Geometry Opt":
self.run_status.value = "Optimizing geometry..."
from quantui import optimize_geometry
diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py
index 4905243..24afbf9 100644
--- a/quantui/app_analysis.py
+++ b/quantui/app_analysis.py
@@ -2,17 +2,72 @@
from __future__ import annotations
+import html as _html_mod
import types as _types_mod
from typing import Any
import ipywidgets as widgets
+_PANEL_UNAVAILABLE_STYLE = (
+ "padding:12px 16px;color:#6b7280;font-size:13px;font-style:italic"
+)
+
+_CALC_TYPE_LABELS = {
+ "single_point": "Single Point",
+ "geometry_opt": "Geometry Opt",
+ "frequency": "Frequency",
+ "tddft": "UV-Vis (TD-DFT)",
+ "nmr": "NMR Shielding",
+ "pes_scan": "PES Scan",
+}
+
+
+def _panel_unavailable_html(message: str) -> str:
+ return f'{_html_mod.escape(message)}
'
+
+
+def _set_panel_unavailable_message(app: Any, panel_name: str, message: str) -> None:
+ panel = app._ana_unavail_msgs.get(panel_name)
+ if panel is not None:
+ panel.value = _panel_unavailable_html(message)
+
+
+def _reset_unavailable_messages_for_context(app: Any, ctx: Any) -> None:
+ expected_panels = {
+ panel_name
+ for panel_name, _method_name, _auto in app._PANEL_REGISTRY.get(
+ ctx.calc_type, []
+ )
+ }
+ calc_label = _CALC_TYPE_LABELS.get(
+ ctx.calc_type,
+ str(ctx.calc_type).replace("_", " ").title(),
+ )
+ for panel_name in app._ana_panel_names:
+ if panel_name in expected_panels:
+ _set_panel_unavailable_message(
+ app,
+ panel_name,
+ (
+ f"Not available for this {calc_label} result: "
+ "required data is missing or could not be loaded."
+ ),
+ )
+ continue
+ when = app._ana_when_map.get(panel_name, "relevant")
+ _set_panel_unavailable_message(
+ app,
+ panel_name,
+ f"Not available - run a {when} calculation first.",
+ )
+
def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
"""Initialise analysis panel state and wire accordion re-render observers."""
panel_meta = [
(name, getattr(app, attr), when) for name, attr, when in app._PANEL_META
]
+ app._ana_when_map = {name: when for name, _acc, when in panel_meta}
app._ana_panel_names = [m[0] for m in panel_meta]
app._ana_accordions = [m[1] for m in panel_meta]
app._ana_available = set()
@@ -27,10 +82,8 @@ def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
app._ana_content_boxes = {}
for name, acc, when in panel_meta:
unavail = widgets.HTML(
- value=(
- f'Not available — run a {when} '
- f"calculation first.
"
+ value=_panel_unavailable_html(
+ f"Not available - run a {when} calculation first."
),
layout=layout_fn(display=""),
)
@@ -84,10 +137,21 @@ def deactivate_all_ana_panels(app: Any) -> None:
def apply_analysis_context(app: Any, ctx: Any) -> None:
"""Populate Analysis panels from context and activate panels with data."""
app._deactivate_all_ana_panels()
+ _reset_unavailable_messages_for_context(app, ctx)
app._pending_traj_result = None
+ app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1
+ app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1
app.traj_accordion.set_title(0, "Trajectory Viewer")
+ app.traj_output.clear_output()
+ app._orb_iso_output.clear_output()
first_auto_selected = False
+ expected_panels = {
+ panel_name
+ for panel_name, _method_name, _want_auto in app._PANEL_REGISTRY.get(
+ ctx.calc_type, []
+ )
+ }
for panel_name, method_name, want_auto in app._PANEL_REGISTRY.get(
ctx.calc_type, []
):
@@ -110,6 +174,21 @@ def apply_analysis_context(app: Any, ctx: Any) -> None:
if do_auto:
first_auto_selected = True
+ missing_expected = sorted(expected_panels - app._ana_available)
+ if missing_expected:
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event(
+ "ana_expected_panel_missing",
+ f"{ctx.calc_type}: {', '.join(missing_expected)}"[:300],
+ calc_type=ctx.calc_type,
+ source=ctx.source,
+ missing_panels=missing_expected,
+ )
+ except Exception:
+ pass
+
source_suffix = " (from History)" if ctx.source == "history" else ""
app._analysis_context_lbl.value = (
f''
@@ -153,14 +232,39 @@ def pop_geo_trajectory(app: Any, ctx: Any) -> bool:
energies = list(getattr(ctx.live_result, "energies_hartree", []))
elif ctx.result_dir is not None:
traj_file = ctx.result_dir / "trajectory.json"
- if traj_file.exists():
- try:
- from quantui.results_storage import load_trajectory
+ if not traj_file.exists():
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Geometry Opt history result: "
+ "trajectory.json is missing."
+ ),
+ )
+ return False
+ try:
+ from quantui.results_storage import load_trajectory
- traj, energies = load_trajectory(ctx.result_dir)
- except Exception:
- return False
+ traj, energies = load_trajectory(ctx.result_dir)
+ except Exception as exc:
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Geometry Opt history result: "
+ f"failed to load trajectory data ({type(exc).__name__})."
+ ),
+ )
+ return False
if not traj or len(traj) < 2:
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Geometry Opt result: "
+ "trajectory data has fewer than 2 frames."
+ ),
+ )
return False
stub = _types_mod.SimpleNamespace(
trajectory=traj,
@@ -184,6 +288,14 @@ def pop_preopt_trajectory(app: Any, ctx: Any) -> bool:
return False
preopt_path = ctx.result_dir / "preopt_trajectory.json"
if not preopt_path.exists():
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Frequency history result: "
+ "preopt_trajectory.json is missing (pre-opt may have been disabled)."
+ ),
+ )
return False
try:
from quantui.results_storage import load_trajectory
@@ -198,8 +310,24 @@ def pop_preopt_trajectory(app: Any, ctx: Any) -> bool:
"pop_preopt_trajectory_error",
f"{type(exc).__name__}: {exc}"[:300],
)
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Frequency history result: "
+ f"failed to load preopt trajectory ({type(exc).__name__})."
+ ),
+ )
return False
if not traj or len(traj) < 2:
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this Frequency result: "
+ "pre-optimization trajectory has fewer than 2 frames."
+ ),
+ )
return False
stub = _types_mod.SimpleNamespace(
trajectory=traj,
@@ -430,14 +558,39 @@ def pop_pes_trajectory(app: Any, ctx: Any) -> bool:
energies = list(getattr(ctx.live_result, "energies_hartree", []))
elif ctx.result_dir is not None:
traj_file = ctx.result_dir / "trajectory.json"
- if traj_file.exists():
- try:
- from quantui.results_storage import load_trajectory
+ if not traj_file.exists():
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this PES Scan history result: "
+ "trajectory.json is missing."
+ ),
+ )
+ return False
+ try:
+ from quantui.results_storage import load_trajectory
- traj, energies = load_trajectory(ctx.result_dir)
- except Exception:
- return False
+ traj, energies = load_trajectory(ctx.result_dir)
+ except Exception as exc:
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this PES Scan history result: "
+ f"failed to load trajectory data ({type(exc).__name__})."
+ ),
+ )
+ return False
if not traj or len(traj) < 2:
+ _set_panel_unavailable_message(
+ app,
+ "Trajectory",
+ (
+ "Not available for this PES Scan result: "
+ "trajectory data has fewer than 2 frames."
+ ),
+ )
return False
stub = _types_mod.SimpleNamespace(
coordinates_list=traj,
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 9b0a8ee..a076b6a 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -437,9 +437,9 @@ def build_shared_widgets(
)
app.preopt_cb = widgets.Checkbox(
value=False,
- description="Pre-optimize geometry (for a crude starting point)",
+ description="Classical pre-optimize geometry (fast, crude starting point)",
disabled=not preopt_available,
- layout=layout_fn(width="400px"),
+ layout=layout_fn(width="100%"),
)
from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS
@@ -501,7 +501,7 @@ def build_shared_widgets(
options=[("(use current molecule)", "")],
description="Seed geometry:",
style={"description_width": "110px"},
- layout=layout_fn(width="420px"),
+ layout=layout_fn(width="auto", flex="1 1 auto", min_width="260px"),
tooltip="Optionally load the final optimised geometry from a previous Geo Opt result",
)
app._freq_seed_refresh_btn = widgets.Button(
@@ -512,7 +512,7 @@ def build_shared_widgets(
)
app._freq_preopt_cb = widgets.Checkbox(
value=False,
- description="Geometry optimization (recommended for unoptimized inputs)",
+ description="Geometry optimization before calculation (QM, slower)",
style={"description_width": "initial"},
layout=layout_fn(width="100%"),
)
@@ -889,6 +889,7 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
app.calc_type_dd,
app.calc_extra_opts,
app.preopt_cb,
+ app._freq_preopt_cb,
widgets.HBox(
[app.solvent_cb, app.solvent_dd],
layout=layout_fn(align_items="center", gap="4px"),
@@ -1089,7 +1090,7 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
],
layout=layout_fn(display="none", margin="8px 0"),
)
- app._orb_accordion.set_title(0, "Orbital Diagram")
+ app._orb_accordion.set_title(0, "Energy-level Diagram")
app._orb_accordion.selected_index = None
app._iso_generate_btn = widgets.Button(
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 51e8e3f..c607931 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -10,6 +10,17 @@
from IPython.display import HTML, Javascript, display
+def _calc_type_badge(calc_type: str) -> str:
+ return {
+ "single_point": "SP",
+ "geometry_opt": "GeoOpt",
+ "frequency": "Freq",
+ "tddft": "UV-Vis",
+ "nmr": "NMR",
+ "pes_scan": "PES",
+ }.get(calc_type, calc_type or "Unknown")
+
+
def on_run_clicked(app: Any, btn: Any) -> None:
"""Reset result panes and start the background run thread."""
app.run_output.clear_output()
@@ -34,6 +45,15 @@ def on_run_clicked(app: Any, btn: Any) -> None:
def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None:
"""Update extra options panel based on selected calculation type."""
ct = change["new"]
+
+ # QM pre-optimization is meaningful for all workflows except Geometry Opt,
+ # which is itself an optimization workflow.
+ if ct == "Geometry Opt":
+ app._freq_preopt_cb.value = False
+ app._freq_preopt_cb.layout.display = "none"
+ else:
+ app._freq_preopt_cb.layout.display = ""
+
if ct == "Geometry Opt":
app.calc_extra_opts.children = [
widgets.HBox(
@@ -46,9 +66,8 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None:
app.calc_extra_opts.children = [
widgets.HBox(
[app._freq_seed_dd, app._freq_seed_refresh_btn],
- layout=layout_fn(align_items="center", gap="6px"),
+ layout=layout_fn(align_items="center", gap="6px", width="100%"),
),
- app._freq_preopt_cb,
app._freq_seed_note,
]
elif ct == "UV-Vis (TD-DFT)":
@@ -614,7 +633,11 @@ def refresh_results_browser(app: Any) -> None:
try:
data = load_result(d)
ts = data.get("timestamp", d.name)
- label = f"{ts} · {data['formula']} {data['method']}/{data['basis']}"
+ calc_badge = _calc_type_badge(data.get("calc_type", ""))
+ label = (
+ f"{ts} · [{calc_badge}] "
+ f"{data['formula']} {data['method']}/{data['basis']}"
+ )
options.append((label, str(d)))
except Exception:
pass
@@ -656,7 +679,11 @@ def populate_compare_list(app: Any) -> None:
try:
data = load_result(d)
ts = data.get("timestamp", d.name[:19])
- label = f"{ts} {data['formula']} {data['method']}/{data['basis']}"
+ calc_badge = _calc_type_badge(data.get("calc_type", ""))
+ label = (
+ f"{ts} [{calc_badge}] "
+ f"{data['formula']} {data['method']}/{data['basis']}"
+ )
options.append((label, str(d)))
except Exception:
options.append((d.name, str(d)))
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index b096d21..855e59b 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -42,6 +42,8 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
if result is None:
return
app._pending_traj_result = None
+ app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1
+ render_token = app._traj_render_token
from IPython.display import HTML as _H
from IPython.display import display as _d
@@ -54,30 +56,56 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
)
)
- def _render() -> None:
- try:
- app._show_opt_trajectory(result)
- except Exception as exc:
- from IPython.display import HTML as _H2
- from IPython.display import display as _d2
-
- app.traj_output.clear_output()
- with app.traj_output:
- _d2(
- _H2(
- f'
⚠ Trajectory rendering failed: {exc}
'
- )
- )
+ try:
+ app._show_opt_trajectory(result, render_token=render_token)
+ except Exception as exc:
+ if render_token != int(getattr(app, "_traj_render_token", 0)):
+ return
+ from IPython.display import HTML as _H2
+ from IPython.display import display as _d2
- threading.Thread(target=_render, daemon=True).start()
+ app.traj_output.clear_output()
+ with app.traj_output:
+ _d2(
+ _H2(
+ f'⚠ Trajectory rendering failed: {exc}
'
+ )
+ )
-def show_opt_trajectory(app: Any, opt_result: Any, *, layout_fn: Any) -> None:
+def show_opt_trajectory(
+ app: Any,
+ opt_result: Any,
+ *,
+ layout_fn: Any,
+ render_token: int | None = None,
+) -> None:
"""Build trajectory carousel and energy chart in trajectory panel."""
import concurrent.futures
from IPython.display import display as _ipy_display
+ def _is_stale() -> bool:
+ return render_token is not None and render_token != int(
+ getattr(app, "_traj_render_token", 0)
+ )
+
+ def _set_cache_label(value: str) -> None:
+ if _is_stale():
+ return
+ cache_label.value = value
+
+ def _show_frame_error(message: str) -> None:
+ if _is_stale():
+ return
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(
+ HTML(
+ f'Frame render failed: {message}
'
+ )
+ )
+
# Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list)
traj = getattr(opt_result, "trajectory", None) or getattr(
opt_result, "coordinates_list", []
@@ -244,6 +272,8 @@ def _build_fig(idx: int):
)
def _display_frame(idx: int) -> None:
+ if _is_stale():
+ return
kind, obj = frame_cache[idx]
frame_out.clear_output()
with frame_out:
@@ -257,6 +287,8 @@ def _display_frame(idx: int) -> None:
_ipy_display(obj)
def _update_frame(change: dict[str, Any]) -> None:
+ if _is_stale():
+ return
idx = change["new"]
step_info.value = app._traj_step_html(idx, traj, energies, rel_e)
if idx in frame_cache:
@@ -273,15 +305,11 @@ def _update_frame(change: dict[str, Any]) -> None:
def _on_demand() -> None:
try:
frame_cache[idx] = _build_fig(idx)
- _display_frame(idx)
+ app._queue_main_thread_callback(_display_frame, idx)
except Exception as exc:
- frame_out.clear_output()
- with frame_out:
- _ipy_display(
- HTML(
- f'Frame render failed: {exc}
'
- )
- )
+ if _is_stale():
+ return
+ app._queue_main_thread_callback(_show_frame_error, str(exc))
threading.Thread(target=_on_demand, daemon=True).start()
@@ -322,16 +350,24 @@ def _do_export() -> None:
else Path.home() / f"{opt_result.formula}_trajectory.html"
)
anim_fig.write_html(str(out_path))
- export_status.value = (
- f''
- f"✓ Saved: {out_path} "
+ app._queue_main_thread_callback(
+ setattr,
+ export_status,
+ "value",
+ (
+ f''
+ f"✓ Saved: {out_path} "
+ ),
)
except Exception as exc:
- export_status.value = (
- f'Export failed: {exc} '
+ app._queue_main_thread_callback(
+ setattr,
+ export_status,
+ "value",
+ f'Export failed: {exc} ',
)
finally:
- _btn.disabled = False
+ app._queue_main_thread_callback(setattr, _btn, "disabled", False)
threading.Thread(target=_do_export, daemon=True).start()
@@ -345,6 +381,8 @@ def _do_export() -> None:
panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status])
# Display panel immediately.
+ if _is_stale():
+ return
app.traj_output.clear_output()
with app.traj_output:
if has_plotly and rel_e:
@@ -352,6 +390,8 @@ def _do_export() -> None:
_ipy_display(panel)
# Show placeholder while frame 0 renders in the background.
+ if _is_stale():
+ return
frame_out.clear_output()
with frame_out:
_ipy_display(
@@ -363,33 +403,40 @@ def _do_export() -> None:
def _prerender_all() -> None:
"""Render all frames (0 first, then 1+) in a background thread."""
+ if _is_stale():
+ return
try:
frame_cache[0] = _build_fig(0)
- _display_frame(0)
- cache_label.value = (
+ app._queue_main_thread_callback(_display_frame, 0)
+ app._queue_main_thread_callback(
+ _set_cache_label,
f''
- f"Pre-rendering frames… 1 / {n} "
+ f"Pre-rendering frames… 1 / {n}",
)
if n > 1:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
futures = {pool.submit(_build_fig, i): i for i in range(1, n)}
done = 1
for fut in concurrent.futures.as_completed(futures):
+ if _is_stale():
+ return
i = futures[fut]
try:
frame_cache[i] = fut.result()
except Exception:
pass
done += 1
- cache_label.value = (
+ app._queue_main_thread_callback(
+ _set_cache_label,
f''
- f"Pre-rendering frames… {done} / {n} "
+ f"Pre-rendering frames… {done} / {n}",
)
except Exception:
pass
- cache_label.value = (
+ app._queue_main_thread_callback(
+ _set_cache_label,
f''
- f"✓ All {n} frames ready "
+ f"✓ All {n} frames ready",
)
threading.Thread(target=_prerender_all, daemon=True).start()
@@ -791,8 +838,16 @@ def show_orbital_diagram(app: Any, result: Any) -> bool:
def on_iso_generate(app: Any, btn: Any) -> None:
"""Generate orbital isosurface for currently selected orbital."""
orbital_label = app._orb_toggle.value
+ app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1
+ render_token = app._iso_render_token
btn.disabled = True
btn.description = "Generating…"
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event("iso_render_start", orbital_label)
+ except Exception:
+ pass
app._orb_iso_output.clear_output()
with app._orb_iso_output:
display(
@@ -803,14 +858,50 @@ def on_iso_generate(app: Any, btn: Any) -> None:
)
)
+ done = threading.Event()
+
+ def _reset_button() -> None:
+ if render_token != int(getattr(app, "_iso_render_token", 0)):
+ return
+ btn.disabled = False
+ btn.description = "Generate Isosurface"
+
def _run() -> None:
try:
- app._render_orbital_isosurface(orbital_label)
+ app._render_orbital_isosurface(orbital_label, render_token=render_token)
finally:
+ done.set()
+ app._queue_main_thread_callback(_reset_button)
+
+ def _watchdog() -> None:
+ if done.wait(timeout=180):
+ return
+
+ def _show_timeout() -> None:
+ if render_token != int(getattr(app, "_iso_render_token", 0)):
+ return
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event("iso_render_timeout", orbital_label)
+ except Exception:
+ pass
btn.disabled = False
btn.description = "Generate Isosurface"
+ app._orb_iso_output.clear_output()
+ with app._orb_iso_output:
+ display(
+ HTML(
+ ''
+ "⚠ Orbital isosurface timed out after 180 s. "
+ "Try a smaller basis set or a smaller molecule.
"
+ )
+ )
+
+ app._queue_main_thread_callback(_show_timeout)
threading.Thread(target=_run, daemon=True).start()
+ threading.Thread(target=_watchdog, daemon=True).start()
def on_orb_range_changed(app: Any, _change: Any = None) -> None:
@@ -846,10 +937,17 @@ def on_orb_range_changed(app: Any, _change: Any = None) -> None:
pass
-def render_orbital_isosurface(app: Any, orbital_label: str) -> None:
+def render_orbital_isosurface(
+ app: Any, orbital_label: str, render_token: int | None = None
+) -> None:
"""Generate cube file and render orbital isosurface (Linux/WSL only)."""
import tempfile
+ def _is_stale() -> bool:
+ return render_token is not None and render_token != int(
+ getattr(app, "_iso_render_token", 0)
+ )
+
orb_info = getattr(app, "_last_orb_info", None)
if orb_info is None:
return
@@ -873,6 +971,8 @@ def render_orbital_isosurface(app: Any, orbital_label: str) -> None:
return
try:
+ import plotly.io as _pio
+
from quantui.orbital_visualization import (
generate_cube_from_arrays,
plot_cube_isosurface,
@@ -881,25 +981,64 @@ def render_orbital_isosurface(app: Any, orbital_label: str) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube"
generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
- fig = plot_cube_isosurface(cube_path, title=f"{orbital_label} Isosurface")
+ is_dark = app.theme_btn.value == "Dark"
+ axis_color = "#dbeafe" if is_dark else "#1f2937"
+ bond_color = "#cbd5e1" if is_dark else "#4b5563"
+ fig = plot_cube_isosurface(
+ cube_path,
+ title=f"{orbital_label} Isosurface",
+ show_molecule=True,
+ show_grid=False,
+ scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
+ axis_color=axis_color,
+ bond_color=bond_color,
+ )
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ )
except Exception as exc:
- from IPython.display import HTML as _H
- from IPython.display import display as _d
+ if _is_stale():
+ return
+ err_msg = f"{type(exc).__name__}: {exc}"
+ try:
+ from quantui import calc_log as _clog
- app._orb_iso_output.clear_output()
- with app._orb_iso_output:
- _d(
- _H(
- f'⚠ Orbital isosurface failed: {exc}
'
- )
+ _clog.log_event(
+ "iso_render_error",
+ f"{orbital_label}: {err_msg}"[:300],
)
+ except Exception:
+ pass
+
+ def _show_err(msg: str = err_msg) -> None:
+ app._orb_iso_output.clear_output()
+ with app._orb_iso_output:
+ display(
+ HTML(
+ f''
+ f"⚠ Orbital isosurface failed: {msg}
"
+ )
+ )
+
+ app._queue_main_thread_callback(_show_err)
+ return
+ if _is_stale():
return
+ try:
+ from quantui import calc_log as _clog
- from IPython.display import display as _ipy_display
+ _clog.log_event("iso_render_done", orbital_label)
+ except Exception:
+ pass
- app._orb_iso_output.clear_output()
- with app._orb_iso_output:
- _ipy_display(fig)
+ app._queue_main_thread_callback(
+ app._set_html_output,
+ app._orb_iso_output,
+ html_str,
+ )
def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None:
diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py
index c1576b6..6fe2fd8 100644
--- a/quantui/orbital_visualization.py
+++ b/quantui/orbital_visualization.py
@@ -28,6 +28,51 @@
# Conversion factor — PySCF stores MO energies in Hartree
HARTREE_TO_EV: float = 27.211386245988
+BOHR_PER_ANGSTROM: float = 1.8897261254578281
+
+# Light-weight chemistry tables for drawing atom/bond overlays on cube plots.
+_COVALENT_RADII_ANGSTROM = {
+ 1: 0.31,
+ 5: 0.84,
+ 6: 0.76,
+ 7: 0.71,
+ 8: 0.66,
+ 9: 0.57,
+ 14: 1.11,
+ 15: 1.07,
+ 16: 1.05,
+ 17: 1.02,
+ 35: 1.20,
+ 53: 1.39,
+}
+_CPK_COLORS = {
+ 1: "#f8fafc", # H
+ 5: "#f59e0b", # B
+ 6: "#374151", # C
+ 7: "#2563eb", # N
+ 8: "#dc2626", # O
+ 9: "#22c55e", # F
+ 14: "#f59e0b", # Si
+ 15: "#f97316", # P
+ 16: "#facc15", # S
+ 17: "#16a34a", # Cl
+ 35: "#b45309", # Br
+ 53: "#7c3aed", # I
+}
+_ATOMIC_SYMBOLS = {
+ 1: "H",
+ 5: "B",
+ 6: "C",
+ 7: "N",
+ 8: "O",
+ 9: "F",
+ 14: "Si",
+ 15: "P",
+ 16: "S",
+ 17: "Cl",
+ 35: "Br",
+ 53: "I",
+}
# ============================================================================
@@ -708,6 +753,52 @@ def parse_cube_file(cube_path: Path) -> dict:
}
+def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) -> dict:
+ """Build marker and bond segments from cube atom records."""
+ atom_x: List[float] = []
+ atom_y: List[float] = []
+ atom_z: List[float] = []
+ atom_colors: List[str] = []
+ atom_sizes: List[float] = []
+ atom_labels: List[str] = []
+
+ for z_num, x, y, z in atoms:
+ atom_x.append(x)
+ atom_y.append(y)
+ atom_z.append(z)
+ atom_colors.append(_CPK_COLORS.get(z_num, "#9ca3af"))
+ atom_sizes.append(max(6.0, 15.0 * _COVALENT_RADII_ANGSTROM.get(z_num, 0.75)))
+ atom_labels.append(_ATOMIC_SYMBOLS.get(z_num, str(z_num)))
+
+ bond_x: List[float] = []
+ bond_y: List[float] = []
+ bond_z: List[float] = []
+ for i, (zi, xi, yi, zi_pos) in enumerate(atoms):
+ for zj, xj, yj, zj_pos in atoms[i + 1 :]:
+ ri = _COVALENT_RADII_ANGSTROM.get(zi, 0.75)
+ rj = _COVALENT_RADII_ANGSTROM.get(zj, 0.75)
+ cutoff = (ri + rj) * 1.25 * BOHR_PER_ANGSTROM
+ dist = float(
+ np.sqrt((xi - xj) ** 2 + (yi - yj) ** 2 + (zi_pos - zj_pos) ** 2)
+ )
+ if dist <= cutoff:
+ bond_x.extend([xi, xj, None])
+ bond_y.extend([yi, yj, None])
+ bond_z.extend([zi_pos, zj_pos, None])
+
+ return {
+ "atom_x": atom_x,
+ "atom_y": atom_y,
+ "atom_z": atom_z,
+ "atom_colors": atom_colors,
+ "atom_sizes": atom_sizes,
+ "atom_labels": atom_labels,
+ "bond_x": bond_x,
+ "bond_y": bond_y,
+ "bond_z": bond_z,
+ }
+
+
def plot_cube_isosurface(
cube_path: Path,
*,
@@ -716,6 +807,11 @@ def plot_cube_isosurface(
width: int = 650,
height: int = 550,
title: Optional[str] = None,
+ show_molecule: bool = False,
+ show_grid: bool = True,
+ scene_bgcolor: str = "white",
+ axis_color: str = "#111827",
+ bond_color: str = "#6b7280",
):
"""
Render an orbital isosurface from a cube file using Plotly.
@@ -770,6 +866,7 @@ def plot_cube_isosurface(
colorscale=[[0, "rgb(49,130,189)"], [1, "rgb(49,130,189)"]],
showscale=False,
name=f"+{isovalue}",
+ caps=dict(x_show=False, y_show=False, z_show=False),
)
)
@@ -787,17 +884,75 @@ def plot_cube_isosurface(
colorscale=[[0, "rgb(222,45,38)"], [1, "rgb(222,45,38)"]],
showscale=False,
name=f"-{isovalue}",
+ caps=dict(x_show=False, y_show=False, z_show=False),
)
)
+ if show_molecule and cube["atoms"]:
+ overlay = _build_molecule_overlay_data(cube["atoms"])
+ if overlay["bond_x"]:
+ fig.add_trace(
+ go.Scatter3d(
+ x=overlay["bond_x"],
+ y=overlay["bond_y"],
+ z=overlay["bond_z"],
+ mode="lines",
+ line=dict(color=bond_color, width=6),
+ name="Bonds",
+ showlegend=False,
+ hoverinfo="skip",
+ )
+ )
+ fig.add_trace(
+ go.Scatter3d(
+ x=overlay["atom_x"],
+ y=overlay["atom_y"],
+ z=overlay["atom_z"],
+ mode="markers",
+ marker=dict(
+ size=overlay["atom_sizes"],
+ color=overlay["atom_colors"],
+ opacity=1.0,
+ line=dict(color=bond_color, width=1),
+ ),
+ text=overlay["atom_labels"],
+ hovertemplate="%{text} ",
+ name="Atoms",
+ showlegend=False,
+ )
+ )
+
fig.update_layout(
width=width,
height=height,
- title=title or "Molecular Orbital Isosurface",
+ title=dict(
+ text=title or "Molecular Orbital Isosurface", font=dict(color=axis_color)
+ ),
+ paper_bgcolor=scene_bgcolor,
+ font=dict(color=axis_color),
scene=dict(
- xaxis_title="X (Bohr)",
- yaxis_title="Y (Bohr)",
- zaxis_title="Z (Bohr)",
+ xaxis=dict(
+ title="X (Bohr)",
+ showgrid=show_grid,
+ showbackground=show_grid,
+ zeroline=False,
+ color=axis_color,
+ ),
+ yaxis=dict(
+ title="Y (Bohr)",
+ showgrid=show_grid,
+ showbackground=show_grid,
+ zeroline=False,
+ color=axis_color,
+ ),
+ zaxis=dict(
+ title="Z (Bohr)",
+ showgrid=show_grid,
+ showbackground=show_grid,
+ zeroline=False,
+ color=axis_color,
+ ),
+ bgcolor=scene_bgcolor,
aspectmode="data",
),
)
diff --git a/tests/test_geo_opt_analysis_history.py b/tests/test_geo_opt_analysis_history.py
index b438379..0a21221 100644
--- a/tests/test_geo_opt_analysis_history.py
+++ b/tests/test_geo_opt_analysis_history.py
@@ -224,6 +224,24 @@ def test_trajectory_absent_when_trajectory_json_missing(
app._apply_analysis_context(ctx)
assert "Trajectory" not in app._ana_available
+ def test_missing_geo_opt_trajectory_message_is_context_aware(
+ self, tmp_path, app, geo_opt_result
+ ):
+ saved = save_result(
+ geo_opt_result,
+ results_dir=tmp_path,
+ calc_type="geometry_opt",
+ spectra={},
+ )
+ save_orbitals(saved, geo_opt_result)
+ ctx = app._build_history_context(saved)
+ app._apply_analysis_context(ctx)
+
+ msg = app._ana_unavail_msgs["Trajectory"].value
+ assert "Not available for this Geometry Opt history result" in msg
+ assert "trajectory.json is missing" in msg
+ assert "run a Geometry Opt / PES Scan / Frequency pre-opt" not in msg
+
def test_no_panels_when_calc_type_wrong(self, tmp_path, app, geo_opt_result):
saved = save_result(
geo_opt_result, results_dir=tmp_path, calc_type="", spectra={}
diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py
index 3430a1a..12294de 100644
--- a/tests/test_orbital_visualization.py
+++ b/tests/test_orbital_visualization.py
@@ -303,6 +303,16 @@ def test_scene_has_axis_labels(self, minimal_cube_file):
fig = plot_cube_isosurface(minimal_cube_file)
assert "Bohr" in fig.layout.scene.xaxis.title.text
+ def test_show_molecule_adds_overlay_traces(self, minimal_cube_file):
+ fig = plot_cube_isosurface(minimal_cube_file, show_molecule=True)
+ assert len(fig.data) >= 3
+
+ def test_show_grid_false_hides_scene_grid(self, minimal_cube_file):
+ fig = plot_cube_isosurface(minimal_cube_file, show_grid=False)
+ assert fig.layout.scene.xaxis.showgrid is False
+ assert fig.layout.scene.yaxis.showgrid is False
+ assert fig.layout.scene.zaxis.showgrid is False
+
# ---------------------------------------------------------------------------
# generate_cube_from_arrays — M6.2 acceptance criteria
From 0b821ddb3ae937d4ce78a5443bb4cbaa47e7204b Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 14 May 2026 10:04:55 -0400
Subject: [PATCH 15/22] Add UV-Vis spectrum UI and plotting
Introduce UV-Vis spectrum support: add ToggleButtons and FWHM slider to the TDDT/F accordion, persist last wavelengths and oscillator strengths on the app, and wire controls to main-thread callbacks. Implement visualization helpers (show_uv_vis_spectrum, wire_uv_controls, on_uv_mode_changed, on_uv_fwhm_changed, update_uv_vis_figure) that render stick or broadened spectra via Plotly (using NumPy for broadening) and log plotting errors. Hook the analysis path to delegate UV-Vis rendering to the new API and observe the tddft accordion. Add unit tests covering the new widgets and basic show/update behaviors.
---
quantui/app.py | 56 ++++++++++++
quantui/app_analysis.py | 49 ++---------
quantui/app_builders.py | 21 ++++-
quantui/app_visualization.py | 161 +++++++++++++++++++++++++++++++++++
tests/test_app.py | 64 ++++++++++++++
5 files changed, 308 insertions(+), 43 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 51292c8..02a8194 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -303,6 +303,12 @@
from quantui.app_visualization import (
on_traj_expand as _viz_on_traj_expand,
)
+from quantui.app_visualization import (
+ on_uv_fwhm_changed as _viz_on_uv_fwhm_changed,
+)
+from quantui.app_visualization import (
+ on_uv_mode_changed as _viz_on_uv_mode_changed,
+)
from quantui.app_visualization import (
on_vib_mode_changed as _viz_on_vib_mode_changed,
)
@@ -330,6 +336,9 @@
from quantui.app_visualization import (
show_result_3d as _viz_show_result_3d,
)
+from quantui.app_visualization import (
+ show_uv_vis_spectrum as _viz_show_uv_vis_spectrum,
+)
from quantui.app_visualization import (
show_vib_animation as _viz_show_vib_animation,
)
@@ -339,9 +348,15 @@
from quantui.app_visualization import (
update_ir_figure as _viz_update_ir_figure,
)
+from quantui.app_visualization import (
+ update_uv_vis_figure as _viz_update_uv_vis_figure,
+)
from quantui.app_visualization import (
wire_ir_controls as _viz_wire_ir_controls,
)
+from quantui.app_visualization import (
+ wire_uv_controls as _viz_wire_uv_controls,
+)
# Import directly from submodules to avoid circular-import issues.
# quantui/__init__.py imports this module (app.py), so using
@@ -772,6 +787,8 @@ class QuantUIApp:
_scan_unit_lbl: Any
_tddft_accordion: Any
_tddft_fig: Any
+ _uv_fwhm_slider: Any
+ _uv_mode_toggle: Any
_to_analysis_btn: Any
_viz_backend: Any
_viz_label: Any
@@ -828,6 +845,8 @@ def __init__(self) -> None:
self._pending_traj_result: Any = None
self._traj_render_token: int = 0
self._iso_render_token: int = 0
+ self._last_uv_wavelengths_nm: list[float] = []
+ self._last_uv_oscillator_strengths: list[float] = []
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -994,6 +1013,13 @@ def _on_ir_accordion_show(self, change) -> None:
self._ir_mode_toggle.value, self._ir_fwhm_slider.value
)
+ def _on_tddft_accordion_show(self, change) -> None:
+ if change["new"] == 0 and getattr(self, "_last_uv_wavelengths_nm", None):
+ self._update_uv_vis_figure(
+ self._uv_mode_toggle.value,
+ self._uv_fwhm_slider.value,
+ )
+
def _on_orb_accordion_show(self, change) -> None:
if change["new"] == 0 and getattr(self, "_last_orb_info", None) is not None:
self._on_orb_range_changed()
@@ -1379,6 +1405,11 @@ def _rerender_plotly_theme(self) -> None:
self._ir_mode_toggle.value,
self._ir_fwhm_slider.value,
)
+ if getattr(self, "_last_uv_wavelengths_nm", None):
+ self._update_uv_vis_figure(
+ self._uv_mode_toggle.value,
+ self._uv_fwhm_slider.value,
+ )
_last_pes = getattr(self, "_last_pes_result", None)
if _last_pes is not None:
self._show_pes_scan_result(_last_pes)
@@ -1944,6 +1975,31 @@ def _on_ir_fwhm_changed(self, change) -> None:
def _update_ir_figure(self, mode: str, fwhm: float) -> None:
_viz_update_ir_figure(self, mode, fwhm)
+ def _show_uv_vis_spectrum(
+ self,
+ energies_ev: list[float],
+ oscillator_strengths: list[float],
+ wavelengths_nm: list[float],
+ ) -> bool:
+ return _viz_show_uv_vis_spectrum(
+ self,
+ energies_ev,
+ oscillator_strengths,
+ wavelengths_nm,
+ )
+
+ def _wire_uv_controls(self) -> None:
+ _viz_wire_uv_controls(self)
+
+ def _on_uv_mode_changed(self, change) -> None:
+ _viz_on_uv_mode_changed(self, change)
+
+ def _on_uv_fwhm_changed(self, change) -> None:
+ _viz_on_uv_fwhm_changed(self, change)
+
+ def _update_uv_vis_figure(self, mode: str, fwhm: float) -> None:
+ _viz_update_uv_vis_figure(self, mode, fwhm)
+
def _show_orbital_diagram(self, result) -> bool:
return _viz_show_orbital_diagram(self, result)
diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py
index 24afbf9..c7788e7 100644
--- a/quantui/app_analysis.py
+++ b/quantui/app_analysis.py
@@ -99,6 +99,9 @@ def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
app._ir_accordion.observe(
app._safe_cb(app._on_ir_accordion_show), names=["selected_index"]
)
+ app._tddft_accordion.observe(
+ app._safe_cb(app._on_tddft_accordion_show), names=["selected_index"]
+ )
app._orb_accordion.observe(
app._safe_cb(app._on_orb_accordion_show), names=["selected_index"]
)
@@ -395,50 +398,12 @@ def pop_uv_vis(app: Any, ctx: Any) -> bool:
wl = [1240.0 / e for e in energies_ev if e > 0]
else:
uv = ctx.spectra_data.get("uv_vis", {})
- energies_ev = uv.get("excitation_energies_ev", [])
- osc = uv.get("oscillator_strengths", [])
- wl = uv.get("wavelengths_nm", [])
+ energies_ev = list(uv.get("excitation_energies_ev") or [])
+ osc = list(uv.get("oscillator_strengths") or [])
+ wl = list(uv.get("wavelengths_nm") or [])
if not energies_ev or not osc:
return False
- try:
- import plotly.graph_objects as _go
- import plotly.io as _pio
-
- fig = _go.Figure()
- fig.add_trace(
- _go.Bar(
- x=wl,
- y=osc,
- name="Osc. strength",
- marker_color="#2563eb",
- width=[4.0] * len(wl),
- )
- )
- tc = app._plotly_theme_colors()
- fig.update_layout(
- xaxis_title="Wavelength (nm)",
- yaxis_title="Oscillator strength",
- height=320,
- margin=dict(l=60, r=20, t=30, b=50),
- plot_bgcolor=tc["plot_bgcolor"],
- paper_bgcolor=tc["paper_bgcolor"],
- font=dict(color=tc["font_color"]),
- xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
- )
- app._apply_plotly_theme(fig)
- app._set_html_output(
- app._tddft_fig,
- _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- return True
- except Exception:
- return False
+ return bool(app._show_uv_vis_spectrum(energies_ev, osc, wl))
def pop_nmr_shielding(app: Any, ctx: Any) -> bool:
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index a076b6a..e2d87a1 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -1124,11 +1124,30 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
app._iso_accordion.set_title(0, "Orbital Isosurface")
app._iso_accordion.selected_index = None
+ app._uv_mode_toggle = widgets.ToggleButtons(
+ options=["Stick", "Broadened"],
+ value="Stick",
+ style={"button_width": "80px"},
+ layout=layout_fn(margin="0 8px 0 0"),
+ )
+ app._uv_fwhm_slider = widgets.FloatSlider(
+ value=20.0,
+ min=5.0,
+ max=100.0,
+ step=5.0,
+ description="Line width:",
+ style={"description_width": "80px"},
+ layout=layout_fn(width="260px", display="none"),
+ )
app._tddft_fig = widgets.Output(layout=layout_fn(width="100%"))
+ uv_controls = widgets.HBox(
+ [app._uv_mode_toggle, app._uv_fwhm_slider],
+ layout=layout_fn(align_items="center", margin="0 0 6px 0"),
+ )
app._tddft_accordion = widgets.Accordion(
children=[
widgets.VBox(
- [app._tddft_fig],
+ [uv_controls, app._tddft_fig],
layout=layout_fn(padding="8px"),
)
],
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 855e59b..154695c 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -750,6 +750,167 @@ def update_ir_figure(app: Any, mode: str, fwhm: float) -> None:
pass
+def show_uv_vis_spectrum(
+ app: Any,
+ energies_ev: List[float],
+ oscillator_strengths: List[float],
+ wavelengths_nm: List[float],
+) -> bool:
+ """Populate UV-Vis spectrum data and render the default stick plot."""
+ wl = list(wavelengths_nm or [])
+ if not wl:
+ wl = [1240.0 / e for e in energies_ev if e and e > 0]
+
+ peaks: list[tuple[float, float]] = []
+ for x0, amp in zip(wl, oscillator_strengths):
+ try:
+ x_val = float(x0)
+ a_val = float(amp)
+ except Exception:
+ continue
+ if x_val <= 0:
+ continue
+ peaks.append((x_val, max(a_val, 0.0)))
+
+ if not peaks:
+ return False
+
+ peaks.sort(key=lambda p: p[0])
+ app._last_uv_wavelengths_nm = [p[0] for p in peaks]
+ app._last_uv_oscillator_strengths = [p[1] for p in peaks]
+
+ app._update_uv_vis_figure("Stick", 20.0)
+
+ # _show_uv_vis_spectrum may run from _do_run background thread.
+ app._queue_main_thread_callback(app._wire_uv_controls)
+ return True
+
+
+def wire_uv_controls(app: Any) -> None:
+ """Rebind UV-Vis controls and reset defaults on the main thread."""
+ app._uv_mode_toggle.unobserve_all()
+ app._uv_fwhm_slider.unobserve_all()
+ app._uv_mode_toggle.observe(app._safe_cb(app._on_uv_mode_changed), names="value")
+ app._uv_fwhm_slider.observe(app._safe_cb(app._on_uv_fwhm_changed), names="value")
+
+ app._uv_mode_toggle.value = "Stick"
+ app._uv_fwhm_slider.value = 20.0
+ app._uv_fwhm_slider.layout.display = "none"
+
+
+def on_uv_mode_changed(app: Any, change: dict[str, Any]) -> None:
+ """Handle Stick/Broadened mode changes for UV-Vis panel."""
+ mode = change["new"]
+ app._uv_fwhm_slider.layout.display = "" if mode == "Broadened" else "none"
+ app._update_uv_vis_figure(mode, app._uv_fwhm_slider.value)
+
+
+def on_uv_fwhm_changed(app: Any, change: dict[str, Any]) -> None:
+ """Re-render broadened UV-Vis trace when line width slider changes."""
+ if app._uv_mode_toggle.value == "Broadened":
+ app._update_uv_vis_figure("Broadened", change["new"])
+
+
+def update_uv_vis_figure(app: Any, mode: str, fwhm: float) -> None:
+ """Re-render UV-Vis spectrum chart for mode and FWHM settings."""
+ wl = list(getattr(app, "_last_uv_wavelengths_nm", []) or [])
+ osc = list(getattr(app, "_last_uv_oscillator_strengths", []) or [])
+ if not wl or not osc:
+ return
+
+ try:
+ import numpy as _np
+ import plotly.graph_objects as _go
+ import plotly.io as _pio
+
+ mode_name = str(mode or "Stick")
+ mode_norm = mode_name.strip().lower()
+ fig = _go.Figure()
+
+ if mode_norm == "broadened":
+ gamma = max(float(fwhm), 1.0) / 2.0
+ x_min = max(100.0, min(wl) - 80.0)
+ x_max = max(wl) + 80.0
+ n_points = max(600, int((x_max - x_min) * 2.0))
+ x_grid = _np.linspace(x_min, x_max, n_points)
+ y_grid = _np.zeros_like(x_grid)
+ for x0, amp in zip(wl, osc):
+ y_grid += amp * (gamma**2 / ((x_grid - x0) ** 2 + gamma**2))
+ fig.add_trace(
+ _go.Scatter(
+ x=x_grid.tolist(),
+ y=y_grid.tolist(),
+ mode="lines",
+ line=dict(color="#2563eb", width=2),
+ name="Broadened",
+ )
+ )
+ else:
+ stick_x: list[float | None] = []
+ stick_y: list[float | None] = []
+ for x0, amp in zip(wl, osc):
+ stick_x.extend([x0, x0, None])
+ stick_y.extend([0.0, amp, None])
+ fig.add_trace(
+ _go.Scatter(
+ x=stick_x,
+ y=stick_y,
+ mode="lines",
+ line=dict(color="#2563eb", width=2),
+ name="Stick",
+ )
+ )
+ fig.add_trace(
+ _go.Scatter(
+ x=wl,
+ y=osc,
+ mode="markers",
+ marker=dict(color="#1d4ed8", size=6),
+ showlegend=False,
+ hovertemplate=(
+ "Wavelength: %{x:.2f} nm"
+ " Oscillator strength: %{y:.3f} "
+ ),
+ )
+ )
+
+ tc = app._plotly_theme_colors()
+ fig.update_layout(
+ xaxis_title="Wavelength (nm)",
+ yaxis_title="Oscillator strength",
+ height=320,
+ margin=dict(l=60, r=20, t=30, b=50),
+ showlegend=False,
+ plot_bgcolor=tc["plot_bgcolor"],
+ paper_bgcolor=tc["paper_bgcolor"],
+ font=dict(color=tc["font_color"]),
+ )
+ fig.update_xaxes(showgrid=True, gridcolor=tc["grid_color"], zeroline=False)
+ fig.update_yaxes(
+ showgrid=True,
+ gridcolor=tc["grid_color"],
+ rangemode="tozero",
+ )
+
+ app._apply_plotly_theme(fig)
+ app._set_html_output(
+ app._tddft_fig,
+ _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ ),
+ )
+ except Exception as exc:
+ try:
+ from quantui import calc_log as _clog
+
+ _clog.log_event("uv_fig_error", f"{type(exc).__name__}: {exc}"[:300])
+ except Exception:
+ pass
+
+
def show_orbital_diagram(app: Any, result: Any) -> bool:
"""Build and reveal interactive orbital diagram accordion."""
mo_energy = getattr(result, "mo_energy_hartree", None)
diff --git a/tests/test_app.py b/tests/test_app.py
index ecf647f..715adf3 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -960,6 +960,70 @@ def test_fwhm_slider_hidden_when_stick(self):
assert app._ir_fwhm_slider.layout.display == "none"
+# ---------------------------------------------------------------------------
+# M-UV — UV-Vis Spectrum accordion widgets
+# ---------------------------------------------------------------------------
+
+
+class TestUVVisSpectrumWidgets:
+ """UV-Vis accordion and controls exist in correct initial state."""
+
+ def test_uv_accordion_exists(self):
+ app = QuantUIApp()
+ assert hasattr(app, "_tddft_accordion")
+ assert isinstance(app._tddft_accordion, widgets.Accordion)
+
+ def test_uv_mode_toggle_exists(self):
+ app = QuantUIApp()
+ assert isinstance(app._uv_mode_toggle, widgets.ToggleButtons)
+
+ def test_uv_mode_toggle_default_stick(self):
+ app = QuantUIApp()
+ assert app._uv_mode_toggle.value == "Stick"
+
+ def test_uv_mode_toggle_has_two_options(self):
+ app = QuantUIApp()
+ assert set(app._uv_mode_toggle.options) == {"Stick", "Broadened"}
+
+ def test_uv_fwhm_slider_hidden_initially(self):
+ app = QuantUIApp()
+ assert app._uv_fwhm_slider.layout.display == "none"
+
+
+class TestShowUVVisSpectrum:
+ """_show_uv_vis_spectrum stores data and wires controls."""
+
+ def test_show_uv_vis_spectrum_returns_true_with_data(self):
+ app = QuantUIApp()
+ ok = app._show_uv_vis_spectrum(
+ [3.0, 4.2, 5.5],
+ [0.12, 0.08, 0.05],
+ [413.3, 295.2, 225.5],
+ )
+ assert ok is True
+
+ def test_uv_fwhm_slider_shown_when_broadened(self):
+ app = QuantUIApp()
+ app._show_uv_vis_spectrum(
+ [3.0, 4.2, 5.5],
+ [0.12, 0.08, 0.05],
+ [413.3, 295.2, 225.5],
+ )
+ app._uv_mode_toggle.value = "Broadened"
+ assert app._uv_fwhm_slider.layout.display == ""
+
+ def test_uv_fwhm_slider_hidden_when_stick(self):
+ app = QuantUIApp()
+ app._show_uv_vis_spectrum(
+ [3.0, 4.2, 5.5],
+ [0.12, 0.08, 0.05],
+ [413.3, 295.2, 225.5],
+ )
+ app._uv_mode_toggle.value = "Broadened"
+ app._uv_mode_toggle.value = "Stick"
+ assert app._uv_fwhm_slider.layout.display == "none"
+
+
# ---------------------------------------------------------------------------
# M6 — Orbital Diagram accordion
# ---------------------------------------------------------------------------
From 6a994b36937dc968a6c9528740fe9e7f842d7658 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 14 May 2026 10:55:22 -0400
Subject: [PATCH 16/22] Add scroll guard for run output
Preserve live-log scrolling behavior in notebook/Voila frontends by installing a JS scroll-guard for the run Output widget. Adds a DOM class to run_output, an app flag _run_output_scroll_guard_installed, and _install_run_output_scroll_guard() which injects a small JS routine (via display(Javascript)) to keep logs pinned to the bottom when the user is already at the bottom while allowing manual scrolling up. The installer is invoked during app init and fails silently outside notebook contexts. Tests updated to assert the run_output CSS class is present.
---
quantui/app.py | 95 ++++++++++++++++++++++++++++++++++++++++-
quantui/app_builders.py | 1 +
tests/test_app.py | 4 ++
3 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/quantui/app.py b/quantui/app.py
index 02a8194..49b45ce 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -25,7 +25,7 @@
import ipywidgets as widgets
from IPython import get_ipython
-from IPython.display import HTML, display
+from IPython.display import HTML, Javascript, display
import quantui
import quantui.calc_log as _calc_log
@@ -847,6 +847,7 @@ def __init__(self) -> None:
self._iso_render_token: int = 0
self._last_uv_wavelengths_nm: list[float] = []
self._last_uv_oscillator_strengths: list[float] = []
+ self._run_output_scroll_guard_installed: bool = False
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -889,6 +890,7 @@ def display(self) -> None:
]
)
)
+ self._install_run_output_scroll_guard()
@property
def widget(self) -> widgets.Tab:
@@ -1872,6 +1874,97 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None:
# notebook path above keeps rendering off the worker thread.
callback(*args, **kwargs)
+ def _install_run_output_scroll_guard(self) -> None:
+ """Install a JS guard that preserves live-log scroll behavior.
+
+ The Output widget can reset scroll position during high-frequency
+ append_stdout updates in notebook/Voila frontends. This observer keeps
+ the log pinned to the bottom while the user is already at the bottom,
+ and preserves manual scrolling when the user scrolls up.
+ """
+ if self._run_output_scroll_guard_installed:
+ return
+
+ js_code = r"""
+(() => {
+ const ROOT_CLASS = "quantui-run-output";
+ const ROOT_MARK = "data-quantui-run-scroll-guard";
+
+ function selectScroller(root) {
+ const candidates = [
+ root,
+ ...root.querySelectorAll(
+ ".jp-OutputArea-output, .output_scroll, .jupyter-widgets-output-area, .output_subarea"
+ ),
+ ];
+ for (const el of candidates) {
+ const style = window.getComputedStyle(el);
+ const overflowY = (style && style.overflowY) || "";
+ const canScroll = /auto|scroll/.test(overflowY);
+ if (canScroll || el.scrollHeight > el.clientHeight + 2) {
+ return el;
+ }
+ }
+ return root;
+ }
+
+ function installForRoot(root) {
+ if (!root || root.getAttribute(ROOT_MARK) === "1") {
+ return;
+ }
+
+ const scroller = selectScroller(root);
+ if (!scroller) {
+ return;
+ }
+
+ root.setAttribute(ROOT_MARK, "1");
+
+ const thresholdPx = 24;
+ let stickToBottom = true;
+
+ const updateStickFlag = () => {
+ const dist = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
+ stickToBottom = dist <= thresholdPx;
+ };
+
+ const pinIfNeeded = () => {
+ if (stickToBottom) {
+ scroller.scrollTop = scroller.scrollHeight;
+ }
+ };
+
+ scroller.addEventListener("scroll", updateStickFlag, { passive: true });
+
+ const obs = new MutationObserver(pinIfNeeded);
+ obs.observe(root, { childList: true, subtree: true, characterData: true });
+
+ updateStickFlag();
+ pinIfNeeded();
+ }
+
+ function scanAndInstall() {
+ const roots = document.querySelectorAll(`.${ROOT_CLASS}`);
+ roots.forEach(installForRoot);
+ }
+
+ scanAndInstall();
+
+ const bodyObserver = new MutationObserver(() => {
+ scanAndInstall();
+ });
+ bodyObserver.observe(document.body, { childList: true, subtree: true });
+})();
+"""
+
+ try:
+ with self._exit_output:
+ display(Javascript(js_code))
+ self._run_output_scroll_guard_installed = True
+ except Exception:
+ # Non-notebook contexts may not support JS display; fail silently.
+ self._run_output_scroll_guard_installed = False
+
def _set_molecule_state_only(self, mol) -> None:
"""Apply only thread-safe molecule state updates."""
self._molecule = mol
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index e2d87a1..f6bbdc5 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -347,6 +347,7 @@ def build_shared_widgets(
overflow_y="auto",
)
)
+ app.run_output.add_class("quantui-run-output")
with app.run_output:
display(
HTML(
diff --git a/tests/test_app.py b/tests/test_app.py
index 715adf3..df185b1 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -82,6 +82,10 @@ def test_method_default(self):
app = QuantUIApp()
assert app.method_dd.value == DEFAULT_METHOD
+ def test_run_output_has_scroll_guard_class(self):
+ app = QuantUIApp()
+ assert "quantui-run-output" in tuple(app.run_output._dom_classes)
+
def test_basis_default(self):
from quantui.config import DEFAULT_BASIS
From 6aebc99d9855397f29f6be53471ceb49615dce8d Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 14 May 2026 11:38:21 -0400
Subject: [PATCH 17/22] Add plot export UI and saving support
Introduce export controls and logic for IR/UV/orbital/PES plots: add dropdowns, save buttons and status widgets in the results UI and wire them to new handlers. Implement QuantUIApp._export_plot_figure to write Plotly figures to HTML or PNG (with kaleido detection) into the current results folder and surface success/error status. Track last rendered figures (_last_ir_fig/_last_uv_fig/_last_orb_fig/_last_pes_fig) and persist last_result_dir when loading history so exports target the correct directory. Avoid unobserve_all() in IR/UV control wiring to prevent removing unrelated trait observers. Add tests for the new export controls, export helper behavior, and existence of the scroll-guard installer.
---
quantui/app.py | 158 +++++++++++++++++++++++++++++++----
quantui/app_builders.py | 40 ++++++++-
quantui/app_history.py | 3 +
quantui/app_visualization.py | 94 ++++++++++++++-------
tests/test_app.py | 112 +++++++++++++++++++++++++
5 files changed, 354 insertions(+), 53 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 49b45ce..24480f7 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -752,6 +752,9 @@ class QuantUIApp:
_freq_seed_refresh_btn: Any
_go_analysis_btn: Any
_go_results_btn: Any
+ _ir_export_btn: Any
+ _ir_export_fmt_dd: Any
+ _ir_export_status: Any
_ir_fig: Any
_ir_fwhm_slider: Any
_ir_mode_toggle: Any
@@ -764,12 +767,18 @@ class QuantUIApp:
_orb_accordion: Any
_orb_diagram_box: Any
_orb_diagram_html: Any
+ _orb_export_btn: Any
+ _orb_export_fmt_dd: Any
+ _orb_export_status: Any
_orb_iso_controls: Any
_orb_iso_output: Any
_orb_n_orb_input: Any
_orb_toggle: Any
_orb_ymax_input: Any
_orb_ymin_input: Any
+ _pes_export_btn: Any
+ _pes_export_fmt_dd: Any
+ _pes_export_status: Any
_pes_plot_html: Any
_pes_scan_accordion: Any
_result_dir_label: Any
@@ -787,6 +796,9 @@ class QuantUIApp:
_scan_unit_lbl: Any
_tddft_accordion: Any
_tddft_fig: Any
+ _uv_export_btn: Any
+ _uv_export_fmt_dd: Any
+ _uv_export_status: Any
_uv_fwhm_slider: Any
_uv_mode_toggle: Any
_to_analysis_btn: Any
@@ -847,6 +859,10 @@ def __init__(self) -> None:
self._iso_render_token: int = 0
self._last_uv_wavelengths_nm: list[float] = []
self._last_uv_oscillator_strengths: list[float] = []
+ self._last_ir_fig: Any = None
+ self._last_uv_fig: Any = None
+ self._last_orb_fig: Any = None
+ self._last_pes_fig: Any = None
self._run_output_scroll_guard_installed: bool = False
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -1248,6 +1264,22 @@ def _wire_callbacks(self) -> None:
# Run
self.run_btn.on_click(self._on_run_clicked)
self.log_clear_btn.on_click(self._on_clear_log)
+ self._ir_mode_toggle.observe(
+ self._safe_cb(self._on_ir_mode_changed), names="value"
+ )
+ self._ir_fwhm_slider.observe(
+ self._safe_cb(self._on_ir_fwhm_changed), names="value"
+ )
+ self._uv_mode_toggle.observe(
+ self._safe_cb(self._on_uv_mode_changed), names="value"
+ )
+ self._uv_fwhm_slider.observe(
+ self._safe_cb(self._on_uv_fwhm_changed), names="value"
+ )
+ self._ir_export_btn.on_click(self._on_ir_export_plot)
+ self._uv_export_btn.on_click(self._on_uv_export_plot)
+ self._orb_export_btn.on_click(self._on_orb_export_plot)
+ self._pes_export_btn.on_click(self._on_pes_export_plot)
# Accumulate / export
self.accumulate_btn.on_click(self._on_accumulate)
self.clear_btn.on_click(self._on_clear)
@@ -1617,6 +1649,98 @@ def _on_export_mol(self, btn) -> None:
def _on_export_pdb(self, btn) -> None:
_exp_on_export_pdb(self, btn)
+ def _on_ir_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_ir_fig", None),
+ stem="ir_spectrum",
+ fmt=self._ir_export_fmt_dd.value,
+ status_widget=self._ir_export_status,
+ )
+
+ def _on_uv_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_uv_fig", None),
+ stem="uv_vis_spectrum",
+ fmt=self._uv_export_fmt_dd.value,
+ status_widget=self._uv_export_status,
+ )
+
+ def _on_orb_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_orb_fig", None),
+ stem="orbital_energy_diagram",
+ fmt=self._orb_export_fmt_dd.value,
+ status_widget=self._orb_export_status,
+ )
+
+ def _on_pes_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_pes_fig", None),
+ stem="pes_scan_profile",
+ fmt=self._pes_export_fmt_dd.value,
+ status_widget=self._pes_export_status,
+ )
+
+ def _export_plot_figure(
+ self,
+ *,
+ fig: Any,
+ stem: str,
+ fmt: str,
+ status_widget: widgets.HTML,
+ ) -> None:
+ """Export a plotly figure to HTML or PNG in the current result folder."""
+ if fig is None:
+ status_widget.value = (
+ ''
+ "No plot available to export yet. "
+ )
+ return
+
+ import re as _re
+ from datetime import datetime as _dt
+
+ import plotly.io as _pio
+
+ target_dir = (
+ self._last_result_dir
+ if isinstance(self._last_result_dir, Path)
+ else self._get_results_dir()
+ )
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ safe_stem = _re.sub(r"[^A-Za-z0-9_.-]+", "_", stem.strip()) or "plot"
+ ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S")
+ ext = "png" if fmt == "png" else "html"
+ dest = target_dir / f"{safe_stem}_{ts}.{ext}"
+
+ try:
+ if fmt == "png":
+ # Requires kaleido for static image export.
+ fig.write_image(str(dest), scale=2)
+ else:
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs=True,
+ full_html=True,
+ config={"responsive": True},
+ )
+ dest.write_text(html_str, encoding="utf-8")
+
+ status_widget.value = (
+ '' f"Saved: {dest} "
+ )
+ except Exception as exc:
+ msg = str(exc)
+ if fmt == "png" and "kaleido" in msg.lower():
+ msg = (
+ "PNG export requires kaleido. " "Install with: pip install kaleido"
+ )
+ status_widget.value = (
+ ''
+ f"Export failed: {msg} "
+ )
+
def _export_molecule_and_label(self):
return _exp_export_molecule_and_label(self)
@@ -1874,18 +1998,18 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None:
# notebook path above keeps rendering off the worker thread.
callback(*args, **kwargs)
- def _install_run_output_scroll_guard(self) -> None:
- """Install a JS guard that preserves live-log scroll behavior.
+ def _install_run_output_scroll_guard(self) -> None:
+ """Install a JS guard that preserves live-log scroll behavior.
- The Output widget can reset scroll position during high-frequency
- append_stdout updates in notebook/Voila frontends. This observer keeps
- the log pinned to the bottom while the user is already at the bottom,
- and preserves manual scrolling when the user scrolls up.
- """
- if self._run_output_scroll_guard_installed:
- return
+ The Output widget can reset scroll position during high-frequency
+ append_stdout updates in notebook/Voila frontends. This observer keeps
+ the log pinned to the bottom while the user is already at the bottom,
+ and preserves manual scrolling when the user scrolls up.
+ """
+ if self._run_output_scroll_guard_installed:
+ return
- js_code = r"""
+ js_code = r"""
(() => {
const ROOT_CLASS = "quantui-run-output";
const ROOT_MARK = "data-quantui-run-scroll-guard";
@@ -1957,13 +2081,13 @@ def _install_run_output_scroll_guard(self) -> None:
})();
"""
- try:
- with self._exit_output:
- display(Javascript(js_code))
- self._run_output_scroll_guard_installed = True
- except Exception:
- # Non-notebook contexts may not support JS display; fail silently.
- self._run_output_scroll_guard_installed = False
+ try:
+ with self._exit_output:
+ display(Javascript(js_code))
+ self._run_output_scroll_guard_installed = True
+ except Exception:
+ # Non-notebook contexts may not support JS display; fail silently.
+ self._run_output_scroll_guard_installed = False
def _set_molecule_state_only(self, mol) -> None:
"""Apply only thread-safe molecule state updates."""
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index f6bbdc5..7b92e6a 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -934,11 +934,36 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
def build_results_section(app: Any, *, layout_fn: Any) -> None:
"""Build results and analysis tab panels/widgets."""
+
+ def _plot_export_row(prefix: str) -> widgets.HBox:
+ fmt_dd = widgets.Dropdown(
+ options=[("HTML", "html"), ("PNG", "png")],
+ value="html",
+ description="Export:",
+ style={"description_width": "55px"},
+ layout=layout_fn(width="170px"),
+ )
+ btn = widgets.Button(
+ description="Save Plot",
+ icon="download",
+ layout=layout_fn(width="130px"),
+ tooltip="Export the current plot",
+ )
+ status = widgets.HTML(value="", layout=layout_fn(margin="0 0 0 8px"))
+ setattr(app, f"_{prefix}_export_fmt_dd", fmt_dd)
+ setattr(app, f"_{prefix}_export_btn", btn)
+ setattr(app, f"_{prefix}_export_status", status)
+ return widgets.HBox(
+ [fmt_dd, btn, status],
+ layout=layout_fn(align_items="center", margin="0 0 6px 0", gap="6px"),
+ )
+
+ pes_export_row = _plot_export_row("pes")
app._pes_plot_html = widgets.Output(layout=layout_fn(width="100%"))
app._pes_scan_accordion = widgets.Accordion(
children=[
widgets.VBox(
- [app._pes_plot_html],
+ [pes_export_row, app._pes_plot_html],
layout=layout_fn(padding="8px"),
)
],
@@ -993,12 +1018,13 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
layout=layout_fn(width="260px", display="none"),
)
app._ir_fig = widgets.Output(layout=layout_fn(width="100%"))
+ ir_export_row = _plot_export_row("ir")
ir_controls = widgets.HBox(
[app._ir_mode_toggle, app._ir_fwhm_slider],
layout=layout_fn(align_items="center", margin="0 0 6px 0"),
)
- ir_body_children = [ir_controls, app._ir_fig]
+ ir_body_children = [ir_controls, ir_export_row, app._ir_fig]
app._ir_accordion = widgets.Accordion(
children=[
widgets.VBox(
@@ -1059,7 +1085,12 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
),
)
app._orb_diagram_html = widgets.Output(layout=layout_fn(width="100%"))
- orb_diagram_content: list[Any] = [orb_controls_row, app._orb_diagram_html]
+ orb_export_row = _plot_export_row("orb")
+ orb_diagram_content: list[Any] = [
+ orb_controls_row,
+ orb_export_row,
+ app._orb_diagram_html,
+ ]
app._orb_diagram_box = widgets.VBox(
orb_diagram_content,
layout=layout_fn(width="100%"),
@@ -1141,6 +1172,7 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
layout=layout_fn(width="260px", display="none"),
)
app._tddft_fig = widgets.Output(layout=layout_fn(width="100%"))
+ uv_export_row = _plot_export_row("uv")
uv_controls = widgets.HBox(
[app._uv_mode_toggle, app._uv_fwhm_slider],
layout=layout_fn(align_items="center", margin="0 0 6px 0"),
@@ -1148,7 +1180,7 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
app._tddft_accordion = widgets.Accordion(
children=[
widgets.VBox(
- [uv_controls, app._tddft_fig],
+ [uv_controls, uv_export_row, app._tddft_fig],
layout=layout_fn(padding="8px"),
)
],
diff --git a/quantui/app_history.py b/quantui/app_history.py
index bc1a782..d000175 100644
--- a/quantui/app_history.py
+++ b/quantui/app_history.py
@@ -65,6 +65,7 @@ def on_view_log(app: Any, btn: Any) -> None:
if not path_str:
return
result_dir = Path(path_str)
+ app._last_result_dir = result_dir
try:
import quantui.calc_log as _calc_log
@@ -164,6 +165,7 @@ def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any:
def history_load_results(app: Any, data: dict[str, Any], result_dir: Path) -> None:
"""Display a history result card in the Results tab and navigate there."""
+ app._last_result_dir = result_dir
app.result_output.clear_output()
with app.result_output:
display(HTML(app._format_past_result(data, result_dir=result_dir)))
@@ -177,6 +179,7 @@ def history_load_results(app: Any, data: dict[str, Any], result_dir: Path) -> No
def history_load_analysis(app: Any, result_dir: Path) -> None:
"""Load analysis panels for a history result and navigate to Analysis tab."""
+ app._last_result_dir = result_dir
log_path = result_dir / "pyscf.log"
text = (
log_path.read_text(encoding="utf-8", errors="replace")
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 154695c..0d62339 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -678,11 +678,8 @@ def show_ir_spectrum(app: Any, freq_result: Any) -> bool:
def wire_ir_controls(app: Any) -> None:
"""Rebind IR controls and reset defaults on the main thread."""
- app._ir_mode_toggle.unobserve_all()
- app._ir_fwhm_slider.unobserve_all()
- app._ir_mode_toggle.observe(app._safe_cb(app._on_ir_mode_changed), names="value")
- app._ir_fwhm_slider.observe(app._safe_cb(app._on_ir_fwhm_changed), names="value")
-
+ # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all()
+ # here because it can remove unrelated trait observers in some frontends.
app._ir_mode_toggle.value = "Stick"
app._ir_fwhm_slider.value = 20.0
app._ir_fwhm_slider.layout.display = "none"
@@ -732,6 +729,7 @@ def update_ir_figure(app: Any, mode: str, fwhm: float) -> None:
yaxis_title=y_title,
)
app._apply_plotly_theme(fig)
+ app._last_ir_fig = fig
app._set_html_output(
app._ir_fig,
_pio.to_html(
@@ -742,6 +740,7 @@ def update_ir_figure(app: Any, mode: str, fwhm: float) -> None:
),
)
except Exception as exc:
+ app._last_ir_fig = None
try:
from quantui import calc_log as _clog
@@ -788,11 +787,8 @@ def show_uv_vis_spectrum(
def wire_uv_controls(app: Any) -> None:
"""Rebind UV-Vis controls and reset defaults on the main thread."""
- app._uv_mode_toggle.unobserve_all()
- app._uv_fwhm_slider.unobserve_all()
- app._uv_mode_toggle.observe(app._safe_cb(app._on_uv_mode_changed), names="value")
- app._uv_fwhm_slider.observe(app._safe_cb(app._on_uv_fwhm_changed), names="value")
-
+ # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all()
+ # here because it can remove unrelated trait observers in some frontends.
app._uv_mode_toggle.value = "Stick"
app._uv_fwhm_slider.value = 20.0
app._uv_fwhm_slider.layout.display = "none"
@@ -893,6 +889,7 @@ def update_uv_vis_figure(app: Any, mode: str, fwhm: float) -> None:
)
app._apply_plotly_theme(fig)
+ app._last_uv_fig = fig
app._set_html_output(
app._tddft_fig,
_pio.to_html(
@@ -903,6 +900,7 @@ def update_uv_vis_figure(app: Any, mode: str, fwhm: float) -> None:
),
)
except Exception as exc:
+ app._last_uv_fig = None
try:
from quantui import calc_log as _clog
@@ -942,6 +940,7 @@ def show_orbital_diagram(app: Any, result: Any) -> bool:
app._orb_ymin_input.value = round(float(yr[0]), 2)
app._orb_ymax_input.value = round(float(yr[1]), 2)
app._apply_plotly_theme(fig)
+ app._last_orb_fig = fig
html_str = _pio.to_html(
fig,
include_plotlyjs="require",
@@ -954,6 +953,7 @@ def show_orbital_diagram(app: Any, result: Any) -> bool:
pass
if not plotly_rendered:
+ app._last_orb_fig = None
import base64
import io as _io
@@ -1085,6 +1085,7 @@ def on_orb_range_changed(app: Any, _change: Any = None) -> None:
yrange=(ymin, ymax),
)
app._apply_plotly_theme(fig)
+ app._last_orb_fig = fig
app._set_html_output(
app._orb_diagram_html,
_pio.to_html(
@@ -1095,6 +1096,7 @@ def on_orb_range_changed(app: Any, _change: Any = None) -> None:
),
)
except Exception:
+ app._last_orb_fig = None
pass
@@ -1102,7 +1104,8 @@ def render_orbital_isosurface(
app: Any, orbital_label: str, render_token: int | None = None
) -> None:
"""Generate cube file and render orbital isosurface (Linux/WSL only)."""
- import tempfile
+ import re as _re
+ from datetime import datetime as _dt
def _is_stale() -> bool:
return render_token is not None and render_token != int(
@@ -1139,27 +1142,45 @@ def _is_stale() -> bool:
plot_cube_isosurface,
)
- with tempfile.TemporaryDirectory() as tmpdir:
- cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube"
- generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
- is_dark = app.theme_btn.value == "Dark"
- axis_color = "#dbeafe" if is_dark else "#1f2937"
- bond_color = "#cbd5e1" if is_dark else "#4b5563"
- fig = plot_cube_isosurface(
- cube_path,
- title=f"{orbital_label} Isosurface",
- show_molecule=True,
- show_grid=False,
- scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
- axis_color=axis_color,
- bond_color=bond_color,
- )
- html_str = _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- )
+ result_dir = getattr(app, "_last_result_dir", None)
+ if not isinstance(result_dir, Path):
+ try:
+ result_dir = app._get_results_dir()
+ except Exception:
+ result_dir = Path.cwd()
+
+ cube_dir = Path(result_dir) / "isosurfaces"
+ cube_dir.mkdir(parents=True, exist_ok=True)
+
+ formula = str(getattr(orb_info, "formula", "") or "molecule")
+ safe_formula = _re.sub(r"[^A-Za-z0-9_.-]+", "_", formula).strip("._")
+ if not safe_formula:
+ safe_formula = "molecule"
+ safe_orb = _re.sub(r"[^A-Za-z0-9_.-]+", "_", orbital_label).strip("._")
+ if not safe_orb:
+ safe_orb = "orbital"
+ ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S-%f")
+ cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube"
+
+ generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
+ is_dark = app.theme_btn.value == "Dark"
+ axis_color = "#dbeafe" if is_dark else "#1f2937"
+ bond_color = "#cbd5e1" if is_dark else "#4b5563"
+ fig = plot_cube_isosurface(
+ cube_path,
+ title=f"{orbital_label} Isosurface",
+ show_molecule=True,
+ show_grid=False,
+ scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
+ axis_color=axis_color,
+ bond_color=bond_color,
+ )
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ )
except Exception as exc:
if _is_stale():
return
@@ -1191,6 +1212,13 @@ def _show_err(msg: str = err_msg) -> None:
try:
from quantui import calc_log as _clog
+ _clog.log_event(
+ "iso_cube_saved",
+ cube_path.name,
+ cube_path=str(cube_path),
+ orbital=orbital_label,
+ session_id=app._session_id,
+ )
_clog.log_event("iso_render_done", orbital_label)
except Exception:
pass
@@ -1346,6 +1374,7 @@ def show_pes_scan_result(app: Any, result: Any) -> bool:
yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
hovermode="closest",
)
+ app._last_pes_fig = fig
app._set_html_output(
app._pes_plot_html,
pio.to_html(
@@ -1356,6 +1385,7 @@ def show_pes_scan_result(app: Any, result: Any) -> bool:
),
)
except Exception:
+ app._last_pes_fig = None
pass
return True
diff --git a/tests/test_app.py b/tests/test_app.py
index df185b1..1b18f87 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -67,6 +67,11 @@ def test_export_btn_initially_disabled(self):
app = QuantUIApp()
assert app.export_btn.disabled is True
+ def test_scroll_guard_installer_method_exists(self):
+ app = QuantUIApp()
+ assert hasattr(app, "_install_run_output_scroll_guard")
+ assert callable(app._install_run_output_scroll_guard)
+
# ---------------------------------------------------------------------------
# Default widget values
@@ -923,6 +928,12 @@ def test_fwhm_slider_range(self):
assert app._ir_fwhm_slider.min == 5.0
assert app._ir_fwhm_slider.max == 100.0
+ def test_ir_export_controls_exist(self):
+ app = QuantUIApp()
+ assert isinstance(app._ir_export_btn, widgets.Button)
+ assert isinstance(app._ir_export_fmt_dd, widgets.Dropdown)
+ assert app._ir_export_fmt_dd.value == "html"
+
class TestShowIRSpectrum:
"""_show_ir_spectrum reveals accordion and wires mode toggle."""
@@ -963,6 +974,13 @@ def test_fwhm_slider_hidden_when_stick(self):
app._ir_mode_toggle.value = "Stick"
assert app._ir_fwhm_slider.layout.display == "none"
+ def test_broadened_toggle_triggers_ir_figure_update(self):
+ app = QuantUIApp()
+ app._show_ir_spectrum(self._make_freq_result())
+ with patch.object(app, "_update_ir_figure") as mock_update:
+ app._ir_mode_toggle.value = "Broadened"
+ mock_update.assert_called_once_with("Broadened", app._ir_fwhm_slider.value)
+
# ---------------------------------------------------------------------------
# M-UV — UV-Vis Spectrum accordion widgets
@@ -993,6 +1011,20 @@ def test_uv_fwhm_slider_hidden_initially(self):
app = QuantUIApp()
assert app._uv_fwhm_slider.layout.display == "none"
+ def test_uv_export_controls_exist(self):
+ app = QuantUIApp()
+ assert isinstance(app._uv_export_btn, widgets.Button)
+ assert isinstance(app._uv_export_fmt_dd, widgets.Dropdown)
+ assert app._uv_export_fmt_dd.value == "html"
+
+
+class TestPESExportWidgets:
+ def test_pes_export_controls_exist(self):
+ app = QuantUIApp()
+ assert isinstance(app._pes_export_btn, widgets.Button)
+ assert isinstance(app._pes_export_fmt_dd, widgets.Dropdown)
+ assert app._pes_export_fmt_dd.value == "html"
+
class TestShowUVVisSpectrum:
"""_show_uv_vis_spectrum stores data and wires controls."""
@@ -1027,6 +1059,17 @@ def test_uv_fwhm_slider_hidden_when_stick(self):
app._uv_mode_toggle.value = "Stick"
assert app._uv_fwhm_slider.layout.display == "none"
+ def test_broadened_toggle_triggers_uv_figure_update(self):
+ app = QuantUIApp()
+ app._show_uv_vis_spectrum(
+ [3.0, 4.2, 5.5],
+ [0.12, 0.08, 0.05],
+ [413.3, 295.2, 225.5],
+ )
+ with patch.object(app, "_update_uv_vis_figure") as mock_update:
+ app._uv_mode_toggle.value = "Broadened"
+ mock_update.assert_called_once_with("Broadened", app._uv_fwhm_slider.value)
+
# ---------------------------------------------------------------------------
# M6 — Orbital Diagram accordion
@@ -1049,6 +1092,12 @@ def test_orb_diagram_html_exists(self):
app = QuantUIApp()
assert hasattr(app, "_orb_diagram_html")
+ def test_orb_export_controls_exist(self):
+ app = QuantUIApp()
+ assert isinstance(app._orb_export_btn, widgets.Button)
+ assert isinstance(app._orb_export_fmt_dd, widgets.Dropdown)
+ assert app._orb_export_fmt_dd.value == "html"
+
def test_orb_toggle_has_four_options(self):
app = QuantUIApp()
assert set(app._orb_toggle.options) == {"HOMO-1", "HOMO", "LUMO", "LUMO+1"}
@@ -1069,6 +1118,25 @@ def test_orb_accordion_collapsed_after_run_clicked(self):
class TestShowOrbitalDiagram:
+
+ class TestPlotExportHelper:
+ def test_export_plot_figure_html_writes_file(self, tmp_path):
+ app = QuantUIApp()
+ app._last_result_dir = tmp_path
+
+ fig = MagicMock()
+ with patch("plotly.io.to_html", return_value="ok"):
+ app._export_plot_figure(
+ fig=fig,
+ stem="ir_spectrum",
+ fmt="html",
+ status_widget=app._ir_export_status,
+ )
+
+ saved = list(tmp_path.glob("ir_spectrum_*.html"))
+ assert len(saved) == 1
+ assert "Saved:" in app._ir_export_status.value
+
"""_show_orbital_diagram reveals accordion when MO data is present."""
def _make_result_with_mo(self):
@@ -1128,6 +1196,50 @@ def test_isosurface_controls_hidden_when_no_mo_coeff(self):
assert app._orb_iso_controls.layout.display == "none"
+class TestIsosurfacePersistence:
+ def test_render_orbital_isosurface_saves_cube_to_disk(self, tmp_path):
+ app = QuantUIApp()
+ app._last_result_dir = tmp_path
+ app._last_orb_info = MagicMock()
+ app._last_orb_info.n_occupied = 1
+ app._last_orb_info.mo_energies_ev = [-10.0, 2.0]
+ app._last_orb_info.formula = "H2O"
+ app._last_orb_mo_coeff = [[1.0, 0.0], [0.0, 1.0]]
+ app._last_orb_mol_atom = [["H", [0.0, 0.0, 0.0]]]
+ app._last_orb_mol_basis = "sto-3g"
+
+ captured: dict[str, object] = {}
+
+ def _fake_generate(_atom, _basis, _coeff, _idx, out_path):
+ captured["path"] = out_path
+ out_path.write_text("cube", encoding="utf-8")
+ return out_path
+
+ with (
+ patch(
+ "quantui.orbital_visualization.generate_cube_from_arrays",
+ side_effect=_fake_generate,
+ ) as mock_gen,
+ patch(
+ "quantui.orbital_visualization.plot_cube_isosurface",
+ return_value=MagicMock(),
+ ) as mock_plot,
+ patch(
+ "plotly.io.to_html",
+ return_value="iso
",
+ ),
+ ):
+ app._render_orbital_isosurface("HOMO")
+
+ saved_path = captured.get("path")
+ assert saved_path is not None
+ assert saved_path.parent == tmp_path / "isosurfaces"
+ assert saved_path.suffix == ".cube"
+ assert saved_path.exists()
+ mock_gen.assert_called_once()
+ mock_plot.assert_called_once()
+
+
# ---------------------------------------------------------------------------
# M-UI — Results tab widgets (M-UI.8)
# ---------------------------------------------------------------------------
From 5946470f25b88d6d2b7dd9f8e4f21399a65ddd11 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 14 May 2026 12:36:16 -0400
Subject: [PATCH 18/22] Add Files tab and activity indicator
Introduce a read-only Files tab with a safe file browser and preview (images/text), rooted to approved locations. Add a toolbar activity indicator (button) and activity tracking APIs (_activity_begin/_end/_pulse/_refresh) used to show compute vs UI activity; wire pulses into tab navigation and many existing callbacks (run, compare, history, file ops). Propagate result timestamps into _AnalysisContext and use them in analysis headings to align history labels. Update builders to create the files panel and activity button, add html import usage, and update tests to cover the new UI elements and the post-optimization single-point behavior (mocking run_in_session). Misc: various safety checks for paths, size formatting, status messages, and minor UI wiring/refresh logic.
---
quantui/app.py | 638 ++++++++++++++++++++++++++++++++++++++--
quantui/app_analysis.py | 20 +-
quantui/app_builders.py | 84 ++++++
quantui/app_history.py | 1 +
tests/test_app.py | 85 +++++-
5 files changed, 801 insertions(+), 27 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 24480f7..4825ec4 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -14,6 +14,7 @@
from __future__ import annotations
import asyncio
+import html as _html
import io
import re
import threading
@@ -81,6 +82,9 @@
from quantui.app_builders import (
build_compare_section as _bld_build_compare_section,
)
+from quantui.app_builders import (
+ build_files_tab as _bld_build_files_tab,
+)
from quantui.app_builders import (
build_help_section as _bld_build_help_section,
)
@@ -643,6 +647,7 @@ class _AnalysisContext:
molecule: Optional[Any] = None # molecule used for the calculation
spectra_data: dict = field(default_factory=dict) # from save_spectra / disk
preopt_result: Optional[Any] = None # OptimizationResult from pre-opt step
+ timestamp: str = "" # result timestamp shown in history dropdown labels
source: str = "live" # "live" | "history"
@property
@@ -701,6 +706,7 @@ class QuantUIApp:
_status_tab_panel: Any
_theme_style: Any
_welcome_html: Any
+ _activity_btn: Any
advanced_accordion: Any
calc_setup_panel: Any
change_mol_btn: Any
@@ -711,6 +717,15 @@ class QuantUIApp:
compare_panel: Any
compare_refresh_btn: Any
compare_select: Any
+ files_tab_panel: Any
+ _files_entries: Any
+ _files_open_btn: Any
+ _files_path_html: Any
+ _files_preview_output: Any
+ _files_refresh_btn: Any
+ _files_root_dd: Any
+ _files_status_html: Any
+ _files_up_btn: Any
help_content_html: Any
help_tab_panel: Any
help_topic_dd: Any
@@ -864,6 +879,12 @@ def __init__(self) -> None:
self._last_orb_fig: Any = None
self._last_pes_fig: Any = None
self._run_output_scroll_guard_installed: bool = False
+ self._files_current_dir: Optional[Path] = None
+ self._files_selected_path: Optional[Path] = None
+ self._files_updating: bool = False
+ self._activity_count: int = 0
+ self._activity_compute_count: int = 0
+ self._activity_lock = threading.Lock()
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -891,6 +912,7 @@ def display(self) -> None:
self._welcome_html,
widgets.HBox(
[
+ self._activity_btn,
self.theme_btn,
self._help_btn,
self._issue_btn,
@@ -927,6 +949,7 @@ def _build_widgets(self) -> None:
self._build_history_section()
self._build_compare_section()
self._build_output_tab()
+ self._build_files_tab()
self._build_help_section()
self._build_issue_widgets()
@@ -948,6 +971,80 @@ def _theme_css(self, theme: str) -> str:
""
)
+ def _set_activity_indicator(self, state: str = "idle", message: str = "") -> None:
+ """Update the toolbar activity light state and tooltip."""
+ if state == "compute":
+ self._activity_btn.description = "Computing"
+ self._activity_btn.icon = "cog"
+ self._activity_btn.button_style = "warning"
+ self._activity_btn.tooltip = message or "Running compute operations..."
+ return
+ if state == "ui":
+ self._activity_btn.description = "UI Active"
+ self._activity_btn.icon = "bolt"
+ self._activity_btn.button_style = "info"
+ self._activity_btn.tooltip = message or "Running UI operations..."
+ return
+
+ self._activity_btn.description = "Idle"
+ self._activity_btn.icon = "circle-o"
+ self._activity_btn.button_style = "success"
+ self._activity_btn.tooltip = "No active operations."
+
+ def _refresh_activity_indicator(self, message: str = "") -> None:
+ """Recompute activity light state from active operation counters."""
+ if self._activity_count <= 0:
+ self._set_activity_indicator("idle")
+ return
+ if self._activity_compute_count > 0:
+ self._set_activity_indicator("compute", message)
+ return
+ self._set_activity_indicator("ui", message)
+
+ def _activity_begin(self, message: str = "", kind: str = "ui") -> None:
+ """Mark one operation as active."""
+ with self._activity_lock:
+ self._activity_count += 1
+ if kind == "compute":
+ self._activity_compute_count += 1
+ self._refresh_activity_indicator(message)
+
+ def _activity_end(self, kind: str = "ui") -> None:
+ """Mark one operation as finished."""
+ with self._activity_lock:
+ if self._activity_count > 0:
+ self._activity_count -= 1
+ if kind == "compute" and self._activity_compute_count > 0:
+ self._activity_compute_count -= 1
+ self._refresh_activity_indicator()
+
+ def _activity_pulse(
+ self, message: str, hold_s: float = 0.18, kind: str = "ui"
+ ) -> None:
+ """Briefly light the activity indicator for quick operations."""
+ self._activity_begin(message, kind=kind)
+ timer = threading.Timer(
+ max(0.05, hold_s),
+ self._activity_end,
+ kwargs={"kind": kind},
+ )
+ timer.daemon = True
+ timer.start()
+
+ def _on_root_tab_changed(self, _change) -> None:
+ """Pulse the activity light on tab navigation actions."""
+ self._activity_pulse("Switching tabs...", hold_s=0.16, kind="ui")
+
+ def _go_to_results_tab(self, _btn) -> None:
+ """Navigate to Results tab with a visible activity pulse."""
+ self._activity_pulse("Navigating to Results tab...", hold_s=0.16, kind="ui")
+ self.root_tab.selected_index = 1
+
+ def _go_to_analysis_tab(self, _btn) -> None:
+ """Navigate to Analysis tab with a visible activity pulse."""
+ self._activity_pulse("Navigating to Analysis tab...", hold_s=0.16, kind="ui")
+ self.root_tab.selected_index = 2
+
# ── Status panel ──────────────────────────────────────────────────────
def _build_status_panel(self) -> None:
@@ -1169,7 +1266,13 @@ def _build_compare_section(self) -> None:
def _build_output_tab(self) -> None:
_bld_build_output_tab(self, layout_fn=_layout)
- # ── Help section (Cell 10) ────────────────────────────────────────────
+ # ── Files tab (Cell 11) ───────────────────────────────────────────────
+
+ def _build_files_tab(self) -> None:
+ _bld_build_files_tab(self, layout_fn=_layout)
+ self._refresh_file_browser()
+
+ # ── Help section (Cell 12) ────────────────────────────────────────────
def _build_help_section(self) -> None:
_bld_build_help_section(self, layout_fn=_layout)
@@ -1206,6 +1309,7 @@ def _assemble_tabs(self) -> None:
self.history_panel,
self.compare_panel,
self.log_tab_panel,
+ self.files_tab_panel,
self._status_tab_panel,
]
)
@@ -1215,7 +1319,11 @@ def _assemble_tabs(self) -> None:
self.root_tab.set_title(3, "History")
self.root_tab.set_title(4, "Compare")
self.root_tab.set_title(5, "Log")
- self.root_tab.set_title(6, "Status")
+ self.root_tab.set_title(6, "Files")
+ self.root_tab.set_title(7, "Status")
+ self.root_tab.observe(
+ self._safe_cb(self._on_root_tab_changed), names="selected_index"
+ )
# ══ CALLBACK WIRING ══════════════════════════════════════════════════════
@@ -1310,6 +1418,16 @@ def _wire_callbacks(self) -> None:
# Clear log cache (event_log.jsonl)
self._clear_log_cache_btn.on_click(self._on_clear_log_cache)
self._clear_log_cache_confirm_btn.on_click(self._on_clear_log_cache_confirm)
+ # Files tab
+ self._files_root_dd.observe(
+ self._safe_cb(self._on_files_root_changed), names="value"
+ )
+ self._files_entries.observe(
+ self._safe_cb(self._on_files_entry_changed), names="value"
+ )
+ self._files_open_btn.on_click(self._on_files_open)
+ self._files_up_btn.on_click(self._on_files_up)
+ self._files_refresh_btn.on_click(self._on_files_refresh)
# Issue reporting
self._issue_btn.on_click(self._on_issue_btn)
self._issue_submit_btn.on_click(self._on_issue_submit)
@@ -1322,15 +1440,9 @@ def _wire_callbacks(self) -> None:
self._safe_cb(self._on_help_topic_changed), names="value"
)
# Tab navigation buttons
- self._go_results_btn.on_click(
- lambda _: setattr(self.root_tab, "selected_index", 1)
- )
- self._go_analysis_btn.on_click(
- lambda _: setattr(self.root_tab, "selected_index", 2)
- )
- self._to_analysis_btn.on_click(
- lambda _: setattr(self.root_tab, "selected_index", 2)
- )
+ self._go_results_btn.on_click(self._go_to_results_tab)
+ self._go_analysis_btn.on_click(self._go_to_analysis_tab)
+ self._to_analysis_btn.on_click(self._go_to_analysis_tab)
# Vibrational mode selector
self.vib_mode_dd.observe(
self._safe_cb(self._on_vib_mode_changed), names="value"
@@ -1348,6 +1460,372 @@ def _wire_callbacks(self) -> None:
# Orbital isosurface generate button
self._iso_generate_btn.on_click(self._on_iso_generate)
+ # ── Files tab ────────────────────────────────────────────────────────
+
+ def _files_allowed_roots(self) -> list[Path]:
+ """Return the approved filesystem roots for the Files tab."""
+ roots: list[Path] = []
+ candidates: list[Optional[Path]] = [self._get_results_dir(), Path.cwd()]
+ _last_dir = getattr(self, "_last_result_dir", None)
+ if isinstance(_last_dir, Path):
+ candidates.append(_last_dir)
+
+ for candidate in candidates:
+ if candidate is None:
+ continue
+ try:
+ resolved = candidate.resolve()
+ except OSError:
+ continue
+ if resolved not in roots:
+ roots.append(resolved)
+
+ return roots
+
+ def _is_path_in_allowed_roots(self, path: Path, roots: list[Path]) -> bool:
+ """True when *path* is inside any configured Files-tab root."""
+ try:
+ resolved = path.resolve()
+ except OSError:
+ return False
+ for root in roots:
+ try:
+ resolved.relative_to(root)
+ return True
+ except ValueError:
+ continue
+ return False
+
+ def _format_file_size(self, size_bytes: int) -> str:
+ """Return a compact human-readable size label."""
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ if size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+
+ def _set_files_status(self, message: str, color: str = "#64748b") -> None:
+ """Update Files tab status text."""
+ self._files_status_html.value = (
+ f''
+ f"{_html.escape(message)} "
+ )
+
+ def _format_files_root_label(self, root: Path) -> str:
+ """Return a readable dropdown label for a root path."""
+ labels: list[tuple[str, Path]] = []
+ try:
+ labels.append(("Results", self._get_results_dir().resolve()))
+ except OSError:
+ pass
+ try:
+ labels.append(("Workspace CWD", Path.cwd().resolve()))
+ except OSError:
+ pass
+ _last_dir = getattr(self, "_last_result_dir", None)
+ if isinstance(_last_dir, Path):
+ try:
+ labels.append(("Current Result", _last_dir.resolve()))
+ except OSError:
+ pass
+
+ for prefix, known_root in labels:
+ if root == known_root:
+ return f"{prefix} ({root})"
+ return str(root)
+
+ def _refresh_file_browser(self) -> None:
+ """Refresh root options and the current directory listing."""
+ roots = self._files_allowed_roots()
+ try:
+ results_root = self._get_results_dir().resolve()
+ except OSError:
+ results_root = None
+ for root in roots:
+ if results_root is not None and root == results_root:
+ try:
+ root.mkdir(parents=True, exist_ok=True)
+ except OSError:
+ pass
+
+ if not roots:
+ self._files_updating = True
+ try:
+ self._files_root_dd.options = [("(no roots)", "")]
+ self._files_root_dd.value = ""
+ self._files_entries.options = [("(no files)", "")]
+ self._files_entries.value = ""
+ finally:
+ self._files_updating = False
+ self._files_current_dir = None
+ self._files_selected_path = None
+ self._files_path_html.value = (
+ ''
+ "Current folder: unavailable "
+ )
+ self._files_open_btn.disabled = True
+ self._files_up_btn.disabled = True
+ self._set_files_status("No readable roots available.", "#b91c1c")
+ self._files_preview_output.clear_output(wait=True)
+ return
+
+ old_root_value = str(self._files_root_dd.value or "")
+ root_options = [
+ (self._format_files_root_label(root), str(root)) for root in roots
+ ]
+ valid_root_values = {value for _, value in root_options}
+ selected_root = old_root_value if old_root_value in valid_root_values else ""
+ if not selected_root:
+ selected_root = root_options[0][1]
+
+ self._files_updating = True
+ try:
+ self._files_root_dd.options = root_options
+ self._files_root_dd.value = selected_root
+ finally:
+ self._files_updating = False
+
+ selected_root_path = Path(selected_root)
+ if (
+ self._files_current_dir is None
+ or not self._is_path_in_allowed_roots(self._files_current_dir, roots)
+ or not self._files_current_dir.exists()
+ or not self._files_current_dir.is_dir()
+ ):
+ self._files_current_dir = selected_root_path
+
+ self._update_files_entries()
+ self._set_files_status("File list refreshed.")
+
+ def _update_files_entries(self) -> None:
+ """Rebuild the directory listing for the current folder."""
+ roots = self._files_allowed_roots()
+ if not roots:
+ self._files_entries.options = [("(no files)", "")]
+ self._files_entries.value = ""
+ self._files_selected_path = None
+ self._files_open_btn.disabled = True
+ self._files_up_btn.disabled = True
+ self._files_preview_output.clear_output(wait=True)
+ return
+
+ current = self._files_current_dir or roots[0]
+ if not self._is_path_in_allowed_roots(current, roots):
+ current = Path(self._files_root_dd.value)
+ if not current.exists() or not current.is_dir():
+ current = Path(self._files_root_dd.value)
+
+ self._files_current_dir = current
+ self._files_path_html.value = (
+ 'Current folder: '
+ f"{_html.escape(str(current))} "
+ )
+
+ try:
+ children = list(current.iterdir())
+ except OSError as exc:
+ self._files_entries.options = [("(unreadable folder)", "")]
+ self._files_entries.value = ""
+ self._files_selected_path = None
+ self._files_open_btn.disabled = True
+ self._files_up_btn.disabled = True
+ self._files_preview_output.clear_output(wait=True)
+ self._set_files_status(f"Cannot list folder: {exc}", "#b91c1c")
+ return
+
+ children.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
+ options: list[tuple[str, str]] = []
+ for child in children:
+ if child.is_dir():
+ options.append((f"[DIR] {child.name}", str(child)))
+ continue
+ try:
+ size_label = self._format_file_size(child.stat().st_size)
+ except OSError:
+ size_label = "?"
+ options.append((f"{child.name} ({size_label})", str(child)))
+
+ if not options:
+ options = [("(empty directory)", "")]
+
+ old_selection = str(self._files_entries.value or "")
+ valid_values = {value for _, value in options if value}
+ new_selection = old_selection if old_selection in valid_values else ""
+ if not new_selection and valid_values:
+ new_selection = next(iter(valid_values))
+
+ self._files_updating = True
+ try:
+ self._files_entries.options = options
+ self._files_entries.value = new_selection
+ finally:
+ self._files_updating = False
+
+ self._files_selected_path = Path(new_selection) if new_selection else None
+ self._files_open_btn.disabled = self._files_selected_path is None
+
+ _parent = current.parent
+ self._files_up_btn.disabled = (
+ _parent == current or not self._is_path_in_allowed_roots(_parent, roots)
+ )
+
+ self._files_preview_output.clear_output(wait=True)
+
+ def _preview_file_path(self, path: Path) -> None:
+ """Render a safe preview for a selected file path."""
+ roots = self._files_allowed_roots()
+ if not self._is_path_in_allowed_roots(path, roots):
+ self._set_files_status("Selected path is outside allowed roots.", "#b91c1c")
+ return
+ if not path.exists() or not path.is_file():
+ self._set_files_status("Selected file no longer exists.", "#b91c1c")
+ return
+
+ self._files_preview_output.clear_output(wait=True)
+ suffix = path.suffix.lower()
+
+ image_ext = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
+ text_ext = {
+ ".txt",
+ ".log",
+ ".json",
+ ".md",
+ ".py",
+ ".csv",
+ ".yaml",
+ ".yml",
+ ".xyz",
+ ".cube",
+ }
+
+ if suffix in image_ext:
+ from IPython.display import Image as _Image
+
+ with self._files_preview_output:
+ display(_Image(filename=str(path)))
+ self._set_files_status(f"Previewing image: {path.name}")
+ return
+
+ is_text = suffix in text_ext
+ if not is_text:
+ try:
+ sample = path.read_bytes()[:512]
+ except OSError as exc:
+ self._set_files_status(f"Cannot read file: {exc}", "#b91c1c")
+ return
+ is_text = b"\x00" not in sample
+
+ if not is_text:
+ with self._files_preview_output:
+ display(
+ HTML(
+ ""
+ "Binary preview is not available for this file type."
+ "
"
+ )
+ )
+ self._set_files_status(f"Binary file selected: {path.name}")
+ return
+
+ max_bytes = 200_000
+ try:
+ raw = path.read_bytes()
+ except OSError as exc:
+ self._set_files_status(f"Cannot read file: {exc}", "#b91c1c")
+ return
+
+ truncated = len(raw) > max_bytes
+ raw = raw[:max_bytes]
+ text = raw.decode("utf-8", errors="replace")
+ if truncated:
+ text += "\n\n[Preview truncated to 200 KB]"
+
+ with self._files_preview_output:
+ display(
+ HTML(
+ ""
+ f"{_html.escape(text)}"
+ " "
+ )
+ )
+ self._set_files_status(f"Previewing text file: {path.name}")
+
+ def _on_files_root_changed(self, change) -> None:
+ if self._files_updating:
+ return
+ new_value = str(change.get("new") or "")
+ if not new_value:
+ return
+
+ new_root = Path(new_value)
+ roots = self._files_allowed_roots()
+ if not self._is_path_in_allowed_roots(new_root, roots):
+ self._set_files_status("Selected root is not allowed.", "#b91c1c")
+ return
+
+ self._files_current_dir = new_root
+ self._update_files_entries()
+ self._set_files_status(f"Root changed to: {new_root}")
+
+ def _on_files_entry_changed(self, change) -> None:
+ if self._files_updating:
+ return
+ new_value = str(change.get("new") or "")
+ self._files_selected_path = Path(new_value) if new_value else None
+ self._files_open_btn.disabled = self._files_selected_path is None
+ if self._files_selected_path is None:
+ self._set_files_status("Select a folder or file.")
+ return
+ if self._files_selected_path.is_dir():
+ self._set_files_status(f"Folder selected: {self._files_selected_path.name}")
+ else:
+ self._set_files_status(f"File selected: {self._files_selected_path.name}")
+
+ def _on_files_open(self, _btn) -> None:
+ self._activity_begin("Opening selected path...")
+ try:
+ selected = self._files_selected_path
+ if selected is None:
+ self._set_files_status("Select a folder or file first.")
+ return
+ if selected.is_dir():
+ self._files_current_dir = selected
+ self._update_files_entries()
+ self._set_files_status(f"Opened folder: {selected}")
+ return
+ self._preview_file_path(selected)
+ finally:
+ self._activity_end()
+
+ def _on_files_up(self, _btn) -> None:
+ self._activity_begin("Moving to parent folder...")
+ try:
+ if self._files_current_dir is None:
+ self._set_files_status("No current folder selected.", "#b91c1c")
+ return
+
+ parent = self._files_current_dir.parent
+ roots = self._files_allowed_roots()
+ if parent == self._files_current_dir or not self._is_path_in_allowed_roots(
+ parent, roots
+ ):
+ self._set_files_status("Already at the top of the selected root.")
+ return
+
+ self._files_current_dir = parent
+ self._update_files_entries()
+ self._set_files_status(f"Moved to parent folder: {parent}")
+ finally:
+ self._activity_end()
+
+ def _on_files_refresh(self, _btn) -> None:
+ self._activity_begin("Refreshing files browser...")
+ try:
+ self._refresh_file_browser()
+ finally:
+ self._activity_end()
+
# ══ CALLBACK METHODS ═════════════════════════════════════════════════════
# ── Theme ─────────────────────────────────────────────────────────────
@@ -1621,6 +2099,11 @@ def _on_basis_help(self, btn) -> None:
# ── Run ───────────────────────────────────────────────────────────────
def _on_run_clicked(self, btn) -> None:
+ self._activity_pulse(
+ "Queueing calculation...",
+ hold_s=0.18,
+ kind="compute",
+ )
_run_on_run_clicked(self, btn)
def _on_solvent_cb_changed(self, change) -> None:
@@ -1751,13 +2234,25 @@ def _molecule_to_rdkit(mol):
# ── Compare ───────────────────────────────────────────────────────────
def _on_compare_refresh(self, btn) -> None:
- _run_on_compare_refresh(self, btn)
+ self._activity_begin("Refreshing comparison choices...")
+ try:
+ _run_on_compare_refresh(self, btn)
+ finally:
+ self._activity_end()
def _on_compare(self, btn) -> None:
- _run_on_compare(self, btn, layout_fn=_layout)
+ self._activity_begin("Building comparison view...")
+ try:
+ _run_on_compare(self, btn, layout_fn=_layout)
+ finally:
+ self._activity_end()
def _on_compare_clear(self, btn) -> None:
- _run_on_compare_clear(self, btn)
+ self._activity_begin("Clearing comparison output...")
+ try:
+ _run_on_compare_clear(self, btn)
+ finally:
+ self._activity_end()
# ── History ───────────────────────────────────────────────────────────
@@ -1765,22 +2260,45 @@ def _on_past_dd_changed(self, change) -> None:
_hist_on_past_dd_changed(self, change, layout_fn=_layout)
def _on_past_refresh(self, btn) -> None:
- _run_on_past_refresh(self, btn)
+ self._activity_begin("Refreshing history list...")
+ try:
+ _run_on_past_refresh(self, btn)
+ finally:
+ self._activity_end()
def _on_copy_results_path(self, btn) -> None:
- _run_on_copy_results_path(self, btn)
+ self._activity_begin("Copying results path...")
+ try:
+ _run_on_copy_results_path(self, btn)
+ finally:
+ self._activity_end()
def _on_view_log(self, btn) -> None:
- _hist_on_view_log(self, btn)
+ self._activity_begin("Loading history log...")
+ try:
+ _hist_on_view_log(self, btn)
+ self._refresh_file_browser()
+ finally:
+ self._activity_end()
def _mol_from_result_dir(self, result_dir: Path, data: dict):
return _hist_mol_from_result_dir(result_dir, data)
def _history_load_results(self, data: dict, result_dir: Path) -> None:
- _hist_history_load_results(self, data, result_dir)
+ self._activity_begin("Loading history result...")
+ try:
+ _hist_history_load_results(self, data, result_dir)
+ self._refresh_file_browser()
+ finally:
+ self._activity_end()
def _history_load_analysis(self, result_dir: Path) -> None:
- _hist_history_load_analysis(self, result_dir)
+ self._activity_begin("Loading history analysis...")
+ try:
+ _hist_history_load_analysis(self, result_dir)
+ self._refresh_file_browser()
+ finally:
+ self._activity_end()
def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]:
return _hist_build_history_context(result_dir, context_cls=_AnalysisContext)
@@ -2247,6 +2765,10 @@ def _do_run(self) -> None:
if mol is None:
self.run_status.value = "Load a molecule first."
return
+ self._activity_begin(
+ "Running compute operations...",
+ kind="compute",
+ )
self.run_btn.disabled = True
self.run_status.value = "Starting..."
@@ -2276,6 +2798,32 @@ def _on_scf_converged() -> None:
if _scf_converged_t is None:
_scf_converged_t = time.perf_counter()
+ def _run_required_final_single_point(target_mol, reason: str):
+ """Run a required post-optimization single point on target geometry."""
+ from quantui import run_in_session
+
+ _solvent = self.solvent_dd.value if self.solvent_cb.value else None
+ self.run_status.value = (
+ "Running required single-point on optimized geometry..."
+ )
+ log.write(
+ f"\n-- Required single-point ({reason}) "
+ "on optimized geometry --------------------------------\n"
+ )
+ sp_result = run_in_session(
+ molecule=target_mol,
+ method=self.method_dd.value,
+ basis=self.basis_dd.value,
+ progress_stream=log, # type: ignore[arg-type]
+ solvent=_solvent,
+ )
+ if not bool(getattr(sp_result, "converged", False)):
+ raise RuntimeError(
+ "Required post-optimization single-point did not converge."
+ )
+ log.write("Required single-point converged on optimized geometry.\n")
+ return sp_result
+
log = _LogCapture(
self.run_output,
self.run_status,
@@ -2350,6 +2898,11 @@ def _on_scf_converged() -> None:
"⚠ Pre-optimisation did not fully converge — "
"proceeding with best available geometry.\n\n"
)
+ if ct != "Single Point":
+ _run_required_final_single_point(
+ calc_mol,
+ f"after pre-optimisation before {ct}",
+ )
if ct == "Geometry Opt":
self.run_status.value = "Optimizing geometry..."
@@ -2363,6 +2916,37 @@ def _on_scf_converged() -> None:
steps=self.max_steps_si.value,
progress_stream=log, # type: ignore[arg-type]
)
+ _sp_result = _run_required_final_single_point(
+ result.molecule,
+ "after geometry optimisation",
+ )
+ _sp_energy = getattr(_sp_result, "energy_hartree", None)
+ if (
+ isinstance(getattr(result, "energies_hartree", None), list)
+ and result.energies_hartree
+ and isinstance(_sp_energy, (int, float))
+ ):
+ result.energies_hartree[-1] = float(_sp_energy)
+ result.converged = bool(result.converged) and bool(
+ getattr(_sp_result, "converged", False)
+ )
+ result.mo_energy_hartree = getattr(
+ _sp_result,
+ "mo_energy_hartree",
+ result.mo_energy_hartree,
+ )
+ result.mo_occ = getattr(_sp_result, "mo_occ", result.mo_occ)
+ result.mo_coeff = getattr(_sp_result, "mo_coeff", result.mo_coeff)
+ result.pyscf_mol_atom = getattr(
+ _sp_result,
+ "pyscf_mol_atom",
+ result.pyscf_mol_atom,
+ )
+ result.pyscf_mol_basis = getattr(
+ _sp_result,
+ "pyscf_mol_basis",
+ result.pyscf_mol_basis,
+ )
result_html = self._format_opt_result(result)
save_spectra, save_type = {}, "geometry_opt"
elif ct == "Frequency":
@@ -2409,6 +2993,10 @@ def _on_scf_converged() -> None:
"⚠ Pre-optimisation did not fully converge — "
"proceeding with best available geometry.\n\n"
)
+ _run_required_final_single_point(
+ calc_mol,
+ "after frequency pre-optimisation",
+ )
# ── Step 3: frequency analysis ────────────────────────────────
self.run_status.value = "Computing frequencies (SCF + Hessian)…"
@@ -2630,7 +3218,10 @@ def _on_scf_converged() -> None:
spectra=save_spectra,
)
self._last_result_dir = _saved_dir
- save_thumbnail(_saved_dir, load_result(_saved_dir))
+ _saved_data = load_result(_saved_dir)
+ save_thumbnail(_saved_dir, _saved_data)
+ _ana_ctx.result_dir = _saved_dir
+ _ana_ctx.timestamp = str(_saved_data.get("timestamp", ""))
# Persist trajectory so history viewer can replay it.
if ct in ("Geometry Opt", "PES Scan"):
_traj = getattr(
@@ -2836,6 +3427,7 @@ def _on_scf_converged() -> None:
finally:
self.run_btn.disabled = False
+ self._activity_end(kind="compute")
def _update_notes(self, change=None) -> None:
_run_update_notes(self, change)
@@ -2864,6 +3456,10 @@ def _safe_cb(self, fn):
"""Wrap an .observe() handler so exceptions are logged instead of silently dropped."""
def _wrapper(change):
+ self._activity_begin(
+ f"Running {getattr(fn, '__name__', 'callback')}...",
+ kind="ui",
+ )
try:
fn(change)
except Exception as _e:
@@ -2879,6 +3475,8 @@ def _wrapper(change):
)
except Exception:
pass
+ finally:
+ self._activity_end(kind="ui")
return _wrapper
diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py
index c7788e7..363ba7a 100644
--- a/quantui/app_analysis.py
+++ b/quantui/app_analysis.py
@@ -21,6 +21,15 @@
"pes_scan": "PES Scan",
}
+_CALC_TYPE_BADGES = {
+ "single_point": "Single Point",
+ "geometry_opt": "Geometry Optimization",
+ "frequency": "Frequency Analysis",
+ "tddft": "UV-Vis (TD-DFT)",
+ "nmr": "NMR Shielding",
+ "pes_scan": "PES Scan",
+}
+
def _panel_unavailable_html(message: str) -> str:
return f'{_html_mod.escape(message)}
'
@@ -62,6 +71,14 @@ def _reset_unavailable_messages_for_context(app: Any, ctx: Any) -> None:
)
+def _analysis_heading_label(ctx: Any) -> str:
+ """Return the analysis heading text aligned with history dropdown labels."""
+ badge = _CALC_TYPE_BADGES.get(ctx.calc_type, str(ctx.calc_type or "Unknown"))
+ core = f"[{badge}] {ctx.label}"
+ ts = str(getattr(ctx, "timestamp", "") or "").strip()
+ return f"{ts} · {core}" if ts else core
+
+
def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
"""Initialise analysis panel state and wire accordion re-render observers."""
panel_meta = [
@@ -193,9 +210,10 @@ def apply_analysis_context(app: Any, ctx: Any) -> None:
pass
source_suffix = " (from History)" if ctx.source == "history" else ""
+ heading = _analysis_heading_label(ctx)
app._analysis_context_lbl.value = (
f''
- f"Analysing: {ctx.label}{source_suffix}
"
+ f"Analysing: {_html_mod.escape(heading)}{source_suffix}"
)
has_any = bool(app._ana_available)
app._to_analysis_btn.layout.display = "" if has_any else "none"
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 7b92e6a..f38dcec 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -678,6 +678,13 @@ def build_theme_selector(app: Any, *, layout_fn: Any) -> None:
app._theme_style = widgets.Output(
layout=layout_fn(height="0px", overflow="hidden", margin="0", padding="0")
)
+ app._activity_btn = widgets.Button(
+ description="Idle",
+ icon="circle-o",
+ tooltip="No active operations.",
+ button_style="success",
+ layout=layout_fn(width="118px", margin="0 8px 0 0"),
+ )
app.theme_btn = widgets.ToggleButtons(
options=["Light", "Dark"],
value="Dark",
@@ -1451,6 +1458,83 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None:
)
+def build_files_tab(app: Any, *, layout_fn: Any) -> None:
+ """Build the read-only Files tab widgets."""
+ app._files_root_dd = widgets.Dropdown(
+ options=[("(loading)", "")],
+ value="",
+ description="Root:",
+ style={"description_width": "40px"},
+ layout=layout_fn(width="520px"),
+ )
+ app._files_path_html = widgets.HTML(
+ value=(
+ ''
+ "Current folder: (not set) "
+ )
+ )
+ app._files_entries = widgets.Select(
+ options=[("(no files)", "")],
+ rows=12,
+ description="",
+ layout=layout_fn(width="100%"),
+ )
+ app._files_up_btn = widgets.Button(
+ description="Up",
+ icon="arrow-up",
+ layout=layout_fn(width="80px"),
+ tooltip="Go to parent folder",
+ )
+ app._files_open_btn = widgets.Button(
+ description="Open",
+ button_style="primary",
+ icon="folder-open",
+ layout=layout_fn(width="100px"),
+ tooltip="Open selected folder or preview selected file",
+ )
+ app._files_refresh_btn = widgets.Button(
+ description="Refresh",
+ icon="refresh",
+ layout=layout_fn(width="100px"),
+ tooltip="Refresh roots, folder contents, and preview",
+ )
+ app._files_status_html = widgets.HTML(
+ value=(
+ ''
+ "Select a file and click Open to preview. "
+ )
+ )
+ app._files_preview_output = widgets.Output(
+ layout=layout_fn(
+ border="1px solid #cbd5e1",
+ min_height="220px",
+ max_height="420px",
+ overflow="auto",
+ padding="6px",
+ )
+ )
+
+ app.files_tab_panel = widgets.VBox(
+ [
+ widgets.HTML(
+ ''
+ "Read-only file browser for result artifacts and logs. "
+ "Browsing is limited to approved roots.
"
+ ),
+ app._files_root_dd,
+ app._files_path_html,
+ widgets.HBox(
+ [app._files_up_btn, app._files_open_btn, app._files_refresh_btn],
+ layout=layout_fn(gap="8px", margin="6px 0"),
+ ),
+ app._files_entries,
+ app._files_status_html,
+ app._files_preview_output,
+ ],
+ layout=layout_fn(padding="8px 0"),
+ )
+
+
def build_help_section(app: Any, *, layout_fn: Any) -> None:
"""Build the floating help panel and top-bar help/exit buttons."""
help_keys = list(HELP_TOPICS.keys())
diff --git a/quantui/app_history.py b/quantui/app_history.py
index d000175..f590c23 100644
--- a/quantui/app_history.py
+++ b/quantui/app_history.py
@@ -220,5 +220,6 @@ def build_history_context(result_dir: Path, *, context_cls: Any) -> Optional[Any
basis=data.get("basis", ""),
result_dir=result_dir,
spectra_data=data.get("spectra", {}),
+ timestamp=data.get("timestamp", ""),
source="history",
)
diff --git a/tests/test_app.py b/tests/test_app.py
index 1b18f87..d57187e 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -13,7 +13,7 @@
import ipywidgets as widgets
import pytest
-from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _LogCapture
+from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _AnalysisContext, _LogCapture
from quantui.molecule import Molecule
# ---------------------------------------------------------------------------
@@ -59,6 +59,25 @@ def test_initial_molecule_is_none(self):
app = QuantUIApp()
assert app._molecule is None
+ def test_activity_indicator_defaults_idle(self):
+ app = QuantUIApp()
+ assert hasattr(app, "_activity_btn")
+ assert app._activity_btn.description == "Idle"
+
+ def test_activity_indicator_compute_state(self):
+ app = QuantUIApp()
+ app._activity_begin("Running compute operations...", kind="compute")
+ assert app._activity_btn.description == "Computing"
+ app._activity_end(kind="compute")
+ assert app._activity_btn.description == "Idle"
+
+ def test_activity_indicator_ui_state(self):
+ app = QuantUIApp()
+ app._activity_begin("Switching tabs...", kind="ui")
+ assert app._activity_btn.description == "UI Active"
+ app._activity_end(kind="ui")
+ assert app._activity_btn.description == "Idle"
+
def test_run_btn_initially_disabled(self):
app = QuantUIApp()
assert app.run_btn.disabled is True
@@ -126,9 +145,9 @@ def test_multiplicity_default(self):
class TestTabStructure:
"""root_tab has the correct number and titles of tabs."""
- def test_seven_tabs(self):
+ def test_eight_tabs(self):
app = QuantUIApp()
- assert len(app.root_tab.children) == 7
+ assert len(app.root_tab.children) == 8
def test_tab_titles(self):
app = QuantUIApp()
@@ -139,12 +158,28 @@ def test_tab_titles(self):
"History",
"Compare",
"Log",
+ "Files",
"Status",
]
for i, title in enumerate(expected):
assert app.root_tab.get_title(i) == title
+class TestFilesTab:
+ """Files tab widgets are available and initialized."""
+
+ def test_files_tab_widgets_exist(self):
+ app = QuantUIApp()
+ assert hasattr(app, "files_tab_panel")
+ assert hasattr(app, "_files_root_dd")
+ assert hasattr(app, "_files_entries")
+ assert hasattr(app, "_files_preview_output")
+
+ def test_files_root_dropdown_has_options(self):
+ app = QuantUIApp()
+ assert len(app._files_root_dd.options) >= 1
+
+
# ---------------------------------------------------------------------------
# Molecule input — collapse / expand pattern
# ---------------------------------------------------------------------------
@@ -342,17 +377,35 @@ def test_geo_opt_dispatch(self, app_with_molecule):
mock_result.energy_hartree = -75.0
mock_result.converged = True
mock_result.n_iterations = 5
+ mock_result.energies_hartree = [-75.0]
mock_result.trajectory = []
mock_result.formula = "H2O"
mock_result.method = "RHF"
mock_result.basis = "STO-3G"
- mock_result.final_molecule = _water()
+ mock_result.molecule = _water()
+ mock_result.mo_energy_hartree = None
+ mock_result.mo_occ = None
+ mock_result.mo_coeff = None
+ mock_result.pyscf_mol_atom = None
+ mock_result.pyscf_mol_basis = None
+ mock_sp = MagicMock()
+ mock_sp.converged = True
+ mock_sp.energy_hartree = -75.1
+ mock_sp.mo_energy_hartree = [0.0]
+ mock_sp.mo_occ = [2.0]
+ mock_sp.mo_coeff = [[0.0]]
+ mock_sp.pyscf_mol_atom = [("O", [0.0, 0.0, 0.0])]
+ mock_sp.pyscf_mol_basis = "STO-3G"
with patch(
"quantui.optimize_geometry", return_value=mock_result, create=True
) as mock_opt:
- with patch("quantui.save_result"):
- app._do_run()
+ with patch(
+ "quantui.run_in_session", return_value=mock_sp, create=True
+ ) as mock_sp_run:
+ with patch("quantui.save_result"):
+ app._do_run()
mock_opt.assert_called_once()
+ mock_sp_run.assert_called_once()
def test_pyscf_unavailable_shows_error(self, app_with_molecule):
app = app_with_molecule
@@ -1313,6 +1366,26 @@ def test_ir_accordion_in_analysis_tab(self):
app = QuantUIApp()
assert app._ir_accordion in app.analysis_tab_panel.children
+ def test_analysis_heading_matches_history_label_shape(self):
+ app = QuantUIApp()
+ ctx = _AnalysisContext(
+ calc_type="frequency",
+ formula="H2O",
+ method="B3LYP",
+ basis="6-31G",
+ timestamp="2026-05-14_10-11-12-123456",
+ source="history",
+ )
+
+ app._apply_analysis_context(ctx)
+
+ heading = app._analysis_context_lbl.value
+ assert "Analysing:" in heading
+ assert "2026-05-14_10-11-12-123456" in heading
+ assert "[Frequency Analysis]" in heading
+ assert "H2O B3LYP/6-31G" in heading
+ assert "(from History)" in heading
+
# ---------------------------------------------------------------------------
# M-ANA — Panel switcher (M-ANA)
From 23e02cb2528491635d3f424e6ded3ed0f0031bcc Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 14 May 2026 12:47:04 -0400
Subject: [PATCH 19/22] Adjust isosurface plot styling and defaults
Increase default isosurface figure size and tweak visual defaults for better integration with app themes: width 760, height 620, transparent paper background, and tighter top margin. Add an optional title_color parameter (used by app_visualization to pass theme font color) and apply it to the title font. Update tests to assert the new defaults and title_color override. These changes improve appearance and theming consistency for orbital isosurface plots.
---
quantui/app_visualization.py | 2 ++
quantui/orbital_visualization.py | 11 +++++++----
tests/test_orbital_visualization.py | 17 +++++++++++++++++
3 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 0d62339..b159e06 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -1166,6 +1166,7 @@ def _is_stale() -> bool:
is_dark = app.theme_btn.value == "Dark"
axis_color = "#dbeafe" if is_dark else "#1f2937"
bond_color = "#cbd5e1" if is_dark else "#4b5563"
+ title_color = app._plotly_theme_colors()["font_color"]
fig = plot_cube_isosurface(
cube_path,
title=f"{orbital_label} Isosurface",
@@ -1173,6 +1174,7 @@ def _is_stale() -> bool:
show_grid=False,
scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
axis_color=axis_color,
+ title_color=title_color,
bond_color=bond_color,
)
html_str = _pio.to_html(
diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py
index 6fe2fd8..7ef9701 100644
--- a/quantui/orbital_visualization.py
+++ b/quantui/orbital_visualization.py
@@ -804,13 +804,14 @@ def plot_cube_isosurface(
*,
isovalue: float = 0.02,
opacity: float = 0.4,
- width: int = 650,
- height: int = 550,
+ width: int = 760,
+ height: int = 620,
title: Optional[str] = None,
show_molecule: bool = False,
show_grid: bool = True,
scene_bgcolor: str = "white",
axis_color: str = "#111827",
+ title_color: Optional[str] = None,
bond_color: str = "#6b7280",
):
"""
@@ -926,9 +927,11 @@ def plot_cube_isosurface(
width=width,
height=height,
title=dict(
- text=title or "Molecular Orbital Isosurface", font=dict(color=axis_color)
+ text=title or "Molecular Orbital Isosurface",
+ font=dict(color=title_color or axis_color),
),
- paper_bgcolor=scene_bgcolor,
+ paper_bgcolor="rgba(0,0,0,0)",
+ margin=dict(l=0, r=0, t=48, b=0),
font=dict(color=axis_color),
scene=dict(
xaxis=dict(
diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py
index 12294de..bad6d3a 100644
--- a/tests/test_orbital_visualization.py
+++ b/tests/test_orbital_visualization.py
@@ -313,6 +313,23 @@ def test_show_grid_false_hides_scene_grid(self, minimal_cube_file):
assert fig.layout.scene.yaxis.showgrid is False
assert fig.layout.scene.zaxis.showgrid is False
+ def test_default_view_window_is_larger(self, minimal_cube_file):
+ fig = plot_cube_isosurface(minimal_cube_file)
+ assert fig.layout.width == 760
+ assert fig.layout.height == 620
+
+ def test_paper_background_transparent(self, minimal_cube_file):
+ fig = plot_cube_isosurface(minimal_cube_file)
+ assert fig.layout.paper_bgcolor == "rgba(0,0,0,0)"
+
+ def test_title_color_override(self, minimal_cube_file):
+ fig = plot_cube_isosurface(
+ minimal_cube_file,
+ title="LUMO Isosurface",
+ title_color="#123456",
+ )
+ assert fig.layout.title.font.color == "#123456"
+
# ---------------------------------------------------------------------------
# generate_cube_from_arrays — M6.2 acceptance criteria
From c948d441eed10facaf185b1eb6942207dd7de504 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 20 May 2026 18:42:01 -0400
Subject: [PATCH 20/22] Cache kernel io_loop and queue main-thread callbacks
Cache the Jupyter kernel io_loop on QuantUIApp (self._kernel_io_loop) and add _get_kernel_io_loop to resolve it lazily. Replace direct UI calls from worker threads with queued main-thread callbacks that use the cached io_loop (or fall back to direct calls when unavailable) to ensure reliable scheduling. Update visualization frame rendering to handle error and Plotly frames more robustly. Add tests covering worker-thread callback scheduling and import threading where needed.
---
quantui/app.py | 52 +++++++++++++++++++++++++-----------
quantui/app_visualization.py | 15 +++++++----
tests/test_app.py | 47 ++++++++++++++++++++++++++++++++
3 files changed, 94 insertions(+), 20 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 4825ec4..6d176cd 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -885,6 +885,11 @@ def __init__(self) -> None:
self._activity_count: int = 0
self._activity_compute_count: int = 0
self._activity_lock = threading.Lock()
+ # Cache kernel io_loop once on the main thread so worker threads can
+ # reliably schedule UI callbacks even when get_ipython() is thread-local.
+ self._kernel_io_loop: Any = getattr(
+ getattr(get_ipython(), "kernel", None), "io_loop", None
+ )
self.root_tab: widgets.Tab
self._session_id: str = _uuid.uuid4().hex[:12]
@@ -1875,14 +1880,24 @@ def _set_html_output(self, out: widgets.Output, html: str) -> None:
figure panels. Rendering through Output display_data executes the JS.
"""
if threading.current_thread() is not threading.main_thread():
- ip = get_ipython()
- io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None)
+ io_loop = self._get_kernel_io_loop()
if io_loop is not None:
io_loop.add_callback(self._set_html_output, out, html)
return
self._clear_output_widget(out)
out.append_display_data(HTML(html))
+ def _get_kernel_io_loop(self) -> Any:
+ """Return a cached kernel io_loop, resolving it lazily when needed."""
+ io_loop = getattr(self, "_kernel_io_loop", None)
+ if io_loop is not None:
+ return io_loop
+ ip = get_ipython()
+ io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None)
+ if io_loop is not None:
+ self._kernel_io_loop = io_loop
+ return io_loop
+
def _render_plotly_figure(self, out: widgets.Output, fig) -> None:
"""Render a Plotly figure through display() in an Output widget."""
self._clear_output_widget(out)
@@ -2505,8 +2520,7 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None:
callback(*args, **kwargs)
return
- ip = get_ipython()
- io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None)
+ io_loop = self._get_kernel_io_loop()
if io_loop is not None:
io_loop.add_callback(callback, *args, **kwargs)
return
@@ -3157,7 +3171,11 @@ def _run_required_final_single_point(target_mol, reason: str):
'margin:6px 0 2px">Optimized geometry'
)
self._viz_label.layout.display = ""
- self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output)
+ self._queue_main_thread_callback(
+ self._show_result_3d,
+ _viz_mol,
+ self._analysis_mol_output,
+ )
_mark("viz_done")
# Populate Analysis panels via the unified registry
@@ -3246,13 +3264,18 @@ def _run_required_final_single_point(target_mol, reason: str):
# Persist MO data for orbital diagram + isosurface replay.
if ct in ("Single Point", "Geometry Opt", "Frequency"):
save_orbitals(_saved_dir, result)
- self._refresh_results_browser()
- self._populate_compare_list()
- self._update_log_panel(
+ self._queue_main_thread_callback(self._refresh_results_browser)
+ self._queue_main_thread_callback(self._populate_compare_list)
+ self._queue_main_thread_callback(
+ self._update_log_panel,
log.getvalue(),
f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}",
)
- self._show_result_log(_saved_dir, log.getvalue())
+ self._queue_main_thread_callback(
+ self._show_result_log,
+ _saved_dir,
+ log.getvalue(),
+ )
except Exception as _save_exc:
try:
from quantui import calc_log as _clog
@@ -3265,13 +3288,12 @@ def _run_required_final_single_point(target_mol, reason: str):
pass
_mark("persist_done")
- # Activate analysis panels AFTER saving/refreshing the results browser.
- # _refresh_results_browser (above) sets past_dd.options, which fires its
- # observer and calls _deactivate_all_ana_panels. Placing this call here
- # means that observer has already run (harmlessly, panels not yet active)
- # by the time we activate them.
+ # Activate analysis panels after scheduling refresh/update callbacks.
+ # Refreshing the history browser may fire past_dd observers that clear
+ # analysis state; queueing this callback after refresh keeps ordering
+ # deterministic on the kernel UI loop.
_mark("analysis_begin")
- self._apply_analysis_context(_ana_ctx)
+ self._queue_main_thread_callback(self._apply_analysis_context, _ana_ctx)
_mark("analysis_done")
# Log performance
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index b159e06..98c4823 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -275,16 +275,21 @@ def _display_frame(idx: int) -> None:
if _is_stale():
return
kind, obj = frame_cache[idx]
- frame_out.clear_output()
- with frame_out:
- if kind == "error":
+ if kind == "error":
+ frame_out.clear_output()
+ with frame_out:
_ipy_display(
HTML(
f'Frame render failed: {obj}
'
)
)
- else:
- _ipy_display(obj)
+ return
+ if kind == "plotly":
+ app._set_plotly_figure_output(frame_out, obj)
+ return
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(obj)
def _update_frame(change: dict[str, Any]) -> None:
if _is_stale():
diff --git a/tests/test_app.py b/tests/test_app.py
index d57187e..5cdd63c 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -8,6 +8,7 @@
from __future__ import annotations
+import threading
from unittest.mock import MagicMock, patch
import ipywidgets as widgets
@@ -137,6 +138,52 @@ def test_multiplicity_default(self):
assert app.mult_si.value == DEFAULT_MULTIPLICITY
+# ---------------------------------------------------------------------------
+# Worker-thread callback scheduling
+# ---------------------------------------------------------------------------
+
+
+class TestMainThreadCallbackQueue:
+ """_queue_main_thread_callback uses cached kernel io_loop from workers."""
+
+ def test_uses_cached_io_loop_from_worker_thread(self):
+ app = QuantUIApp()
+ cb = MagicMock()
+ io_loop = MagicMock()
+ app._kernel_io_loop = io_loop
+
+ t = threading.Thread(
+ target=lambda: app._queue_main_thread_callback(cb, "ok"),
+ daemon=True,
+ )
+ t.start()
+ t.join(timeout=2)
+
+ io_loop.add_callback.assert_called_once()
+ called_cb, called_arg = io_loop.add_callback.call_args[0][:2]
+ assert called_cb is cb
+ assert called_arg == "ok"
+ cb.assert_not_called()
+
+ def test_falls_back_to_direct_call_without_io_loop(self):
+ app = QuantUIApp()
+ app._kernel_io_loop = None
+ called = []
+
+ def _cb() -> None:
+ called.append(True)
+
+ with patch("quantui.app.get_ipython", return_value=None):
+ t = threading.Thread(
+ target=lambda: app._queue_main_thread_callback(_cb),
+ daemon=True,
+ )
+ t.start()
+ t.join(timeout=2)
+
+ assert called == [True]
+
+
# ---------------------------------------------------------------------------
# Tab structure
# ---------------------------------------------------------------------------
From 8ef44871518635cef83d2b15380f624600b9bb93 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 21 May 2026 10:30:58 -0400
Subject: [PATCH 21/22] Create wsl_pyc_audit.sh
---
scripts/wsl_pyc_audit.sh | 121 +++++++++++++++++++++++++++++++++++++++
1 file changed, 121 insertions(+)
create mode 100644 scripts/wsl_pyc_audit.sh
diff --git a/scripts/wsl_pyc_audit.sh b/scripts/wsl_pyc_audit.sh
new file mode 100644
index 0000000..24c4dd3
--- /dev/null
+++ b/scripts/wsl_pyc_audit.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# WSL pyc co_filename audit
+#
+# Runs the full pytest suite under WSL (conda env: quantui), then marshal-scans
+# tests/**/*.pyc to count Windows-style vs WSL-style `co_filename` strings and
+# detect any stale `repos-DEVS/QuantUI-local` references.
+#
+# Purpose / context: see GOTCHAS.md → "Windows + WSL / pytest co_filename path style".
+# Recurring use case: after switching between Windows-host and WSL-host pytest runs,
+# confirm the regenerated pyc cache reflects the current execution environment.
+#
+# Usage:
+# bash scripts/wsl_pyc_audit.sh # audit existing pyc cache
+# bash scripts/wsl_pyc_audit.sh --wipe # wipe tests/**/__pycache__ first, then audit
+
+WIPE=0
+for arg in "$@"; do
+ case "$arg" in
+ --wipe|-w)
+ WIPE=1
+ ;;
+ -h|--help)
+ sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "ERROR: unknown argument: $arg" >&2
+ echo "Usage: $0 [--wipe]" >&2
+ exit 2
+ ;;
+ esac
+done
+
+set -u -o pipefail
+cd /mnt/c/Users/schul/Documents/local-code-dir/repos-PUBLIC/QuantUI
+
+if [ "$WIPE" = "1" ]; then
+ find tests -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null
+ find tests -type f -name '*.pyc' -delete 2>/dev/null
+ echo "--- tests pycache wiped ---"
+fi
+
+start_epoch=$(date +%s.%N)
+export START_EPOCH="$start_epoch"
+interp="python"
+if [ -f "$HOME/miniconda3/etc/profile.d/conda.sh" ]; then
+ . "$HOME/miniconda3/etc/profile.d/conda.sh"
+ if conda env list 2>/dev/null | awk 'NR>2 {gsub(/\*/,"",$1); if($1!="") print $1}' | grep -qx "quantui"; then
+ conda activate quantui >/dev/null 2>&1 || true
+ fi
+fi
+if ! command -v "$interp" >/dev/null 2>&1; then
+ interp="python3"
+fi
+pytest_out=$(mktemp)
+$interp -m pytest tests/ -q --no-cov -o addopts='' >"$pytest_out" 2>&1
+pytest_rc=$?
+pytest_summary=$(grep -E '([0-9]+ (passed|failed|skipped|xfailed|xpassed|error|errors))|=+ .* in [0-9.]+s' "$pytest_out" | tail -n 1)
+if [ -z "$pytest_summary" ]; then
+ pytest_summary=$(tail -n 1 "$pytest_out")
+fi
+printf "pytest_summary=%s\n" "$pytest_summary"
+printf "pytest_exit_code=%s\n" "$pytest_rc"
+rm -f "$pytest_out"
+$interp - <<'PY'
+from pathlib import Path
+import marshal
+import os
+
+root = Path('.').resolve()
+tests = root / 'tests'
+start_epoch = float(os.environ.get('START_EPOCH', '0'))
+stale_needles = ('repos-DEVS/QuantUI-local', 'repos-DEVS\\\\QuantUI-local')
+
+def read_co_filename(path: Path) -> str:
+ try:
+ with path.open('rb') as f:
+ f.read(16)
+ code_obj = marshal.load(f)
+ return getattr(code_obj, 'co_filename', '') or ''
+ except Exception:
+ return ''
+
+pycs = [p for p in tests.rglob('*.pyc') if p.parent.name == '__pycache__']
+stale = 0
+win_style = 0
+wsl_style = 0
+for p in pycs:
+ cof = read_co_filename(p)
+ if any(n in cof for n in stale_needles):
+ stale += 1
+ if cof.startswith('C:\\\\'):
+ win_style += 1
+ if cof.startswith('/mnt/c/'):
+ wsl_style += 1
+
+vis_pycs = [p for p in pycs if p.name.startswith('test_visualization_integration')]
+if vis_pycs:
+ latest = max(vis_pycs, key=lambda p: p.stat().st_mtime)
+ sample_cof = read_co_filename(latest)
+else:
+ sample_cof = ''
+
+outside_touched = []
+for d in root.rglob('__pycache__'):
+ rel = d.relative_to(root).as_posix()
+ if rel.startswith('tests/'):
+ continue
+ try:
+ if d.stat().st_mtime >= start_epoch:
+ outside_touched.append(rel)
+ except FileNotFoundError:
+ pass
+
+print(f'total_pyc_scanned={len(pycs)}')
+print(f'stale_count_old_clone={stale}')
+print(f'count_windows_style_paths={win_style}')
+print(f'count_wsl_paths={wsl_style}')
+print(f'sample_co_filename={sample_cof}')
+print(f'touched_pycache_outside_tests_count={len(set(outside_touched))}')
+PY
From 4d5646ca0d09db81a358bd1d601e1ac7764aa503 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 21 May 2026 11:10:49 -0400
Subject: [PATCH 22/22] Update app_visualization.py
---
quantui/app_visualization.py | 103 ++++++++++++++++++++++++++++++-----
1 file changed, 88 insertions(+), 15 deletions(-)
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 98c4823..c839abc 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -39,6 +39,12 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
if change["new"] != 0:
return
result = app._pending_traj_result
+ try:
+ from quantui import calc_log as _clog_te
+
+ _clog_te.log_event("traj_expand", f"pending={result is not None}")
+ except Exception:
+ pass
if result is None:
return
app._pending_traj_result = None
@@ -59,6 +65,15 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
try:
app._show_opt_trajectory(result, render_token=render_token)
except Exception as exc:
+ try:
+ from quantui import calc_log as _clog_te2
+
+ _clog_te2.log_event(
+ "traj_expand_error",
+ f"{type(exc).__name__}: {exc}"[:300],
+ )
+ except Exception:
+ pass
if render_token != int(getattr(app, "_traj_render_token", 0)):
return
from IPython.display import HTML as _H2
@@ -275,6 +290,12 @@ def _display_frame(idx: int) -> None:
if _is_stale():
return
kind, obj = frame_cache[idx]
+ try:
+ from quantui import calc_log as _clog_df
+
+ _clog_df.log_event("traj_frame_display", f"idx={idx} kind={kind}")
+ except Exception:
+ pass
if kind == "error":
frame_out.clear_output()
with frame_out:
@@ -285,7 +306,21 @@ def _display_frame(idx: int) -> None:
)
return
if kind == "plotly":
- app._set_plotly_figure_output(frame_out, obj)
+ # Render via to_html + append_display_data — same pattern proven to
+ # work in vib_output and other Plotly panels. The previous
+ # `with frame_out: display(fig)` path silently failed to update
+ # this *nested* Output widget after the parent VBox had already
+ # been displayed. See GOTCHAS: _render_vib_mode workaround.
+ import plotly.io as _pio
+
+ _html = _pio.to_html(
+ obj,
+ full_html=False,
+ include_plotlyjs="require",
+ config={"responsive": True},
+ )
+ frame_out.clear_output()
+ frame_out.append_display_data(HTML(_html))
return
frame_out.clear_output()
with frame_out:
@@ -385,7 +420,37 @@ def _do_export() -> None:
)
panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status])
- # Display panel immediately.
+ # Build and render frame 0 SYNCHRONOUSLY on the main thread before
+ # displaying the panel, so the Output widget arrives at the browser with
+ # frame 0 already in its outputs list. This avoids the io_loop-callback
+ # latency that left frame 0 invisible until the first slider click.
+ if _is_stale():
+ return
+ try:
+ frame_cache[0] = _build_fig(0)
+ _display_frame(0)
+ sync_frame0_ok = True
+ except Exception as _f0_exc:
+ sync_frame0_ok = False
+ try:
+ from quantui import calc_log as _clog_f0
+
+ _clog_f0.log_event(
+ "traj_frame0_sync_error",
+ f"{type(_f0_exc).__name__}: {_f0_exc}"[:300],
+ )
+ except Exception:
+ pass
+ frame_out.clear_output()
+ with frame_out:
+ _ipy_display(
+ HTML(
+ ''
+ "Rendering frame 0…
"
+ )
+ )
+
+ # Display panel.
if _is_stale():
return
app.traj_output.clear_output()
@@ -393,26 +458,26 @@ def _do_export() -> None:
if has_plotly and rel_e:
_ipy_display(energy_fig)
_ipy_display(panel)
+ try:
+ from quantui import calc_log as _clog_sp
- # Show placeholder while frame 0 renders in the background.
- if _is_stale():
- return
- frame_out.clear_output()
- with frame_out:
- _ipy_display(
- HTML(
- ''
- "Rendering frame 0…
"
- )
+ _clog_sp.log_event(
+ "traj_show_panel",
+ f"n={n} plotlymol_fast={plotlymol_fast} "
+ f"sync_frame0_ok={sync_frame0_ok}",
)
+ except Exception:
+ pass
def _prerender_all() -> None:
- """Render all frames (0 first, then 1+) in a background thread."""
+ """Render remaining frames in a background thread (frame 0 already
+ built+displayed synchronously above when sync_frame0_ok)."""
if _is_stale():
return
try:
- frame_cache[0] = _build_fig(0)
- app._queue_main_thread_callback(_display_frame, 0)
+ if 0 not in frame_cache:
+ frame_cache[0] = _build_fig(0)
+ app._queue_main_thread_callback(_display_frame, 0)
app._queue_main_thread_callback(
_set_cache_label,
f''
@@ -443,6 +508,14 @@ def _prerender_all() -> None:
f''
f"✓ All {n} frames ready ",
)
+ try:
+ from quantui import calc_log as _clog_pre
+
+ _clog_pre.log_event(
+ "traj_prerender_complete", f"n={n} cached={len(frame_cache)}"
+ )
+ except Exception:
+ pass
threading.Thread(target=_prerender_all, daemon=True).start()