diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index af6f544..ccebf3a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,15 +1,15 @@ -# QuantUI-local — AI Assistant Context +# QuantUI — AI Assistant Context > Stable project context for GitHub Copilot, Claude, and other AI coding assistants. > Describes what the project IS and how it is built — not where development currently -> stands (see `planning/TODO/STATUS.md` for that). Update this file when +> stands (see your project STATUS.md for current session state). Update this file when > architecture or conventions change, not every session. --- ## Overview -QuantUI-local is an interactive Jupyter/Voilà interface for running PySCF quantum +QuantUI is an interactive Jupyter/Voilà interface for running PySCF quantum chemistry calculations locally — no cluster account, no SLURM, no queueing. Students design molecules, launch RHF/UHF/DFT calculations in their own Python kernel, and visualize results in minutes. It is a downstream port of the cluster-focused @@ -23,7 +23,7 @@ University. The UI runs as a Voilà app — students never see code. ## Repository Structure ``` -QuantUI-local/ +QuantUI/ ├── quantui/ ← Main Python package (imports as `quantui`) │ ├── app.py ← QuantUIApp class — all widgets, callbacks, state │ ├── molecule.py ← Molecule dataclass + XYZ/SMILES parsing @@ -31,39 +31,36 @@ QuantUI-local/ │ ├── optimizer.py ← QM geometry optimization (ASE-BFGS + PySCF) │ ├── freq_calc.py ← Frequency / vibrational analysis │ ├── tddft_calc.py ← TD-DFT UV-Vis excited states +│ ├── nmr_calc.py ← NMR shielding (GIAO), returns NMRResult +│ ├── pes_scan.py ← Potential energy surface (bond/angle/dihedral) │ ├── calculator.py ← PySCFCalculation abstraction │ ├── comparison.py ← Side-by-side result comparison table │ ├── results_storage.py ← Persist/reload calculation results (JSON + log) │ ├── calc_log.py ← Performance + event logging (JSONL) │ ├── pubchem.py ← PubChem molecule search -│ ├── visualization_py3dmol.py ← 3D molecular viewer (py3Dmol) +│ ├── visualization_py3dmol.py ← 3D molecular viewer (py3Dmol / plotlyMol) │ ├── ase_bridge.py ← ASE structure I/O + molecule library │ ├── preopt.py ← ASE force-field pre-optimisation (fast, no PySCF) │ ├── progress.py ← StepProgress widget │ ├── help_content.py ← HELP_TOPICS dict — in-app educational text │ ├── orbital_visualization.py ← Orbital energy diagrams, cube file viewer +│ ├── ir_plot.py ← IR spectrum Plotly figure builder │ ├── config.py ← All constants/defaults (methods, basis sets, etc.) │ ├── utils.py ← Session resource checks, sanitize_filename, etc. │ └── security.py ← SecurityError exception class ├── notebooks/ │ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) │ └── tutorials/ ← 01–05 step-by-step tutorial notebooks -├── tests/ ← pytest suite (~440 tests) -├── planning/ ← Planning docs (not committed to git) -│ ├── TODO/ -│ │ ├── STATUS.md ← Start here each session — current state -│ │ ├── TODO.md ← Milestone task list with acceptance criteria -│ │ ├── DECISIONS.md ← Resolved design decisions -│ │ └── GOTCHAS.md ← Known pitfalls and deliberate deferrals -│ ├── archive/ ← Old SESSION-HANDOFF and FR specs -│ └── feature-requests.md ← FR backlog +├── tests/ ← pytest suite (~700 tests) +├── .github/ +│ └── copilot-instructions.md ← This file ├── apptainer/ -│ ├── quantui-local.def ← Apptainer container definition +│ ├── quantui.def ← Apptainer container definition │ └── build.sh ← Build script ├── local-setup/ ← Conda environment YAMLs ├── launch-app.bat ← Windows double-click launcher (Voilà app mode) ├── launch-dev.bat ← Windows double-click launcher (JupyterLab mode) -├── pyproject.toml ← Package config (name: quantui-local, imports as quantui) +├── pyproject.toml ← Package config (name: quantui, imports as quantui) └── pytest.ini ← pytest configuration ``` @@ -84,11 +81,12 @@ notebooks/molecule_computations.ipynb │ _build_molecule_section() → mol_input_container │ │ _build_calc_setup() → method_dd, basis_dd, etc. │ │ _build_run_section() → run_btn, run_panel │ - │ _build_results_section() → results_panel │ + │ _build_results_section() → all panel accordions │ │ _build_history_section() → history_panel │ │ _build_compare_section() → compare_panel │ │ _build_output_tab() → log viewer │ │ _build_help_section() → help panel │ + │ _build_ana_switcher() → Analysis tab button strip │ │ _assemble_tabs() → root_tab (Tab widget) │ └──────────────────────────────────────────────────────────┘ │ _do_run() dispatches by calc_type_dd.value: @@ -98,6 +96,14 @@ notebooks/molecule_computations.ipynb │ Geometry Opt → optimizer.optimize_geometry() │ │ Frequency → freq_calc.run_freq_calc() │ │ UV-Vis → tddft_calc.run_tddft_calc() │ + │ NMR Shielding → nmr_calc.run_nmr_calc() │ + │ PES Scan → pes_scan.run_pes_scan() │ + └──────────────────────────────────────────────────────────────┘ + │ + ▼ _apply_analysis_context(_AnalysisContext) + ┌──────────────────────────────────────────────────────────────┐ + │ _PANEL_REGISTRY[calc_type] → [(_panel_name, _pop_fn, auto)]│ + │ Each _pop_xxx() returns bool → _activate_ana_panel() │ └──────────────────────────────────────────────────────────────┘ │ ▼ @@ -108,6 +114,95 @@ notebooks/molecule_computations.ipynb --- +## The Panel Registry Pattern — CRITICAL + +This is the core architecture for the Analysis tab. **Any work touching analysis panels +must understand this pattern.** + +### How it works + +After a calculation completes (live or history), `_apply_analysis_context(ctx)` is the +single entry point for all panel population. It: + +1. Calls `_deactivate_all_ana_panels()` to reset all 8 panels to grey/hidden +2. Looks up `_PANEL_REGISTRY[ctx.calc_type]` for an ordered list of + `(panel_name, populate_method_name, auto_select)` tuples +3. Calls each populate method (e.g. `_pop_energies(ctx)`) +4. If it returns `True`, calls `_activate_ana_panel(panel_name)` to make the panel + available and optionally auto-select it +5. Updates the Analysis tab context label and navigation button visibility + +### `_AnalysisContext` dataclass + +```python +@dataclass +class _AnalysisContext: + calc_type: str # "single_point" | "geometry_opt" | "frequency" | etc. + formula: str + method: str + basis: str + live_result: Any = None # result object from _do_run; None for history + result_dir: Optional[Path] = None # saved result dir + molecule: Optional[Any] = None + spectra_data: dict = field(default_factory=dict) + preopt_result: Optional[Any] = None # OptimizationResult from Frequency pre-opt + source: str = "live" # "live" | "history" +``` + +### `_PANEL_REGISTRY` + +```python +_PANEL_REGISTRY = { + "single_point": [("Energies", "_pop_energies", True), ("Isosurface", "_pop_isosurface", False)], + "geometry_opt": [("Trajectory", "_pop_geo_trajectory", True), ("Energies", "_pop_energies", False), ...], + "frequency": [("Trajectory", "_pop_preopt_trajectory", False), ("Vibrational", "_pop_vibrational", True), ("IR Spectrum", "_pop_ir_spectrum", False)], + "tddft": [("UV-Vis", "_pop_uv_vis", True)], + "nmr": [("NMR", "_pop_nmr_shielding", True)], + "pes_scan": [("PES Scan", "_pop_pes_plot", True), ("Trajectory", "_pop_pes_trajectory", False)], +} +``` + +### Rules for all panel populate methods (`_pop_xxx`) + +- **Must return `bool`** — `True` if data was populated, `False` if data is missing/unavailable +- **Must NOT call `_activate_ana_panel()`** — the registry loop handles activation +- Support both live (use `ctx.live_result`) and history (use `ctx.result_dir` / `ctx.spectra_data`) +- Must be side-effect safe: if data is missing, clear/no-op the widget and return `False` + +### Adding a new panel — exactly these steps + +1. Create the accordion widget in `_build_results_section()` (follow existing pattern) +2. Add it to the `analysis_tab_panel` VBox in `_build_analysis_section()` +3. Add `(panel_name, accordion, tooltip)` to `_PANEL_META` in `_build_ana_switcher()` +4. Add the matching tooltip to `_deactivate_all_ana_panels()` tooltip list (must mirror `_PANEL_META`) +5. Write `_pop_xxx(self, ctx: _AnalysisContext) -> bool` +6. Add the entry to `_PANEL_REGISTRY` +7. If history replay needs data: ensure `save_spectra` in `_do_run` saves it + +### Live run vs history replay — same code path + +Both `_do_run()` and the history loaders (`_on_view_log`, `_history_load_analysis`) +build an `_AnalysisContext` and call `_apply_analysis_context()`. They are guaranteed +to show identical panels for the same result data. Use `_build_history_context(result_dir)` +to build the context from disk. + +--- + +## Analysis Tab — 8 Panels + +| Panel | Accordion | Activated by calc types | +|---|---|---| +| Energies | `_orb_accordion` | Single Point, Geometry Opt | +| Trajectory | `traj_accordion` | Geometry Opt, PES Scan, Frequency (pre-opt only) | +| Vibrational | `vib_accordion` | Frequency | +| IR Spectrum | `_ir_accordion` | Frequency | +| PES Scan | `_pes_scan_accordion` | PES Scan | +| Isosurface | `_iso_accordion` | Single Point (Linux/WSL) | +| UV-Vis | `_tddft_accordion` | UV-Vis (TD-DFT) | +| NMR | `_nmr_accordion` | NMR Shielding | + +--- + ## Key Files | File | Purpose | @@ -121,9 +216,12 @@ notebooks/molecule_computations.ipynb | `quantui/optimizer.py` | `optimize_geometry()` — ASE-BFGS + custom `_QuantUIPySCFCalc` | | `quantui/freq_calc.py` | `run_freq_calc()` — vibrational analysis via `pyscf.hessian` | | `quantui/tddft_calc.py` | `run_tddft_calc()` — excited states via `pyscf.tddft` | +| `quantui/nmr_calc.py` | `run_nmr_calc()` — GIAO shielding; returns `NMRResult` | +| `quantui/pes_scan.py` | `run_pes_scan()` — bond/angle/dihedral PES; returns `PESScanResult` | +| `quantui/ir_plot.py` | `plot_ir_spectrum()` — Plotly stick/broadened IR chart | | `notebooks/molecule_computations.ipynb` | Thin launcher — 3 cells only (do not add logic here) | -| `planning/TODO/STATUS.md` | **Read this first every session** — current state, git log, open tasks | -| `planning/feature-requests.md` | FR backlog | +| your project `STATUS.md` | **Read this first every session** — current state, git log, open tasks | +| your project `feature-requests.md` | FR backlog | --- @@ -144,10 +242,10 @@ notebooks/molecule_computations.ipynb 3. **Thread-safe widget updates only.** `_do_run()` runs in a background thread. Widget updates from threads must use `.value =` assignment, `.append_stdout()`, or `.append_display_data()`. Never call `display()` inside - `with output_widget:` from a background thread. + `with output_widget:` from a background thread except via `with output_widget:` context. 4. **No new top-level dependencies** without updating both `pyproject.toml` - and the Apptainer container `apptainer/quantui-local.def`. + and the Apptainer container `apptainer/quantui.def`. 5. **All constants in `config.py`.** Method names, basis sets, layout widths, and other shared literals must be defined in `config.py` and imported from @@ -157,19 +255,26 @@ notebooks/molecule_computations.ipynb Any new fields must be additive (never remove or rename existing keys). Bump `_SCHEMA_VERSION` only when a breaking change is unavoidable. +7. **Panel populate methods must not call `_activate_ana_panel()`.** Only the + registry loop in `_apply_analysis_context()` activates panels. Helper methods + (`_show_orbital_diagram`, `_show_vib_animation`, `_show_ir_spectrum`, + `_show_pes_scan_result`) return `bool` and must not call `_activate_ana_panel()`. + --- ## Supported Calculations -| Calc type | Module | Key function | Returns | -| --- | --- | --- | --- | -| Single Point | `session_calc` | `run_in_session()` | `SessionResult` | -| Geometry Opt | `optimizer` | `optimize_geometry()` | `OptResult` | -| Frequency | `freq_calc` | `run_freq_calc()` | `FreqResult` | -| UV-Vis TD-DFT | `tddft_calc` | `run_tddft_calc()` | `TDDFTResult` | +| Calc type | Module | Key function | Returns | save_type | +| --- | --- | --- | --- | --- | +| Single Point | `session_calc` | `run_in_session()` | `SessionResult` | `"single_point"` | +| Geometry Opt | `optimizer` | `optimize_geometry()` | `OptResult` | `"geometry_opt"` | +| Frequency | `freq_calc` | `run_freq_calc()` | `FreqResult` | `"frequency"` | +| UV-Vis TD-DFT | `tddft_calc` | `run_tddft_calc()` | `TDDFTResult` | `"tddft"` | +| NMR Shielding | `nmr_calc` | `run_nmr_calc()` | `NMRResult` | `"nmr"` | +| PES Scan | `pes_scan` | `run_pes_scan()` | `PESScanResult` | `"pes_scan"` | -**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X (defined in -`config.SUPPORTED_METHODS`). +**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X, MP2, CCSD, CAM-B3LYP, +M06-L, wB97X-D, PBE-D3 (defined in `config.SUPPORTED_METHODS`). **Supported basis sets:** STO-3G, 3-21G, 6-31G, 6-31G\*, 6-31G\*\*, cc-pVDZ, cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). @@ -186,16 +291,33 @@ __init__() _build_molecule_section() _build_calc_setup() _build_run_section() # uses self.calc_type_dd from _build_calc_setup - _build_results_section() + _build_results_section() # builds all panel accordions incl. tddft + nmr _build_history_section() _build_compare_section() _build_output_tab() _build_help_section() + _build_ana_switcher() # builds 8-panel switcher strip _assemble_tabs() # builds self.root_tab _wire_callbacks() # all .observe() and .on_click() wiring ``` -### Key instance state +### Key instance state — Analysis tab + +| Attribute | Type | Purpose | +| --- | --- | --- | +| `self._ana_available` | `set[str]` | Names of panels with data; populated by `_activate_ana_panel()` | +| `self._ana_active` | `str` | Currently visible panel name | +| `self._ana_panel_names` | `list[str]` | Ordered panel names matching `_PANEL_META` | +| `self._ana_accordions` | `list[Accordion]` | Ordered accordions matching `_PANEL_META` | +| `self._ana_btns` | `list[Button]` | Ordered switcher buttons matching `_PANEL_META` | +| `self._pending_traj_result` | `Any` | Trajectory stub; rendered lazily on accordion expand | +| `self._last_orb_info` | `Optional[...]` | Orbital info from last successful `_show_orbital_diagram` | +| `self._last_ir_freqs` | `list[float]` | IR frequencies stored by `_show_ir_spectrum` | +| `self._last_ir_ints` | `list[float]` | IR intensities stored by `_show_ir_spectrum` | +| `self._last_vib_data` | `Optional[VibrationalData]` | plotlyMol vib data for mode selector | +| `self._last_pes_result` | `Optional[...]` | PES result stored by `_show_pes_scan_result` | + +### Key instance state — general | Attribute | Type | Purpose | | --- | --- | --- | @@ -206,21 +328,9 @@ __init__() | `self.root_tab` | `widgets.Tab` | Top-level displayed widget | | `self.method_dd` | `widgets.Dropdown` | Selected QC method | | `self.basis_dd` | `widgets.Dropdown` | Selected basis set | -| `self.calc_type_dd` | `widgets.Dropdown` | Single Point / Geo Opt / Frequency / UV-Vis | - -### Molecule collapse/expand pattern - -`mol_input_container` is a `widgets.VBox` whose `.children` is swapped: -- **Expanded** (initial): `[mol_input_expanded, mol_info_html, viz_output]` -- **Collapsed** (after `_set_molecule()`): `[mol_input_collapsed, viz_output]` - -Clicking "Change molecule" re-expands. - -### CSS injection - -`display(HTML(_APP_CSS))` fires inside `display()` before `display(self.root_tab)`. -Never import this module in a context where IPython display is not available without -catching the resulting error — or just don't call `.display()` (instantiation is safe). +| `self.calc_type_dd` | `widgets.Dropdown` | Calc type selector | +| `self.result_viz_output` | `widgets.Output` | 3D molecule rendered after every calc | +| `self._last_result_dir` | `Optional[Path]` | Saved result directory from most recent run | --- @@ -231,10 +341,12 @@ Results are saved to timestamped subdirectories: /___/ result.json ← schema v2 (see below) pyscf.log ← raw PySCF stdout (may be absent) + trajectory.json ← Geo Opt / PES Scan trajectories + orbitals.npz ← MO arrays for Single Point / Geo Opt + thumbnail.png ← auto-generated molecule thumbnail ``` Default results dir: `Path("results")` relative to cwd, or `$QUANTUI_RESULTS_DIR`. -In the Apptainer container: `$HOME/.quantui/results`. ### result.json schema (version 2) @@ -242,7 +354,7 @@ In the Apptainer container: `$HOME/.quantui/results`. { "_schema_version": 2, "timestamp": "YYYY-MM-DD_HH-MM-SS-ffffff", - "calc_type": "single_point | geometry_opt | frequency | tddft", + "calc_type": "single_point | geometry_opt | frequency | tddft | nmr | pes_scan", "formula": "H2O", "method": "RHF", "basis": "STO-3G", @@ -252,13 +364,15 @@ In the Apptainer container: `$HOME/.quantui/results`. "converged": true, "n_iterations": 12, "spectra": { - "ir": {"frequencies_cm1": [...], "ir_intensities": [...], "zpve_hartree": 0.021}, - "uv_vis": {"excitation_energies_ev": [...], "oscillator_strengths": [...], "wavelengths_nm": [...]} + "ir": {"frequencies_cm1": [...], "ir_intensities": [...], "zpve_hartree": 0.021, "displacements": [...]}, + "uv_vis": {"excitation_energies_ev": [...], "oscillator_strengths": [...], "wavelengths_nm": [...]}, + "nmr": {"atom_symbols": [...], "shielding_iso_ppm": [...], "chemical_shifts_ppm": {"0": 1.2, ...}, "reference_compound": "TMS"}, + "molecule": {"atoms": [...], "coords": [...], "charge": 0, "multiplicity": 1} } } ``` -Timestamp includes microseconds (`-%f`) to prevent same-second directory collisions. +Schema rules: new fields must be additive only. Never remove or rename existing keys. --- @@ -266,13 +380,14 @@ Timestamp includes microseconds (`-%f`) to prevent same-second directory collisi Two JSONL files under `~/.quantui/logs/` (override with `$QUANTUI_LOG_DIR`): -| File | Contents | Retention | -| --- | --- | --- | -| `perf_log.jsonl` | One record per converged run: formula, n_atoms, n_electrons, method, basis, elapsed_s | Permanent | -| `event_log.jsonl` | Startup / calc_start / calc_done / calc_error events | 7-day auto-prune | +| File | Contents | +| --- | --- | +| `perf_log.jsonl` | One record per converged run: formula, n_atoms, n_electrons, method, basis, elapsed_s, n_basis | +| `event_log.jsonl` | Startup / calc_start / calc_done / calc_error events (7-day auto-prune) | -Key API: `log_perf_record()`, `get_perf_history()`, `get_recent_events(n)`, -`reset_perf_log()`, `estimate_time(n_atoms, n_electrons, method, basis)`. +Key API: `log_calculation()`, `get_perf_history()`, `estimate_time()`, `reset_perf_log()`. +Performance estimation uses a 4-strategy priority chain: N_basis-normalised efficiency → +electron-count scaling → cross-method N_basis → cross-method electron-count. --- @@ -283,6 +398,7 @@ Key API: `log_perf_record()`, `get_perf_history()`, `get_recent_events(n)`, - **Private methods/helpers:** leading underscore — e.g., `_do_run()`, `_set_molecule()` - **Builder methods in `QuantUIApp`:** `_build_
()` — e.g., `_build_calc_setup()` - **Callback methods:** `_on__()` — e.g., `_on_run_clicked()`, `_on_theme_changed()` +- **Panel populate methods:** `_pop_()` — e.g., `_pop_energies()`, `_pop_ir_spectrum()` - **Module-level availability flags:** `_PYSCF_AVAILABLE`, `_PREOPT_AVAILABLE`, `ASE_AVAILABLE` - **Config constants:** `ALL_CAPS_SNAKE_CASE` in `config.py` - **Section banners in `app.py`:** `# ══ SECTION NAME ══` delimiters for VS Code outline navigation @@ -294,7 +410,7 @@ Key API: `log_perf_record()`, `get_perf_history()`, `get_recent_events(n)`, ```powershell # Activate environment (Windows PowerShell) & "$env:USERPROFILE\miniconda3\shell\condabin\conda-hook.ps1" -conda activate quantui-local +conda activate quantui # Voilà app mode (student-facing — no code visible) voila notebooks/molecule_computations.ipynb @@ -312,7 +428,11 @@ pip install -e ".[dev]" python -c "from quantui.app import QuantUIApp; print('OK')" ``` -**Python executable:** `C:\Users\schul\miniconda3\envs\quantui-local\python.exe` +**WSL note:** Always `cd` to the repo root before running Python in WSL. The `''` +entry in `sys.path` resolves to the current directory; running from the wrong directory +can shadow the editable install with a different repo's `quantui/` package. + +**Python executable:** `C:\Users\schul\miniconda3\envs\quantui\python.exe` Note: PySCF calculations will show "unavailable" on Windows — this is expected. All UI, molecule, visualization, and PubChem features work natively on Windows. @@ -325,18 +445,25 @@ Test files in `tests/`: | File | What it covers | | --- | --- | +| `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, history replay | | `test_molecule.py` | Molecule parsing, validation, formula | -| `test_session_calc.py` | `run_in_session()` — PySCF-gated with `pyscf_only` marker | +| `test_session_calc.py` | `run_in_session()` — PySCF-gated | | `test_notebook_workflows.py` | End-to-end HF/DFT/preopt/thread-safety — PySCF-gated | | `test_optimizer.py` | `optimize_geometry()` — PySCF + ASE required | +| `test_freq_calc.py` | `run_freq_calc()` — PySCF-gated | +| `test_tddft_calc.py` | `run_tddft_calc()` — PySCF-gated | +| `test_nmr_calc.py` | `run_nmr_calc()` — PySCF + pyscf.prop required | +| `test_pes_scan.py` | `run_pes_scan()` — PySCF + ASE required | | `test_comparison.py` | Result comparison tables | | `test_results_storage.py` | Save/load/list round-trip | | `test_security.py` | `SecurityError`, `sanitize_filename()` | -| `test_phase1.py` | `QuantUIApp` instantiation (no display) | **PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. On Windows, these become skips — not failures. +**Baseline (WSL, 2026-04-27):** 699 passed, 15 skipped, 10 failed (all pre-existing: +7 NMR pyscf.prop, 1 orbital viz, 1 PES scan, 1 freq thermo). + --- ## Optional Dependencies @@ -345,6 +472,8 @@ On Windows, these become skips — not failures. | --- | --- | --- | | `pyscf` | `pyscf>=2.3.0` | `_PYSCF_AVAILABLE` flag | | `ase` | `ase>=3.22.0` | `ASE_AVAILABLE` flag | +| `plotly` | `plotly` | checked inline at render time | +| `plotlymol` | `plotlymol3d` (local) | checked inline at render time | | `app` | `voila, jupyterlab` | Always present in the conda env | Install all: `pip install -e ".[pyscf,ase,app,dev]"` @@ -362,14 +491,14 @@ Install all: `pip install -e ".[pyscf,ase,app,dev]"` ## Apptainer Container -The container at `apptainer/quantui-local.def` bundles Python + PySCF + ASE + +The container at `apptainer/quantui.def` bundles Python + PySCF + ASE + py3Dmol + Voilà into a single portable `.sif` file. This is the supported path for Windows users. - Build: `bash apptainer/build.sh --clean` (requires Linux/WSL with Apptainer ≥ 1.0) -- Run (app mode): `apptainer run quantui-local.sif app` -- Run (JupyterLab): `apptainer run quantui-local.sif` -- Verify: `apptainer test quantui-local.sif` +- Run (app mode): `apptainer run quantui.sif app` +- Run (JupyterLab): `apptainer run quantui.sif` +- Verify: `apptainer test quantui.sif` The container sets `QUANTUI_RESULTS_DIR=$HOME/.quantui/results` so results survive across kernel restarts and are accessible from the host (home dir is bind-mounted). @@ -378,9 +507,8 @@ across kernel restarts and are accessible from the host (home dir is bind-mounte ## Relationship to Source Repo -QuantUI-local is a downstream port of `NCCU-Schultz-Lab/QuantUI` (the cluster version). +QuantUI is a downstream port of `NCCU-Schultz-Lab/QuantUI` (the cluster version). Bug fixes and module updates originate in `QuantUI` and are ported here. -Never make independent architectural changes in this repo — propose them in `QuantUI` first. | Removed from source | Reason | | --- | --- | @@ -389,10 +517,3 @@ Never make independent architectural changes in this repo — propose them in `Q | `slurm_errors.py` | SLURM error translation | | `visualization.py` | PlotlyMol fallback (excluded here) | | SLURM templates in `config.py` | No cluster | - ---- - -## Active Development Branch - -Branch: `app-restructure` — FR-012 App Module Refactor in progress. -See `planning/TODO/STATUS.md` for current phase and uncommitted changes. diff --git a/.gitignore b/.gitignore index 9ec106e..1a4c3cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Dev launcher stamp (written by launch-native.bat to skip redundant pip install) +.dev_install_stamp + # Python __pycache__/ *.py[cod] @@ -54,3 +57,6 @@ Thumbs.db *.swo planning/ +nul +/.claude/ +/temp - untracked/ diff --git a/README.md b/README.md index 5e0e8b6..e870ce6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# QuantUI-local +# QuantUI -[![CI](https://github.com/The-Schultz-Lab/QuantUI-local/actions/workflows/ci.yml/badge.svg)](https://github.com/The-Schultz-Lab/QuantUI-local/actions/workflows/ci.yml) -[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://the-schultz-lab.github.io/QuantUI-local/) +[![CI](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml/badge.svg)](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://the-schultz-lab.github.io/QuantUI/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.9%20|%203.10%20|%203.11-blue)](https://www.python.org) @@ -17,18 +17,30 @@ Built for classroom teaching at the ## What it does -- **Molecule input** — paste XYZ coordinates, draw from a preset library, or - search PubChem by name or SMILES -- **3D visualization** — interactive py3Dmol viewer, directly in the notebook -- **In-session calculations** — RHF and UHF via PySCF, running in your Python - kernel (no batch submission) -- **Results** — total energy, HOMO-LUMO gap, convergence status, and a - side-by-side comparison table for multiple calculations +- **Molecule input** — paste XYZ coordinates, draw from a 20+ preset library, + or search PubChem by name or SMILES +- **3D visualization** — interactive py3Dmol or PlotlyMol viewer with a live + backend toggle when both are installed; post-calculation structure rendered + automatically in the results panel +- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, and NMR + shielding via PySCF, running in your Python kernel (no batch submission) +- **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, + Acetonitrile) via a single checkbox +- **Rich results** — total energy, HOMO-LUMO gap, Mulliken charges, dipole + moment, thermochemistry (H, S, G at 298 K), IR spectrum chart (stick and + Lorentzian-broadened), ¹H/¹³C NMR chemical shifts, orbital energy-level + diagram, HOMO/LUMO isosurface (cube-file rendering with toggle for HOMO-1, + HOMO, LUMO, LUMO+1), and a side-by-side comparison table for multiple + calculations +- **Geometry optimization** — BFGS optimizer with step-by-step trajectory + animation; vibrational frequency analysis with animated normal modes - **Results persistence** — every calculation is saved automatically to a timestamped directory; a built-in browser lets students reload past results - after a kernel restart -- **Script export** — download a standalone `.py` file to run or study outside - the notebook + after a kernel restart; the full `pyscf.log` is shown inline +- **Structure exports** — download XYZ, MOL/SDF, or PDB files alongside the + saved results; script export for a standalone `.py` file +- **Timing calibration** — one-click benchmark suite populates the time + estimator with real machine data so predictions are accurate from the first run - **Voilà app mode** — serve the notebook as a polished widget-only UI (no code visible) for classroom demos, with dark mode toggle and dedicated output log @@ -45,7 +57,7 @@ Built for classroom teaching at the ### Windows users: Apptainer container PySCF does not install on Windows natively. The -[`apptainer/quantui-local.def`](apptainer/quantui-local.def) container bundles +[`apptainer/quantui.def`](apptainer/quantui.def) container bundles the complete environment and runs anywhere Apptainer/Singularity is available. See [`apptainer/README.md`](apptainer/README.md) for build and run instructions. @@ -57,8 +69,8 @@ See [`apptainer/README.md`](apptainer/README.md) for build and run instructions. ```bash # Create a dedicated environment -conda create -n quantui-local python=3.11 -conda activate quantui-local +conda create -n quantui python=3.11 +conda activate quantui # Install with PySCF and ASE pip install -e ".[pyscf,ase,app]" @@ -67,7 +79,7 @@ pip install -e ".[pyscf,ase,app]" ### Option B — pip only ```bash -python -m pip install quantui-local[pyscf,ase,app] +python -m pip install quantui[pyscf,ase,app] ``` ### Option C — Apptainer container (Windows / reproducible deployment) @@ -80,7 +92,7 @@ See [apptainer/README.md](apptainer/README.md). ```bash # Activate your environment -conda activate quantui-local +conda activate quantui # JupyterLab (full IDE — shows code) jupyter lab notebooks/molecule_computations.ipynb @@ -110,13 +122,37 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): ## Supported calculations -| Method | When to use | +### Methods + +| Method | Type | Best for | +| --- | --- | --- | +| RHF | Hartree-Fock | Closed-shell molecules; baseline reference | +| UHF | Hartree-Fock | Radicals and open-shell systems | +| B3LYP | DFT hybrid | General organic chemistry (default DFT choice) | +| PBE | DFT GGA | Large molecules; metals; when speed matters | +| PBE0 | DFT hybrid | Charge-transfer, band gaps | +| M06-2X | DFT meta-hybrid | Thermochemistry, barrier heights | +| wB97X-D | DFT range-sep. + D3 | Non-covalent interactions, excited states | +| CAM-B3LYP | DFT range-sep. | Charge-transfer UV-Vis, Rydberg states | +| M06-L | DFT local meta-GGA | Large molecules; transition metals | +| HSE06 | DFT screened hybrid | Band gaps, large molecules | +| PBE-D3 | DFT GGA + dispersion | Van der Waals complexes, stacking | +| MP2 | Post-HF | Accurate energetics for small molecules (O(N⁵)) | + +### Calculation types + +| Type | Output | | --- | --- | -| RHF | Closed-shell molecules — all electrons paired | -| UHF | Open-shell molecules — radicals or unpaired electrons | +| Single Point | Energy, HOMO-LUMO gap, Mulliken charges, dipole moment | +| Geometry Opt | Optimised structure, trajectory animation | +| Frequency | Vibrational frequencies, ZPVE, IR intensities, thermochemistry (H/S/G at 298 K), animated normal modes, IR spectrum chart (stick / Lorentzian broadened) | +| UV-Vis (TD-DFT) | Excitation energies, oscillator strengths, UV-Vis spectrum plot | +| NMR Shielding | ¹H and ¹³C chemical shifts relative to TMS via GIAO; tabulated by element | + +### Basis sets -**Basis sets:** STO-3G (fast, good for learning) → 6-31G (common research -choice) → cc-pVTZ (high accuracy) +STO-3G (fast, good for learning) → 3-21G → 6-31G / 6-31G\* / 6-31G\*\* → +cc-pVDZ / cc-pVTZ → def2-SVP / def2-TZVP --- @@ -141,18 +177,27 @@ pytest -m "not network" \ ```text quantui/ Main package + app.py QuantUIApp widget class (all tabs, UI logic) molecule.py Molecule input and validation - session_calc.py In-session PySCF runner - visualization_py3dmol.py 3D viewer + session_calc.py In-session PySCF runner (RHF/UHF/DFT/MP2/PCM) + freq_calc.py Vibrational frequency + thermochemistry analysis + ir_plot.py IR spectrum chart (stick and Lorentzian broadened) + tddft_calc.py TD-DFT UV-Vis excited-state calculations + nmr_calc.py NMR shielding + ¹H/¹³C chemical shift prediction + optimizer.py QM geometry optimization with trajectory + visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) pubchem.py PubChem molecule search comparison.py Side-by-side result tables + results_storage.py Timestamped result persistence + calc_log.py Performance logging and time estimation + benchmarks.py Timing calibration benchmark suite + config.py Methods, basis sets, solvent/NMR options, presets ase_bridge.py ASE structure I/O - optimizer.py QM geometry optimization - ... + preopt.py LJ force-field pre-optimization notebooks/ molecule_computations.ipynb Main student-facing interface - tutorials/ Step-by-step guided notebooks -tests/ pytest test suite (439 tests) + tutorials/ Step-by-step guided notebooks (01–05) +tests/ pytest test suite (575+ tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config @@ -162,10 +207,10 @@ pyproject.toml Package metadata and tool config ## Relationship to the cluster version -QuantUI-local is a downstream port of the cluster-based -[QuantUI](https://github.com/The-Schultz-Lab/QuantUI) repository. All SLURM +QuantUI (this repo) is a downstream port of the cluster-based +[QuantUI-cluster](https://github.com/The-Schultz-Lab/QuantUI) repository. All SLURM infrastructure (job manager, job storage, batch templates) has been removed. -Bug fixes flow `QuantUI → QuantUI-local`, not the other way around. +Bug fixes flow from the cluster repo into this one, not the other way around. --- diff --git a/SECURITY.md b/SECURITY.md index 8a4c950..d44a17c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -QuantUI-local is an educational teaching tool designed for classroom and local +QuantUI is an educational teaching tool designed for classroom and local research use. It runs calculations inside your own Python session — there is no server, no user accounts, and no data stored outside your local machine. @@ -19,7 +19,7 @@ GitHub issue**. Instead, report it privately via one of these channels: - **GitHub private vulnerability reporting** — use the - [Security tab](https://github.com/The-Schultz-Lab/QuantUI-local/security/advisories/new) + [Security tab](https://github.com/The-Schultz-Lab/QuantUI/security/advisories/new) on this repository (preferred). - **Email** — contact the lab maintainer through the GitHub profile [@The-Schultz-Lab](https://github.com/The-Schultz-Lab). @@ -28,7 +28,7 @@ Please include: 1. A short description of the issue and its potential impact 2. Steps to reproduce (or a minimal proof-of-concept) -3. The version of QuantUI-local affected +3. The version of QuantUI affected 4. Your suggested fix, if you have one We aim to acknowledge reports within **5 business days** and to release a fix @@ -55,7 +55,7 @@ Issues that are explicitly **out of scope**: ## Dependencies -QuantUI-local relies on PySCF, ASE, NumPy, Matplotlib, ipywidgets, and +QuantUI relies on PySCF, ASE, NumPy, Matplotlib, ipywidgets, and py3Dmol. Security advisories for these packages are tracked automatically via Dependabot. If you become aware of a critical CVE in one of these dependencies before Dependabot picks it up, please report it using the diff --git a/apptainer/README.md b/apptainer/README.md index 96550a0..262f624 100644 --- a/apptainer/README.md +++ b/apptainer/README.md @@ -1,4 +1,4 @@ -# QuantUI-local — Apptainer Container +# QuantUI — Apptainer Container The Apptainer container packages Python, PySCF, ASE, py3Dmol, and Voilà into a single portable `.sif` file. It is the **recommended path for Windows @@ -11,13 +11,13 @@ students copy one file and run it. | File | Purpose | | --- | --- | -| `quantui-local.def` | Container definition file (the "recipe") | +| `quantui.def` | Container definition file (the "recipe") | | `build.sh` | Build script with clean/test/fakeroot options | | `README.md` (this file) | Build, run, and distribution guide | The compiled `.sif` image is **not** committed to git — it is too large (~4–5 GB). Build it locally (see below) or download the latest release asset from the -[GitHub Releases page](https://github.com/The-Schultz-Lab/QuantUI-local/releases). +[GitHub Releases page](https://github.com/The-Schultz-Lab/QuantUI/releases). --- @@ -25,8 +25,8 @@ Build it locally (see below) or download the latest release asset from the ### Option A — Download a pre-built release (easiest) -1. Go to [Releases](https://github.com/The-Schultz-Lab/QuantUI-local/releases) -2. Download `quantui-local.sif` from the latest release +1. Go to [Releases](https://github.com/The-Schultz-Lab/QuantUI/releases) +2. Download `quantui.sif` from the latest release 3. Run it directly — no build step needed ### Option B — Build from source @@ -70,7 +70,7 @@ optional clean builds, and post-build testing. ```bash # From the repo root: -cd /path/to/QuantUI-local +cd /path/to/QuantUI # Standard build bash apptainer/build.sh @@ -115,7 +115,7 @@ Double-click either file. The browser opens automatically at Launches the notebook as a clean widget-only interface. Students see no code. ```bash -apptainer run quantui-local.sif app +apptainer run quantui.sif app ``` Then open a browser at [http://localhost:8866](http://localhost:8866). @@ -123,7 +123,7 @@ Then open a browser at [http://localhost:8866](http://localhost:8866). #### JupyterLab mode — for exploration or development ```bash -apptainer run quantui-local.sif +apptainer run quantui.sif ``` Then open the URL printed in the terminal (contains a login token). @@ -135,8 +135,8 @@ cell changes take effect on browser refresh — no container rebuild needed. Note: changes to `quantui/` Python files still require a rebuild. ```bash -cd /path/to/QuantUI-local -apptainer exec quantui-local.sif voila notebooks/molecule_computations.ipynb \ +cd /path/to/QuantUI +apptainer exec quantui.sif voila notebooks/molecule_computations.ipynb \ --no-browser --port=8866 \ --ServerApp.disable_check_xsrf=True ``` @@ -159,14 +159,14 @@ container: ```bash # Work from a specific project folder cd ~/my-calculations -apptainer run /path/to/quantui-local.sif app +apptainer run /path/to/quantui.sif app ``` #### Custom port ```bash # Voilà on port 9000 -apptainer run quantui-local.sif app --port=9000 +apptainer run quantui.sif app --port=9000 ``` --- @@ -178,10 +178,10 @@ packages loaded correctly: ```bash # Built-in %test section -apptainer test quantui-local.sif +apptainer test quantui.sif # Manual import check -apptainer exec quantui-local.sif python -c " +apptainer exec quantui.sif python -c " import quantui, pyscf, ase, py3Dmol from quantui import Molecule, parse_xyz_input atoms, coords = parse_xyz_input('O 0 0 0\nH 0.757 0.587 0\nH -0.757 0.587 0') @@ -195,7 +195,7 @@ Expected output: `OK: H2O` and package import messages. ### Run the full test suite ```bash -apptainer exec --cleanenv --writable-tmpfs quantui-local.sif bash -c ' +apptainer exec --cleanenv --writable-tmpfs quantui.sif bash -c ' pip install pytest -q 2>/dev/null python -m pytest tests/test_notebook_workflows.py -v --tb=short --override-ini="addopts=" ' @@ -208,7 +208,7 @@ and thread-safety checks. Expected: **20 passed** in ~25 seconds. ### Quick calculation check ```bash -apptainer exec --cleanenv quantui-local.sif python -c " +apptainer exec --cleanenv quantui.sif python -c " from quantui.molecule import Molecule from quantui import run_in_session @@ -270,13 +270,13 @@ The `.sif` is a single self-contained file — share it however is convenient: ```bash # Network drive / shared folder -cp quantui-local.sif /shared/drive/ +cp quantui.sif /shared/drive/ # SCP to a department server students can pull from -scp quantui-local.sif user@server.dept.edu:/shared/tools/ +scp quantui.sif user@server.dept.edu:/shared/tools/ # USB drive -cp quantui-local.sif /media/usb/ +cp quantui.sif /media/usb/ ``` Students on Windows download the `.sif` and the `launch-app.bat` file. Then: @@ -288,7 +288,7 @@ Students on Windows download the `.sif` and the `launch-app.bat` file. Then: Students on Linux/Mac run: ```bash -apptainer run quantui-local.sif app +apptainer run quantui.sif app ``` No Python, no conda, no pip — everything is bundled. @@ -316,7 +316,7 @@ Apptainer uses `/tmp` as scratch space. Redirect it to somewhere with more room: ```bash export APPTAINER_TMPDIR=~/apptainer-tmp mkdir -p ~/apptainer-tmp -apptainer build quantui-local.sif apptainer/quantui-local.def +apptainer build quantui.sif apptainer/quantui.def ``` ### "Permission denied" or "root required" @@ -324,7 +324,7 @@ apptainer build quantui-local.sif apptainer/quantui-local.def Use `--fakeroot` if your HPC or server supports it: ```bash -apptainer build --fakeroot quantui-local.sif apptainer/quantui-local.def +apptainer build --fakeroot quantui.sif apptainer/quantui.def ``` On a personal machine or in WSL you typically have root access and don't @@ -350,7 +350,7 @@ PySCF requires OpenMP. If running in a restricted environment: ```bash export OMP_NUM_THREADS=1 -apptainer run quantui-local.sif app +apptainer run quantui.sif app ``` ### XSRF 403 warning on shutdown @@ -379,7 +379,7 @@ sudo apt-get install -y apptainer | Base | `continuumio/miniconda3:latest` (Debian + conda) | | conda-forge | jupyter, jupyterlab, ipywidgets, pyscf, numpy, scipy, matplotlib, plotly, h5py | | pip | voila, ase, py3dmol, requests | -| QuantUI-local | installed from `/opt/quantui` (the repo root, copied at build time) | +| QuantUI | installed from `/opt/quantui` (the repo root, copied at build time) | The `.git` directory and `__pycache__` folders are removed during build to keep the image lean. @@ -388,7 +388,7 @@ keep the image lean. ## Updating the container version -Edit `%labels` in `quantui-local.def` to bump the version string, then rebuild: +Edit `%labels` in `quantui.def` to bump the version string, then rebuild: ```singularity %labels diff --git a/apptainer/build.sh b/apptainer/build.sh index 7fb3468..886f717 100644 --- a/apptainer/build.sh +++ b/apptainer/build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# build.sh — Build the QuantUI-local Apptainer container +# build.sh — Build the QuantUI Apptainer container # # Usage (run from the repo root): # bash apptainer/build.sh # build to repo root @@ -13,8 +13,8 @@ set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── -DEF="apptainer/quantui-local.def" -SIF="quantui-local.sif" +DEF="apptainer/quantui.def" +SIF="quantui.sif" APPTAINER_CMD="${APPTAINER_CMD:-apptainer}" # ── Parse flags ─────────────────────────────────────────────────────────────── diff --git a/apptainer/quantui-local.def b/apptainer/quantui.def similarity index 92% rename from apptainer/quantui-local.def rename to apptainer/quantui.def index 64d36c5..f37036a 100644 --- a/apptainer/quantui-local.def +++ b/apptainer/quantui.def @@ -2,21 +2,21 @@ Bootstrap: docker FROM: continuumio/miniconda3:latest %help - QuantUI-local — Educational Quantum Chemistry Interface + QuantUI — Educational Quantum Chemistry Interface - Lightweight local version of QuantUI for teaching PySCF calculations + Lightweight local teaching interface for running PySCF calculations without a cluster. Includes PySCF, ASE, py3Dmol, and Voilà. Usage: # Clean widget UI (students see no code — recommended for class) - apptainer run quantui-local.sif app + apptainer run quantui.sif app # JupyterLab (exploration / development mode) - apptainer run quantui-local.sif + apptainer run quantui.sif Build: # From the repo root: - apptainer build quantui-local.sif apptainer/quantui-local.def + apptainer build quantui.sif apptainer/quantui.def %labels Maintainer "Jonathan Schultz" @@ -63,7 +63,7 @@ FROM: continuumio/miniconda3:latest py3dmol \ requests - echo "Installing QuantUI-local..." + echo "Installing QuantUI..." cd /opt/quantui pip install --no-cache-dir -e . diff --git a/docs/index.html b/docs/index.html index 7e45e8b..e95f87c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,13 +3,15 @@ - QuantUI-local — Quantum Chemistry in Jupyter + QuantUI — Quantum Chemistry in Jupyter - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launch-app.bat b/launch-app.bat index 650d94c..aca1f79 100644 --- a/launch-app.bat +++ b/launch-app.bat @@ -1,10 +1,10 @@ @echo off -echo QuantUI-local — Starting... +echo QuantUI — Starting... echo. REM Check that the .sif exists before trying to launch -if not exist "%~dp0quantui-local.sif" ( - echo ERROR: quantui-local.sif not found. +if not exist "%~dp0quantui.sif" ( + echo ERROR: quantui.sif not found. echo Build it first: bash apptainer/build.sh echo Or download it from the GitHub Releases page. pause @@ -15,7 +15,7 @@ REM Convert the Windows repo path to a WSL path for portability for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i REM Launch Voila in a new WSL window (stays open so you can see logs) -start "QuantUI-local" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer run quantui-local.sif app" +start "QuantUI" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer run quantui.sif app" REM Wait for Voila to start, then open the browser echo Waiting for Voila to start... diff --git a/launch-dev.bat b/launch-dev.bat index 16b2ff5..2f66058 100644 --- a/launch-dev.bat +++ b/launch-dev.bat @@ -1,19 +1,25 @@ @echo off -echo QuantUI-local DEV MODE — Using local notebook (no rebuild needed) +echo QuantUI DEV MODE — Using local notebook (no rebuild needed) echo. -if not exist "%~dp0quantui-local.sif" ( - echo ERROR: quantui-local.sif not found. +if not exist "%~dp0quantui.sif" ( + echo ERROR: quantui.sif not found. pause exit /b 1 ) +echo NOTE: The container provides the quantui Python package. +echo Notebook edits (notebooks/) are always live. +echo Edits to quantui/*.py require a container rebuild to take effect. +echo For package-level dev, use launch-native.bat instead. +echo. + REM Convert the Windows repo path to a WSL path for portability for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i REM Uses the local notebook on disk instead of the baked-in copy. REM Edits to notebooks/ take effect immediately — no container rebuild needed. -start "QuantUI-local [dev]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer exec --cleanenv quantui-local.sif voila notebooks/molecule_computations.ipynb --no-browser --port=8866 --ServerApp.disable_check_xsrf=True" +start "QuantUI [dev]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer exec --cleanenv quantui.sif voila notebooks/molecule_computations.ipynb --no-browser --port=8866 --ServerApp.disable_check_xsrf=True" echo Waiting for Voila to start... timeout /t 6 /nobreak > nul diff --git a/launch-native.bat b/launch-native.bat new file mode 100644 index 0000000..47aec47 --- /dev/null +++ b/launch-native.bat @@ -0,0 +1,24 @@ +@echo off +echo QuantUI NATIVE MODE — Local conda env in WSL, no container +echo Use this when you have edited quantui/*.py and want to test immediately. +echo. + +REM Convert the Windows repo path to a WSL path for portability +for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i + +REM Runs Voila 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 8867 to avoid conflict with container-based launchers on 8866. +start "QuantUI [native]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && source ~/miniconda3/etc/profile.d/conda.sh && conda activate quantui && if [ pyproject.toml -nt .dev_install_stamp ] || ! python -c 'import quantui' 2>/dev/null; then pip install -e . -q && touch .dev_install_stamp; fi && voila notebooks/molecule_computations.ipynb --no-browser --port=8867 --ServerApp.disable_check_xsrf=True" + +echo Waiting for Voila to start... +timeout /t 6 /nobreak > nul +start http://localhost:8867 + +echo. +echo Native dev server running at http://localhost:8867 +echo All local quantui/*.py changes are live — no rebuild needed. +echo Close the WSL window to stop. diff --git a/local-setup/environment.yml b/local-setup/environment.yml index a3876f7..b289385 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -1,4 +1,4 @@ -name: quantui-local +name: quantui channels: - conda-forge - defaults @@ -45,6 +45,7 @@ dependencies: # Code formatting and linting (not on conda-forge) - black>=24.0.0 - ruff>=0.4.0 - # Install QuantUI-local in editable mode - # On Linux/WSL also run: conda install -c conda-forge pyscf + # Install QuantUI in editable mode + # On Linux/WSL also run: conda install -c conda-forge pyscf pyscf-properties + - pyscf-properties # NMR and other properties (pyscf.prop); moved out of pyscf core in v2.0 - -e .. diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb index 90bf2e3..7c10d72 100644 --- a/notebooks/molecule_computations.ipynb +++ b/notebooks/molecule_computations.ipynb @@ -5,18 +5,7 @@ "id": "0", "metadata": {}, "source": [ - "# QuantUI-local — Quantum Chemistry Calculator\n", - "\n", - "Run PySCF quantum chemistry calculations directly in this notebook.\n", - "\n", - "**How to use:**\n", - "1. Select or enter a molecule in **Molecule Input**\n", - "2. Choose a method and basis set in **Calculation Setup**\n", - "3. Click **Run Calculation** — results appear below\n", - "4. Optionally add results to **Compare** or **Export** a standalone script\n", - "\n", - "> **Platform note:** PySCF requires Linux, macOS, or WSL. \\\n", - "> Windows users: `apptainer run quantui-local.sif`\n" + "# QuantUI\n" ] }, { @@ -34,12 +23,22 @@ "# Environment check — verifies correct conda environment.\n", "# Tagged skip-execution and remove-input so it is hidden in Voilà.\n", "import sys as _sys\n", + "from pathlib import Path as _Path\n", + "\n", + "# Ensure the repo root is importable so `import quantui` finds the local source\n", + "# even when the installed package is stale or absent.\n", + "_here = _Path().resolve()\n", + "for _p in (_here, _here.parent, _here.parent.parent):\n", + " if (_p / \"quantui\" / \"__init__.py\").exists():\n", + " if str(_p) not in _sys.path:\n", + " _sys.path.insert(0, str(_p))\n", + " break\n", "\n", "_env = _sys.prefix\n", "if \"quantui\" not in _env.lower():\n", - " print(\"Warning: active environment may not be quantui-local\")\n", + " print(\"Warning: active environment may not be quantui\")\n", " print(f\"Active: {_env}\")\n", - " print(\"Run: conda activate quantui-local\")\n" + " print(\"Run: conda activate quantui\")" ] }, { @@ -53,17 +52,33 @@ "\n", "QuantUIApp().display()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python (quantui)", "language": "python", - "name": "python3" + "name": "quantui" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.15" } }, "nbformat": 4, diff --git a/notebooks/tutorials/01_first_calculation.ipynb b/notebooks/tutorials/01_first_calculation.ipynb index a89396d..0c12aa6 100644 --- a/notebooks/tutorials/01_first_calculation.ipynb +++ b/notebooks/tutorials/01_first_calculation.ipynb @@ -13,7 +13,7 @@ "## 🎓 Learning Objectives\n", "\n", "By the end of this tutorial, you will:\n", - "- ✅ Submit a calculation to the QuantUI-local cluster \n", + "- ✅ Submit a calculation to the QuantUI cluster \n", "- ✅ Monitor job progress \n", "- ✅ Interpret basic results (SCF energy, convergence) \n", "- ✅ Understand what a Hartree-Fock calculation does \n", @@ -88,7 +88,7 @@ "print(\"\\n\" + \"=\"*60)\n", "print(\"✓ Setup complete! Ready for your first calculation.\")\n", "print(\"=\"*60)\n", - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule, parse_xyz_input,\n", " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", @@ -438,9 +438,9 @@ "\n", "### What happens when you submit?\n", "1. QuantUI generates a Python script with your calculation\n", - "2. QuantUI generates a QuantUI-local submission script\n", + "2. QuantUI generates a QuantUI submission script\n", "3. Job is submitted to local execution\n", - "4. QuantUI-local finds available resources\n", + "4. QuantUI finds available resources\n", "5. Your calculation runs on a compute node\n", "6. Results are saved to your directory\n", "\n", @@ -458,7 +458,7 @@ "**Submission successful if you see:**\n", "- ✅ \"JOB SUBMITTED SUCCESSFULLY!\"\n", "- ✅ Internal ID (starts with your username)\n", - "- ✅ QuantUI-local Job ID (number)\n", + "- ✅ QuantUI Job ID (number)\n", "- ✅ Status: \"PENDING\" or \"RUNNING\"\n", "\n", "**Job States:**\n", @@ -611,7 +611,7 @@ " print(f\"❌ Error viewing results: {e}\")\n", " import traceback\n", " traceback.print_exc()\n", - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule, parse_xyz_input,\n", " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", @@ -662,7 +662,7 @@ "1. ✅ Set up QuantUI environment\n", "2. ✅ Defined a molecule (H₂O) with coordinates\n", "3. ✅ Configured a Hartree-Fock calculation\n", - "4. ✅ Submitted job to QuantUI-local cluster\n", + "4. ✅ Submitted job to QuantUI cluster\n", "5. ✅ Monitored job status\n", "6. ✅ Interpreted results\n", "\n", @@ -683,7 +683,7 @@ "- Trade-off: accuracy vs. speed\n", "- 6-31G is good medium choice\n", "\n", - "**QuantUI-local Job Management:**\n", + "**QuantUI Job Management:**\n", "- Submit to queue\n", "- Monitor status\n", "- Retrieve results\n", diff --git a/notebooks/tutorials/02_basis_set_study.ipynb b/notebooks/tutorials/02_basis_set_study.ipynb index 945d53f..501d910 100644 --- a/notebooks/tutorials/02_basis_set_study.ipynb +++ b/notebooks/tutorials/02_basis_set_study.ipynb @@ -41,7 +41,7 @@ "metadata": {}, "outputs": [], "source": [ - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule,\n", " parse_xyz_input,\n", @@ -156,9 +156,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (quantui-local)", + "display_name": "Python 3 (quantui)", "language": "python", - "name": "quantui-local" + "name": "quantui" }, "language_info": { "name": "python", diff --git a/notebooks/tutorials/03_multiplicity_radicals.ipynb b/notebooks/tutorials/03_multiplicity_radicals.ipynb index e5299e8..ce3c06b 100644 --- a/notebooks/tutorials/03_multiplicity_radicals.ipynb +++ b/notebooks/tutorials/03_multiplicity_radicals.ipynb @@ -147,7 +147,7 @@ "print(\"\\n\" + \"=\"*60)\n", "print(\"✓ Setup complete! Ready to study multiplicity.\")\n", "print(\"=\"*60)\n", - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule, parse_xyz_input,\n", " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", diff --git a/notebooks/tutorials/04_charged_species.ipynb b/notebooks/tutorials/04_charged_species.ipynb index 5e2990a..a9dcf41 100644 --- a/notebooks/tutorials/04_charged_species.ipynb +++ b/notebooks/tutorials/04_charged_species.ipynb @@ -108,7 +108,7 @@ "\n", "print(\"✓ QuantUI initialized for charged species\")\n", "print(f\"✓ User: {username}\")\n", - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule, parse_xyz_input,\n", " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", diff --git a/notebooks/tutorials/05_comparing_results.ipynb b/notebooks/tutorials/05_comparing_results.ipynb index 10a7065..24c9260 100644 --- a/notebooks/tutorials/05_comparing_results.ipynb +++ b/notebooks/tutorials/05_comparing_results.ipynb @@ -105,7 +105,7 @@ "print(\"✓ QuantUI initialized for results analysis\")\n", "print(f\"✓ User: {username}\")\n", "print(f\"✓ Job manager ready to retrieve results\")\n", - "# QuantUI-local — in-session calculation imports\n", + "# QuantUI — in-session calculation imports\n", "from quantui import (\n", " Molecule, parse_xyz_input,\n", " MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n", diff --git a/pyproject.toml b/pyproject.toml index 1253c2d..fb9c928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] -name = "quantui-local" +name = "quantui" version = "0.1.0" description = "Educational quantum chemistry interface for local PySCF calculations" readme = "README.md" @@ -31,6 +31,7 @@ dependencies = [ "py3Dmol>=2.0.0", "matplotlib>=3.7.0", "plotly>=5.0.0", + "plotlymol>=0.2.1", ] [tool.setuptools] @@ -38,9 +39,11 @@ packages = ["quantui"] [project.optional-dependencies] # PySCF requires Linux/macOS/WSL — not available on Windows natively. -# Use the Apptainer container (apptainer/quantui-local.def) for Windows. +# Use the Apptainer container (apptainer/quantui.def) for Windows. +# pyscf-properties provides pyscf.prop (NMR, etc.) — moved out of core in PySCF 2.0. pyscf = [ "pyscf>=2.3.0", + "pyscf-properties", ] # ASE: structure I/O, extended molecule library, geometry optimisation @@ -103,6 +106,10 @@ ignore = [ "E722", # bare except — cosmetic, verbatim copies ] +[tool.ruff.lint.per-file-ignores] +# __init__.py sets up a NullHandler before deferred optional imports — E402 is intentional +"quantui/__init__.py" = ["E402"] + [tool.ruff.lint.isort] known-first-party = ["quantui"] @@ -125,6 +132,7 @@ module = [ "psutil", "ase.*", "rdkit.*", + "plotlymol3d.*", ] ignore_missing_imports = true diff --git a/quantui/__init__.py b/quantui/__init__.py index 1fb2cbb..23ed0bb 100644 --- a/quantui/__init__.py +++ b/quantui/__init__.py @@ -1,5 +1,5 @@ """ -QuantUI-local Package +QuantUI Package Lightweight educational quantum chemistry interface for local PySCF calculations. No cluster or SLURM required — calculations run directly in the Jupyter session. @@ -9,6 +9,10 @@ __version__ = "0.1.0" +import logging + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + from .calculator import PySCFCalculation, create_calculation # Calculation comparison @@ -111,6 +115,12 @@ except ImportError: pass +# 1D PES scan (optional — requires ase>=3.22 + pyscf, Linux/WSL) +try: + from .pes_scan import PESScanResult, run_pes_scan # noqa: F401 +except ImportError: + pass + # PubChem integration (optional — requires internet) try: from .pubchem import ( diff --git a/quantui/app.py b/quantui/app.py index fd66e9c..e52993c 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1,5 +1,5 @@ """ -QuantUI-local application class. +QuantUI application class. All widget creation, state management, callbacks, and tab wiring live here. The notebook is a thin launcher:: @@ -15,11 +15,16 @@ import asyncio import io +import os import re +import sys import threading import time +import types as _types_mod +import uuid as _uuid +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, List, Optional +from typing import Any, ClassVar, List, Literal, Optional import ipywidgets as widgets from IPython import get_ipython @@ -27,6 +32,7 @@ import quantui import quantui.calc_log as _calc_log +import quantui.issue_tracker as _issue_tracker # Import directly from submodules to avoid circular-import issues. # quantui/__init__.py imports this module (app.py), so using @@ -56,12 +62,53 @@ ASE_AVAILABLE = False try: - from quantui.visualization_py3dmol import display_molecule as _display_molecule + from quantui.visualization_py3dmol import ( + DEFAULT_LIGHTING as _DEFAULT_LIGHTING, + ) + from quantui.visualization_py3dmol import ( + DEFAULT_STYLE as _DEFAULT_VIZ_STYLE, + ) + from quantui.visualization_py3dmol import ( + LIGHTING_OPTIONS as _LIGHTING_OPTIONS, + ) + from quantui.visualization_py3dmol import ( + PLOTLYMOL_AVAILABLE as _PLOTLYMOL_VIZ, + ) + from quantui.visualization_py3dmol import ( + PY3DMOL_AVAILABLE as _PY3DMOL_VIZ, + ) + from quantui.visualization_py3dmol import ( + VIZ_STYLE_OPTIONS as _VIZ_STYLE_OPTIONS, + ) + from quantui.visualization_py3dmol import ( + display_molecule as _display_molecule, + ) VISUALIZATION_AVAILABLE = True except ImportError: VISUALIZATION_AVAILABLE = False _display_molecule = None # type: ignore[assignment] + _PLOTLYMOL_VIZ = False + _PY3DMOL_VIZ = False + _DEFAULT_VIZ_STYLE = "ball+stick" + _DEFAULT_LIGHTING = "soft" + _VIZ_STYLE_OPTIONS = [ + ("Ball & Stick", "ball+stick"), + ("Stick", "stick"), + ("Sphere (VDW)", "sphere"), + ("Line", "line"), + ] + _LIGHTING_OPTIONS = [ + ("Soft", "soft"), + ("Default", "default"), + ("Bright", "bright"), + ("Metallic", "metallic"), + ("Dramatic", "dramatic"), + ] + +_VizBackend = Literal["auto", "py3dmol", "plotlymol"] +_BOTH_VIZ_AVAILABLE: bool = _PLOTLYMOL_VIZ and _PY3DMOL_VIZ +_DEFAULT_VIZ_BACKEND: _VizBackend = "plotlymol" if _PLOTLYMOL_VIZ else "py3dmol" try: from quantui.pubchem import ( @@ -90,6 +137,34 @@ except (ImportError, AttributeError): _PREOPT_AVAILABLE = False +_RDKIT_AVAILABLE: bool = bool(PUBCHEM_AVAILABLE) + +from quantui.benchmarks import ( # noqa: E402 + BENCHMARK_SUITE as _BENCHMARK_SUITE, +) +from quantui.benchmarks import ( # noqa: E402 + BENCHMARK_SUITE_LONG as _BENCHMARK_SUITE_LONG, +) +from quantui.benchmarks import ( # noqa: E402 + load_last_calibration as _load_last_calibration_raw, +) + + +def _load_last_calibration_label() -> str: + """Return a human-readable timestamp of the last calibration, or ''.""" + data = _load_last_calibration_raw() + if data is None: + return "" + ts = str(data.get("timestamp", "")) + try: + from datetime import datetime + + dt = datetime.fromisoformat(ts).astimezone() + return dt.strftime("%Y-%m-%d %H:%M %Z") + except Exception: + return ts[:19] if ts else "" + + # ── Module-level constants ──────────────────────────────────────────────────── _THEME_HUE: dict = {"Dark": 180} @@ -132,6 +207,11 @@ } .widget-dropdown select { border-radius: 5px !important; } .widget-button, .widget-toggle-button { border-radius: 5px !important; } + +/* Suppress Jupyter stderr pink — invert+hue-rotate turns it dark red in Dark mode */ +.jp-OutputArea-stderr, .output_stderr { + background: transparent !important; +} """ @@ -185,12 +265,42 @@ def getvalue(self) -> str: return self._buf.getvalue() +# ══ ANALYSIS CONTEXT ═════════════════════════════════════════════════════════ + + +@dataclass +class _AnalysisContext: + """All data needed to populate Analysis panels for one result. + + Created by ``_do_run()`` for live results and by the history loaders for + saved results. Passed to ``QuantUIApp._apply_analysis_context()``, which + uses ``_PANEL_REGISTRY`` to populate and activate the appropriate panels. + """ + + calc_type: str # "single_point" | "geometry_opt" | etc. + formula: str + method: str + basis: str + live_result: Any = None # result object from _do_run; None for history + result_dir: Optional[Any] = None # Path to saved dir; None before save_result + 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 + source: str = "live" # "live" | "history" + + @property + def label(self) -> str: + if self.method: + return f"{self.formula} {self.method}/{self.basis}" + return self.formula + + # ══ APP CLASS ════════════════════════════════════════════════════════════════ class QuantUIApp: """ - Self-contained QuantUI-local application widget. + Self-contained QuantUI application widget. Instantiate once; call ``display()`` to inject CSS and show the UI:: @@ -203,6 +313,9 @@ def __init__(self) -> None: self._molecule: Optional[Molecule] = None self._last_result: Any = None self._results: List = [] + self._pending_traj_result: Any = None + self.root_tab: widgets.Tab + self._session_id: str = _uuid.uuid4().hex[:12] # Availability (copied from module-level flags) self._pyscf_available: bool = _PYSCF_AVAILABLE @@ -215,9 +328,7 @@ def __init__(self) -> None: # Log startup, but never let optional logging I/O break app startup. try: - _calc_log.log_event( - "startup", f"QuantUI-local {quantui.__version__} started" - ) + _calc_log.log_event("startup", f"QuantUI {quantui.__version__} started") except OSError: pass @@ -227,14 +338,22 @@ def display(self) -> None: display( widgets.VBox( [ + self._welcome_html, widgets.HBox( - [self.theme_btn], + [ + self.theme_btn, + self._help_btn, + self._issue_btn, + self._exit_btn, + ], layout=widgets.Layout( justify_content="flex-end", margin="0 0 4px" ), ), + self._issue_overlay, + self._exit_output, self._theme_style, - self._status_html, + self.help_tab_panel, self.root_tab, ] ) @@ -250,6 +369,7 @@ def widget(self) -> widgets.Tab: def _build_widgets(self) -> None: self._build_theme_selector() self._build_status_panel() + self._build_welcome_header() self._build_shared_widgets() self._build_molecule_section() self._build_calc_setup() @@ -259,6 +379,7 @@ def _build_widgets(self) -> None: self._build_compare_section() self._build_output_tab() self._build_help_section() + self._build_issue_widgets() # ── Theme selector ──────────────────────────────────────────────────── @@ -297,6 +418,11 @@ def _theme_css(self, theme: str) -> str: 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 = '' @@ -321,14 +447,119 @@ def _ok(flag: bool, extra: str = "") -> str: 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-local {quantui.__version__}" - f'{_rows}
' + f'
' + f"QuantUI {quantui.__version__}" + f'' + f"Python {_py_ver}{_env_badge}
" + f'{_rows}
' + f"{_cal_line}" + f"
" + ) + + _steps = [ + "Select a molecule — library dropdown, XYZ paste, or PubChem search", + "Choose a method (RHF / DFT / MP2) and basis set in the Calculate tab", + "Click Run Calculation — SCF progress appears in real time", + "Explore results in the Results and Analysis tabs", + "Browse past calculations in History; compare them in Compare", + ] + _steps_html = "".join( + f'
  • {s}
  • ' + for s in _steps + ) + _guide_html = widgets.HTML( + f'
    ' + f'
    ' + f"Quick start
    " + f'
      {_steps_html}
    ' + f"
    " + ) + + self._status_tab_panel = widgets.VBox( + [self._status_html, _guide_html], + layout=widgets.Layout(padding="8px 0"), + ) + + # ── 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) # ── Shared widgets (Cell 3) ─────────────────────────────────────────── @@ -357,7 +588,50 @@ def _build_shared_widgets(self) -> None: ) ) 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=widgets.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=widgets.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=widgets.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=widgets.Layout(gap="8px", margin="2px 0 0 0", align_items="center"), + ) self.notes_output = widgets.Output() self.perf_estimate_html = widgets.HTML() @@ -405,9 +679,32 @@ def _build_shared_widgets(self) -> None: layout=widgets.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=widgets.Layout(width="240px"), + ) + self.solvent_dd = widgets.Dropdown( + options=list(_SOLVENT_OPTS.keys()), + value="Water", + description="Solvent:", + style={"description_width": "70px"}, + layout=widgets.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)"], + options=[ + "Single Point", + "Geometry Opt", + "Frequency", + "UV-Vis (TD-DFT)", + "NMR Shielding", + "PES Scan", + ], value="Single Point", description="Calc. Type:", style={"description_width": "100px"}, @@ -438,6 +735,105 @@ def _build_shared_widgets(self) -> None: style={"description_width": "100px"}, layout=widgets.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=widgets.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=widgets.Layout(width="32px", height="32px"), + tooltip="Refresh the list of saved geometry optimisations", + ) + self._freq_preopt_cb = widgets.Checkbox( + value=False, + description="Pre-optimize geometry first (recommended for unoptimised inputs)", + style={"description_width": "initial"}, + layout=widgets.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=widgets.Layout(width="220px"), + ) + _atom_idx_layout = widgets.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=widgets.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=widgets.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=widgets.Layout(width="140px"), + ) + self._scan_steps = widgets.BoundedIntText( + value=10, + min=2, + max=100, + description="Points:", + style={"description_width": "50px"}, + layout=widgets.Layout(width="120px"), + ) + self._scan_unit_lbl = widgets.HTML( + 'Å' + ) + self.calc_extra_opts = widgets.VBox([]) # Context-help buttons @@ -495,6 +891,32 @@ def _build_shared_widgets(self) -> None: layout=widgets.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=widgets.Layout(width="130px"), + ) + self.export_mol_btn = widgets.Button( + description="Export MOL", + icon="download", + disabled=True, + tooltip=_rdkit_tip, + layout=widgets.Layout(width="130px"), + ) + self.export_pdb_btn = widgets.Button( + description="Export PDB", + icon="download", + disabled=True, + tooltip=_rdkit_tip, + layout=widgets.Layout(width="130px"), + ) + self.struct_export_status = widgets.Label() # ── Molecule section (Cell 4) ───────────────────────────────────────── @@ -596,8 +1018,17 @@ def _build_molecule_section(self) -> None: [self.mol_summary_compact, self.change_mol_btn], layout=widgets.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( - [self.mol_input_expanded, self.mol_info_html, self.viz_output], + _mol_container_children, layout=widgets.Layout(margin="0 0 4px 0"), ) @@ -632,6 +1063,10 @@ def _build_calc_setup(self) -> None: self.calc_type_dd, self.calc_extra_opts, self.preopt_cb, + widgets.HBox( + [self.solvent_cb, self.solvent_dd], + layout=widgets.Layout(align_items="center", gap="4px"), + ), self.notes_output, ] ) @@ -671,711 +1106,3564 @@ def _build_run_section(self) -> None: # ── Results panel (Cell 7) ──────────────────────────────────────────── def _build_results_section(self) -> None: - self.results_panel = widgets.VBox( - [ - widgets.HTML('

    Results

    '), - self.result_output, - ] + # PES scan energy plot accordion (hidden until a PES Scan completes) + self._pes_plot_html = widgets.HTML( + value="", layout=widgets.Layout(width="100%") ) - - # ── History panel (Cell 8) ──────────────────────────────────────────── - - def _build_history_section(self) -> None: - self.past_dd = widgets.Dropdown( - description="Load:", - options=[("(no saved results)", "")], + self._pes_scan_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self._pes_plot_html], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.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=widgets.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._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=widgets.Layout(width="500px"), + layout=widgets.Layout(width="360px"), ) - self.past_refresh_btn = widgets.Button( - description="Refresh", - button_style="", - icon="refresh", - layout=widgets.Layout(width="100px"), - tooltip="Rescan the results directory", + self.vib_output = widgets.Output() + self.vib_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self.vib_mode_dd, self.vib_output], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), ) - self.copy_path_btn = widgets.Button( - description="Copy path", - button_style="", - icon="clipboard", - layout=widgets.Layout(width="120px"), - tooltip="Copy the results directory path to clipboard", + self.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", "font_size": "12px"}, + layout=widgets.Layout(margin="0 8px 0 0"), ) - self.results_path_lbl = widgets.HTML() - self.past_output = widgets.Output() - self.view_log_btn = widgets.Button( - description="View log", - button_style="", - icon="file-text-o", - layout=widgets.Layout(width="110px"), - tooltip="Open the full PySCF output log in the Output tab", + 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=widgets.Layout(width="260px", display="none"), ) + self._ir_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) - # Performance stats widgets - self._perf_stats_html = widgets.HTML() - self._perf_events_html = widgets.HTML() - self._reset_btn = widgets.Button( - description="Reset performance database", - button_style="danger", - icon="trash", - layout=widgets.Layout(width="230px"), + _ir_controls = widgets.HBox( + [self._ir_mode_toggle, self._ir_fwhm_slider], + layout=widgets.Layout(align_items="center", margin="0 0 6px 0"), ) - self._reset_confirm_html = widgets.HTML( - '' - "Warning: This will permanently delete all performance records. " - "Time estimates will reset to “no data”." + _ir_body_children = [_ir_controls, self._ir_fig] + self._ir_accordion = widgets.Accordion( + children=[ + widgets.VBox( + _ir_body_children, + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), ) - self._reset_confirm_yes = widgets.Button( - description="Yes, delete all records", - button_style="danger", - icon="check", - layout=widgets.Layout(width="190px"), + 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=widgets.Layout(width="140px"), + style={"description_width": "45px"}, ) - self._reset_confirm_no = widgets.Button( - description="Cancel", - button_style="", - icon="times", - layout=widgets.Layout(width="90px"), + self._orb_ymax_input = widgets.BoundedFloatText( + value=5.0, + min=-500.0, + max=500.0, + step=1.0, + description="Y max:", + layout=widgets.Layout(width="140px"), + style={"description_width": "45px"}, ) - self._reset_confirm_box = widgets.VBox( + self._orb_n_orb_input = widgets.BoundedIntText( + value=20, + min=4, + max=200, + step=2, + description="Show N:", + layout=widgets.Layout(width="120px"), + style={"description_width": "50px"}, + ) + _orb_controls_row = widgets.HBox( [ - self._reset_confirm_html, - widgets.HBox( - [self._reset_confirm_yes, self._reset_confirm_no], - layout=widgets.Layout(gap="8px", margin="4px 0 0"), + widgets.HTML( + 'Y range:' ), + self._orb_ymin_input, + self._orb_ymax_input, + widgets.HTML( + '' + "Orbitals shown:" + ), + self._orb_n_orb_input, ], layout=widgets.Layout( - display="none", - border="1px solid #fca5a5", - padding="8px 10px", - margin="6px 0 0", + align_items="center", + flex_wrap="wrap", + gap="4px", + margin="0 0 6px 0", ), ) - - _perf_stats_panel = widgets.VBox( + self._orb_diagram_html = widgets.HTML( + value="", layout=widgets.Layout(width="100%") + ) + _orb_diagram_content: list = [_orb_controls_row, self._orb_diagram_html] + self._orb_diagram_box = widgets.VBox( + _orb_diagram_content, + layout=widgets.Layout(width="100%"), + ) + self._orb_toggle = widgets.ToggleButtons( + options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], + value="HOMO", + style={"button_width": "70px", "font_size": "12px"}, + layout=widgets.Layout(margin="8px 0 4px 0"), + ) + self._orb_iso_output = widgets.Output() + self._orb_iso_controls = widgets.VBox( [ - self._perf_stats_html, widgets.HTML( - '

    ' - "Recent events (last 20)

    " - ), - self._perf_events_html, - widgets.HBox( - [self._reset_btn], - layout=widgets.Layout(margin="14px 0 4px"), + '' + "Orbital isosurface:" ), - self._reset_confirm_box, - ] + self._orb_toggle, + self._orb_iso_output, + ], + layout=widgets.Layout(display="none", margin="8px 0 0 0"), ) - self._perf_accordion = widgets.Accordion( - children=[_perf_stats_panel], selected_index=None + self._orb_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self._orb_diagram_box], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), ) - self._perf_accordion.set_title(0, "Performance stats") + self._orb_accordion.set_title(0, "Orbital Diagram") + self._orb_accordion.selected_index = None - self.history_panel = widgets.VBox( + # 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=widgets.Layout(width="200px", margin="8px 0 4px 0"), + ) + _iso_body = widgets.VBox( [ widgets.HTML( - '

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

    " - ), - widgets.HBox( - [ - self.past_dd, - self.past_refresh_btn, - self.copy_path_btn, - self.view_log_btn, - ], - layout=widgets.Layout(align_items="center", gap="8px"), + '

    ' + "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.results_path_lbl, - self.past_output, - self._perf_accordion, - ] + self._orb_iso_controls, + self._iso_generate_btn, + ], + layout=widgets.Layout(padding="8px"), ) + self._iso_accordion = widgets.Accordion( + children=[_iso_body], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self._iso_accordion.set_title(0, "Orbital Isosurface") + self._iso_accordion.selected_index = None - # Populate on startup - self._refresh_results_browser() - self._refresh_perf_stats() + # ── UV-Vis spectrum accordion (TD-DFT only — hidden until result) ── + self._tddft_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._tddft_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self._tddft_fig], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum") + self._tddft_accordion.selected_index = None - # ── Compare panel (Cell 9) ──────────────────────────────────────────── + # ── NMR shielding accordion (NMR only — hidden until result) ──────── + self._nmr_output = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._nmr_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self._nmr_output], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self._nmr_accordion.set_title(0, "NMR Chemical Shifts") + self._nmr_accordion.selected_index = None - def _build_compare_section(self) -> None: - self.compare_select = widgets.SelectMultiple( - options=[("(no saved results)", "")], - rows=8, - description="", - layout=widgets.Layout(width="100%"), + # ── Result directory path label (hidden until a calculation saves) ── + self._result_dir_label = widgets.HTML( + value="", + layout=widgets.Layout(display="none", margin="4px 0 0 0"), ) - self.compare_refresh_btn = widgets.Button( - description="Refresh", - button_style="", - icon="refresh", - layout=widgets.Layout(width="100px"), + + # ── 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=widgets.Layout(display="none", margin="8px 0 0 0"), ) - self.compare_btn = widgets.Button( - description="Compare selected", - button_style="primary", - icon="bar-chart", - disabled=True, - layout=widgets.Layout(width="180px"), + 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=widgets.Layout(width="130px"), ) - self.compare_clear_btn = widgets.Button( - description="Clear", - button_style="warning", - icon="times", - layout=widgets.Layout(width="90px"), + self._go_analysis_btn = widgets.Button( + description="→ View Analysis", + button_style="info", + layout=widgets.Layout(width="140px"), ) - self.compare_output = widgets.Output() - - self.compare_panel = widgets.VBox( + self._completion_mol_lbl = widgets.HTML(value="") + self._completion_banner = widgets.HBox( [ widgets.HTML( - '

    Compare Calculations

    ' - '

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

    " + '' + "✓ Calculation complete — " ), - widgets.HBox([self.compare_refresh_btn]), - self.compare_select, - widgets.HBox( - [self.compare_btn, self.compare_clear_btn], - layout=widgets.Layout(gap="8px", margin="6px 0"), - ), - self.compare_output, + self._completion_mol_lbl, + self._go_results_btn, + self._go_analysis_btn, ], - layout=widgets.Layout(padding="8px 0"), - ) - - # Export accordion (Advanced) - _export_content = widgets.VBox( - [ - widgets.HTML( - '

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

    " - ), - widgets.HBox([self.export_btn, self.export_status]), - ] + layout=widgets.Layout( + display="none", + align_items="center", + gap="8px", + padding="10px 12px", + border="1px solid #bbf7d0", + border_radius="6px", + background_color="#f0fdf4", + margin="8px 0", + ), ) - self.advanced_accordion = widgets.Accordion(children=[_export_content]) - self.advanced_accordion.set_title(0, "Export Script") - self.advanced_accordion.selected_index = None - # Populate on startup - self._populate_compare_list() - - # ── Output log tab (Cell 10) ────────────────────────────────────────── - - def _build_output_tab(self) -> None: - self._log_output_html = widgets.HTML( - '' - "No log yet — run a calculation first, or use " - "View log in the History tab." - ) - self._log_source_lbl = widgets.HTML() - self._log_clear_btn = widgets.Button( - description="Clear", + # ── Results tab panel (Tab 1) ───────────────────────────────────── + self._to_analysis_btn = widgets.Button( + description="→ View Analysis", button_style="", - icon="times", - layout=widgets.Layout(width="80px"), + icon="bar-chart", + layout=widgets.Layout(display="none", width="160px", margin="8px 0 0 0"), ) - self.log_tab_panel = widgets.VBox( + # 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=widgets.Layout(display="none"), + ) + self.results_tab_panel = widgets.VBox( [ - widgets.HTML( - '

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

    " - ), - widgets.HBox( - [self._log_clear_btn], - layout=widgets.Layout(margin="0 0 8px"), - ), - self._log_source_lbl, - self._log_output_html, + 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=widgets.Layout(padding="8px 0"), ) + # Backward-compat alias — existing methods that reference results_panel still work + self.results_panel = self.results_tab_panel - # ── Help section (Cell 10) ──────────────────────────────────────────── + # ── Analysis tab: molecule viewer (shown for all calc types) ───── + self._analysis_mol_output = widgets.Output() - def _build_help_section(self) -> None: - _help_keys = list(HELP_TOPICS.keys()) - _help_labels = [HELP_TOPICS[k]["title"] for k in _help_keys] - self.help_topic_dd = widgets.Dropdown( - options=list(zip(_help_labels, _help_keys)), - description="Topic:", - style={"description_width": "60px"}, - layout=widgets.Layout(width="460px"), + # ── 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.help_content_html = widgets.HTML() - self._render_help_topic() # render first topic immediately - - self.help_tab_panel = widgets.VBox( + 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=widgets.Layout(display="none"), + ) + self._build_ana_switcher() + self.analysis_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, + self._analysis_context_lbl, + self._analysis_mol_output, + self._analysis_empty_html, + self._ana_switcher_box, + 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=widgets.Layout(padding="8px 0"), ) + # Backward-compat alias for post_calc_panel references in tests + self.post_calc_panel = self.analysis_tab_panel - # ── Tab assembly (Cell 10) ──────────────────────────────────────────── + # ── Analysis panel switcher ─────────────────────────────────────────── - def _assemble_tabs(self) -> None: - _calculate_content = widgets.VBox( + def _build_ana_switcher(self) -> None: + """Build the always-visible panel switcher strip for the Analysis tab.""" + _PANEL_META = [ + ("Energies", self._orb_accordion, "Single Point / Geometry Opt"), + ( + "Trajectory", + self.traj_accordion, + "Geometry Opt / PES Scan / Frequency pre-opt", + ), + ("Vibrational", self.vib_accordion, "Frequency"), + ("IR Spectrum", self._ir_accordion, "Frequency"), + ("PES Scan", self._pes_scan_accordion, "PES Scan"), + ("Isosurface", self._iso_accordion, "Single Point (Linux/WSL only)"), + ("UV-Vis", self._tddft_accordion, "UV-Vis (TD-DFT)"), + ("NMR", self._nmr_accordion, "NMR Shielding"), + ] + 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=widgets.Layout(display="none", margin="4px 0 8px"), + ) + self._ana_btns: list = [] + for name, _acc, when in _PANEL_META: + btn = widgets.Button( + description=name, + button_style="", + tooltip=f"Available after: {when}", + layout=widgets.Layout(margin="0"), + ) + btn.layout.opacity = "0.35" + btn.on_click(lambda _b, n=name: self._on_ana_panel_click(n)) + self._ana_btns.append(btn) + self._ana_switcher_box = widgets.HBox( + self._ana_btns, + layout=widgets.Layout( + flex_wrap="wrap", + gap="4px", + margin="0 0 6px 0", + padding="6px 4px", + border="1px solid #e2e8f0", + border_radius="6px", + ), + ) + + def _on_ana_panel_click(self, name: str) -> None: + if name in self._ana_available: + self._select_ana_panel(name) + else: + # Grey out all buttons except clicked; show "not available" note + for btn in self._ana_btns: + btn.button_style = "" + for btn, pname in zip(self._ana_btns, self._ana_panel_names): + if pname == name: + btn.button_style = "warning" + for acc in self._ana_accordions: + acc.layout.display = "none" + self._ana_unavail_html.value = ( + f'

    ' + f"{name} is not available for this calculation type.

    " + ) + self._ana_unavail_html.layout.display = "" + self._ana_active = "" + + def _select_ana_panel(self, name: str) -> None: + """Show the named panel; hide all others and update button styles.""" + self._ana_active = name + self._ana_unavail_html.layout.display = "none" + for pname, acc, btn in zip( + self._ana_panel_names, self._ana_accordions, self._ana_btns + ): + if pname == name: + acc.layout.display = "" + acc.selected_index = 0 + btn.button_style = "primary" + else: + acc.layout.display = "none" + btn.button_style = "" + + def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: + """Mark a panel as available (full opacity) and optionally select it.""" + self._ana_available.add(name) + for btn, pname in zip(self._ana_btns, self._ana_panel_names): + if pname == name: + btn.layout.opacity = "1.0" + btn.tooltip = name + if auto_select: + self._select_ana_panel(name) + + def _deactivate_all_ana_panels(self) -> None: + """Reset all panels to hidden/unavailable; used at start of each new run.""" + self._ana_available.clear() + self._ana_active = "" + self._ana_unavail_html.layout.display = "none" + for acc, btn, _name, meta in zip( + self._ana_accordions, + self._ana_btns, + self._ana_panel_names, + # Re-read tooltips from scratch — must mirror _PANEL_META order [ - self.step_progress.widget, - self.mol_input_container, - self.calc_setup_panel, - self.run_panel, - self.results_panel, - self.advanced_accordion, + "Single Point / Geometry Opt", + "Geometry Opt / PES Scan / Frequency pre-opt", + "Frequency", + "Frequency", + "PES Scan", + "Single Point (Linux/WSL only)", + "UV-Vis (TD-DFT)", + "NMR Shielding", ], - layout=widgets.Layout(padding="8px 0"), - ) + ): + acc.layout.display = "none" + acc.selected_index = None + btn.button_style = "" + btn.layout.opacity = "0.35" + btn.tooltip = f"Available after: {meta}" + + # ── Panel registry and unified applier ─────────────────────────────────── + # + # _PANEL_REGISTRY maps calc_type string → ordered list of + # (panel_name, populate_method_name, auto_select) tuples. + # + # Rules: + # • populate_method_name is a string — looked up via getattr at runtime. + # • auto_select=True on the FIRST entry that returns True activates that + # panel as the default view; subsequent entries with auto_select=True are + # treated as False (only one panel is auto-selected per result). + # • If a populate method returns False / None the panel stays disabled. + # • Populate methods must NOT call _activate_ana_panel themselves. + + _PANEL_REGISTRY: ClassVar[dict] = { + "single_point": [ + ("Energies", "_pop_energies", True), + ("Isosurface", "_pop_isosurface", False), + ], + "geometry_opt": [ + ("Trajectory", "_pop_geo_trajectory", True), + ("Energies", "_pop_energies", False), + ("Isosurface", "_pop_isosurface", False), + ], + "frequency": [ + ("Vibrational", "_pop_vibrational", True), + ("IR Spectrum", "_pop_ir_spectrum", True), + ("Trajectory", "_pop_preopt_trajectory", False), + ("Energies", "_pop_energies", True), + ], + "tddft": [ + ("UV-Vis", "_pop_uv_vis", True), + ], + "nmr": [ + ("NMR", "_pop_nmr_shielding", True), + ], + "pes_scan": [ + ("PES Scan", "_pop_pes_plot", True), + ("Trajectory", "_pop_pes_trajectory", False), + ], + } + + 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 - self.root_tab = widgets.Tab( - children=[ - _calculate_content, - self.history_panel, - self.compare_panel, - self.log_tab_panel, - self.help_tab_panel, - ] + _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}

    " ) - self.root_tab.set_title(0, "Calculate") - self.root_tab.set_title(1, "History") - self.root_tab.set_title(2, "Compare") - self.root_tab.set_title(3, "Output") - self.root_tab.set_title(4, "Help") - - # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ + _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 "" - def _wire_callbacks(self) -> None: - # Theme - self.theme_btn.observe(self._on_theme_changed, names="value") - # Molecule input - self.preset_dd.observe(self._on_load_preset, names="value") - self.xyz_btn.on_click(self._on_load_xyz) - self.pubchem_btn.on_click(self._on_search_pubchem) - self.change_mol_btn.on_click(self._on_expand_mol_input) - # Calc type - self.calc_type_dd.observe(self._on_calc_type_changed, names="value") - # Notes + estimate - self.method_dd.observe(self._update_notes, names="value") - self.basis_dd.observe(self._update_notes, names="value") - self.method_dd.observe(self._update_estimate, names="value") - self.basis_dd.observe(self._update_estimate, names="value") - # Help buttons - self.method_help_btn.on_click(self._on_method_help) - self.basis_help_btn.on_click(self._on_basis_help) - # Run - self.run_btn.on_click(self._on_run_clicked) - self.log_clear_btn.on_click(self._on_clear_log) - # Accumulate / export - self.accumulate_btn.on_click(self._on_accumulate) - self.clear_btn.on_click(self._on_clear) - self.export_btn.on_click(self._on_export) - # History - self.past_dd.observe(self._on_past_dd_changed, names="value") - self.past_refresh_btn.on_click(self._on_past_refresh) - self.copy_path_btn.on_click(self._on_copy_results_path) - self.view_log_btn.on_click(self._on_view_log) - # Perf stats reset - self._reset_btn.on_click(self._on_reset_click) - self._reset_confirm_yes.on_click(self._on_confirm_yes) - self._reset_confirm_no.on_click(self._on_confirm_no) - # Compare - self.compare_refresh_btn.on_click(self._on_compare_refresh) - self.compare_btn.on_click(self._on_compare) - self.compare_clear_btn.on_click(self._on_compare_clear) - # Output log - self._log_clear_btn.on_click(self._on_log_clear) - # Help - self.help_topic_dd.observe(self._on_help_topic_changed, names="value") + # ── Panel populate methods ──────────────────────────────────────────────── + # Each receives an _AnalysisContext and returns True if data was rendered. - # ══ CALLBACK METHODS ═════════════════════════════════════════════════════ + 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 - # ── Theme ───────────────────────────────────────────────────────────── + orb = load_orbitals(ctx.result_dir) + orb.formula = ctx.formula + result = orb + except Exception: + return False + return self._show_orbital_diagram(result) - def _on_theme_changed(self, change) -> None: - self._theme_style.clear_output() - css = self._theme_css(change["new"]) - if css: - with self._theme_style: - display(HTML(css)) + 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 + ) - # ── Molecule input ──────────────────────────────────────────────────── + 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 - def _on_load_preset(self, change) -> None: - name = change["new"] - if name.startswith("("): - return - d = MOLECULE_LIBRARY[name] - self._set_molecule( - Molecule( - atoms=d["atoms"], - coordinates=d["coordinates"], - charge=d["charge"], - multiplicity=d["multiplicity"], - ), - d["description"], + 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 + + def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool: + # Pre-opt trajectory is only available for live Frequency runs that + # had the pre-opt checkbox enabled. Not stored to disk, so history + # replay cannot show it. + pre = ctx.preopt_result + if pre is None: + return False + traj = getattr(pre, "trajectory", None) + energies = list(getattr(pre, "energies_hartree", [])) + 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 + + 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 ints 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) - def _on_load_xyz(self, btn) -> None: + 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) + + 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: - atoms, coords = parse_xyz_input(self.xyz_area.value.strip()) - mol = Molecule(atoms=atoms, coordinates=coords) - self._set_molecule(mol, "Loaded from XYZ input") - self.xyz_msg.value = "" - except Exception as exc: - self.xyz_msg.value = f"Parse error: {exc}" + 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._tddft_fig.value = _pio.to_html( + _fig, include_plotlyjs="cdn", full_html=False + ) + return True + except Exception: + return False - def _apply_pubchem_search_result( - self, - query: str, - mol: Optional[Molecule] = None, - error: Optional[Exception] = None, - ) -> None: - if error is None and mol is not None: - self._set_molecule(mol, f"PubChem: {query}") - self.pubchem_msg.value = f"Loaded {mol.get_formula()} from PubChem." + 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: - self.pubchem_msg.value = f"Not found: {error}" - self.pubchem_btn.disabled = False + 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 + ) - def _on_search_pubchem(self, btn) -> None: - query = self.pubchem_txt.value.strip() - if not query: - self.pubchem_msg.value = "Enter a molecule name or SMILES." - return - if _student_friendly_fetch is None: - self.pubchem_msg.value = "PubChem module not available." - return - self.pubchem_msg.value = f'Searching for "{query}"...' - self.pubchem_btn.disabled = True + shielding_rows = "".join( + f'{sym}{i + 1}' + f'{s:.2f}' + for i, (sym, s) in enumerate(zip(atom_symbols, shielding)) + ) + html = ( + f'
    ' + f'' + f'' + f'' + f"{shielding_rows}
    Atomσ (ppm)
    " + f'' + f"{_shift_table('¹H', h_shifts, 'H')}" + f"{_shift_table('¹³C', c_shifts, 'C')}" + f"
    " + ) + self._nmr_output.value = html + return True + + def _pop_pes_plot(self, ctx: _AnalysisContext) -> bool: + result = ctx.live_result + if result is None: + return False # PES energy data not stored to disk; live-only for now + return self._show_pes_scan_result(result) + + 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 + + # ── History panel (Cell 8) ──────────────────────────────────────────── + + def _build_history_section(self) -> None: + self.past_dd = widgets.Dropdown( + description="Load:", + options=[("(no saved results)", "")], + style={"description_width": "50px"}, + layout=widgets.Layout(width="500px"), + ) + self.past_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=widgets.Layout(width="100px"), + tooltip="Rescan the results directory", + ) + self.copy_path_btn = widgets.Button( + description="Copy path", + button_style="", + icon="clipboard", + layout=widgets.Layout(width="120px"), + tooltip="Copy the results directory path to clipboard", + ) + self.results_path_lbl = widgets.HTML() + self.past_output = widgets.Output() + self.view_log_btn = widgets.Button( + description="View log", + button_style="", + icon="file-text-o", + layout=widgets.Layout(width="110px"), + tooltip="Open the full PySCF output log in the Output tab", + ) + + # 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=widgets.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=widgets.Layout(width="180px"), + ) + self._cal_stop_btn = widgets.Button( + description="Stop", + button_style="warning", + icon="stop", + layout=widgets.Layout(width="90px", display="none"), + ) + self._cal_progress = widgets.IntProgress( + min=0, + max=len(_BENCHMARK_SUITE), + value=0, + description="", + bar_style="info", + layout=widgets.Layout(width="300px", display="none"), + ) + self._cal_step_label = widgets.HTML( + value="", + layout=widgets.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=widgets.Layout(width="230px"), + ) + self._reset_confirm_html = widgets.HTML( + '' + "Warning: This will permanently delete all performance records. " + "Time estimates will reset to “no data”." + ) + self._reset_confirm_yes = widgets.Button( + description="Yes, delete all records", + button_style="danger", + icon="check", + layout=widgets.Layout(width="190px"), + ) + self._reset_confirm_no = widgets.Button( + description="Cancel", + button_style="", + icon="times", + layout=widgets.Layout(width="90px"), + ) + self._reset_confirm_box = widgets.VBox( + [ + self._reset_confirm_html, + widgets.HBox( + [self._reset_confirm_yes, self._reset_confirm_no], + layout=widgets.Layout(gap="8px", margin="4px 0 0"), + ), + ], + layout=widgets.Layout( + display="none", + border="1px solid #fca5a5", + padding="8px 10px", + margin="6px 0 0", + ), + ) + + _perf_stats_panel = widgets.VBox( + [ + self._perf_stats_html, + widgets.HTML( + '

    ' + "Recent events (last 20)

    " + ), + self._perf_events_html, + widgets.HBox( + [self._reset_btn], + layout=widgets.Layout(margin="14px 0 4px"), + ), + self._reset_confirm_box, + ] + ) + self._perf_accordion = widgets.Accordion( + children=[_perf_stats_panel], selected_index=None + ) + self._perf_accordion.set_title(0, "Performance stats") + + # 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=widgets.Layout(gap="6px", align_items="center"), + ), + self._cal_progress, + self._cal_step_label, + self._cal_results_html, + ], + layout=widgets.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=widgets.Layout(align_items="center", gap="8px"), + ), + self.results_path_lbl, + self.past_output, + self._perf_accordion, + self._cal_accordion, + ] + ) + + # Populate on startup + self._refresh_results_browser() + self._refresh_perf_stats() + + # ── Compare panel (Cell 9) ──────────────────────────────────────────── + + def _build_compare_section(self) -> None: + self.compare_select = widgets.SelectMultiple( + options=[("(no saved results)", "")], + rows=8, + description="", + layout=widgets.Layout(width="100%"), + ) + self.compare_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=widgets.Layout(width="100px"), + ) + self.compare_btn = widgets.Button( + description="Compare selected", + button_style="primary", + icon="bar-chart", + disabled=True, + layout=widgets.Layout(width="180px"), + ) + self.compare_clear_btn = widgets.Button( + description="Clear", + button_style="warning", + icon="times", + layout=widgets.Layout(width="90px"), + ) + self.compare_output = widgets.Output() + + self.compare_panel = widgets.VBox( + [ + widgets.HTML( + '

    Compare Calculations

    ' + '

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

    " + ), + widgets.HBox([self.compare_refresh_btn]), + self.compare_select, + widgets.HBox( + [self.compare_btn, self.compare_clear_btn], + layout=widgets.Layout(gap="8px", margin="6px 0"), + ), + self.compare_output, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # Export accordion (Advanced) + _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=widgets.Layout(flex_flow="row wrap", gap="6px"), + ), + self.struct_export_status, + ] + ) + 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=widgets.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=widgets.Layout(width="160px"), + ) + self._clear_log_cache_confirm_btn = widgets.Button( + description="Confirm clear?", + button_style="danger", + layout=widgets.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=widgets.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=widgets.Layout(align_items="center", gap="8px"), + ), + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # ── Help section (Cell 10) ──────────────────────────────────────────── + + def _build_help_section(self) -> None: + _help_keys = list(HELP_TOPICS.keys()) + _help_labels = [HELP_TOPICS[k]["title"] for k in _help_keys] + self.help_topic_dd = widgets.Dropdown( + options=list(zip(_help_labels, _help_keys)), + description="Topic:", + style={"description_width": "60px"}, + layout=widgets.Layout(width="460px"), + ) + self.help_content_html = widgets.HTML() + self._render_help_topic() # render first topic immediately + + # [?] toggle button shown in the top bar + self._help_btn = widgets.Button( + description="?", + button_style="", + tooltip="Help topics", + layout=widgets.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=widgets.Layout(width="64px", margin="0 0 0 8px"), + ) + self._exit_output = widgets.Output( + layout=widgets.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=widgets.Layout( + display="none", + padding="8px 0", + border="1px solid #e2e8f0", + border_radius="6px", + padding_left="12px", + margin="0 0 8px", + ), + ) + + 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=widgets.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=widgets.Layout(width="100%", height="90px"), + ) + self._issue_submit_btn = widgets.Button( + description="Submit", + button_style="success", + layout=widgets.Layout(width="90px"), + ) + self._issue_cancel_btn = widgets.Button( + description="Cancel", + button_style="", + layout=widgets.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=widgets.Layout(margin="6px 0 0", gap="8px"), + ), + self._issue_status_html, + ], + layout=widgets.Layout( + display="none", + border="1px solid #f59e0b", + border_radius="6px", + padding="12px 14px", + margin="0 0 6px", + background_color="#fffbeb", + ), + ) + + # ── Tab assembly (Cell 10) ──────────────────────────────────────────── + + def _assemble_tabs(self) -> None: + _calculate_content = widgets.VBox( + [ + self.step_progress.widget, + self.mol_input_container, + self.calc_setup_panel, + self.run_panel, + self._completion_banner, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # Splice advanced_accordion into results_tab_panel before _to_analysis_btn. + # It cannot be referenced in _build_results_section because it is built later + # in _build_compare_section. + _rtp = list(self.results_tab_panel.children) + _rtp.insert(_rtp.index(self._to_analysis_btn), self.advanced_accordion) + self.results_tab_panel.children = tuple(_rtp) + + self.root_tab = widgets.Tab( + children=[ + _calculate_content, + self.results_tab_panel, + self.analysis_tab_panel, + self.history_panel, + self.compare_panel, + self.log_tab_panel, + self._status_tab_panel, + ] + ) + self.root_tab.set_title(0, "Calculate") + self.root_tab.set_title(1, "Results") + self.root_tab.set_title(2, "Analysis") + 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") + + # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ + + def _wire_callbacks(self) -> None: + # 3D viewer backend toggle (only wired when both backends are available) + if self.viz_backend_toggle is not None: + self.viz_backend_toggle.observe(self._on_viz_backend_changed, names="value") + # 3D viewer style and lighting controls + if VISUALIZATION_AVAILABLE: + self.viz_style_dd.observe(self._on_viz_style_changed, names="value") + self.viz_lighting_dd.observe(self._on_viz_lighting_changed, names="value") + # Theme + self.theme_btn.observe(self._on_theme_changed, names="value") + # Molecule input + self.preset_dd.observe(self._on_load_preset, names="value") + self.xyz_btn.on_click(self._on_load_xyz) + self.pubchem_btn.on_click(self._on_search_pubchem) + self.change_mol_btn.on_click(self._on_expand_mol_input) + # Calc type + self.calc_type_dd.observe(self._on_calc_type_changed, names="value") + self._freq_seed_dd.observe(self._on_freq_seed_changed, names="value") + self._scan_type_dd.observe(self._update_scan_widgets, names="value") + self._freq_seed_refresh_btn.on_click( + lambda _btn: self._refresh_freq_seed_options() + ) + # Notes + estimate + self.method_dd.observe(self._update_notes, names="value") + self.basis_dd.observe(self._update_notes, names="value") + self.method_dd.observe(self._update_estimate, names="value") + self.basis_dd.observe(self._update_estimate, names="value") + # Help buttons + self.method_help_btn.on_click(self._on_method_help) + self.basis_help_btn.on_click(self._on_basis_help) + # Run + self.run_btn.on_click(self._on_run_clicked) + self.log_clear_btn.on_click(self._on_clear_log) + # Accumulate / export + self.accumulate_btn.on_click(self._on_accumulate) + self.clear_btn.on_click(self._on_clear) + self.solvent_cb.observe(self._on_solvent_cb_changed, names="value") + self._cal_run_btn.on_click(self._on_cal_run) + self._cal_stop_btn.on_click(self._on_cal_stop) + self.export_btn.on_click(self._on_export) + self.export_xyz_btn.on_click(self._on_export_xyz) + self.export_mol_btn.on_click(self._on_export_mol) + self.export_pdb_btn.on_click(self._on_export_pdb) + # History + self.past_dd.observe(self._on_past_dd_changed, names="value") + self.past_refresh_btn.on_click(self._on_past_refresh) + self.copy_path_btn.on_click(self._on_copy_results_path) + self.view_log_btn.on_click(self._on_view_log) + # Perf stats reset + self._reset_btn.on_click(self._on_reset_click) + self._reset_confirm_yes.on_click(self._on_confirm_yes) + self._reset_confirm_no.on_click(self._on_confirm_no) + # Compare + self.compare_refresh_btn.on_click(self._on_compare_refresh) + self.compare_btn.on_click(self._on_compare) + self.compare_clear_btn.on_click(self._on_compare_clear) + # Output log + self._log_clear_btn.on_click(self._on_log_clear) + # 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) + # Issue reporting + self._issue_btn.on_click(self._on_issue_btn) + self._issue_submit_btn.on_click(self._on_issue_submit) + self._issue_cancel_btn.on_click(self._on_issue_cancel) + # Help [?] toggle + self._help_btn.on_click(self._on_help_toggle) + # Exit + self._exit_btn.on_click(self._on_exit_clicked) + self.help_topic_dd.observe(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) + ) + # Vibrational mode selector + self.vib_mode_dd.observe(self._on_vib_mode_changed, names="value") + # Orbital diagram axis controls + self._orb_ymin_input.observe(self._on_orb_range_changed, names="value") + self._orb_ymax_input.observe(self._on_orb_range_changed, names="value") + self._orb_n_orb_input.observe(self._on_orb_range_changed, names="value") + # Orbital isosurface generate button + self._iso_generate_btn.on_click(self._on_iso_generate) + + # ══ CALLBACK METHODS ═════════════════════════════════════════════════════ + + # ── Theme ───────────────────────────────────────────────────────────── + + def _on_theme_changed(self, change) -> None: + self._theme_style.clear_output() + css = self._theme_css(change["new"]) + if css: + with self._theme_style: + display(HTML(css)) + self._rerender_plotly_theme() + + def _plotly_theme_colors(self) -> dict: + """Return plot colors tuned for the current theme. + + The dark theme is a CSS invert+hue-rotate filter on the whole page. + For SVG/div elements (2D charts): html filter already inverts, so we + use light values and let the filter make them dark. + For WebGL canvas (3D scenes): canvas has a counter-filter that cancels + the html filter, so the color appears as-is — use scene_bgcolor. + """ + is_dark = self.theme_btn.value == "Dark" + return { + "plot_bgcolor": "white", # html filter darkens this in dark mode + "paper_bgcolor": "white", # html filter darkens this in dark mode + "font_color": "#111827", # html filter lightens → white text in dark + "grid_color": "#e5e7eb", # html filter darkens → subtle grid in dark + "scene_bgcolor": "#000000" if is_dark else "#ffffff", + } + + def _apply_plotly_theme(self, fig) -> None: + """Apply current theme colors to a plotly Figure in-place.""" + tc = self._plotly_theme_colors() + fig.update_layout( + plot_bgcolor=tc["plot_bgcolor"], + paper_bgcolor=tc["paper_bgcolor"], + font=dict(color=tc["font_color"]), + xaxis=dict(gridcolor=tc["grid_color"]), + yaxis=dict(gridcolor=tc["grid_color"]), + ) + + def _rerender_plotly_theme(self) -> None: + """Re-render all visible Plotly charts in the updated theme.""" + if getattr(self, "_last_orb_info", None) is not None: + self._on_orb_range_changed() + if getattr(self, "_last_ir_freqs", None) is not None: + self._update_ir_figure( + 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) + # 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() + with self.viz_output: + _display_molecule( + self._molecule, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + def _on_viz_backend_changed(self, change) -> None: + self._viz_backend = change["new"] # type: ignore[assignment] + # Lighting only works with the PlotlyMol backend + _lighting_usable = _PLOTLYMOL_VIZ and self._viz_backend == "plotlymol" + self.viz_lighting_dd.disabled = not _lighting_usable + self.viz_lighting_dd.layout.visibility = ( + "visible" if _lighting_usable else "hidden" + ) + if self._molecule is not None and _display_molecule is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule( + self._molecule, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + def _on_viz_style_changed(self, change) -> None: + self._viz_style = change["new"] + if self._molecule is not None and _display_molecule is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule( + self._molecule, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + def _on_viz_lighting_changed(self, change) -> None: + self._viz_lighting = change["new"] + if self._molecule is not None and _display_molecule is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule( + self._molecule, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + # ── Molecule input ──────────────────────────────────────────────────── + + def _on_load_preset(self, change) -> None: + name = change["new"] + if name.startswith("("): + return + d = MOLECULE_LIBRARY[name] + self._set_molecule( + Molecule( + atoms=d["atoms"], + coordinates=d["coordinates"], + charge=d["charge"], + multiplicity=d["multiplicity"], + ), + d["description"], + ) + + def _on_load_xyz(self, btn) -> None: + try: + atoms, coords = parse_xyz_input(self.xyz_area.value.strip()) + mol = Molecule(atoms=atoms, coordinates=coords) + self._set_molecule(mol, "Loaded from XYZ input") + self.xyz_msg.value = "" + except Exception as exc: + self.xyz_msg.value = f"Parse error: {exc}" + + def _apply_pubchem_search_result( + self, + query: str, + mol: Optional[Molecule] = None, + error: Optional[Exception] = None, + ) -> None: + if error is None and mol is not None: + self._set_molecule(mol, f"PubChem: {query}") + self.pubchem_msg.value = f"Loaded {mol.get_formula()} from PubChem." + else: + self.pubchem_msg.value = f"Not found: {error}" + try: + _calc_log.log_event( + "pubchem_search_failed", + f"PubChem query not found: '{query}'", + query=query, + error=str(error)[:200], + session_id=self._session_id, + ) + except Exception: + pass + self.pubchem_btn.disabled = False + + def _on_search_pubchem(self, btn) -> None: + query = self.pubchem_txt.value.strip() + if not query: + self.pubchem_msg.value = "Enter a molecule name or SMILES." + return + if _student_friendly_fetch is None: + self.pubchem_msg.value = "PubChem module not available." + return + self.pubchem_msg.value = f'Searching for "{query}"...' + self.pubchem_btn.disabled = True + + loop = asyncio.get_running_loop() + + def _do(): + try: + xyz_str, _msg = _student_friendly_fetch(query) + if xyz_str is None: + raise ValueError(_msg) + atoms, coords = parse_xyz_input(xyz_str) + mol = Molecule(atoms=atoms, coordinates=coords) + loop.call_soon_threadsafe( + self._apply_pubchem_search_result, + query, + mol, + None, + ) + except Exception as exc: + loop.call_soon_threadsafe( + self._apply_pubchem_search_result, + query, + None, + exc, + ) + + 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 + + # ── Calc type ───────────────────────────────────────────────────────── + + def _on_calc_type_changed(self, change) -> None: + ct = change["new"] + if ct == "Geometry Opt": + self.calc_extra_opts.children = [ + widgets.HBox( + [self.fmax_fi, self.max_steps_si], + layout=widgets.Layout(gap="8px"), + ), + ] + elif ct == "Frequency": + self._refresh_freq_seed_options() + self.calc_extra_opts.children = [ + widgets.HBox( + [self._freq_seed_dd, self._freq_seed_refresh_btn], + layout=widgets.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=widgets.Layout(margin="0 0 4px 0"), + ), + widgets.HBox( + [self._scan_atom1, self._scan_atom2], + layout=widgets.Layout(gap="4px"), + ), + self._scan_atom34_box, + widgets.HBox( + [ + self._scan_start, + self._scan_stop, + self._scan_steps, + self._scan_unit_lbl, + ], + layout=widgets.Layout(gap="4px", align_items="center"), + ), + ] + else: + self.calc_extra_opts.children = [] + + 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 = ( + '°' + ) + + 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 + + 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 = "" + + # ── Help buttons ────────────────────────────────────────────────────── + + def _on_method_help(self, btn) -> None: + self._show_help_topic("method") + + def _on_basis_help(self, btn) -> None: + self._show_help_topic("basis_set") + + # ── Run ─────────────────────────────────────────────────────────────── + + def _on_run_clicked(self, btn) -> None: + 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._pes_plot_html.value = "" + 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() + + def _on_solvent_cb_changed(self, change) -> None: + self.solvent_dd.layout.display = "" if change["new"] else "none" + + def _on_clear_log(self, btn) -> None: + self.run_output.clear_output() + + # ── Accumulate / export ─────────────────────────────────────────────── + + def _on_accumulate(self, btn) -> None: + r = self._last_result + if r is None: + return + self._results.append(r) + self._refresh_comparison() + + def _on_clear(self, btn) -> None: + self._results.clear() + self.comparison_output.clear_output() + + def _on_export(self, btn) -> None: + if self._molecule is None: + self.export_status.value = "Load a molecule first." + return + try: + from quantui import PySCFCalculation + + calc = PySCFCalculation( + self._molecule, + method=self.method_dd.value, + basis=self.basis_dd.value, + ) + fname = ( + f"{self._molecule.get_formula()}" + f"_{self.method_dd.value}_{self.basis_dd.value}.py" + ) + calc.generate_calculation_script(Path(fname)) + self.export_status.value = f"Saved: {fname}" + except Exception as exc: + self.export_status.value = f"Error: {exc}" + + 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}" + + 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}" + + 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}" + + 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 + + @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 + + # ── Compare ─────────────────────────────────────────────────────────── + + def _on_compare_refresh(self, btn) -> None: + self._populate_compare_list() + + def _on_compare(self, btn) -> None: + selected = self.compare_select.value + if not selected or selected == ("",): + return + self.compare_output.clear_output(wait=True) + from quantui import ( + comparison_table_html, + plot_comparison, + summary_from_saved_result, + ) + from quantui.results_storage import load_result + + summaries = [] + 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=widgets.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=widgets.Layout(gap="4px"))) + + def _on_compare_clear(self, btn) -> None: + self.compare_select.value = () + self.compare_output.clear_output() + + # ── History ─────────────────────────────────────────────────────────── + + def _on_past_dd_changed(self, change) -> None: + path_str = change["new"] + # 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=widgets.Layout(width="130px"), + tooltip="Show this result in the Results tab", + ) + _btn_ana = widgets.Button( + description="→ View Analysis", + button_style="info", + layout=widgets.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=widgets.Layout(gap="8px", margin="6px 0 0"), + ) + ) + except Exception as exc: + print(f"Could not load result: {exc}") + + def _on_past_refresh(self, btn) -> None: + self._refresh_results_browser() + + def _on_copy_results_path(self, btn) -> None: + p = self._get_results_dir() + p.mkdir(parents=True, exist_ok=True) + path_str = str(p).replace("\\", "\\\\").replace("'", "\\'") + display(Javascript(f"navigator.clipboard.writeText('{path_str}')")) + self.results_path_lbl.value = ( + f'Copied: {p}' + ) + + def _reset(): + time.sleep(3) + self.results_path_lbl.value = ( + f'{p}' + ) + + threading.Thread(target=_reset, daemon=True).start() + + def _on_view_log(self, btn) -> None: + path_str = self.past_dd.value + if not path_str: + return + 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() + + 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 + + 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 + + def _history_load_analysis(self, result_dir: Path) -> None: + """Load analysis panels for a history result and navigate to Analysis tab.""" + log_path = result_dir / "pyscf.log" + text = ( + log_path.read_text(encoding="utf-8", errors="replace") + if log_path.exists() + else "(No pyscf.log found for this result.)" + ) + self._update_log_panel(result_dir.name if log_path.exists() else "", text) + self._show_result_log(result_dir, text) + + ctx = self._build_history_context(result_dir) + if ctx is not None: + _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} + try: + _mol = self._mol_from_result_dir(result_dir, _data_stub) + if _mol is not None: + self._show_result_3d(_mol, extra_output=self._analysis_mol_output) + else: + self._analysis_mol_output.clear_output() + except Exception: + pass + self._apply_analysis_context(ctx) + + self.root_tab.selected_index = 2 + + def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]: + """Load result.json from *result_dir* and return an ``_AnalysisContext``. + + Returns ``None`` if result.json cannot be read. + """ + 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", + ) + + # ── Perf stats reset ────────────────────────────────────────────────── + + def _on_reset_click(self, btn) -> None: + self._reset_confirm_box.layout.display = "" + + def _on_confirm_yes(self, btn) -> None: + from quantui.calc_log import reset_perf_log + + reset_perf_log() + self._reset_confirm_box.layout.display = "none" + self._refresh_perf_stats() + + def _on_confirm_no(self, btn) -> None: + self._reset_confirm_box.layout.display = "none" + + # ── 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…' + ) + 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() + + def _do_calibration(self) -> None: + from quantui.benchmarks import run_calibration + + mode = self._cal_mode_toggle.value + + def _progress( + step_n: int, total: int, label: str, status: str, elapsed: float + ) -> None: + _icon = {"ok": "✓", "timed_out": "⏱", "stopped": "⛔", "error": "✗"}.get( + status, "?" + ) + self._cal_progress.value = step_n + self._cal_step_label.value = ( + f'' + f"Step {step_n} / {total} — {label} " + f"[{_icon} {elapsed:.1f} s]" + ) + + result = run_calibration( + progress_cb=_progress, + stop_event=self._cal_stop_event, + timeout_per_step=300.0 if mode == "long" else 120.0, + mode=mode, + ) + + # Render results table + _rows = "".join( + f"" + f'{s.label}' + f'' + f"{s.n_electrons}" + f'' + f"{s.n_basis if s.n_basis is not None else '—'}" + f'' + f"{s.elapsed_s:.2f} s" + f'' + f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}' + f"" + f"" + for s in result.steps + ) + _summary = f"Completed {result.n_completed} / {result.n_total} steps." + ( + " (stopped early)" if result.stopped_early else "" + ) + self._cal_results_html.value = ( + f'
    ' + f'

    {_summary}

    ' + f'' + f"" + f'' + f'' + f'' + f'' + f'' + f"" + f"{_rows}
    Calculatione⁻Basis fnsWall timeStatus
    " + ) + + self._cal_step_label.value = ( + 'Calibration complete. ' + "Time estimates are now active." + if result.n_completed > 0 + else 'No steps completed.' + ) + self._cal_stop_btn.layout.display = "none" + self._cal_run_btn.disabled = not _PYSCF_AVAILABLE + self._cal_mode_toggle.disabled = False + self._refresh_perf_stats() + + # ── Output log ──────────────────────────────────────────────────────── + + def _on_log_clear(self, btn) -> None: + self._log_output_html.value = ( + 'Log cleared.' + ) + self._log_source_lbl.value = "" + + # ── 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 = "" + + def _on_issue_cancel(self, _=None) -> None: + self._issue_overlay.layout.display = "none" + + 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 + + def _build_issue_context(self) -> dict: + """Snapshot the current app state to attach to an issue report.""" + ctx: dict = {} + if self._molecule is not None: + try: + ctx["molecule"] = { + "formula": self._molecule.get_formula(), + "n_atoms": len(self._molecule.atoms), + "charge": self._molecule.charge, + "multiplicity": self._molecule.multiplicity, + } + except Exception: + pass + try: + ctx["settings"] = { + "method": self.method_dd.value, + "basis": self.basis_dd.value, + "calc_type": self.calc_type_dd.value, + } + except Exception: + pass + if self._last_result is not None: + try: + ctx["last_result"] = { + "formula": getattr(self._last_result, "formula", None), + "method": getattr(self._last_result, "method", None), + "basis": getattr(self._last_result, "basis", None), + "converged": getattr(self._last_result, "converged", None), + "energy_hartree": getattr( + self._last_result, "energy_hartree", None + ), + } + except Exception: + pass + try: + ctx["recent_events"] = _calc_log.get_recent_events(15) + except Exception: + pass + return ctx + + # ── 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 + + 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 + + # ── 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() + + # ── 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 "" + + def _on_help_topic_changed(self, change=None) -> None: + self._render_help_topic() + + # ══ LOGIC METHODS ════════════════════════════════════════════════════════ + + def _set_molecule(self, mol: Molecule, label: str = "") -> None: + """Update shared state and refresh dependent widgets.""" + self._molecule = mol + self.run_btn.disabled = False + self.export_btn.disabled = False + self.export_xyz_btn.disabled = False + self.export_mol_btn.disabled = not _RDKIT_AVAILABLE + self.export_pdb_btn.disabled = not _RDKIT_AVAILABLE + + try: + _calc_log.log_event( + "molecule_load", + f"{mol.get_formula()} — {label or 'unknown source'}", + formula=mol.get_formula(), + n_atoms=len(mol.atoms), + charge=mol.charge, + multiplicity=mol.multiplicity, + source=label or "unknown", + session_id=self._session_id, + ) + except Exception: + pass + + try: + n_e = mol.get_electron_count() + e_str = f"{n_e} electrons" + except Exception: + e_str = "" + + _lbl = f'
    {label}' if label else "" + _summary = ( + f'{mol.get_formula()}' + f' ' + f"{len(mol.atoms)} atoms" + + (f" • {e_str}" if e_str else "") + + f" • charge {mol.charge} • mult {mol.multiplicity}" + + f"{_lbl}" + ) + self.mol_info_html.value = _summary + self.mol_summary_compact.value = ( + f'
    ' + f"{_summary}
    " + ) + + self.charge_si.value = mol.charge + self.mult_si.value = mol.multiplicity + if mol.multiplicity > 1 and self.method_dd.value == "RHF": + self.method_dd.value = "UHF" + + self.viz_output.clear_output() + if _display_molecule is not None: + with self.viz_output: + _display_molecule( + mol, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + self._update_notes() + + # Advance step indicator + if self.step_progress._states[2] != "active": + if self.step_progress._states[2] in ("done", "fail"): + self.step_progress.reset() + self.step_progress.complete(0) + self.step_progress.start(1) + + self._update_estimate() + + # Collapse molecule input to compact view + _collapsed_children = [self.mol_input_collapsed, self.viz_output] + if self.viz_backend_toggle is not None: + _collapsed_children.append(self.viz_backend_toggle) + if VISUALIZATION_AVAILABLE: + _collapsed_children.append(self.viz_controls_box) + self.mol_input_container.children = _collapsed_children + + def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None: + """Run a callback on the notebook/kernel thread when possible.""" + if threading.current_thread() is threading.main_thread(): + callback(*args, **kwargs) + return + + ip = get_ipython() + io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None) + if io_loop is not None: + io_loop.add_callback(callback, *args, **kwargs) + return + + # Best-effort fallback for non-notebook contexts where no kernel loop + # is available. This preserves existing behaviour, but the normal + # notebook path above keeps rendering off the worker thread. + callback(*args, **kwargs) + + def _set_molecule_state_only(self, mol) -> None: + """Apply only thread-safe molecule state updates.""" + self._molecule = mol + + def _set_molecule_threadsafe(self, mol, status_message: str) -> None: + """Update molecule state safely and render on the main thread only.""" + if threading.current_thread() is threading.main_thread(): + self._set_molecule(mol, status_message) + return + + self._set_molecule_state_only(mol) + self._queue_main_thread_callback(self._set_molecule, mol, status_message) + + def _show_result_3d(self, molecule, extra_output=None) -> None: + """Render molecule 3D structure in the result visualization panel. + + Renders into ``result_viz_output`` and, if supplied, into *extra_output* + as well (used to mirror the structure into the Analysis tab viewer). + Safe to call from a background thread — uses ``with output:`` context. + """ + 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"], + ) + + def _show_result_log(self, saved_dir: Path, log_text: str) -> None: + """Populate the result-directory label and output-log accordion. + + Safe to call from a background thread. + """ + # Path label + self._result_dir_label.value = ( + f'' + f"Saved to: {saved_dir}" + ) + self._result_dir_label.layout.display = "" + + # Log accordion — prefer on-disk file (written by save_result) over in-memory string + import html as _html_mod + + _log_path = saved_dir / "pyscf.log" + try: + log_content = _log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + log_content = log_text + + if not log_content.strip(): + log_content = "(No output captured for this calculation.)" + + self._result_log_output.clear_output() + with self._result_log_output: + display( + HTML( + f'
    '
    +                    f"{_html_mod.escape(log_content)}
    " + ) + ) + self._result_log_accordion.layout.display = "" + + 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() + + 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 - loop = asyncio.get_running_loop() + 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 _do(): + 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: - xyz_str, _msg = _student_friendly_fetch(query) - if xyz_str is None: - raise ValueError(_msg) - atoms, coords = parse_xyz_input(xyz_str) - mol = Molecule(atoms=atoms, coordinates=coords) - loop.call_soon_threadsafe( - self._apply_pubchem_search_result, - query, - mol, - None, + 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: - loop.call_soon_threadsafe( - self._apply_pubchem_search_result, - query, - None, - 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=widgets.Layout(width="360px"), + ) + _step_info = widgets.HTML(value=self._traj_step_html(0, traj, energies, rel_e)) + _frame_out = widgets.Output(layout=widgets.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…

    ' + ) ) - threading.Thread(target=_do, daemon=True).start() + 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}

    ' + ) + ) - def _on_expand_mol_input(self, btn) -> None: - self.mol_input_container.children = [ - self.mol_input_expanded, - self.mol_info_html, - self.viz_output, - ] + threading.Thread(target=_on_demand, daemon=True).start() - # ── Calc type ───────────────────────────────────────────────────────── + _step_slider.observe(_update_frame, names="value") - def _on_calc_type_changed(self, change) -> None: - ct = change["new"] - if ct == "Geometry Opt": - self.calc_extra_opts.children = [ - widgets.HBox( - [self.fmax_fi, self.max_steps_si], - layout=widgets.Layout(gap="8px"), - ), - ] - elif ct == "UV-Vis (TD-DFT)": - self.calc_extra_opts.children = [ - self.nstates_si, - widgets.HTML( - '⚠ Requires a DFT ' - "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) " - "instead." - ), - ] - else: - self.calc_extra_opts.children = [] + # --- Export button --- + _export_btn = widgets.Button( + description="Export Animation", + icon="download", + layout=widgets.Layout(width="160px", margin="0 0 0 12px"), + tooltip="Generate a standalone HTML animation file (may take a minute)", + ) + _export_status = widgets.HTML() - # ── Help buttons ────────────────────────────────────────────────────── + def _on_export(_btn): + _btn.disabled = True + _export_status.value = ( + f'' + f"Generating {n}-frame animation, please wait…" + ) - def _on_method_help(self, btn) -> None: - self._show_help_topic("method") + 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 - def _on_basis_help(self, btn) -> None: - self._show_help_topic("basis_set") + threading.Thread(target=_do_export, daemon=True).start() - # ── Run ─────────────────────────────────────────────────────────────── + _export_btn.on_click(_on_export) - def _on_run_clicked(self, btn) -> None: - self.run_output.clear_output() - self.result_output.clear_output() - threading.Thread(target=self._do_run, daemon=True).start() + # --- Assemble layout --- + _header = widgets.HBox( + [_step_slider, _export_btn], + layout=widgets.Layout(align_items="center", margin="4px 0"), + ) + _panel = widgets.VBox( + [_header, _step_info, _cache_label, _frame_out, _export_status] + ) - def _on_clear_log(self, btn) -> None: - self.run_output.clear_output() + # 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…

    " + ) + ) - # ── Accumulate / export ─────────────────────────────────────────────── + # 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" + ) - def _on_accumulate(self, btn) -> None: - r = self._last_result - if r is None: - return - self._results.append(r) - self._refresh_comparison() + threading.Thread(target=_prerender_all, daemon=True).start() - def _on_clear(self, btn) -> None: - self._results.clear() - self.comparison_output.clear_output() + def _traj_step_html(self, step: int, traj, energies, rel_e) -> str: + """One-line info label for the given trajectory step index.""" + n = len(traj) + mol = traj[step] + e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—" + delta = ( + f"  ·  ΔE = {rel_e[step]:+.3f} kcal/mol" + if rel_e and step < len(rel_e) + else "" + ) + return ( + f'' + f"Step {step} / {n - 1}  ·  {mol.get_formula()}" + f"  ·  E = {e_abs}{delta}" + ) - def _on_export(self, btn) -> None: - if self._molecule is None: - self.export_status.value = "Load a molecule first." - return + 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 import PySCFCalculation + from quantui.visualization_py3dmol import visualize_molecule_plotlymol - calc = PySCFCalculation( - self._molecule, - method=self.method_dd.value, - basis=self.basis_dd.value, + fig = visualize_molecule_plotlymol( + molecule, mode="ball+stick", resolution=8, width=460, height=340 ) - fname = ( - f"{self._molecule.get_formula()}" - f"_{self.method_dd.value}_{self.basis_dd.value}.py" + _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()}" ) - calc.generate_calculation_script(Path(fname)) - self.export_status.value = f"Saved: {fname}" + 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: - self.export_status.value = f"Error: {exc}" + output_widget.clear_output() + with output_widget: + display( + HTML( + f'

    Frame render failed: {exc}

    ' + ) + ) - # ── Compare ─────────────────────────────────────────────────────────── + def _build_vib_data_from_freq_result(self, freq_result, molecule): + """Construct a ``plotlymol3d.VibrationalData`` from a FreqResult. - def _on_compare_refresh(self, btn) -> None: - self._populate_compare_list() + Args: + freq_result: ``FreqResult`` with ``displacements`` populated. + molecule: The ``Molecule`` used for the frequency calculation. - 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, + Returns: + ``VibrationalData`` or ``None`` if prerequisites are missing. + """ + try: + import numpy as np + from plotlymol3d import VibrationalData, VibrationalMode + except ImportError: + return None + + 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", ) - from quantui.results_storage import load_result - summaries = [] - for path_str in selected: - if not path_str: + 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 - try: - data = load_result(Path(path_str)) - summaries.append(summary_from_saved_result(data)) - except Exception as exc: - with self.compare_output: - display( - HTML( - f'

    Error loading result: {exc}

    ' - ) - ) - if not summaries: - return - with self.compare_output: - display(HTML(comparison_table_html(summaries))) - if len(summaries) > 1: - try: - import matplotlib.pyplot as plt + 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)) - fig = plot_comparison(summaries) - display(fig) - plt.close(fig) - except Exception: - pass + if not options: + return False - def _on_compare_clear(self, btn) -> None: - self.compare_select.value = () - self.compare_output.clear_output() + self.vib_mode_dd.options = options + self.vib_mode_dd.value = options[0][1] - # ── History ─────────────────────────────────────────────────────────── + # Store vib_data for callback use. + self._last_vib_data = vib_data + self._last_vib_molecule = molecule - def _on_past_dd_changed(self, change) -> None: - path_str = change["new"] - if not path_str: - self.past_output.clear_output() - return - self.past_output.clear_output(wait=True) + # Show loading indicator and render in a background thread so _do_run + # is not blocked while the animation is generated (can take several seconds). + _first_label, _first_mode = options[0] + self.vib_output.clear_output() + with self.vib_output: + display( + 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 + + 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) + + # Wire callbacks (replace any prior bindings) + self._ir_mode_toggle.unobserve_all() + self._ir_fwhm_slider.unobserve_all() + + def _on_mode(change) -> None: + mode = change["new"] + self._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none" + self._update_ir_figure(mode, self._ir_fwhm_slider.value) + + def _on_fwhm(change) -> None: + if self._ir_mode_toggle.value == "Broadened": + self._update_ir_figure("Broadened", change["new"]) + + self._ir_mode_toggle.observe(_on_mode, names="value") + self._ir_fwhm_slider.observe(_on_fwhm, 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" + + return True + + def _update_ir_figure(self, mode: str, fwhm: float) -> None: + """Re-render the IR spectrum chart for the given mode and FWHM.""" try: - from quantui import load_result + import plotly.io as _pio - data = load_result(Path(path_str)) - self.past_output.append_display_data(HTML(self._format_past_result(data))) - except Exception as exc: - self.past_output.append_stdout(f"Could not load result: {exc}\n") + from quantui.ir_plot import plot_ir_spectrum - def _on_past_refresh(self, btn) -> None: - self._refresh_results_browser() + _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._ir_fig.value = _pio.to_html( + fig, include_plotlyjs="cdn", full_html=False + ) + except Exception: + pass - 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 _show_orbital_diagram(self, result) -> bool: + """Build and reveal the interactive orbital diagram accordion. - def _reset(): - time.sleep(3) - self.results_path_lbl.value = ( - f'{p}' + 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="cdn", full_html=False) + self._orb_diagram_html.value = html_str + _plotly_rendered = True + except Exception: + pass - threading.Thread(target=_reset, daemon=True).start() + if not _plotly_rendered: + # Fallback: static matplotlib PNG (plotly not installed) + import base64 + import io as _io - def _on_view_log(self, btn) -> None: - path_str = self.past_dd.value - if not path_str: - return - log_path = Path(path_str) / "pyscf.log" - if log_path.exists(): - text = log_path.read_text(encoding="utf-8", errors="replace") - label = Path(path_str).name - else: - text = "(No pyscf.log found for this result.)" - label = "" - self._update_log_panel(text, label) - self._goto_output_tab() + try: + from matplotlib.backends.backend_agg import ( + FigureCanvasAgg as _AggCanvas, + ) - # ── Perf stats reset ────────────────────────────────────────────────── + 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._orb_diagram_html.value = ( + f'' + ) + except Exception: + pass - def _on_reset_click(self, btn) -> None: - self._reset_confirm_box.layout.display = "" + 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 + + 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 _on_confirm_yes(self, btn) -> None: - from quantui.calc_log import reset_perf_log + def _run(): + try: + self._render_orbital_isosurface(orbital_label) + finally: + btn.disabled = False + btn.description = "Generate Isosurface" - reset_perf_log() - self._reset_confirm_box.layout.display = "none" - self._refresh_perf_stats() + threading.Thread(target=_run, daemon=True).start() - def _on_confirm_no(self, btn) -> None: - self._reset_confirm_box.layout.display = "none" + 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 - # ── Output log ──────────────────────────────────────────────────────── + from quantui.orbital_visualization import plot_orbital_diagram_plotly - def _on_log_clear(self, btn) -> None: - self._log_output_html.value = ( - 'Log cleared.' - ) - self._log_source_lbl.value = "" + fig = plot_orbital_diagram_plotly( + info, + max_orbitals=self._orb_n_orb_input.value, + yrange=(ymin, ymax), + ) + self._apply_plotly_theme(fig) + self._orb_diagram_html.value = _pio.to_html( + fig, include_plotlyjs="cdn", full_html=False + ) + except Exception: + pass - # ── Help ────────────────────────────────────────────────────────────── + def _render_orbital_isosurface(self, orbital_label: str) -> None: + """Generate a cube file and render an orbital isosurface (Linux/WSL only).""" + import tempfile - def _on_help_topic_changed(self, change=None) -> None: - self._render_help_topic() + orb_info = getattr(self, "_last_orb_info", None) + if orb_info is None: + return - # ══ LOGIC METHODS ════════════════════════════════════════════════════════ + n_occ = orb_info.n_occupied + n_total = len(orb_info.mo_energies_ev) + _idx_map = { + "HOMO-1": n_occ - 2, + "HOMO": n_occ - 1, + "LUMO": n_occ, + "LUMO+1": n_occ + 1, + } + orb_idx = _idx_map.get(orbital_label) + if orb_idx is None or orb_idx < 0 or orb_idx >= n_total: + return - def _set_molecule(self, mol: Molecule, label: str = "") -> None: - """Update shared state and refresh dependent widgets.""" - self._molecule = mol - self.run_btn.disabled = False - self.export_btn.disabled = False + 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: - n_e = mol.get_electron_count() - e_str = f"{n_e} electrons" - except Exception: - e_str = "" - - _lbl = f'
    {label}' if label else "" - _summary = ( - f'{mol.get_formula()}' - f' ' - f"{len(mol.atoms)} atoms" - + (f" • {e_str}" if e_str else "") - + f" • charge {mol.charge} • mult {mol.multiplicity}" - + f"{_lbl}" - ) - self.mol_info_html.value = _summary - self.mol_summary_compact.value = ( - f'
    ' - f"{_summary}
    " - ) + from quantui.orbital_visualization import ( + generate_cube_from_arrays, + plot_cube_isosurface, + ) - self.charge_si.value = mol.charge - self.mult_si.value = mol.multiplicity - if mol.multiplicity > 1 and self.method_dd.value == "RHF": - self.method_dd.value = "UHF" + 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 - self.viz_output.clear_output() - if _display_molecule is not None: - with self.viz_output: - _display_molecule(mol) + from IPython.display import display as _ipy_display - self._update_notes() + self._orb_iso_output.clear_output() + with self._orb_iso_output: + _ipy_display(fig) - # Advance step indicator - if self.step_progress._states[2] != "active": - if self.step_progress._states[2] in ("done", "fail"): - self.step_progress.reset() - self.step_progress.complete(0) - self.step_progress.start(1) + def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: + """Render vibrational animation for the given mode into ``vib_output``. - self._update_estimate() + Safe to call from background thread via ``with output:`` context. + """ + from IPython.display import HTML as _H + from IPython.display import display as _ipy_display - # Collapse molecule input to compact view - self.mol_input_container.children = [self.mol_input_collapsed, self.viz_output] + def _err(msg: str) -> None: + self.vib_output.clear_output() + with self.vib_output: + _ipy_display(_H(f'

    ⚠ {msg}

    ')) - def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None: - """Run a callback on the notebook/kernel thread when possible.""" - if threading.current_thread() is threading.main_thread(): - callback(*args, **kwargs) + 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 - ip = get_ipython() - io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None) - if io_loop is not None: - io_loop.add_callback(callback, *args, **kwargs) + # 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 - # Best-effort fallback for non-notebook contexts where no kernel loop - # is available. This preserves existing behaviour, but the normal - # notebook path above keeps rendering off the worker thread. - callback(*args, **kwargs) + 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: + _err(f"Animation generation failed: {exc}") + return - def _set_molecule_state_only(self, mol) -> None: - """Apply only thread-safe molecule state updates.""" - self._molecule = mol + self.vib_output.clear_output() + with self.vib_output: + _ipy_display(anim_fig) - def _set_molecule_threadsafe(self, mol, status_message: str) -> None: - """Update molecule state safely and render on the main thread only.""" - if threading.current_thread() is threading.main_thread(): - self._set_molecule(mol, status_message) + 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 - - self._set_molecule_state_only(mol) - self._queue_main_thread_callback(self._set_molecule, mol, status_message) + # 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() + with self.vib_output: + display( + HTML( + f'

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

    " + ) + ) + threading.Thread( + target=self._render_vib_mode, + args=(vib_data, molecule, mode_number), + daemon=True, + ).start() def _do_run(self) -> None: """Main calculation dispatch — runs in a background thread.""" @@ -1395,8 +4683,31 @@ def _do_run(self) -> None: n_atoms=len(mol.atoms), ) _run_wall_t = time.perf_counter() + _run_cpu_t = time.process_time() log = _LogCapture(self.run_output, self.run_status) + # Write structured log header immediately so it appears at the top of output + try: + from quantui.log_utils import format_log_header as _fmt_log_hdr + + _hdr_calc_type = { + "Geometry Opt": "geometry_opt", + "Frequency": "frequency", + "UV-Vis (TD-DFT)": "tddft", + "NMR Shielding": "nmr", + "PES Scan": "pes_scan", + }.get(self.calc_type_dd.value, "single_point") + log.write( + _fmt_log_hdr( + formula=mol.get_formula(), + method=self.method_dd.value, + basis=self.basis_dd.value, + calc_type=_hdr_calc_type, + ) + ) + except Exception: + pass + try: calc_mol = mol if self.preopt_cb.value and _PREOPT_AVAILABLE: @@ -1412,6 +4723,7 @@ def _do_run(self) -> None: result_html: str = "" save_spectra: dict = {} save_type: str = "single_point" + _pre_opt: Any = None # OptimizationResult from Frequency pre-opt step if ct == "Geometry Opt": self.run_status.value = "Optimizing geometry..." from quantui import optimize_geometry @@ -1427,9 +4739,52 @@ def _do_run(self) -> None: result_html = self._format_opt_result(result) save_spectra, save_type = {}, "geometry_opt" elif ct == "Frequency": - self.run_status.value = "Computing frequencies (SCF + Hessian)..." from quantui.freq_calc import run_freq_calc + # ── Step 1: resolve seed geometry ───────────────────────────── + _seed_path = self._freq_seed_dd.value + if _seed_path: + from quantui.results_storage import load_trajectory + + self.run_status.value = "Loading seed geometry from history…" + _seed_traj, _ = load_trajectory(Path(_seed_path)) + calc_mol = _seed_traj[-1] + log.write( + f"\nSeed geometry loaded from: {Path(_seed_path).name}\n" + f" Formula: {calc_mol.get_formula()} " + f"Atoms: {len(calc_mol.atoms)}\n\n" + ) + + # ── Step 2: optional geometry pre-optimisation ──────────────── + if self._freq_preopt_cb.value: + from quantui import optimize_geometry + + self.run_status.value = "Pre-optimizing geometry before frequency…" + log.write( + "\n── Pre-optimisation (before frequency analysis) ──────────────────\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" + ) + + # ── Step 3: frequency analysis ──────────────────────────────── + self.run_status.value = "Computing frequencies (SCF + Hessian)…" result = run_freq_calc( molecule=calc_mol, method=self.method_dd.value, @@ -1437,12 +4792,31 @@ def _do_run(self) -> None: progress_stream=log, # type: ignore[arg-type] ) result_html = self._format_freq_result(result) + _displacements_serialized = None + if result.displacements is not None: + try: + import numpy as _np_d + + _displacements_serialized = _np_d.asarray( + result.displacements + ).tolist() + except Exception: + pass save_spectra = { "ir": { "frequencies_cm1": result.frequencies_cm1, "ir_intensities": result.ir_intensities, "zpve_hartree": result.zpve_hartree, - } + "displacements": _displacements_serialized, + }, + "molecule": { + "atoms": list(calc_mol.atoms), + "coords": [ + list(map(float, row)) for row in calc_mol.coordinates + ], + "charge": calc_mol.charge, + "multiplicity": calc_mol.multiplicity, + }, } save_type = "frequency" elif ct == "UV-Vis (TD-DFT)": @@ -1465,47 +4839,189 @@ def _do_run(self) -> None: } } save_type = "tddft" + elif ct == "NMR Shielding": + self.run_status.value = "Running NMR shielding (SCF + GIAO)..." + from quantui.nmr_calc import run_nmr_calc + + result = run_nmr_calc( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_nmr_result(result) + save_spectra = { + "nmr": { + "atom_symbols": list(result.atom_symbols), + "shielding_iso_ppm": list(result.shielding_iso_ppm), + "chemical_shifts_ppm": { + str(k): v for k, v in result.chemical_shifts_ppm.items() + }, + "reference_compound": result.reference_compound, + } + } + save_type = "nmr" + elif ct == "PES Scan": + self.run_status.value = "Running PES scan…" + from quantui.pes_scan import run_pes_scan + + _st = self._scan_type_dd.value.lower() + _atom_idx: list = [ + self._scan_atom1.value - 1, + self._scan_atom2.value - 1, + ] + if _st in ("angle", "dihedral"): + _atom_idx.append(self._scan_atom3.value - 1) + if _st == "dihedral": + _atom_idx.append(self._scan_atom4.value - 1) + + result = run_pes_scan( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + scan_type=_st, + atom_indices=_atom_idx, + start=self._scan_start.value, + stop=self._scan_stop.value, + steps=self._scan_steps.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_pes_scan_result(result) + save_spectra, save_type = {}, "pes_scan" else: # Single Point self.run_status.value = "Calculating..." from quantui import run_in_session + # MP2 heavy-atom warning + if self.method_dd.value.upper() == "MP2": + _n_heavy = sum(1 for a in calc_mol.atoms if a != "H") + if _n_heavy > 20: + self.result_output.append_display_data( + HTML( + '
    ' + f"⚠️ MP2 scales as O(N⁵) — this molecule has {_n_heavy} heavy atoms " + "and may be slow. Consider using DFT instead.
    " + ) + ) + + _solvent = self.solvent_dd.value if self.solvent_cb.value else None result = run_in_session( molecule=calc_mol, method=self.method_dd.value, basis=self.basis_dd.value, progress_stream=log, # type: ignore[arg-type] + solvent=_solvent, ) result_html = self._format_result(result) save_spectra, save_type = {}, "single_point" _elapsed = time.perf_counter() - _run_wall_t + _elapsed_cpu = time.process_time() - _run_cpu_t self._last_result = result self.accumulate_btn.disabled = False self.result_output.append_display_data(HTML(result_html)) self.run_status.value = f"Done in {_elapsed:.1f} s." + # Show 3D structure in the result panel and mirrored in Analysis tab + _viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol + if ct == "Geometry Opt": + self._viz_label.value = ( + '

    Optimized geometry

    ' + ) + self._viz_label.layout.display = "" + self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output) + + # Populate Analysis panels via the unified registry + _ana_ctx = _AnalysisContext( + calc_type=save_type, + formula=result.formula, + method=self.method_dd.value, + basis=self.basis_dd.value, + live_result=result, + molecule=calc_mol, + spectra_data=save_spectra, + preopt_result=_pre_opt, + source="live", + ) + self._apply_analysis_context(_ana_ctx) + self.step_progress.complete(2) self.step_progress.complete(3) + # Update completion banner + _mol_label = _ana_ctx.label + self._completion_mol_lbl.value = ( + f'' + f"{_mol_label}" + ) + self._completion_banner.layout.display = "" + + # Write structured log footer + try: + from quantui.log_utils import format_log_footer as _fmt_log_ftr + + log.write( + _fmt_log_ftr( + result=result, + wall_time=_elapsed, + cpu_time=_elapsed_cpu, + log_text=log.getvalue(), + success=True, + ) + ) + except Exception: + pass + # Persist to disk try: - from quantui import save_result + from quantui import load_result, save_result + from quantui.results_storage import ( + save_orbitals, + save_thumbnail, + save_trajectory, + ) - save_result( + _saved_dir = save_result( result, pyscf_log=log.getvalue(), calc_type=save_type, spectra=save_spectra, ) + self._last_result_dir = _saved_dir + save_thumbnail(_saved_dir, load_result(_saved_dir)) + # Persist trajectory so history viewer can replay it. + if ct in ("Geometry Opt", "PES Scan"): + _traj = getattr( + result, + "trajectory" if ct == "Geometry Opt" else "coordinates_list", + None, + ) + _e_list = getattr(result, "energies_hartree", []) + if _traj: + save_trajectory(_saved_dir, _traj, _e_list or []) + # 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( log.getvalue(), f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}", ) - except Exception: - pass + self._show_result_log(_saved_dir, log.getvalue()) + except Exception as _save_exc: + try: + from quantui import calc_log as _clog + + _clog.log_event( + "save_error", + f"{type(_save_exc).__name__}: {_save_exc}"[:300], + ) + except Exception: + pass # Log performance try: @@ -1515,9 +5031,13 @@ def _do_run(self) -> None: n_electrons=calc_mol.get_electron_count(), method=result.method, basis=result.basis, - n_iterations=getattr(result, "n_iterations", -1), + n_iterations=getattr(result, "n_iterations", None), elapsed_s=_elapsed, converged=result.converged, + n_basis=_calc_log.count_basis_functions( + calc_mol.atoms, result.basis + ), + n_cores=1, ) _calc_log.log_event( "calc_done", @@ -1535,7 +5055,7 @@ def _do_run(self) -> None: f"Import error: {_err_detail}\n\n" "A required calculation dependency could not be loaded.\n" "On Windows: use the Apptainer container.\n" - " apptainer run quantui-local.sif\n" + " apptainer run quantui.sif\n" ) log.write(_msg) _err_html = ( @@ -1544,7 +5064,7 @@ def _do_run(self) -> None: '⚠ Dependency Not Available
    ' f'{_err_detail}

    ' 'On Windows, use the Apptainer container: ' - "apptainer run quantui-local.sif. " + "apptainer run quantui.sif. " "Full details are in the Output tab." "" ) @@ -1557,9 +5077,25 @@ def _do_run(self) -> None: import traceback as _tb _elapsed = time.perf_counter() - _run_wall_t + _elapsed_cpu = time.process_time() - _run_cpu_t _tb_str = _tb.format_exc() # Full details → Output tab (for debugging/instructors) log.write(f"\n--- Calculation Error ---\n{exc}\n\n{_tb_str}") + # Structured failure footer + try: + from quantui.log_utils import format_log_footer as _fmt_log_ftr + + log.write( + _fmt_log_ftr( + result=None, + wall_time=_elapsed, + cpu_time=_elapsed_cpu, + log_text=log.getvalue(), + success=False, + ) + ) + except Exception: + pass # Write to persistent error log try: import datetime as _dt @@ -1642,11 +5178,15 @@ def _update_estimate(self, change=None) -> 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: @@ -1675,6 +5215,9 @@ def _refresh_results_browser(self) -> None: 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() def _refresh_comparison(self) -> None: from quantui import comparison_table_html, summary_from_session_result @@ -1716,27 +5259,102 @@ def _populate_compare_list(self) -> None: def _show_help_topic(self, topic: str) -> None: if topic in HELP_TOPICS: self.help_topic_dd.value = topic - self.root_tab.selected_index = 4 + self.help_tab_panel.layout.display = "" def _update_log_panel(self, log_text: str, label: str = "") -> None: self._render_log(log_text, label) def _goto_output_tab(self) -> None: - self.root_tab.selected_index = 3 + self.root_tab.selected_index = 5 def _render_log(self, text: str, source_label: str = "") -> None: import html as _html_mod + import re as _re + + _bfgs_re = _re.compile(r"^BFGS:\s+(\d+)\s+\S+\s+([-\d.]+)\s+([\d.]+)") lines = text.splitlines() rows = [] for line in lines: esc = _html_mod.escape(line) - if "converged SCF energy" in line or "SCF converged" in line: - style = "color:#16a34a;font-weight:600" - elif "cycle=" in line and "E=" in line: + # ── Log header / footer structure ───────────────────────────────── + if len(line) >= 40 and line == "=" * len(line): + style = "color:#1e3a5f;font-weight:700" + elif "QuantUI — Quantum Chemistry Interface" in line: + style = "color:#6d28d9;font-weight:700" + elif line.startswith(" ── "): + style = "color:#334155;font-weight:700" + elif line.startswith(" ✓"): + style = "color:#16a34a;font-weight:700" + elif line.startswith(" ✗"): + style = "color:#dc2626;font-weight:700" + elif ( + line.startswith(" Machine:") + or line.startswith(" GPU:") + or line.startswith(" Threads:") + ): style = "color:#475569" - elif "HOMO" in line or "LUMO" in line: + elif ( + line.startswith(" Molecule:") + or line.startswith(" Method/Basis:") + or line.startswith(" Calc type:") + or line.startswith(" Started:") + ): + style = "color:#1d4ed8" + elif ( + line.startswith(" Energy:") + or line.startswith(" HOMO-LUMO gap:") + or line.startswith(" ZPVE:") + ): + style = "color:#0f766e;font-weight:600" + elif line.startswith(" Wall time:"): + style = "color:#64748b" + elif line.startswith(" ✔") or line.startswith(" ⚠"): + style = "color:#d97706" + # ── Geometry optimisation (ASE BFGS) ────────────────────────────── + elif line.startswith("BFGS:"): + m = _bfgs_re.match(line) + if m: + fmax = float(m.group(3)) + # Colour by convergence: green when nearly converged, teal otherwise + style = ( + "color:#16a34a;font-weight:600" + if fmax < 0.1 + else "color:#0d9488" + ) + else: + style = "color:#0d9488" + elif line.strip() == "Step Time Energy fmax": + style = "color:#334155;font-weight:700" + # ── Post-optimisation summary ────────────────────────────────────── + elif line.startswith("── Final SCF"): + style = "color:#6d28d9;font-weight:600" + elif "HOMO-LUMO gap:" in line: + style = "color:#6d28d9;font-weight:600" + # ── SCF convergence ──────────────────────────────────────────────── + elif "converged SCF energy" in line or "SCF converged" in line: + style = "color:#16a34a;font-weight:600" + elif line.lstrip().startswith("cycle=") and "E=" in line: + style = "color:#64748b" + # ── MO / orbital info (verbose=4) ────────────────────────────────── + elif "MO energies" in line or "** MO" in line: + style = "color:#1d4ed8;font-weight:600" + elif "HOMO" in line or "LUMO" in line or "All MO energies" in line: style = "color:#2563eb" + elif line.lstrip().startswith("occupied:") or line.lstrip().startswith( + "virtual:" + ): + style = "color:#3b82f6" + # ── Thermo / properties ──────────────────────────────────────────── + elif "Mulliken" in line or "mulliken" in line: + style = "color:#7c3aed" + elif "dipole" in line.lower() or "Dipole" in line: + style = "color:#7c3aed" + elif "nuclear repulsion" in line.lower() or "Nuclear repulsion" in line: + style = "color:#94a3b8" + elif "E(MP2)" in line or "MP2 correlation" in line: + style = "color:#0891b2" + # ── Warnings / errors ────────────────────────────────────────────── elif "Warning" in line or "warning" in line: style = "color:#d97706" elif "Error" in line or "error" in line or "failed" in line: @@ -1866,15 +5484,56 @@ def _format_result(self, r) -> str: ), ("HOMO-LUMO gap", _gap, "#000"), ("SCF converged", _conv, _cc), - ("SCF iterations", str(r.n_iterations), "#000"), + ( + "SCF iterations", + ( + "—" + if getattr(r, "n_iterations", None) in (None, -1) + else str(r.n_iterations) + ), + "#000", + ), ] ) + _extra = "" + # MP2: show HF reference energy separately + _mp2_corr = getattr(r, "mp2_correlation_hartree", None) + if _mp2_corr is not None: + _hf_e = r.energy_hartree - _mp2_corr + _extra += ( + f'HF reference' + f'{_hf_e:.8f} Ha' + f'MP2 correlation' + f'{_mp2_corr:.8f} Ha' + ) + _solvent = getattr(r, "solvent", None) + if _solvent is not None: + _extra += ( + f'Solvent (PCM)' + f'{_solvent}' + ) + _dip = getattr(r, "dipole_moment_debye", None) + if _dip is not None: + _extra += ( + f'Dipole moment' + f'{_dip:.4f} D' + ) + _chg = getattr(r, "mulliken_charges", None) + _syms = getattr(r, "atom_symbols", None) + if _chg is not None and _syms is not None: + _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg)) + _extra += ( + f'' + f"Mulliken charges" + f'{_charge_str}' + ) return ( f'
    ' f"{r.formula} — {r.method}/{r.basis}" f'' - f"{_rows}
    " + f"{_rows}{_extra}" ) def _format_opt_result(self, r) -> str: @@ -1934,12 +5593,29 @@ def _format_freq_result(self, r) -> str: 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}
    " + f"{_rows}{_thermo_rows}" ) def _format_tddft_result(self, r) -> str: @@ -1992,7 +5668,170 @@ def _format_tddft_result(self, r) -> str: f"{header_rows}{exc_table}" ) - def _format_past_result(self, data: dict) -> str: + 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}
    " + ) + + def _format_pes_scan_result(self, r) -> str: + """Format a PESScanResult as an HTML result card.""" + _conv = "Yes" if r.converged_all else "No (some points did not converge)" + _cc = "green" if r.converged_all else "#c00" + if r.energies_hartree: + e_min = min(r.energies_hartree) + e_max = max(r.energies_hartree) + barrier_kcal = (e_max - e_min) * 627.509474 + _e_row = ( + f'Min energy' + f'{e_min:.8f} Ha' + f'Energy range' + f'{barrier_kcal:.2f} kcal/mol' + ) + else: + _e_row = "" + _idx_str = "–".join(str(i + 1) for i in r.atom_indices) + return ( + f'
    ' + f"PES Scan — {r.formula} ({r.method}/{r.basis})" + f'' + f'' + f'' + f'' + f'" + f"{_e_row}" + f'' + f'' + f"
    Scan type{r.scan_type.capitalize()} ({_idx_str})
    Range{r.scan_parameter_values[0]:.3f} → ' + f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " + f"({r.n_steps} points)
    All converged{_conv}
    " + ) + + 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._pes_plot_html.value = pio.to_html( + fig, include_plotlyjs="cdn", full_html=False + ) + except Exception: + pass + + 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 = ( @@ -2013,13 +5852,36 @@ def _format_past_result(self, data: dict) -> str: ), ("HOMO-LUMO gap", _gap, "#000"), ("SCF converged", _conv, _cc), - ("SCF iterations", str(data.get("n_iterations", "?")), "#000"), + ( + "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'padding:10px 14px;border-radius:4px;margin:6px 0;overflow:hidden">' + f"{_thumb_html}" + f"{_ct_badge}
    " f'{data["formula"]} — {data["method"]}/{data["basis"]}' f' {ts}' f'' diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py new file mode 100644 index 0000000..9740710 --- /dev/null +++ b/quantui/benchmarks.py @@ -0,0 +1,533 @@ +""" +Timing calibration benchmark suite for QuantUI. + +Runs a fixed set of small calculations that span the student-relevant +method/basis/molecule-size space. Each completed step is logged to +``perf_log.jsonl`` via :func:`~quantui.calc_log.log_calculation` so that +:func:`~quantui.calc_log.estimate_time` immediately becomes useful on a +fresh install. + +Typical usage (from the UI):: + + import threading + from quantui.benchmarks import run_calibration + + stop = threading.Event() + result = run_calibration( + progress_cb=lambda *a: print(a), + stop_event=stop, + timeout_per_step=120, + ) +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, List, Optional + +# --------------------------------------------------------------------------- +# Benchmark suite definition +# --------------------------------------------------------------------------- + +#: Each entry: (label, atoms, coordinates, charge, multiplicity, method, basis) +#: Molecules are kept deliberately small so the full suite finishes quickly on +#: any modern laptop. +BENCHMARK_SUITE: list[tuple] = [ + ( + "H₂ RHF/STO-3G", + ["H", "H"], + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "H₂O RHF/STO-3G", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "H₂O B3LYP/STO-3G", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "B3LYP", + "STO-3G", + ), + ( + "H₂O RHF/6-31G*", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "RHF", + "6-31G*", + ), + ( + "CH₄ RHF/STO-3G", + ["C", "H", "H", "H", "H"], + [ + [0.0, 0.0, 0.0], + [0.629, 0.629, 0.629], + [-0.629, -0.629, 0.629], + [-0.629, 0.629, -0.629], + [0.629, -0.629, -0.629], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₄ RHF/STO-3G", + ["C", "C", "H", "H", "H", "H"], + [ + [0.0, 0.0, 0.670], + [0.0, 0.0, -0.670], + [0.0, 0.924, 1.241], + [0.0, -0.924, 1.241], + [0.0, 0.924, -1.241], + [0.0, -0.924, -1.241], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₆O (ethanol) RHF/STO-3G", + ["C", "C", "O", "H", "H", "H", "H", "H", "H"], + [ + [-1.232, 0.026, 0.000], + [0.281, 0.026, 0.000], + [0.829, 1.310, 0.000], + [-1.566, 1.059, 0.000], + [-1.609, -0.506, 0.880], + [-1.609, -0.506, -0.880], + [0.668, -0.497, 0.890], + [0.668, -0.497, -0.890], + [1.802, 1.311, 0.000], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₆O (ethanol) B3LYP/6-31G*", + ["C", "C", "O", "H", "H", "H", "H", "H", "H"], + [ + [-1.232, 0.026, 0.000], + [0.281, 0.026, 0.000], + [0.829, 1.310, 0.000], + [-1.566, 1.059, 0.000], + [-1.609, -0.506, 0.880], + [-1.609, -0.506, -0.880], + [0.668, -0.497, 0.890], + [0.668, -0.497, -0.890], + [1.802, 1.311, 0.000], + ], + 0, + 1, + "B3LYP", + "6-31G*", + ), +] + +#: Extended suite for a full calibration run (~3–6 min on a modern laptop). +#: Includes the short suite plus larger molecules and more expensive methods +#: to anchor the efficiency model across the student-relevant size range. +BENCHMARK_SUITE_LONG: list[tuple] = [ + *BENCHMARK_SUITE, + # ── Additional entries ───────────────────────────────────────────────── + ( + "H₂O RHF/cc-pVDZ", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "RHF", + "cc-pVDZ", + ), + ( + "C₂H₆O (ethanol) RHF/6-31G*", + ["C", "C", "O", "H", "H", "H", "H", "H", "H"], + [ + [-1.232, 0.026, 0.000], + [0.281, 0.026, 0.000], + [0.829, 1.310, 0.000], + [-1.566, 1.059, 0.000], + [-1.609, -0.506, 0.880], + [-1.609, -0.506, -0.880], + [0.668, -0.497, 0.890], + [0.668, -0.497, -0.890], + [1.802, 1.311, 0.000], + ], + 0, + 1, + "RHF", + "6-31G*", + ), + ( + "C₆H₆ (benzene) RHF/STO-3G", + ["C", "C", "C", "C", "C", "C", "H", "H", "H", "H", "H", "H"], + [ + [1.395, 0.000, 0.000], + [0.698, 1.209, 0.000], + [-0.698, 1.209, 0.000], + [-1.395, 0.000, 0.000], + [-0.698, -1.209, 0.000], + [0.698, -1.209, 0.000], + [2.479, 0.000, 0.000], + [1.240, 2.147, 0.000], + [-1.240, 2.147, 0.000], + [-2.479, 0.000, 0.000], + [-1.240, -2.147, 0.000], + [1.240, -2.147, 0.000], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₆H₆ (benzene) RHF/6-31G*", + ["C", "C", "C", "C", "C", "C", "H", "H", "H", "H", "H", "H"], + [ + [1.395, 0.000, 0.000], + [0.698, 1.209, 0.000], + [-0.698, 1.209, 0.000], + [-1.395, 0.000, 0.000], + [-0.698, -1.209, 0.000], + [0.698, -1.209, 0.000], + [2.479, 0.000, 0.000], + [1.240, 2.147, 0.000], + [-1.240, 2.147, 0.000], + [-2.479, 0.000, 0.000], + [-1.240, -2.147, 0.000], + [1.240, -2.147, 0.000], + ], + 0, + 1, + "RHF", + "6-31G*", + ), + ( + "C₆H₆ (benzene) B3LYP/6-31G*", + ["C", "C", "C", "C", "C", "C", "H", "H", "H", "H", "H", "H"], + [ + [1.395, 0.000, 0.000], + [0.698, 1.209, 0.000], + [-0.698, 1.209, 0.000], + [-1.395, 0.000, 0.000], + [-0.698, -1.209, 0.000], + [0.698, -1.209, 0.000], + [2.479, 0.000, 0.000], + [1.240, 2.147, 0.000], + [-1.240, 2.147, 0.000], + [-2.479, 0.000, 0.000], + [-1.240, -2.147, 0.000], + [1.240, -2.147, 0.000], + ], + 0, + 1, + "B3LYP", + "6-31G*", + ), + ( + "C₁₀H₈ (naphthalene) RHF/STO-3G", + [ + "C", + "C", + "C", + "C", + "C", + "C", + "C", + "C", + "C", + "C", + "H", + "H", + "H", + "H", + "H", + "H", + "H", + "H", + ], + [ + [1.243, 1.400, 0.000], + [2.440, 0.725, 0.000], + [2.440, -0.725, 0.000], + [1.243, -1.400, 0.000], + [0.000, -0.720, 0.000], + [0.000, 0.720, 0.000], + [-1.243, 1.400, 0.000], + [-2.440, 0.725, 0.000], + [-2.440, -0.725, 0.000], + [-1.243, -1.400, 0.000], + [1.237, 2.488, 0.000], + [3.377, 1.244, 0.000], + [3.377, -1.244, 0.000], + [1.237, -2.488, 0.000], + [-1.237, -2.488, 0.000], + [-3.377, -1.244, 0.000], + [-3.377, 1.244, 0.000], + [-1.237, 2.488, 0.000], + ], + 0, + 1, + "RHF", + "STO-3G", + ), +] + +# --------------------------------------------------------------------------- +# Result dataclass +# --------------------------------------------------------------------------- + +_STATUS_OK = "ok" +_STATUS_TIMEOUT = "timed_out" +_STATUS_STOPPED = "stopped" +_STATUS_ERROR = "error" + + +@dataclass +class BenchmarkStep: + """Result for a single benchmark step.""" + + label: str + method: str + basis: str + n_atoms: int + n_electrons: int + status: str # "ok" | "timed_out" | "stopped" | "error" + elapsed_s: float = 0.0 + error_msg: str = "" + n_basis: Optional[int] = None + + +@dataclass +class CalibrationResult: + """Summary result from :func:`run_calibration`.""" + + timestamp: str + steps: List[BenchmarkStep] = field(default_factory=list) + stopped_early: bool = False + mode: str = "short" + + @property + def n_completed(self) -> int: + return sum(1 for s in self.steps if s.status == _STATUS_OK) + + @property + def n_total(self) -> int: + return len(BENCHMARK_SUITE if self.mode == "short" else BENCHMARK_SUITE_LONG) + + +# --------------------------------------------------------------------------- +# Main calibration runner +# --------------------------------------------------------------------------- + +ProgressCallback = Callable[[int, int, str, str, float], None] +"""progress_cb(step_n, total, label, status, elapsed_s)""" + + +def _count_electrons(atoms: list[str], charge: int) -> int: + """Rough electron count: sum of atomic numbers minus charge.""" + _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, + } + return sum(_Z.get(a, 6) for a in atoms) - charge + + +def run_calibration( + progress_cb: Optional[ProgressCallback] = None, + stop_event=None, + timeout_per_step: float = 120.0, + mode: str = "short", +) -> CalibrationResult: + """Run the benchmark suite and populate ``perf_log.jsonl``. + + Args: + progress_cb: Called after each step with + ``(step_n, total, label, status, elapsed_s)``. + stop_event: A :class:`threading.Event`; checked before each step. + Set it to abort the suite cleanly. + timeout_per_step: Wall-clock seconds allowed per step. Steps that + exceed this are marked ``"timed_out"`` and skipped. + mode: ``"short"`` (default, ~10 s) runs :data:`BENCHMARK_SUITE`; + ``"long"`` (~3–6 min) runs :data:`BENCHMARK_SUITE_LONG`. + + Returns: + :class:`CalibrationResult` with per-step outcomes. + """ + import concurrent.futures + import json + + from quantui import calc_log as _calc_log + from quantui.molecule import Molecule + + _pyscf_available = False + try: + import pyscf # noqa: F401 + + _pyscf_available = True + except ImportError: + pass + + suite = BENCHMARK_SUITE if mode == "short" else BENCHMARK_SUITE_LONG + timestamp = datetime.now(timezone.utc).isoformat() + result = CalibrationResult(timestamp=timestamp, mode=mode) + total = len(suite) + + for step_n, entry in enumerate(suite, start=1): + label, atoms, coords, charge, mult, method, basis = entry + + # --- honour stop request --- + if stop_event is not None and stop_event.is_set(): + result.stopped_early = True + break + + nb = _calc_log.count_basis_functions(atoms, basis) + step = BenchmarkStep( + label=label, + method=method, + basis=basis, + n_atoms=len(atoms), + n_electrons=_count_electrons(atoms, charge), + status=_STATUS_ERROR, + n_basis=nb, + ) + + if not _pyscf_available: + step.status = _STATUS_ERROR + step.error_msg = "PySCF not available" + result.steps.append(step) + if progress_cb is not None: + progress_cb(step_n, total, label, step.status, 0.0) + continue + + def _run_step( + atoms=atoms, + coords=coords, + charge=charge, + mult=mult, + method=method, + basis=basis, + ): + from quantui.session_calc import run_in_session + + mol = Molecule(atoms, coords, charge=charge, multiplicity=mult) + t0 = time.perf_counter() + res = run_in_session(mol, method=method, basis=basis, verbose=0) + return res, time.perf_counter() - t0 + + t_start = time.perf_counter() + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_run_step) + try: + res, elapsed = future.result(timeout=timeout_per_step) + step.elapsed_s = elapsed + step.status = _STATUS_OK + # Log to perf_log.jsonl so estimate_time() can use it + _calc_log.log_calculation( + formula=res.formula, + n_atoms=step.n_atoms, + n_electrons=step.n_electrons, + method=method, + basis=basis, + n_iterations=res.n_iterations, + elapsed_s=elapsed, + converged=res.converged, + n_basis=step.n_basis, + n_cores=1, + ) + except concurrent.futures.TimeoutError: + step.status = _STATUS_TIMEOUT + step.elapsed_s = time.perf_counter() - t_start + except Exception as exc: + step.status = _STATUS_ERROR + step.error_msg = str(exc) + step.elapsed_s = time.perf_counter() - t_start + + result.steps.append(step) + if progress_cb is not None: + progress_cb(step_n, total, label, step.status, step.elapsed_s) + + # --- persist calibration summary --- + _cal_path = Path.home() / ".quantui" / "calibration.json" + try: + _cal_path.parent.mkdir(parents=True, exist_ok=True) + _cal_path.write_text( + json.dumps( + { + "timestamp": result.timestamp, + "mode": result.mode, + "stopped_early": result.stopped_early, + "steps": [ + { + "label": s.label, + "method": s.method, + "basis": s.basis, + "n_atoms": s.n_atoms, + "n_electrons": s.n_electrons, + "n_basis": s.n_basis, + "status": s.status, + "elapsed_s": round(s.elapsed_s, 3), + "error_msg": s.error_msg, + } + for s in result.steps + ], + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + except OSError: + pass + + return result + + +def load_last_calibration() -> Optional[dict]: + """Return the last calibration summary dict, or ``None`` if absent.""" + import json + + path = Path.home() / ".quantui" / "calibration.json" + if not path.exists(): + return None + try: + data: dict = json.loads(path.read_text(encoding="utf-8")) + return data + except Exception: + return None diff --git a/quantui/calc_log.py b/quantui/calc_log.py index e694bd6..085ea4b 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -1,5 +1,5 @@ """ -Performance and event logging for QuantUI-local. +Performance and event logging for QuantUI. Two separate log files, both stored in ``~/.quantui/logs/`` by default (override with the ``QUANTUI_LOG_DIR`` environment variable): @@ -38,6 +38,216 @@ "PBE": 2.0, "PBE0": 2.5, "M06-2X": 3.0, + "wB97X-D": 3.0, + "CAM-B3LYP": 2.5, + "M06-L": 2.0, + "HSE06": 2.5, + "PBE-D3": 2.1, + "MP2": 8.0, +} + +# Contracted basis function counts per element per basis set (spherical harmonics, +# PySCF default). Used by count_basis_functions() and estimate_time(). +_BASIS_FUNCTIONS: dict[str, dict[str, int]] = { + "STO-3G": { + "H": 1, + "He": 1, + "Li": 5, + "Be": 5, + "B": 5, + "C": 5, + "N": 5, + "O": 5, + "F": 5, + "Ne": 5, + "Na": 9, + "Mg": 9, + "Al": 9, + "Si": 9, + "P": 9, + "S": 9, + "Cl": 9, + "Ar": 9, + }, + "3-21G": { + "H": 2, + "He": 2, + "Li": 9, + "Be": 9, + "B": 9, + "C": 9, + "N": 9, + "O": 9, + "F": 9, + "Ne": 9, + "Na": 13, + "Mg": 13, + "Al": 15, + "Si": 15, + "P": 15, + "S": 15, + "Cl": 15, + "Ar": 15, + }, + "6-31G": { + "H": 2, + "He": 2, + "Li": 9, + "Be": 9, + "B": 9, + "C": 9, + "N": 9, + "O": 9, + "F": 9, + "Ne": 9, + "Na": 13, + "Mg": 13, + "Al": 15, + "Si": 15, + "P": 15, + "S": 15, + "Cl": 15, + "Ar": 15, + }, + "6-31G*": { + "H": 2, + "He": 2, + "Li": 9, + "Be": 9, + "B": 14, + "C": 14, + "N": 14, + "O": 14, + "F": 14, + "Ne": 14, + "Na": 13, + "Mg": 13, + "Al": 20, + "Si": 20, + "P": 20, + "S": 20, + "Cl": 20, + "Ar": 20, + }, + "6-31G**": { + "H": 5, + "He": 2, + "Li": 9, + "Be": 9, + "B": 14, + "C": 14, + "N": 14, + "O": 14, + "F": 14, + "Ne": 14, + "Na": 13, + "Mg": 13, + "Al": 20, + "Si": 20, + "P": 20, + "S": 20, + "Cl": 20, + "Ar": 20, + }, + "cc-pVDZ": { + "H": 5, + "He": 5, + "Li": 9, + "Be": 9, + "B": 14, + "C": 14, + "N": 14, + "O": 14, + "F": 14, + "Ne": 14, + "Na": 18, + "Mg": 18, + "Al": 23, + "Si": 23, + "P": 23, + "S": 23, + "Cl": 23, + "Ar": 23, + }, + "cc-pVTZ": { + "H": 14, + "He": 14, + "Li": 20, + "Be": 20, + "B": 30, + "C": 30, + "N": 30, + "O": 30, + "F": 30, + "Ne": 30, + "Na": 35, + "Mg": 35, + "Al": 43, + "Si": 43, + "P": 43, + "S": 43, + "Cl": 43, + "Ar": 43, + }, + "def2-SVP": { + "H": 5, + "He": 5, + "Li": 9, + "Be": 9, + "B": 14, + "C": 14, + "N": 14, + "O": 14, + "F": 14, + "Ne": 14, + "Na": 18, + "Mg": 18, + "Al": 23, + "Si": 23, + "P": 23, + "S": 23, + "Cl": 23, + "Ar": 23, + }, + "def2-TZVP": { + "H": 14, + "He": 14, + "Li": 20, + "Be": 20, + "B": 30, + "C": 30, + "N": 30, + "O": 30, + "F": 30, + "Ne": 30, + "Na": 35, + "Mg": 35, + "Al": 43, + "Si": 43, + "P": 43, + "S": 43, + "Cl": 43, + "Ar": 43, + }, +} + +# Formal scaling exponents in N_basis. HF/DFT: formally O(N³–N⁴), empirically +# ~3.5 in the student size range. Correlated methods scale more steeply. +_METHOD_SCALE_EXP: dict[str, float] = { + "RHF": 3.5, + "UHF": 3.5, + "B3LYP": 3.5, + "PBE": 3.5, + "PBE0": 3.5, + "M06-2X": 3.5, + "wB97X-D": 3.5, + "CAM-B3LYP": 3.5, + "M06-L": 3.5, + "HSE06": 3.5, + "PBE-D3": 3.5, + "MP2": 5.0, + "CCSD": 6.0, + "CCSD(T)": 7.0, } @@ -78,6 +288,35 @@ def _read_all(path: Path) -> list[dict]: return records +# --------------------------------------------------------------------------- +# Basis function utilities +# --------------------------------------------------------------------------- + + +def count_basis_functions(atoms: list[str], basis: str) -> Optional[int]: + """ + Return the total number of contracted basis functions for a molecule. + + Args: + atoms: Element symbols (e.g. ``["O", "H", "H"]``). + basis: Basis set name (e.g. ``"STO-3G"``). + + Returns: + Total basis function count, or ``None`` if the basis set or any + element is not in the lookup table. + """ + table = _BASIS_FUNCTIONS.get(basis) + if table is None: + return None + total = 0 + for atom in atoms: + n = table.get(atom) + if n is None: + return None + total += n + return total + + # --------------------------------------------------------------------------- # Performance log # --------------------------------------------------------------------------- @@ -89,25 +328,29 @@ def log_calculation( n_electrons: int, method: str, basis: str, - n_iterations: int, + n_iterations: Optional[int], elapsed_s: float, converged: bool, + n_basis: Optional[int] = None, + n_cores: Optional[int] = None, ) -> None: """Append one performance record to ``perf_log.jsonl``.""" - _append( - _perf_path(), - { - "timestamp": datetime.now(timezone.utc).isoformat(), - "formula": formula, - "n_atoms": n_atoms, - "n_electrons": n_electrons, - "method": method, - "basis": basis, - "n_iterations": n_iterations, - "elapsed_s": round(elapsed_s, 3), - "converged": converged, - }, - ) + record: dict = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "formula": formula, + "n_atoms": n_atoms, + "n_electrons": n_electrons, + "method": method, + "basis": basis, + "n_iterations": n_iterations, + "elapsed_s": round(elapsed_s, 3), + "converged": converged, + } + if n_basis is not None: + record["n_basis"] = n_basis + if n_cores is not None: + record["n_cores"] = n_cores + _append(_perf_path(), record) def estimate_time( @@ -115,6 +358,8 @@ def estimate_time( n_electrons: int, method: str, basis: str, + n_basis: Optional[int] = None, + n_cores: Optional[int] = None, ) -> Optional[dict]: """ Return a time estimate dict, or ``None`` if there is insufficient data. @@ -127,25 +372,66 @@ def estimate_time( Prediction strategy (in priority order): - 1. **Exact match** (same method + basis, ≥ 2 converged runs): - Median elapsed time scaled by ``(n_electrons / median_n_electrons)^2.7``. - HF/DFT formal scaling is O(N³–N⁴); 2.7 is a practical empirical exponent - for the sizes typical in a teaching context. + 1. **Exact method + basis, basis-function efficiency** (≥ 2 records with + ``n_basis``): Computes a normalised efficiency + ``eff = elapsed_s × n_cores_hist / n_basis_hist^β`` for each record, + then predicts ``median(eff) × n_basis_new^β / n_cores_current``. + β is method-specific (RHF/DFT ≈ 3.5, MP2 = 5.0, CCSD = 6.0, …). Confidence: high (≥ 5 samples) or medium (2–4 samples). - 2. **Same basis, any method** (≥ 2 converged runs): - Same electron-count scaling plus a relative method-cost factor from - ``_METHOD_COST``. Confidence: low. + 2. **Exact method + basis, electron-count fallback** (≥ 2 records): + Median elapsed time scaled by ``(n_electrons / median_n_e)^2.7``. + Used when older records lack ``n_basis``. + Confidence: high / medium. + + 3. **Same basis, any method, basis-function efficiency** (≥ 2 records with + ``n_basis``): Like strategy 1, plus a method-cost correction factor. + Confidence: low. + + 4. **Same basis, any method, electron-count fallback** (≥ 2 records): + Same as the original strategy 2. Confidence: low. Returns ``None`` when fewer than 2 converged records are available for - either strategy. + any strategy. """ records = _read_all(_perf_path()) converged = [r for r in records if r.get("converged")] if not converged: return None - # ── Strategy 1: exact method + basis ──────────────────────────────────── + beta_new = _METHOD_SCALE_EXP.get(method, 3.5) + n_cores_current = n_cores if n_cores is not None else 1 + + def _eff(r: dict) -> Optional[float]: + """Normalised efficiency: elapsed_s × n_cores / n_basis^β.""" + nb: float = float(r.get("n_basis") or 0) + if not nb: + return None + rc: float = float(r.get("n_cores") or 1) + r_method: str = str(r.get("method") or method) + beta: float = _METHOD_SCALE_EXP.get(r_method, 3.5) + elapsed: float = float(r["elapsed_s"]) + return float(elapsed * rc / (nb**beta)) + + # ── Strategy 1: exact method + basis, basis-function efficiency ────────── + if n_basis is not None: + exact_nb = [ + r + for r in converged + if r.get("method") == method + and r.get("basis") == basis + and r.get("n_basis") is not None + ] + effs = [e for r in exact_nb for e in [_eff(r)] if e is not None] + if len(effs) >= 2: + predicted = statistics.median(effs) * (n_basis**beta_new) / n_cores_current + return { + "seconds": predicted, + "confidence": "high" if len(effs) >= 5 else "medium", + "n_samples": len(effs), + } + + # ── Strategy 2: exact method + basis, electron-count fallback ──────────── exact = [ r for r in converged if r.get("method") == method and r.get("basis") == basis ] @@ -159,7 +445,33 @@ def estimate_time( "n_samples": len(exact), } - # ── Strategy 2: same basis, any method ────────────────────────────────── + # ── Strategy 3: same basis, any method, basis-function efficiency ───────── + if n_basis is not None: + same_basis_nb = [ + r + for r in converged + 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] + if len(effs) >= 2: + ref_cost = statistics.median( + _METHOD_COST.get(r.get("method", "RHF"), 1.0) for r in same_basis_nb + ) + tgt_cost = _METHOD_COST.get(method, 1.0) + cost_factor = tgt_cost / ref_cost if ref_cost > 0 else 1.0 + predicted = ( + statistics.median(effs) + * (n_basis**beta_new) + * cost_factor + / n_cores_current + ) + return { + "seconds": predicted, + "confidence": "low", + "n_samples": len(effs), + } + + # ── Strategy 4: same basis, any method, electron-count fallback ─────────── same_basis = [r for r in converged if r.get("basis") == basis] if len(same_basis) >= 2: median_ne = statistics.median(r["n_electrons"] for r in same_basis) @@ -227,6 +539,19 @@ def reset_perf_log() -> None: path.unlink() +def clear_event_log() -> None: + """Delete the session event log (``event_log.jsonl``). + + Removes the file entirely. A fresh file is created automatically on the + next :func:`log_event` call. ``perf_log.jsonl`` and ``issues.db`` are + **not** affected. + """ + path = _event_path() + with _LOCK: + if path.exists(): + path.unlink() + + # --------------------------------------------------------------------------- # Event log (7-day TTL) # --------------------------------------------------------------------------- diff --git a/quantui/calculator.py b/quantui/calculator.py index ed4cebb..a871888 100644 --- a/quantui/calculator.py +++ b/quantui/calculator.py @@ -1,9 +1,9 @@ """ -QuantUI-local Calculator Module +QuantUI Calculator Module Generates standalone PySCF Python scripts that students can download and run independently. This is an "Export Script" feature — the primary -calculation path in QuantUI-local is session_calc.run_in_session(), not +calculation path in QuantUI is session_calc.run_in_session(), not batch script submission. """ @@ -20,7 +20,7 @@ class PySCFCalculation: """ Generates standalone PySCF scripts for a given molecule and method. - The primary use in QuantUI-local is the "Export Script" button in the + The primary use in QuantUI is the "Export Script" button in the notebook, which lets students download a self-contained .py file they can study or run outside the notebook environment. """ diff --git a/quantui/config.py b/quantui/config.py index d177b49..0adee08 100644 --- a/quantui/config.py +++ b/quantui/config.py @@ -1,5 +1,5 @@ """ -QuantUI-local Configuration Module +QuantUI Configuration Module Configuration constants and defaults for the local teaching interface. SLURM resource limits, job history paths, and cluster settings have been @@ -13,7 +13,20 @@ PROJECT_ROOT = Path(__file__).parent.parent # Supported quantum chemistry methods -SUPPORTED_METHODS = ["RHF", "UHF", "B3LYP", "PBE", "PBE0", "M06-2X"] +SUPPORTED_METHODS = [ + "RHF", + "UHF", + "B3LYP", + "PBE", + "PBE0", + "M06-2X", + "wB97X-D", + "CAM-B3LYP", + "M06-L", + "HSE06", + "PBE-D3", + "MP2", +] # Educational metadata for each method — shown to students in the UI METHOD_INFO = { @@ -74,6 +87,66 @@ ), "use_for": "Organic reaction energies, conformational analysis, barrier heights.", }, + "wB97X-D": { + "type": "dft", + "label": "wB97X-D — Range-Separated Hybrid + D3 Dispersion", + "description": ( + "Range-separated hybrid functional with empirical D3 dispersion correction. " + "Excellent for non-covalent interactions, charge-transfer excitations, " + "and systems where long-range exchange matters." + ), + "use_for": "Non-covalent interactions, excited states, large organic molecules.", + }, + "CAM-B3LYP": { + "type": "dft", + "label": "CAM-B3LYP — Coulomb-Attenuating B3LYP", + "description": ( + "Range-separated version of B3LYP. More reliable than B3LYP for " + "charge-transfer excited states and Rydberg transitions. " + "Good general-purpose alternative to B3LYP." + ), + "use_for": "Charge-transfer states, UV-Vis spectra, long-range interactions.", + }, + "M06-L": { + "type": "dft", + "label": "M06-L — Local Meta-GGA DFT", + "description": ( + "Local (no HF exchange) Minnesota meta-GGA. Faster than hybrid " + "functionals for the same system size. Good for transition metals " + "and main-group thermochemistry." + ), + "use_for": "Larger molecules where hybrid cost is prohibitive; transition metals.", + }, + "HSE06": { + "type": "dft", + "label": "HSE06 — Screened Hybrid DFT", + "description": ( + "Heyd-Scuseria-Ernzerhof screened hybrid. Uses short-range HF exchange " + "only, making it efficient for large systems. Often used for solids; " + "also accurate for molecular band gaps." + ), + "use_for": "Band gaps, large molecules, when PBE0 is too expensive.", + }, + "PBE-D3": { + "type": "dft", + "label": "PBE-D3 — PBE + D3 Dispersion Correction", + "description": ( + "PBE GGA functional with Grimme's D3BJ empirical dispersion correction. " + "Dramatically improves non-covalent interaction energies over plain PBE " + "at negligible extra cost." + ), + "use_for": "Van der Waals complexes, stacking interactions, large organic molecules.", + }, + "MP2": { + "type": "wavefunction", + "label": "MP2 — 2nd-Order Møller-Plesset", + "description": ( + "Post-HF wavefunction method that adds electron correlation via 2nd-order " + "perturbation theory. More accurate than HF for energetics and geometries, " + "but scales as O(N⁵). Avoid for molecules with > ~20 heavy atoms." + ), + "use_for": "Accurate energetics for small closed-shell molecules; bond dissociation.", + }, } # Supported basis sets @@ -89,6 +162,30 @@ "def2-TZVP", ] +# Implicit solvent options — name → dielectric constant (ε) +SOLVENT_OPTIONS: Dict[str, float] = { + "Water": 78.39, + "Ethanol": 24.55, + "THF": 7.58, + "DMSO": 46.70, + "Acetonitrile": 35.69, +} + +# TMS isotropic shielding reference constants for NMR chemical shift computation. +# Key: "method/basis" → {element: σ_TMS (ppm)}. δ = σ_TMS − σ_molecule. +# Source: Cheeseman et al., J. Chem. Phys. 104 (1996) 5497; CCCBDB. +NMR_REFERENCE_SHIELDINGS: Dict[str, Dict[str, float]] = { + "B3LYP/6-31G*": {"H": 31.72, "C": 183.71}, + "B3LYP/6-311G**": {"H": 31.60, "C": 188.94}, + "B3LYP/cc-pVDZ": {"H": 31.54, "C": 186.12}, + "B3LYP/def2-SVP": {"H": 31.65, "C": 184.20}, + "RHF/6-31G*": {"H": 32.00, "C": 196.00}, + "RHF/STO-3G": {"H": 30.50, "C": 195.00}, + "PBE0/6-31G*": {"H": 31.60, "C": 184.50}, + "PBE/6-31G*": {"H": 31.50, "C": 185.00}, +} +NMR_DEFAULT_REFERENCE: Dict[str, float] = {"H": 31.72, "C": 183.71} # B3LYP/6-31G* + # Default calculation settings DEFAULT_METHOD = "RHF" DEFAULT_BASIS = "6-31G" @@ -470,7 +567,7 @@ PYSCF_SCRIPT_TEMPLATE = """#!/usr/bin/env python3 \"\"\" PySCF Calculation Script -Generated by QuantUI-local +Generated by QuantUI Calculation: {job_name} Method: {method} diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index f82a9d0..ec4f0a9 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -39,12 +39,30 @@ # 1 cm^-1 = h·c·100 / E_h (NIST 2018 CODATA) _CM1_TO_HARTREE: float = 4.556335252912e-6 +# Exact: 1 Hartree = HARTREE_TO_EV * e * N_A joules/mol +_HARTREE_TO_JMOL: float = 2625499.6 # J/mol per Hartree (NIST 2018 CODATA) + # ============================================================================ # Result dataclass # ============================================================================ +@dataclass +class ThermoData: + """Thermochemical data from the harmonic approximation at 298.15 K / 1 atm. + + All energies are in Hartrees; entropy is in J/(mol·K). + H and G include the SCF electronic energy. + """ + + zpve_hartree: float + H_hartree: float + S_jmol: float + G_hartree: float + temperature_k: float = 298.15 + + @dataclass class FreqResult: """Structured output from a vibrational frequency analysis. @@ -75,6 +93,18 @@ class FreqResult: frequencies_cm1: List[float] = field(default_factory=list) ir_intensities: List[float] = field(default_factory=list) zpve_hartree: float = 0.0 + thermo: Optional[ThermoData] = None + displacements: Optional[List] = None + """Normalized displacement vectors from PySCF harmonic analysis. + + Shape: ``(n_modes, n_atoms, 3)`` stored as a nested Python list. + ``None`` if the Hessian calculation failed or PySCF version does not + provide ``norm_mode``. + """ + mo_energy_hartree: Optional[List] = None + mo_occ: Optional[List] = None + pyscf_mol_atom: Optional[List] = None + pyscf_mol_basis: Optional[str] = None @property def energy_ev(self) -> float: @@ -145,7 +175,7 @@ def run_freq_calc( mol.basis = basis mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 - mol.verbose = 3 + mol.verbose = 4 mol.stdout = stream mol.build() @@ -190,13 +220,34 @@ def run_freq_calc( except Exception: pass + # ── MO data for orbital energy diagram (best-effort) ───────────────────── + mo_energy_hartree: Optional[List] = None + mo_occ_list: Optional[List] = None + pyscf_mol_atom: Optional[List] = None + try: + import numpy as _np_mo + + _moe = mf.mo_energy + _moo = mf.mo_occ + if isinstance(_moe, (list, _np_mo.ndarray)) and hasattr(_moe[0], "__len__"): + _moe, _moo = _moe[0], _moo[0] + mo_energy_hartree = _np_mo.asarray(_moe, dtype=float).tolist() + mo_occ_list = _np_mo.asarray(_moo, dtype=float).tolist() + pyscf_mol_atom = [(str(s), list(map(float, c))) for s, c in mol._atom] + except Exception: + pass + # ── Hessian + frequency analysis ───────────────────────────────────────── frequencies_cm1: List[float] = [] ir_intensities: List[float] = [] zpve_hartree: float = 0.0 + displacements: Optional[List] = None + thermo_data: Optional[ThermoData] = None try: hess_obj = mf.Hessian() + hess_obj.verbose = mol.verbose + hess_obj.stdout = stream h = hess_obj.kernel() freq_info = pyscf_thermo.harmonic_analysis(mol, h) @@ -215,6 +266,25 @@ def run_freq_calc( # ZPVE = ½ · Σ ν_i (positive modes only), converted cm⁻¹ → Hartree zpve_hartree = sum(0.5 * f * _CM1_TO_HARTREE for f in frequencies_cm1 if f > 0) + # Normalized displacement vectors: shape (n_modes, n_atoms, 3). + # Stored as a nested Python list for JSON-friendliness and to avoid + # a hard numpy dependency in the dataclass. + try: + import numpy as _np + + norm_mode = freq_info.get("norm_mode") + if norm_mode is not None: + # norm_mode has shape (n_modes, n_atoms*3) or (n_modes, n_atoms, 3); + # reshape to (n_modes, n_atoms, 3) if needed. + nm = _np.array(norm_mode, dtype=float) + n_modes_out = nm.shape[0] + n_atoms = len(molecule.atoms) + if nm.ndim == 2: + nm = nm.reshape(n_modes_out, n_atoms, 3) + displacements = nm.tolist() + except Exception: + displacements = None + # IR intensities — best-effort; silently omitted if unavailable try: ir_info = pyscf_thermo.ir_spectrum(mf, h) @@ -222,6 +292,66 @@ def run_freq_calc( except Exception: ir_intensities = [] + # Thermochemistry at 298.15 K / 1 atm — best-effort + try: + import numpy as _np + + _freq_au = freq_info.get("freq_au") + if _freq_au is None: + _freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE + else: + # PySCF may return complex freq_au for imaginary modes; take real parts. + _freq_au = _np.array( + [f.real if hasattr(f, "real") else f for f in _freq_au], + dtype=float, + ) + + # PySCF 2.x thermo() may or may not accept the pressure argument. + try: + _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15, 101325) + except TypeError: + _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15) + + # PySCF 2.x returns (value, unit_string) tuples; earlier versions + # return plain floats. _tv() extracts the numeric value either way. + def _tv(v): + if isinstance(v, (tuple, list)): + return float(v[0]) + if hasattr(v, "item"): + return float(v.item()) + return float(v) + + # PySCF 2.x (>=2.6) uses "H_tot"/"S_tot"; earlier versions used "H"/"S". + _H_raw, _S_raw, _Z_raw = None, None, None + for _k in ("H_tot", "H", "Htot", "H_0K"): + if _tout.get(_k) is not None: + _H_raw = _tout[_k] + break + for _k in ("S_tot", "S", "Stot"): + if _tout.get(_k) is not None: + _S_raw = _tout[_k] + break + for _k in ("ZPE", "zpve", "ZPE_vib"): + if _tout.get(_k) is not None: + _Z_raw = _tout[_k] + break + if _H_raw is None or _S_raw is None: + raise KeyError( + f"Missing H or S in thermo dict (keys: {sorted(_tout.keys())})" + ) + _H = _tv(_H_raw) + _S = _tv(_S_raw) # J/(mol·K) + _zpve = _tv(_Z_raw) if _Z_raw is not None else zpve_hartree + _G = _H - 298.15 * _S / _HARTREE_TO_JMOL + thermo_data = ThermoData( + zpve_hartree=_zpve, + H_hartree=_H, + S_jmol=_S, + G_hartree=_G, + ) + except Exception as _exc: + logger.warning("Thermochemistry failed: %s", _exc) + except Exception as exc: logger.warning("Hessian/frequency computation failed: %s", exc) if progress_stream is not None: @@ -241,4 +371,10 @@ def run_freq_calc( frequencies_cm1=frequencies_cm1, ir_intensities=ir_intensities, zpve_hartree=zpve_hartree, + thermo=thermo_data, + displacements=displacements, + mo_energy_hartree=mo_energy_hartree, + mo_occ=mo_occ_list, + pyscf_mol_atom=pyscf_mol_atom, + pyscf_mol_basis=basis, ) diff --git a/quantui/help_content.py b/quantui/help_content.py index 653c2fa..23fae71 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -23,6 +23,29 @@ # --------------------------------------------------------------------------- HELP_TOPICS: Dict[str, Dict[str, str]] = { + "getting_started": { + "title": "Getting Started", + "body": ( + "

    How to run a calculation:

    " + "
      " + "
    1. Select or enter a molecule in Molecule Input (XYZ text, " + "SMILES string, library preset, or PubChem search)
    2. " + "
    3. Choose a calculation type, method, and basis set in " + "Calculation Setup
    4. " + "
    5. Click Run Calculation — results appear in the " + "Results tab immediately
    6. " + "
    7. View orbital diagrams, trajectories, and spectra in the " + "Analysis tab
    8. " + "
    9. Optionally compare results in Compare, or use " + "History to reload a previous run
    10. " + "
    " + "

    Platform note: PySCF calculations require Linux, macOS, " + "or WSL. On Windows, run the pre-built container: " + "apptainer run quantui.sif

    " + "

    Each dropdown in the Calculate tab has a ? button for " + "context-sensitive help on that specific option.

    " + ), + }, "method": { "title": "RHF vs UHF — which method should I use?", "body": ( @@ -182,7 +205,7 @@ "and G. K.-L. Chan, " "J. Chem. Phys.153, 024109 (2020)." "" - "

    Also cite QuantUI-local (your instructor will provide the reference).

    " + "

    Also cite QuantUI (your instructor will provide the reference).

    " "

    BibTeX key: Sun2020 — search for " "'PySCF 2020' in Google Scholar or your reference manager.

    " ), diff --git a/quantui/ir_plot.py b/quantui/ir_plot.py new file mode 100644 index 0000000..2e1951a --- /dev/null +++ b/quantui/ir_plot.py @@ -0,0 +1,113 @@ +""" +IR spectrum visualization: stick chart and Lorentzian broadened lineshape. + +Accepts vibrational frequencies (cm⁻¹) and IR intensities (km/mol) +from a frequency calculation and returns a Plotly Figure. + +Typical usage:: + + from quantui.ir_plot import plot_ir_spectrum + fig = plot_ir_spectrum(result.frequencies_cm1, result.ir_intensities) + fig = plot_ir_spectrum(freqs, intensities, mode="broadened", fwhm=30.0) +""" + +from __future__ import annotations + +from typing import List, Optional + +import numpy as np +import plotly.graph_objects as go + +# x-axis range follows the standard IR convention: high → low wavenumber +_XRANGE = [4000, 400] +_XGRID = np.arange(400, 4001, 1.0) # 1 cm⁻¹ resolution for broadened mode + + +def plot_ir_spectrum( + frequencies: List[float], + intensities: List[float], + *, + fwhm: float = 20.0, + mode: str = "stick", + yaxis_title: str = "IR Intensity (km/mol)", +) -> go.Figure: + """Return a Plotly figure for the IR absorption spectrum. + + Args: + frequencies: Vibrational frequencies in cm⁻¹. + Values ≤ 0 (imaginary / translation / rotation) are silently skipped. + intensities: IR intensities in km/mol, same length as *frequencies*. + fwhm: Full width at half maximum for the Lorentzian lineshape in cm⁻¹. + Only used when ``mode="broadened"``. Default: 20. + mode: Display mode. + ``"stick"`` — vertical bars at each active frequency. + ``"broadened"`` — Lorentzian convolution of all peaks. + + Returns: + :class:`plotly.graph_objects.Figure` ready for display or wrapping + in a :class:`~plotly.graph_objects.FigureWidget`. + """ + real_pairs = [(f, i) for f, i in zip(frequencies, intensities) if f > 0] + + _base_layout = dict( + xaxis=dict( + title="Wavenumber (cm⁻¹)", + range=_XRANGE, + showgrid=True, + gridcolor="#e5e7eb", + ), + yaxis=dict( + title=yaxis_title, + rangemode="tozero", + showgrid=True, + gridcolor="#e5e7eb", + ), + template="plotly_white", + showlegend=False, + margin=dict(l=60, r=20, t=20, b=55), + height=300, + plot_bgcolor="#fafafa", + ) + + fig = go.Figure(layout=_base_layout) + + if not real_pairs: + return fig + + freqs_real, ints_real = zip(*real_pairs) + + if mode == "broadened": + half_gamma = fwhm / 2.0 + y_broad = np.zeros_like(_XGRID) + for nu0, inten in zip(freqs_real, ints_real): + y_broad += inten * half_gamma**2 / ((_XGRID - nu0) ** 2 + half_gamma**2) + + fig.add_trace( + go.Scatter( + x=_XGRID, + y=y_broad, + mode="lines", + line=dict(color="#2563eb", width=1.5), + name="IR (broadened)", + hovertemplate="%{x:.0f} cm⁻¹ | %{y:.2f} km/mol", + ) + ) + else: # stick + x_stick: List[Optional[float]] = [] + y_stick: List[Optional[float]] = [] + for nu, inten in zip(freqs_real, ints_real): + x_stick.extend([nu, nu, None]) + y_stick.extend([0.0, inten, None]) + + fig.add_trace( + go.Scatter( + x=x_stick, + y=y_stick, + mode="lines", + line=dict(color="#2563eb", width=2), + name="IR (stick)", + hovertemplate="%{x:.0f} cm⁻¹", + ) + ) + + return fig diff --git a/quantui/issue_tracker.py b/quantui/issue_tracker.py new file mode 100644 index 0000000..0324c90 --- /dev/null +++ b/quantui/issue_tracker.py @@ -0,0 +1,170 @@ +""" +Issue tracking for QuantUI. + +User-reported issues are stored in a local SQLite database alongside the +session event log. Each issue captures a description and a snapshot of the +app state at the time of the report, making it possible to reconstruct the +sequence of events leading up to a problem. + +Database location +----------------- +``/issues.db`` where ```` is the same directory used by +``calc_log`` (``~/.quantui/logs`` by default, or ``$QUANTUI_LOG_DIR``). + +Public API +---------- +``log_issue(description, context, session_id)`` + Save an issue and mirror it to the event log. + +``get_issues(n)`` + Return the *n* most recent issues as a list of dicts. + +``clear_issues()`` + Delete all issue records (drops and recreates the table). +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +_LOCK = threading.Lock() + +# --------------------------------------------------------------------------- +# Path helpers (mirror calc_log so both use the same QUANTUI_LOG_DIR env var) +# --------------------------------------------------------------------------- + + +def _log_dir() -> Path: + env = os.environ.get("QUANTUI_LOG_DIR") + return Path(env) if env else Path.home() / ".quantui" / "logs" + + +def _db_path() -> Path: + return _log_dir() / "issues.db" + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + description TEXT NOT NULL, + session_id TEXT, + context_json TEXT +); +""" + + +def _init_db() -> None: + db = _db_path() + db.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(str(db)) as conn: + conn.execute(_CREATE_TABLE) + conn.commit() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def log_issue( + description: str, + context: Optional[dict] = None, + session_id: Optional[str] = None, +) -> int: + """Save an issue report to SQLite and the event log. + + Args: + description: Free-text description of the observed issue. + context: Optional snapshot dict (molecule, settings, last result, + recent events). Stored as JSON in the DB. + session_id: Caller-supplied session identifier for cross-referencing + with the event log. + + Returns: + The auto-incremented issue id. + """ + _init_db() + ts = datetime.now(timezone.utc).isoformat() + ctx_json = json.dumps(context or {}, ensure_ascii=False) + with _LOCK: + with sqlite3.connect(str(_db_path())) as conn: + cursor = conn.execute( + "INSERT INTO issues (timestamp, description, session_id, context_json)" + " VALUES (?, ?, ?, ?)", + (ts, description, session_id, ctx_json), + ) + conn.commit() + issue_id: int = cursor.lastrowid # type: ignore[assignment] + + # Mirror to the sequential event log so issues appear in context + try: + from quantui import calc_log as _clog + + _clog.log_event( + "issue_filed", + description[:200], + issue_id=issue_id, + session_id=session_id, + ) + except Exception: + pass + + return issue_id + + +def get_issues(n: int = 50) -> list[dict]: + """Return the *n* most recent issues, newest first. + + Args: + n: Maximum number of issues to return. + + Returns: + List of dicts with keys: ``id``, ``timestamp``, ``description``, + ``session_id``, ``context``. + """ + db = _db_path() + if not db.exists(): + return [] + with sqlite3.connect(str(db)) as conn: + rows = conn.execute( + "SELECT id, timestamp, description, session_id, context_json" + " FROM issues ORDER BY id DESC LIMIT ?", + (n,), + ).fetchall() + return [ + { + "id": row[0], + "timestamp": row[1], + "description": row[2], + "session_id": row[3], + "context": json.loads(row[4] or "{}"), + } + for row in rows + ] + + +def clear_issues() -> None: + """Delete all issue records from the database. + + Drops and recreates the ``issues`` table. The database file itself is + kept so the path remains stable. + """ + db = _db_path() + if not db.exists(): + return + with _LOCK: + with sqlite3.connect(str(db)) as conn: + conn.execute("DROP TABLE IF EXISTS issues") + conn.execute(_CREATE_TABLE) + conn.commit() diff --git a/quantui/log_utils.py b/quantui/log_utils.py new file mode 100644 index 0000000..e0cbd6d --- /dev/null +++ b/quantui/log_utils.py @@ -0,0 +1,331 @@ +""" +Structured log header/footer for QuantUI calculation output. + +Collects machine metadata (CPU, RAM, GPU, OMP threads), formats a banner +header written before each calculation, and a summary footer written after +with wall/CPU timing, convergence status, key energies, and a warnings digest. +""" + +from __future__ import annotations + +import os +import platform +import subprocess +from datetime import datetime +from functools import lru_cache +from typing import Any, Dict, Optional + +_WIDTH = 80 # total width of === border lines +_SEP = "=" * _WIDTH + + +# ============================================================================ +# System-info helpers +# ============================================================================ + + +def _read_proc_cpu() -> str: + """Return CPU model name from /proc/cpuinfo (Linux/WSL).""" + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return "" + + +def _read_proc_ram_gb() -> Optional[float]: + """Return total RAM in GiB from /proc/meminfo (Linux/WSL).""" + try: + with open("/proc/meminfo") as f: + for line in f: + if line.startswith("MemTotal:"): + kb = int(line.split()[1]) + return kb / (1024**2) + except (OSError, ValueError): + pass + return None + + +def _psutil_ram_gb() -> Optional[float]: + try: + import psutil + + return float(psutil.virtual_memory().total) / (1024**3) + except Exception: + return None + + +def _detect_gpu() -> Optional[Dict[str, str]]: + """Try nvidia-smi first, then cupy. Returns dict or None.""" + try: + out = subprocess.run( + [ + "nvidia-smi", + "--query-gpu=name,memory.total,driver_version", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=5, + ) + if out.returncode == 0: + line = out.stdout.strip().splitlines()[0] + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + return {"name": parts[0], "mem_mb": parts[1], "driver": parts[2]} + elif len(parts) == 2: + return {"name": parts[0], "mem_mb": parts[1], "driver": ""} + elif len(parts) == 1: + return {"name": parts[0], "mem_mb": "", "driver": ""} + except Exception: + pass + + # cupy fallback + try: + import cupy + + n = cupy.cuda.runtime.getDeviceCount() + if n > 0: + props = cupy.cuda.runtime.getDeviceProperties(0) + name = props.get("name", b"Unknown GPU") + if isinstance(name, bytes): + name = name.decode() + total_mem_mb = props.get("totalGlobalMem", 0) // (1024 * 1024) + return {"name": name, "mem_mb": str(total_mem_mb), "driver": ""} + except Exception: + pass + + return None + + +def collect_system_info() -> Dict[str, Any]: + """Gather CPU, RAM, GPU, and thread count. Safe on all platforms.""" + cpu_model = ( + _read_proc_cpu() or platform.processor() or platform.machine() or "Unknown CPU" + ) + cpu_count = os.cpu_count() or 1 + + ram_gb = _read_proc_ram_gb() or _psutil_ram_gb() + ram_str = f"{ram_gb:.0f} GB" if ram_gb else "Unknown" + + gpu = _detect_gpu() + + omp = os.environ.get("OMP_NUM_THREADS", None) + + return { + "cpu_model": cpu_model, + "cpu_count": cpu_count, + "ram_str": ram_str, + "gpu": gpu, + "omp_threads": omp, + } + + +@lru_cache(maxsize=1) +def get_system_info() -> Dict[str, Any]: + """Lazy-cached version of collect_system_info(). Populated on first call.""" + return collect_system_info() + + +# ============================================================================ +# Duration formatter +# ============================================================================ + + +def _fmt_duration(seconds: float) -> str: + """Format a duration in seconds as HH:MM:SS.t""" + if seconds < 0: + seconds = 0.0 + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = seconds % 60 + return f"{h:02d}:{m:02d}:{s:04.1f}" + + +# ============================================================================ +# Header +# ============================================================================ + +_CALC_TYPE_LABELS: Dict[str, str] = { + "single_point": "Single Point Energy", + "geometry_opt": "Geometry Optimization", + "frequency": "Frequency Analysis", + "tddft": "TD-DFT (UV-Vis)", + "nmr": "NMR Shielding", +} + + +def format_log_header( + *, + formula: str, + method: str, + basis: str, + calc_type: str, + timestamp: Optional[str] = None, +) -> str: + """Return a formatted header string to prepend to calculation log output.""" + sysinfo = get_system_info() + + if timestamp is None: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + ct_label = _CALC_TYPE_LABELS.get(calc_type, calc_type.replace("_", " ").title()) + + gpu = sysinfo["gpu"] + if gpu: + mem = f" | {gpu['mem_mb']} MB" if gpu.get("mem_mb") else "" + drv = f" | Driver {gpu['driver']}" if gpu.get("driver") else "" + gpu_line = f" GPU: {gpu['name']}{mem}{drv}" + else: + gpu_line = " GPU: (none detected)" + + omp = sysinfo["omp_threads"] + omp_str = ( + f"OMP_NUM_THREADS={omp}" + if omp + else f"OMP_NUM_THREADS not set (cores: {sysinfo['cpu_count']})" + ) + + lines = [ + "", + _SEP, + " QuantUI — Quantum Chemistry Interface", + _SEP, + f" Machine: {sysinfo['cpu_model']} | {sysinfo['cpu_count']} cores | RAM: {sysinfo['ram_str']}", + gpu_line, + f" Threads: {omp_str}", + "", + f" Molecule: {formula}", + f" Method/Basis: {method} / {basis}", + f" Calc type: {ct_label}", + f" Started: {timestamp}", + _SEP, + "", + ] + return "\n".join(lines) + + +# ============================================================================ +# Footer +# ============================================================================ + + +def _extract_warnings(log_text: str) -> list[str]: + """Return list of unique warning/error lines found in log_text.""" + seen: set[str] = set() + found = [] + for line in log_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + lower = stripped.lower() + if any( + kw in lower + for kw in ("warn", "error", "failed", "not converge", "imaginary") + ): + if stripped not in seen: + seen.add(stripped) + found.append(stripped) + return found + + +def format_log_footer( + *, + result: Any, + wall_time: float, + cpu_time: float, + log_text: str = "", + success: bool = True, +) -> str: + """Return a formatted footer string to append to calculation log output. + + Args: + result: Any result dataclass (SessionResult, OptResult, FreqResult, etc.) + or None if the calculation failed. + wall_time: Elapsed wall-clock seconds. + cpu_time: Elapsed process CPU seconds. + log_text: The log body text to scan for warnings. + success: Whether the calculation completed without an exception. + """ + from .session_calc import HARTREE_TO_EV # local import — avoids circular deps + + lines: list[str] = ["", _SEP, " ── Result " + "─" * (_WIDTH - 12)] + + if result is not None: + converged = getattr(result, "converged", None) + n_iter = getattr(result, "n_iterations", None) + energy = getattr(result, "energy_hartree", None) + gap_ev = getattr(result, "homo_lumo_gap_ev", None) + zpve = getattr(result, "zpve_hartree", None) + n_steps = getattr(result, "n_steps", None) # OptResult + + # Convergence line + if converged is not None: + tick = "✓" if converged else "✗" + conv_word = "converged" if converged else "did NOT converge" + iter_str = f" | Iterations: {n_iter}" if n_iter is not None else "" + lines.append(f" {tick} SCF {conv_word}{iter_str}") + if n_steps is not None: + lines.append(f" Geometry optimization: {n_steps} steps") + + # Energy + if energy is not None: + ev = energy * HARTREE_TO_EV + lines.append(f" Energy: {energy:.8f} Ha ({ev:.4f} eV)") + + # HOMO-LUMO gap + if gap_ev is not None: + lines.append(f" HOMO-LUMO gap: {gap_ev:.4f} eV") + + # ZPVE (frequency only) + if zpve is not None and zpve != 0.0: + lines.append( + f" ZPVE: {zpve:.6f} Ha ({zpve * HARTREE_TO_EV:.4f} eV)" + ) + + # Imaginary frequencies (FreqResult) + n_imag = None + try: + n_imag = result.n_imaginary_modes() # type: ignore[attr-defined] + except AttributeError: + pass + if n_imag is not None and n_imag > 0: + lines.append( + f" ⚠ {n_imag} imaginary frequency mode(s) — geometry may not be a true minimum" + ) + + # Timing section + lines.append(" ── Timing " + "─" * (_WIDTH - 12)) + wall_str = _fmt_duration(wall_time) + cpu_str = _fmt_duration(cpu_time) + ratio = cpu_time / wall_time if wall_time > 0 else 0.0 + lines.append( + f" Wall time: {wall_str} CPU time: {cpu_str} CPU/Wall: {ratio:.1f}×" + ) + + # Warnings digest + lines.append(" ── Warnings Digest " + "─" * (_WIDTH - 22)) + warnings = _extract_warnings(log_text) + if warnings: + for w in warnings[:10]: # cap at 10 + # Truncate very long lines + w_disp = w if len(w) <= 74 else w[:71] + "..." + lines.append(f" ⚠ {w_disp}") + if len(warnings) > 10: + lines.append(f" ... and {len(warnings) - 10} more (see full log)") + else: + lines.append(" (none)") + + # Final status line + lines.append(_SEP) + if success: + lines.append(" ✓ Calculation completed successfully.") + else: + lines.append(" ✗ Calculation ended with errors — see log above.") + lines.append(_SEP) + lines.append("") + + return "\n".join(lines) diff --git a/quantui/molecule.py b/quantui/molecule.py index 8558e58..eccac77 100644 --- a/quantui/molecule.py +++ b/quantui/molecule.py @@ -245,7 +245,7 @@ def to_xyz_string(self) -> str: lines = [] for atom, coord in zip(self.atoms, self.coordinates): x, y, z = coord - lines.append(f"{atom} {x} {y} {z}") + lines.append(f"{atom} {x:.10f} {y:.10f} {z:.10f}") return "\n".join(lines) diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py new file mode 100644 index 0000000..0c13a67 --- /dev/null +++ b/quantui/nmr_calc.py @@ -0,0 +1,173 @@ +""" +NMR chemical shift prediction using PySCF GIAO. + +Computes isotropic NMR shielding tensors via GIAO (Gauge-Including +Atomic Orbitals) and converts to ¹H/¹³C chemical shifts relative to +TMS using tabulated reference constants from config.py. + +Typical usage:: + + from quantui.nmr_calc import run_nmr_calc + result = run_nmr_calc(molecule, method="B3LYP", basis="6-31G*") + for atom_idx, delta_ppm in result.h_shifts(): + print(f"H-{atom_idx+1}: {delta_ppm:.2f} ppm") +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Dict, List, Tuple + +from .molecule import Molecule + + +@dataclass +class NMRResult: + """Structured output from an NMR shielding calculation.""" + + atom_symbols: List[str] + shielding_iso_ppm: List[float] + chemical_shifts_ppm: Dict[int, float] # atom_index → δ (ppm), ¹H and ¹³C only + method: str + basis: str + formula: str + reference_compound: str = "TMS" + converged: bool = True + + def h_shifts(self) -> List[Tuple[int, float]]: + """(atom_index, δ ppm) pairs for all H atoms in molecule order.""" + return [ + (i, d) + for i, d in sorted(self.chemical_shifts_ppm.items()) + if self.atom_symbols[i] == "H" + ] + + def c_shifts(self) -> List[Tuple[int, float]]: + """(atom_index, δ ppm) pairs for all C atoms in molecule order.""" + return [ + (i, d) + for i, d in sorted(self.chemical_shifts_ppm.items()) + if self.atom_symbols[i] == "C" + ] + + +def run_nmr_calc( + molecule: Molecule, + method: str = "B3LYP", + basis: str = "6-31G*", + progress_stream=None, +) -> NMRResult: + """Run NMR shielding calculation and return ¹H/¹³C chemical shifts. + + Uses PySCF GIAO (Gauge-Including Atomic Orbitals) formalism. + Chemical shifts are reported relative to TMS using reference constants + from :data:`~quantui.config.NMR_REFERENCE_SHIELDINGS`. + + Args: + molecule: Validated :class:`~quantui.molecule.Molecule` object. + method: SCF or DFT method. Recommended: B3LYP. + basis: Basis set. Recommended: 6-31G* or better. + progress_stream: Optional writable text stream for PySCF output. + + Returns: + :class:`NMRResult` with per-atom shieldings and ¹H/¹³C shifts. + + Raises: + ImportError: If PySCF is not installed. + RuntimeError: If the SCF or GIAO-NMR calculation fails. + """ + try: + from pyscf import dft, gto, scf + except ImportError as exc: + raise ImportError( + "PySCF is not installed — cannot run NMR calculations.\n" + "Note: PySCF is Linux / macOS / WSL only." + ) from exc + + import numpy as _np + + from . import config as _config + from .session_calc import _XC_ALIAS + + stream = progress_stream if progress_stream is not None else sys.stdout + + mol = gto.Mole() + mol.atom = molecule.to_pyscf_format() + mol.basis = basis + mol.charge = molecule.charge + mol.spin = molecule.multiplicity - 1 + mol.verbose = 4 + mol.stdout = stream + mol.build() + + method_upper = method.upper() + if method_upper == "RHF": + mf = scf.RHF(mol) + elif method_upper == "UHF": + mf = scf.UHF(mol) + else: + xc_string = _XC_ALIAS.get(method, method) + mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + mf.xc = xc_string + + try: + mf.kernel() + except Exception as exc: + raise RuntimeError( + f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}" + ) from exc + + converged = bool(getattr(mf, "converged", False)) + + try: + from pyscf.prop import nmr as _pyscf_nmr + except ImportError as exc: + raise ImportError( + "PySCF NMR module (pyscf.prop.nmr) not found. " + "Ensure PySCF>=2.0 is installed: pip install 'pyscf>=2.0'" + ) from exc + + try: + if method_upper == "RHF": + nmr_obj = _pyscf_nmr.RHF(mf) + elif method_upper == "UHF": + nmr_obj = _pyscf_nmr.UHF(mf) + else: + nmr_obj = _pyscf_nmr.RKS(mf) if mol.spin == 0 else _pyscf_nmr.UKS(mf) + tensors = nmr_obj.kernel() + except Exception as exc: + raise RuntimeError( + f"NMR shielding failed for {molecule.get_formula()}: {exc}" + ) from exc + + shielding_iso: List[float] = [] + for tensor in tensors: + arr = _np.array(tensor) + if arr.ndim == 2: + shielding_iso.append(float(_np.trace(arr) / 3.0)) + else: + shielding_iso.append(float(arr)) + + key = f"{method}/{basis}" + ref_map = _config.NMR_REFERENCE_SHIELDINGS.get(key, _config.NMR_DEFAULT_REFERENCE) + ref_H = float(ref_map.get("H", _config.NMR_DEFAULT_REFERENCE["H"])) + ref_C = float(ref_map.get("C", _config.NMR_DEFAULT_REFERENCE["C"])) + + atoms = list(molecule.atoms) + chemical_shifts: Dict[int, float] = {} + for i, (atom, sigma) in enumerate(zip(atoms, shielding_iso)): + if atom == "H": + chemical_shifts[i] = round(ref_H - sigma, 2) + elif atom == "C": + chemical_shifts[i] = round(ref_C - sigma, 2) + + return NMRResult( + atom_symbols=atoms, + shielding_iso_ppm=shielding_iso, + chemical_shifts_ppm=chemical_shifts, + method=method, + basis=basis, + formula=molecule.get_formula(), + converged=converged, + ) diff --git a/quantui/optimizer.py b/quantui/optimizer.py index fe9fa2e..ecc379b 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -47,7 +47,7 @@ import tempfile from dataclasses import dataclass from pathlib import Path -from typing import IO, List, Optional +from typing import IO, Any, List, Optional from .ase_bridge import ASE_AVAILABLE, atoms_to_molecule, molecule_to_atoms from .molecule import Molecule @@ -120,14 +120,15 @@ def calculate( raise RuntimeError("No Atoms object attached to calculator.") # Build PySCF molecule from the current ASE geometry - mol = gto.Mole() - mol.atom = [ + _atom_list_for_cube = [ (sym, pos) for sym, pos in zip( self.atoms.get_chemical_symbols(), self.atoms.get_positions().tolist(), ) ] + mol = gto.Mole() + mol.atom = _atom_list_for_cube mol.basis = self.basis mol.charge = self.charge mol.spin = self.spin @@ -151,6 +152,10 @@ def calculate( mf.stdout = _sink mf.kernel() + # Save final SCF state for orbital visualization + self._last_mf = mf + self._last_atom_list = _atom_list_for_cube + # Analytical nuclear gradient (Hartree/Bohr) grad_driver = mf.nuc_grad_method() grad_driver.verbose = 0 @@ -203,6 +208,11 @@ class OptimizationResult: method: str basis: str formula: str + mo_energy_hartree: Optional[Any] = None # from final SCF step + mo_occ: Optional[Any] = None + mo_coeff: Optional[Any] = None + pyscf_mol_atom: Optional[Any] = None # atom list at final geometry (Angstrom) + pyscf_mol_basis: Optional[str] = None @property def energy_hartree(self) -> float: @@ -419,6 +429,73 @@ def optimize_geometry( n_steps = max(0, len(trajectory) - 1) formula = molecule.get_formula() + # Extract MO data from the final SCF step (non-fatal) + _opt_mo_energy: Optional[Any] = None + _opt_mo_occ: Optional[Any] = None + _opt_mo_coeff: Optional[Any] = None + _opt_mol_atom: Optional[Any] = None + _opt_mol_basis: Optional[str] = None + try: + import numpy as _np_mo + + _last_mf = getattr(atoms.calc, "_last_mf", None) + _last_atom_list = getattr(atoms.calc, "_last_atom_list", None) + if _last_mf is not None: + _opt_mo_energy = _np_mo.array(_last_mf.mo_energy) + _opt_mo_occ = _np_mo.array(_last_mf.mo_occ) + _opt_mo_coeff = _np_mo.array(_last_mf.mo_coeff) + _opt_mol_atom = _last_atom_list + _opt_mol_basis = basis + except Exception: + pass + + # Write a final MO summary to the progress stream (replaces per-step verbose output + # which is suppressed to avoid thousands of SCF lines for long optimizations). + if _opt_mo_energy is not None and _opt_mo_occ is not None: + try: + import numpy as _np_summary + + _HARTREE_TO_EV_s = 27.211386245988 + _e_ev_raw = _np_summary.asarray(_opt_mo_energy) * _HARTREE_TO_EV_s + _occ_raw = _np_summary.asarray(_opt_mo_occ) + # For UHF the arrays are (2, n_mo); use alpha spin for summary. + if _e_ev_raw.ndim == 2: + _e_ev_1d = _e_ev_raw[0] + _occ_1d = _occ_raw[0] + else: + _e_ev_1d = _e_ev_raw + _occ_1d = _occ_raw + _homo_idx = ( + int(_np_summary.where(_occ_1d > 0)[0][-1]) + if (_occ_1d > 0).any() + else -1 + ) + _lumo_idx = ( + int(_np_summary.where(_occ_1d == 0)[0][0]) + if (_occ_1d == 0).any() + else -1 + ) + _stream.write( + "\n── Final SCF (optimised geometry) ────────────────────────────────────\n" + ) + if _homo_idx >= 0: + _stream.write( + f" HOMO (MO #{_homo_idx}): {_e_ev_1d[_homo_idx]:.4f} eV\n" + ) + if _lumo_idx >= 0: + _stream.write( + f" LUMO (MO #{_lumo_idx}): {_e_ev_1d[_lumo_idx]:.4f} eV\n" + ) + if _homo_idx >= 0 and _lumo_idx >= 0: + _stream.write( + f" HOMO-LUMO gap: {_e_ev_1d[_lumo_idx] - _e_ev_1d[_homo_idx]:.4f} eV\n" + ) + _stream.write( + f" All MO energies (eV): {' '.join(f'{e:.3f}' for e in _e_ev_1d)}\n" + ) + except Exception: + pass + logger.info( "Geometry optimization: %s %s/%s steps=%d converged=%s " "E_final=%.8f Ha RMSD~%.4f Å", @@ -440,6 +517,11 @@ def optimize_geometry( method=method, basis=basis, formula=formula, + mo_energy_hartree=_opt_mo_energy, + mo_occ=_opt_mo_occ, + mo_coeff=_opt_mo_coeff, + pyscf_mol_atom=_opt_mol_atom, + pyscf_mol_basis=_opt_mol_basis, ) diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 2c01bf1..c1576b6 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -189,7 +189,7 @@ def plot_orbital_diagram( matplotlib.figure.Figure """ import matplotlib.patches as mpatches - import matplotlib.pyplot as plt + from matplotlib.figure import Figure energies = info.mo_energies_ev n_occ = info.n_occupied @@ -202,7 +202,10 @@ def plot_orbital_diagram( subset = energies[start:end] subset_occ = np.arange(start, end) < n_occ - fig, ax = plt.subplots(figsize=figsize) + # Use Figure directly (not plt.subplots) to avoid triggering the IPython + # GUI event loop in interactive / test environments. + fig = Figure(figsize=figsize) + ax = fig.add_subplot(111) # Draw energy levels line_half_width = 0.3 @@ -285,6 +288,174 @@ def plot_orbital_diagram( return fig +# ============================================================================ +# Plotly interactive energy-level diagram +# ============================================================================ + + +def plot_orbital_diagram_plotly( + info: OrbitalInfo, + *, + max_orbitals: int = 20, + yrange: Optional[Tuple[float, float]] = None, + title: Optional[str] = None, + width: int = 380, + height: int = 460, +): + """Interactive Plotly orbital energy-level diagram. + + Returns a ``plotly.graph_objects.Figure`` suitable for embedding in a + ``go.FigureWidget``. Each MO is drawn as a short horizontal line; + hover shows the MO index and energy in eV. HOMO/LUMO are highlighted + with labels and a gap annotation. + + Parameters + ---------- + info: + Orbital data. + max_orbitals: + Maximum number of MOs to display, centred on the HOMO–LUMO gap. + yrange: + Explicit ``(y_min, y_max)`` in eV; auto-computed when ``None``. + title: + Custom plot title; defaults to ``"Orbital Energy Levels — {formula}"``. + width, height: + Figure dimensions in pixels. + + Returns + ------- + plotly.graph_objects.Figure + """ + import plotly.graph_objects as go + + energies = info.mo_energies_ev + n_occ = info.n_occupied + n_total = len(energies) + + half = max_orbitals // 2 + start = max(0, n_occ - half) + end = min(n_total, n_occ + half) + + LHW = 0.3 # half-width of each horizontal line in x + + traces = [] + for idx in range(start, end): + e = float(energies[idx]) + is_homo = idx == n_occ - 1 + is_lumo = idx == n_occ + is_occ = idx < n_occ + + if is_homo: + color, lw = "#2171b5", 3.0 + hover = f"MO #{idx + 1} — HOMO
    {e:+.4f} eV" + elif is_lumo: + color, lw = "#e6550d", 3.0 + hover = f"MO #{idx + 1} — LUMO
    {e:+.4f} eV" + elif is_occ: + color, lw = "#2171b5", 1.5 + hover = f"MO #{idx + 1} (occupied)
    {e:+.4f} eV" + else: + color, lw = "#9e9e9e", 1.5 + hover = f"MO #{idx + 1} (virtual)
    {e:+.4f} eV" + + traces.append( + go.Scatter( + x=[-LHW, LHW], + y=[e, e], + mode="lines", + line=dict(color=color, width=lw), + hovertemplate=hover + "", + showlegend=False, + name="", + ) + ) + + homo_e = info.homo_energy_ev + lumo_e = info.lumo_energy_ev + gap = info.homo_lumo_gap_ev + bracket_x = -LHW - 0.15 + + annotations = [ + dict( + x=LHW + 0.04, + y=homo_e, + xref="x", + yref="y", + text="HOMO", + showarrow=False, + font=dict(size=11, color="#2171b5"), + xanchor="left", + yanchor="middle", + ), + dict( + x=LHW + 0.04, + y=lumo_e, + xref="x", + yref="y", + text="LUMO", + showarrow=False, + font=dict(size=11, color="#e6550d"), + xanchor="left", + yanchor="middle", + ), + dict( + x=bracket_x, + y=homo_e, + ax=bracket_x, + ay=lumo_e, + xref="x", + yref="y", + axref="x", + ayref="y", + text=f"{gap:.2f} eV", + font=dict(size=10, color="#e6550d"), + arrowhead=2, + arrowwidth=1.5, + arrowcolor="#e6550d", + xanchor="right", + ), + ] + + subset = energies[start:end] + span = float(subset.max()) - float(subset.min()) + margin = max(0.5, span * 0.08 + 0.5) + if yrange is None: + y_min = float(subset.min()) - margin + y_max = float(subset.max()) + margin + else: + y_min, y_max = yrange + + fig = go.Figure(data=traces) + fig.update_layout( + width=width, + height=height, + margin=dict(l=60, r=110, t=50, b=30), + title=dict( + text=title or f"Orbital Energy Levels — {info.formula}", + font=dict(size=13, family="Arial"), + ), + xaxis=dict( + range=[-0.9, 0.9], + showticklabels=False, + showgrid=False, + zeroline=False, + fixedrange=True, + ), + yaxis=dict( + title="Energy (eV)", + range=[y_min, y_max], + showgrid=True, + gridcolor="#e5e7eb", + tickformat=".1f", + ), + plot_bgcolor="white", + paper_bgcolor="white", + annotations=annotations, + hovermode="closest", + ) + return fig + + # ============================================================================ # Summary HTML (for notebooks) # ============================================================================ @@ -396,6 +567,86 @@ def generate_cube_file( return output_path +def generate_cube_from_arrays( + mol_atom: list, + mol_basis: str, + mo_coeff: np.ndarray, + orbital_index: int, + output_path: Path, + *, + nx: int = 60, + ny: int = 60, + nz: int = 60, + margin: float = 5.0, + spin: int = 0, +) -> Path: + """ + Generate a cube file from in-session MO data (no ``.npz`` file required). + + Unlike :func:`generate_cube_file`, this function takes the atom list + and MO coefficient array directly, as stored in :class:`SessionResult` + or :class:`OptimizationResult`. + + Parameters + ---------- + mol_atom : list + Atom list in PySCF format — list of ``(symbol, [x, y, z])`` tuples + with coordinates in Angstrom. + mol_basis : str + Basis set string (e.g. ``'6-31G*'``). + mo_coeff : ndarray + MO coefficient matrix, shape ``(n_ao, n_mo)`` for RHF or + ``(2, n_ao, n_mo)`` for UHF. Alpha-spin coefficients are used for UHF. + orbital_index : int + 0-based MO index to visualise. + output_path : Path + Where to write the ``.cube`` file. + nx, ny, nz : int + Grid resolution along each axis. + margin : float + Extra space (Bohr) beyond atomic extents. + + Returns + ------- + Path + The written cube file path. + + Raises + ------ + ImportError + If PySCF is not available. + """ + try: + from pyscf import gto + from pyscf.tools import cubegen + except ImportError as exc: + raise ImportError( + "PySCF is required for cube file generation (Linux/WSL only).\n" + " conda install -c conda-forge pyscf" + ) from exc + + mol = gto.M(atom=mol_atom, basis=mol_basis, unit="Angstrom", spin=spin) + + coeff = np.asarray(mo_coeff) + if coeff.ndim == 3: + coeff = coeff[0] # UHF: use alpha spin + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + cubegen.orbital( + mol, + str(output_path), + coeff[:, orbital_index], + nx=nx, + ny=ny, + nz=nz, + margin=margin, + ) + logger.info("Wrote cube file: %s", output_path) + return output_path + + # ============================================================================ # Cube-file isosurface viewer (plotly — works anywhere) # ============================================================================ diff --git a/quantui/pes_scan.py b/quantui/pes_scan.py new file mode 100644 index 0000000..91c2fcf --- /dev/null +++ b/quantui/pes_scan.py @@ -0,0 +1,342 @@ +""" +1D Potential Energy Surface (PES) scan using constrained QM optimizations. + +Drives a single internal coordinate (bond length, bond angle, or dihedral +angle) through a range of values. At each scan point all other degrees of +freedom are relaxed via a constrained geometry optimization (BFGS + ASE +FixInternals). The resulting energy profile and set of geometries can be +plotted and animated in the notebook. + +Platform notes +-------------- +Requires PySCF and ASE — Linux / macOS / WSL only. + +Educational value +----------------- +* H–H bond-stretch curve illustrates dissociation and the bond-strength concept. +* H–O–H angle bending shows the shallow vs. steep sides of the energy well. +* Ethane C–C dihedral scan reveals the staggered / eclipsed energy difference. +* All three examples connect directly to thermochemistry and reaction barriers. +""" + +from __future__ import annotations + +import io +import logging +import math +import sys +from dataclasses import dataclass +from typing import IO, List, Optional + +from .ase_bridge import ASE_AVAILABLE, atoms_to_molecule, molecule_to_atoms +from .molecule import Molecule +from .optimizer import _QuantUIPySCFCalc +from .session_calc import HARTREE_TO_EV + +logger = logging.getLogger(__name__) + +_HARTREE_TO_KCAL: float = 627.509474 # 1 Ha = 627.509474 kcal/mol + + +# ============================================================================ +# Result dataclass +# ============================================================================ + + +@dataclass +class PESScanResult: + """Structured output from a completed 1D PES scan. + + Attributes: + formula: Hill-notation molecular formula of the input molecule. + method: SCF method used (e.g. ``'RHF'``). + basis: Basis set used (e.g. ``'STO-3G'``). + scan_type: One of ``'bond'``, ``'angle'``, ``'dihedral'``. + atom_indices: 0-based atom indices defining the scanned coordinate. + Length 2 for bond, 3 for angle, 4 for dihedral. + scan_parameter_values: Coordinate value at each scan point. + Angstroms for bond scans; degrees for angle / dihedral scans. + energies_hartree: SCF energy in Hartrees at each scan point. + Same length as ``scan_parameter_values``. + coordinates_list: Geometry (as :class:`~quantui.molecule.Molecule`) + at each scan point after constrained relaxation. + converged_all: ``True`` if every constrained geometry optimization + converged within the force threshold. + """ + + formula: str + method: str + basis: str + scan_type: str + atom_indices: List[int] + scan_parameter_values: List[float] + energies_hartree: List[float] + coordinates_list: List[Molecule] + converged_all: bool + + # ── Convenience properties ────────────────────────────────────────────── + + @property + def energy_hartree(self) -> float: + """Minimum SCF energy across all scan points (Hartrees).""" + return min(self.energies_hartree) if self.energies_hartree else float("nan") + + @property + def energy_ev(self) -> float: + """Minimum SCF energy in electronvolts.""" + return self.energy_hartree * HARTREE_TO_EV + + @property + def converged(self) -> bool: + """``True`` if all constrained optimizations converged.""" + return self.converged_all + + @property + def n_steps(self) -> int: + """Number of scan points completed.""" + return len(self.scan_parameter_values) + + @property + def energies_relative_kcal(self) -> List[float]: + """Energy relative to the lowest scan point, in kcal/mol.""" + if not self.energies_hartree: + return [] + e_min = min(self.energies_hartree) + return [(e - e_min) * _HARTREE_TO_KCAL for e in self.energies_hartree] + + @property + def scan_unit(self) -> str: + """Unit label for the scan parameter axis.""" + return "Å" if self.scan_type == "bond" else "°" + + @property + def scan_coordinate_label(self) -> str: + """Axis label for the scanned coordinate (1-based atom numbers).""" + idx = [i + 1 for i in self.atom_indices] + if self.scan_type == "bond": + return f"Bond {idx[0]}–{idx[1]} / Å" + if self.scan_type == "angle": + return f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °" + return f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °" + + def summary(self) -> str: + """Return a multi-line human-readable result summary.""" + if not self.energies_hartree: + return "No scan points computed." + e_min = min(self.energies_hartree) + e_max = max(self.energies_hartree) + barrier = (e_max - e_min) * _HARTREE_TO_KCAL + min_idx = self.energies_hartree.index(e_min) + lines = [ + "=" * 60, + "PES Scan Results", + "=" * 60, + f" Molecule : {self.formula}", + f" Method/Basis : {self.method}/{self.basis}", + f" Scan type : {self.scan_type}", + f" Scan range : {self.scan_parameter_values[0]:.3f}" + f" → {self.scan_parameter_values[-1]:.3f} {self.scan_unit}", + f" Scan points : {self.n_steps}", + f" Min energy : {e_min:.8f} Ha (point {min_idx + 1})", + f" Barrier height : {barrier:.2f} kcal/mol", + f" All converged : {'Yes' if self.converged_all else 'No'}", + "=" * 60, + ] + return "\n".join(lines) + + +# ============================================================================ +# Main function +# ============================================================================ + + +def run_pes_scan( + molecule: Molecule, + method: str = "RHF", + basis: str = "STO-3G", + scan_type: str = "bond", + atom_indices: List[int] = (0, 1), # type: ignore[assignment] + start: float = 0.5, + stop: float = 2.0, + steps: int = 10, + fmax: float = 0.05, + max_opt_steps: int = 100, + progress_stream: Optional[IO[str]] = None, +) -> PESScanResult: + """Run a 1D PES scan along an internal coordinate. + + At each scan point the target coordinate is set, a constraint is added to + hold it there, and a BFGS geometry optimization relaxes all remaining + degrees of freedom. The geometry and energy from each constrained + optimization form the potential energy profile. + + Args: + molecule: Starting geometry. + method: SCF method — ``'RHF'``, ``'UHF'``, or a DFT functional. + basis: Basis set (``'STO-3G'``, ``'6-31G*'``, …). + scan_type: ``'bond'``, ``'angle'``, or ``'dihedral'``. + atom_indices: 0-based atom indices defining the coordinate. + Exactly 2 for bond, 3 for angle, 4 for dihedral. + start: Starting value of the scanned coordinate + (Å for bond; degrees for angle/dihedral). + stop: Ending value. + steps: Number of evenly spaced scan points (including start and stop). + fmax: Force convergence threshold (eV/Å) for each constrained optimization. + max_opt_steps: Maximum BFGS steps per scan point. + progress_stream: Optional writable stream for per-step progress messages. + + Returns: + :class:`PESScanResult` with the full energy profile and geometries. + + Raises: + ImportError: If ASE or PySCF is not installed. + ValueError: If ``atom_indices`` has the wrong length for ``scan_type``, + or if any index is out of range for the molecule. + RuntimeError: If the scan fails unexpectedly. + """ + + # --- Dependency checks --- + if not ASE_AVAILABLE or _QuantUIPySCFCalc is None: + raise ImportError( + "ASE is not installed — cannot run PES scan.\n" + " pip install 'ase>=3.22.0'" + ) + try: + import pyscf as _pyscf # noqa: F401 + except ImportError as exc: + raise ImportError( + "PySCF is not installed — cannot run PES scan.\n" + "Note: PySCF is Linux / macOS / WSL only." + ) from exc + + try: + import contextlib + + from ase.constraints import FixInternals + from ase.optimize import BFGS + except ImportError as exc: + raise ImportError("ase.optimize.BFGS is not available.") from exc + + # --- Validate atom indices --- + _expected = {"bond": 2, "angle": 3, "dihedral": 4} + if scan_type not in _expected: + raise ValueError( + f"scan_type must be 'bond', 'angle', or 'dihedral', got {scan_type!r}" + ) + n_required = _expected[scan_type] + atom_indices = list(atom_indices) + if len(atom_indices) != n_required: + raise ValueError( + f"scan_type={scan_type!r} requires {n_required} atom indices, " + f"got {len(atom_indices)}" + ) + n_atoms = len(molecule.atoms) + for idx in atom_indices: + if not (0 <= idx < n_atoms): + raise ValueError( + f"Atom index {idx} is out of range for molecule with {n_atoms} atoms." + ) + if len(set(atom_indices)) != len(atom_indices): + raise ValueError("Atom indices must be unique.") + + if steps < 2: + raise ValueError("steps must be >= 2.") + + # --- Set up ASE atoms + PySCF calculator --- + atoms = molecule_to_atoms(molecule) + atoms.calc = _QuantUIPySCFCalc( + method=method, + basis=basis, + charge=molecule.charge, + spin=molecule.multiplicity - 1, + ) + + _stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout + _null = io.StringIO() + + import numpy as np + + scan_values = np.linspace(start, stop, steps).tolist() + + energies_hartree: List[float] = [] + coordinates_list: List[Molecule] = [] + converged_all = True + + i1, i2 = atom_indices[0], atom_indices[1] + i3 = atom_indices[2] if len(atom_indices) >= 3 else 0 + i4 = atom_indices[3] if len(atom_indices) >= 4 else 0 + + for step_num, val in enumerate(scan_values, start=1): + _stream.write( + f"\nScan point {step_num}/{steps}: " + f"{scan_type} = {val:.4f} {('Å' if scan_type == 'bond' else '°')}\n" + ) + + try: + # Drive the coordinate to the target value + if scan_type == "bond": + atoms.set_distance(i1, i2, val, fix=0.5) + + # Diatomic bond scans have zero relaxable DOF — FixInternals + # has an off-by-one on 2-atom systems, so skip BFGS entirely. + _diatomic_bond = scan_type == "bond" and n_atoms <= 2 + + if _diatomic_bond: + ok = True + else: + if scan_type == "bond": + constraint = FixInternals(bonds=[(val, [i1, i2])]) + elif scan_type == "angle": + atoms.set_angle(i1, i2, i3, val) + constraint = FixInternals( + angles=[(math.radians(val), [i1, i2, i3])] + ) + else: # dihedral + atoms.set_dihedral(i1, i2, i3, i4, val) + constraint = FixInternals( + dihedrals=[(math.radians(val), [i1, i2, i3, i4])] + ) + + atoms.set_constraint(constraint) + + dyn = BFGS(atoms, logfile=_stream) + with contextlib.redirect_stdout(_null): + ok = bool(dyn.run(fmax=fmax, steps=max_opt_steps)) + + converged_all = converged_all and ok + + # Record energy (convert eV → Hartree) and geometry + e_ev = atoms.get_potential_energy() + e_ha = e_ev / HARTREE_TO_EV + energies_hartree.append(e_ha) + + mol_at_point = atoms_to_molecule( + atoms, charge=molecule.charge, multiplicity=molecule.multiplicity + ) + coordinates_list.append(mol_at_point) + + _stream.write( + f" E = {e_ha:.8f} Ha ({'converged' if ok else 'not converged'})\n" + ) + + except Exception as exc: + _stream.write(f" ⚠ Scan point {step_num} failed: {exc}\n") + energies_hartree.append(float("nan")) + coordinates_list.append(molecule) + converged_all = False + + finally: + # Always clear the constraint before the next scan point + atoms.set_constraint() + + return PESScanResult( + formula=molecule.get_formula(), + method=method, + basis=basis, + scan_type=scan_type, + atom_indices=list(atom_indices), + scan_parameter_values=scan_values, + energies_hartree=energies_hartree, + coordinates_list=coordinates_list, + converged_all=converged_all, + ) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index 87cf7c0..fbb9639 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -1,5 +1,5 @@ """ -results_storage — Persist and reload QuantUI-local calculation results. +results_storage — Persist and reload QuantUI calculation results. Each calculation is saved to a timestamped subdirectory:: @@ -143,3 +143,263 @@ def load_result(result_dir: Path) -> dict: """Return the parsed ``result.json`` from *result_dir*.""" data: dict = json.loads((result_dir / "result.json").read_text()) return data + + +def save_orbitals(result_dir: Path, result: object) -> None: + """Persist MO data to *result_dir*/orbitals.npz and orbitals_meta.json. + + Saves ``mo_energy_hartree``, ``mo_occ``, and ``mo_coeff`` as a compressed + NumPy archive and ``pyscf_mol_atom`` / ``pyscf_mol_basis`` as JSON so the + orbital diagram and isosurface can be replayed from history. + """ + import numpy as _np + + mo_e = getattr(result, "mo_energy_hartree", None) + mo_occ = getattr(result, "mo_occ", None) + mo_coeff = getattr(result, "mo_coeff", None) + mol_atom = getattr(result, "pyscf_mol_atom", None) + mol_basis = getattr(result, "pyscf_mol_basis", None) + + if mo_e is None and mo_occ is None: + return + + arrays: dict = {} + if mo_e is not None: + arrays["mo_energy_hartree"] = _np.asarray(mo_e) + if mo_occ is not None: + arrays["mo_occ"] = _np.asarray(mo_occ) + if mo_coeff is not None: + arrays["mo_coeff"] = _np.asarray(mo_coeff) + if arrays: + _np.savez_compressed(str(result_dir / "orbitals.npz"), **arrays) + + meta: dict = {} + if mol_atom is not None: + # Convert list-of-tuples to JSON-safe list-of-lists. + meta["mol_atom"] = [[sym, list(coords)] for sym, coords in mol_atom] + if mol_basis is not None: + meta["mol_basis"] = mol_basis + if meta: + (result_dir / "orbitals_meta.json").write_text(json.dumps(meta)) + + +def load_orbitals(result_dir: Path): + """Reload MO data saved by :func:`save_orbitals`. + + Returns a ``SimpleNamespace`` with ``mo_energy_hartree``, ``mo_occ``, + ``mo_coeff``, ``pyscf_mol_atom``, ``pyscf_mol_basis``, and ``formula`` + (empty string if not known). + + Raises + ------ + FileNotFoundError + If ``orbitals.npz`` does not exist in *result_dir*. + """ + import types + + import numpy as _np + + npz_path = result_dir / "orbitals.npz" + if not npz_path.exists(): + raise FileNotFoundError(npz_path) + + data = _np.load(str(npz_path)) + stub = types.SimpleNamespace( + mo_energy_hartree=( + data["mo_energy_hartree"] if "mo_energy_hartree" in data else None + ), + mo_occ=data["mo_occ"] if "mo_occ" in data else None, + mo_coeff=data["mo_coeff"] if "mo_coeff" in data else None, + pyscf_mol_atom=None, + pyscf_mol_basis=None, + formula="", + ) + meta_path = result_dir / "orbitals_meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text()) + stub.pyscf_mol_atom = meta.get("mol_atom") + stub.pyscf_mol_basis = meta.get("mol_basis") + return stub + + +def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: + """Persist geometry-optimisation trajectory to *result_dir*/trajectory.json. + + Parameters + ---------- + result_dir: + Directory returned by :func:`save_result`. + trajectory: + List of ``Molecule`` objects (one per optimisation step). + energies: + List of total energies in Hartree, parallel to *trajectory*. + """ + if not trajectory: + return + mol0 = trajectory[0] + data = { + "atoms": list(mol0.atoms), + "charge": mol0.charge, + "multiplicity": mol0.multiplicity, + "steps": [ + { + "coords": [list(row) for row in mol.coordinates], + "energy": energies[i] if i < len(energies) else None, + } + for i, mol in enumerate(trajectory) + ], + } + (result_dir / "trajectory.json").write_text(json.dumps(data)) + + +def load_trajectory(result_dir: Path): + """Reload a saved trajectory as (molecules, energies). + + Returns + ------- + tuple[list, list] + ``(trajectory, energies_hartree)`` where *trajectory* is a list of + ``Molecule`` objects and *energies_hartree* is a parallel list of + floats (``None`` entries are dropped to an empty list if all absent). + + Raises + ------ + FileNotFoundError + If ``trajectory.json`` does not exist in *result_dir*. + """ + from quantui.molecule import Molecule + + raw = json.loads((result_dir / "trajectory.json").read_text()) + atoms = raw["atoms"] + charge = raw.get("charge", 0) + mult = raw.get("multiplicity", 1) + trajectory = [] + energies = [] + for step in raw["steps"]: + trajectory.append( + Molecule(atoms, step["coords"], charge=charge, multiplicity=mult) + ) + energies.append(step["energy"]) + # If every energy is None the list is meaningless; return empty instead. + if all(e is None for e in energies): + energies = [] + return trajectory, energies + + +def save_thumbnail(result_dir: Path, data: dict) -> None: + """Generate a compact PNG thumbnail card for the saved result. + + Silently skips if matplotlib is unavailable or any error occurs. + """ + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except ImportError: + return + + _colors: dict = { + "single_point": ("#2563eb", "#dbeafe"), + "geometry_opt": ("#7c3aed", "#ede9fe"), + "frequency": ("#15803d", "#dcfce7"), + "tddft": ("#b45309", "#fef3c7"), + "nmr": ("#0d9488", "#ccfbf1"), + } + _ct_labels: dict = { + "single_point": "Single Point", + "geometry_opt": "Geometry Opt", + "frequency": "Frequency", + "tddft": "TD-DFT", + "nmr": "NMR", + } + ct = data.get("calc_type", "") + fg, bg = _colors.get(ct, ("#555555", "#f3f4f6")) + ct_label = _ct_labels.get(ct, ct.replace("_", " ").title()) + + fig = plt.figure(figsize=(2.4, 1.5), facecolor=bg) + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_facecolor(bg) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis("off") + + # Colored header strip + ax.axhspan(0.80, 1.0, color=fg) + ax.text( + 0.5, + 0.90, + ct_label, + ha="center", + va="center", + fontsize=9, + fontweight="bold", + color="white", + transform=ax.transAxes, + ) + + # Formula + ax.text( + 0.5, + 0.65, + data.get("formula", "?"), + ha="center", + va="center", + fontsize=13, + fontweight="bold", + color=fg, + transform=ax.transAxes, + ) + + # Method / basis + ax.text( + 0.5, + 0.50, + f'{data.get("method", "?")} / {data.get("basis", "?")}', + ha="center", + va="center", + fontsize=8, + color="#444444", + transform=ax.transAxes, + ) + + # Energy + e_ha = data.get("energy_hartree") + if e_ha is not None and e_ha == e_ha: # skip NaN + ax.text( + 0.5, + 0.34, + f"E = {e_ha:.5f} Ha", + ha="center", + va="center", + fontsize=7, + color="#333333", + transform=ax.transAxes, + family="monospace", + ) + + # Converged indicator + conv = data.get("converged") + if conv is not None: + ax.text( + 0.5, + 0.16, + "✓ Converged" if conv else "✗ Not converged", + ha="center", + va="center", + fontsize=7.5, + fontweight="bold", + color="#15803d" if conv else "#c00000", + transform=ax.transAxes, + ) + + try: + fig.savefig( + str(result_dir / "thumbnail.png"), + dpi=72, + bbox_inches="tight", + facecolor=bg, + pad_inches=0.05, + ) + finally: + plt.close(fig) diff --git a/quantui/security.py b/quantui/security.py index 915d04e..1f1d88d 100644 --- a/quantui/security.py +++ b/quantui/security.py @@ -1,5 +1,5 @@ """ -QuantUI-local Security Module +QuantUI Security Module Provides a catchable SecurityError exception for the local teaching interface. diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 58624b6..47c0a03 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -23,7 +23,7 @@ import logging import sys from dataclasses import dataclass -from typing import IO, Optional +from typing import IO, Any, List, Optional from .molecule import Molecule @@ -64,6 +64,16 @@ class SessionResult: method: str basis: str formula: str + atom_symbols: Optional[List[str]] = None + mulliken_charges: Optional[List[float]] = None + dipole_moment_debye: Optional[float] = None + mp2_correlation_hartree: Optional[float] = None + solvent: Optional[str] = None + mo_energy_hartree: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF + mo_occ: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF + mo_coeff: Optional[Any] = None # np.ndarray (n_ao, n_mo) or (2, n_ao, n_mo) UHF + pyscf_mol_atom: Optional[Any] = None # list of (symbol, [x,y,z]) tuples (Angstrom) + pyscf_mol_basis: Optional[str] = None # basis set string for cube generation @property def energy_ev(self) -> float: @@ -101,12 +111,24 @@ def summary(self) -> str: # ============================================================================ +# Maps QuantUI display names → PySCF xc strings where they differ. +_XC_ALIAS: dict = { + "M06-L": "m06l", + "wB97X-D": "wb97x-d", + "CAM-B3LYP": "camb3lyp", + "PBE-D3": "pbe", # base functional; D3 applied separately +} +# Methods that require Grimme D3 dispersion correction via pyscf.dftd3. +_NEEDS_D3: frozenset = frozenset({"PBE-D3"}) + + def run_in_session( molecule: Molecule, method: str = "RHF", basis: str = "6-31G", - verbose: int = 3, + verbose: int = 4, progress_stream: Optional[IO[str]] = None, + solvent: Optional[str] = None, ) -> SessionResult: """ Run a quantum chemistry calculation in the current kernel using PySCF. @@ -176,19 +198,54 @@ def run_in_session( # --- Select SCF method --- method_upper = method.upper() + # Normalise to the key used in _XC_ALIAS / _NEEDS_D3 (preserve original case) + _method_key = next((k for k in _XC_ALIAS if k.upper() == method_upper), method) + if method_upper == "RHF": mf = scf.RHF(mol) elif method_upper == "UHF": mf = scf.UHF(mol) + elif method_upper == "MP2": + mf = scf.RHF(mol) # MP2 runs on top of RHF else: - # DFT: auto-select RKS (closed-shell) or UKS (open-shell) based on spin + # DFT: resolve alias then auto-select RKS / UKS + xc_string = _XC_ALIAS.get(_method_key, method) if mol.spin == 0: mf = dft.RKS(mol) else: mf = dft.UKS(mol) - mf.xc = method # PySCF recognises functional names directly (B3LYP, PBE, etc.) - - # --- Run calculation --- + mf.xc = xc_string + # Apply D3 dispersion correction where needed + if _method_key in _NEEDS_D3: + try: + from pyscf import dftd3 as _dftd3 + + mf = _dftd3.dftd3(mf) + except ImportError: + if progress_stream is not None: + progress_stream.write( + f"\n⚠ pyscf.dftd3 not available — running {method} " + "without D3 correction.\n" + ) + + # --- Wrap with implicit solvent (PCM) if requested --- + if solvent is not None: + from . import config as _cfg + + _eps = _cfg.SOLVENT_OPTIONS.get(solvent) + if _eps is not None: + try: + from pyscf.solvent import PCM as _PCM + + mf = _PCM(mf) + mf.with_solvent.eps = _eps + except Exception: + if progress_stream is not None: + progress_stream.write( + "\n⚠ PCM solvent unavailable — running in gas phase.\n" + ) + + # --- Run SCF --- try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -197,6 +254,21 @@ def run_in_session( f"({method}/{basis}): {exc}" ) from exc + # --- MP2 correlation energy (post-HF) --- + mp2_correlation_hartree: Optional[float] = None + if method_upper == "MP2": + try: + from pyscf import mp as _mp + + _mp2 = _mp.MP2(mf) + _e_corr, _ = _mp2.kernel() + mp2_correlation_hartree = float(_e_corr) + energy_hartree += float(_e_corr) + except Exception as exc: + raise RuntimeError( + f"MP2 correction failed for {molecule.get_formula()}: {exc}" + ) from exc + # --- Extract results from the mean-field object --- converged = bool(getattr(mf, "converged", False)) n_iterations = int(getattr(mf, "cycles", -1)) @@ -225,6 +297,37 @@ def run_in_session( except Exception: pass # gap stays None — non-fatal + mulliken_charges: Optional[List[float]] = None + dipole_moment_debye: Optional[float] = None + if method_upper != "UHF": + try: + _, chg = mf.mulliken_pop(verbose=0) + mulliken_charges = [float(c) for c in chg] + except Exception: + pass + try: + import numpy as _np2 + + dip = mf.dip_moment(verbose=0) + dipole_moment_debye = float(_np2.linalg.norm(dip)) + except Exception: + pass + + # MO arrays for orbital visualization (non-fatal if extraction fails) + _mo_energy_ha_arr: Optional[Any] = None + _mo_occ_arr: Optional[Any] = None + _mo_coeff_arr: Optional[Any] = None + _pyscf_mol_atom: Optional[Any] = None + _pyscf_mol_basis: Optional[str] = None + try: + _mo_energy_ha_arr = _np.array(mf.mo_energy) + _mo_occ_arr = _np.array(mf.mo_occ) + _mo_coeff_arr = _np.array(mf.mo_coeff) + _pyscf_mol_atom = molecule.to_pyscf_format() + _pyscf_mol_basis = basis + except Exception: + pass + formula = molecule.get_formula() logger.info( "Session calculation: %s %s/%s E=%.8f Ha converged=%s iters=%d", @@ -244,4 +347,14 @@ def run_in_session( method=method, basis=basis, formula=formula, + atom_symbols=list(molecule.atoms), + mulliken_charges=mulliken_charges, + dipole_moment_debye=dipole_moment_debye, + mp2_correlation_hartree=mp2_correlation_hartree, + solvent=solvent, + mo_energy_hartree=_mo_energy_ha_arr, + mo_occ=_mo_occ_arr, + mo_coeff=_mo_coeff_arr, + pyscf_mol_atom=_pyscf_mol_atom, + pyscf_mol_basis=_pyscf_mol_basis, ) diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index a6f573f..4291a59 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -148,7 +148,7 @@ def run_tddft_calc( mol.basis = basis mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 - mol.verbose = 3 + mol.verbose = 4 mol.stdout = stream mol.build() diff --git a/quantui/utils.py b/quantui/utils.py index a283f90..d7f383c 100644 --- a/quantui/utils.py +++ b/quantui/utils.py @@ -1,5 +1,5 @@ """ -QuantUI-local Utilities Module +QuantUI Utilities Module Helper functions for validation, session resource detection, and general utilities used across the application. SLURM-specific helpers (job ID diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index fa8f2e5..a0486a0 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -10,11 +10,13 @@ """ import logging +import os +import tempfile from typing import Literal, cast logger = logging.getLogger(__name__) -Py3DmolStyle = Literal["stick", "sphere", "line", "cartoon"] +Py3DmolStyle = Literal["ball+stick", "stick", "sphere", "line", "cartoon"] BackendName = Literal["auto", "py3dmol", "plotlymol"] # Check available visualization backends @@ -28,12 +30,45 @@ try: from plotlymol3d import draw_3D_rep + from plotlymol3d import format_lighting as _plotlymol_format_lighting PLOTLYMOL_AVAILABLE = True except ImportError: PLOTLYMOL_AVAILABLE = False + _plotlymol_format_lighting = None # type: ignore[assignment] logger.info("PlotlyMol not available (optional)") +# ── Visualization style and lighting constants ──────────────────────────────── + +# Display-style options presented in the UI. The value is the canonical key +# used internally; each backend maps it to its own representation. +VIZ_STYLE_OPTIONS: list[tuple[str, str]] = [ + ("Ball & Stick", "ball+stick"), + ("Stick", "stick"), + ("Sphere (VDW)", "sphere"), + ("Line", "line"), +] + +# Named lighting presets — identical to those in the plotlyMol dash app. +# Only applied when the PlotlyMol backend is active. +LIGHTING_PRESETS: dict[str, dict] = { + "soft": {"ambient": 0.4, "diffuse": 0.8, "specular": 0.1, "roughness": 0.8}, + "default": {"ambient": 0.0, "diffuse": 1.0, "specular": 0.0, "roughness": 1.0}, + "bright": {"ambient": 0.5, "diffuse": 0.8, "specular": 0.3, "roughness": 0.5}, + "metallic": {"ambient": 0.2, "diffuse": 0.7, "specular": 1.0, "roughness": 0.1}, + "dramatic": {"ambient": 0.0, "diffuse": 1.0, "specular": 0.6, "roughness": 0.2}, +} +LIGHTING_OPTIONS: list[tuple[str, str]] = [ + ("Soft", "soft"), + ("Default", "default"), + ("Bright", "bright"), + ("Metallic", "metallic"), + ("Dramatic", "dramatic"), +] + +DEFAULT_STYLE: str = "ball+stick" +DEFAULT_LIGHTING: str = "soft" + def is_visualization_available() -> bool: """ @@ -80,10 +115,11 @@ def molecule_to_xyz_string(molecule) -> str: def visualize_molecule_py3dmol( molecule, - style: Py3DmolStyle = "stick", + style: Py3DmolStyle = "ball+stick", width: int = 600, height: int = 500, bgcolor: str = "white", + lighting: str = "soft", # accepted for API symmetry; py3Dmol has no preset lighting ): """ Create interactive 3D visualization using py3Dmol. @@ -134,8 +170,11 @@ def visualize_molecule_py3dmol( # Add molecule view.addModel(xyz_string, "xyz") - # Set style - view.setStyle({style: {}}) + # Set style — "ball+stick" requires a compound spec in py3Dmol + if style == "ball+stick": + view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) + else: + view.setStyle({style: {}}) # Set background view.setBackgroundColor(bgcolor) @@ -147,7 +186,13 @@ def visualize_molecule_py3dmol( def _validate_py3dmol_style(style: str) -> Py3DmolStyle: - valid_styles: tuple[Py3DmolStyle, ...] = ("stick", "sphere", "line", "cartoon") + valid_styles: tuple[Py3DmolStyle, ...] = ( + "ball+stick", + "stick", + "sphere", + "line", + "cartoon", + ) if style not in valid_styles: raise ValueError(f"style must be one of {list(valid_styles)}, got '{style}'") return cast(Py3DmolStyle, style) @@ -160,6 +205,7 @@ def visualize_molecule_plotlymol( width: int = 600, height: int = 500, bgcolor: str = "#ffffff", + lighting: str = "soft", ): """ Create interactive 3D visualization using PlotlyMol (optional backend). @@ -203,32 +249,44 @@ def visualize_molecule_plotlymol( f"(mode={mode}, resolution={resolution})" ) - # Create visualization using PlotlyMol - fig = draw_3D_rep( - xyzblock=xyz_string, - charge=charge, - mode=mode, - resolution=resolution, - bgcolor=bgcolor, + # draw_3D_rep takes a file path, not an in-memory string + full_xyz = f"{len(molecule.atoms)}\n\n{xyz_string}\n" + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".xyz", delete=False, encoding="utf-8" ) + try: + tmp.write(full_xyz) + tmp.close() + fig = draw_3D_rep( + xyzfile=tmp.name, + charge=charge, + mode=mode, + resolution=resolution, + ) + if _plotlymol_format_lighting is not None: + preset = LIGHTING_PRESETS.get(lighting, LIGHTING_PRESETS["soft"]) + fig = _plotlymol_format_lighting(fig, **preset) + finally: + os.unlink(tmp.name) - # Set figure size and title fig.update_layout( width=width, height=height, title=f"{molecule.get_formula()} - {mode.replace('+', ' & ').title()}", + paper_bgcolor=bgcolor, + scene=dict(bgcolor=bgcolor), ) - return fig def visualize_molecule( molecule, backend: BackendName = "auto", - style: str = "stick", + style: str = "ball+stick", width: int = 600, height: int = 500, bgcolor: str = "white", + lighting: str = "soft", **kwargs, ): """ @@ -266,10 +324,10 @@ def visualize_molecule( """ # Determine backend if backend == "auto": - if PY3DMOL_AVAILABLE: - backend = "py3dmol" - elif PLOTLYMOL_AVAILABLE: + if PLOTLYMOL_AVAILABLE: backend = "plotlymol" + elif PY3DMOL_AVAILABLE: + backend = "py3dmol" else: raise ImportError( "No visualization backend available. Install one of:\n" @@ -281,14 +339,30 @@ def visualize_molecule( if backend == "py3dmol": py3dmol_style = _validate_py3dmol_style(style) return visualize_molecule_py3dmol( - molecule, style=py3dmol_style, width=width, height=height, bgcolor=bgcolor + molecule, + style=py3dmol_style, + width=width, + height=height, + bgcolor=bgcolor, + lighting=lighting, ) elif backend == "plotlymol": - # Map common styles to PlotlyMol modes - mode_map = {"stick": "stick", "sphere": "vdw"} + # Map UI style keys to PlotlyMol mode names + mode_map = { + "ball+stick": "ball+stick", + "stick": "stick", + "sphere": "vdw", + "line": "stick", # plotlyMol has no line mode; use stick + } mode = mode_map.get(style, "ball+stick") return visualize_molecule_plotlymol( - molecule, mode=mode, width=width, height=height, bgcolor=bgcolor, **kwargs + molecule, + mode=mode, + width=width, + height=height, + bgcolor=bgcolor, + lighting=lighting, + **kwargs, ) else: raise ValueError(f"Unknown backend: {backend}") @@ -297,10 +371,12 @@ def visualize_molecule( def display_molecule( molecule, backend: Literal["auto", "py3dmol", "plotlymol"] = "auto", - style: str = "stick", + style: str = "ball+stick", show_info: bool = True, width: int = 600, height: int = 500, + bgcolor: str = "#ffffff", + lighting: str = "soft", ): """ Display molecule in Jupyter notebook with optional info box. @@ -362,7 +438,13 @@ def display_molecule( # Create and display visualization try: viz = visualize_molecule( - molecule, backend=backend, style=style, width=width, height=height + molecule, + backend=backend, + style=style, + width=width, + height=height, + bgcolor=bgcolor, + lighting=lighting, ) # display(viz) triggers py3Dmol's _repr_html_() method, which embeds diff --git a/tests/conftest.py b/tests/conftest.py index 459e3f3..c259c38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,25 @@ import pytest +@pytest.fixture(autouse=True, scope="session") +def _suppress_plotly_browser(): + """Prevent plotly from opening browser tabs during tests. + + plotly's default renderer is "browser" in this env, so any display(fig) + on a plotly Figure would call pio.show() → open a tab. Setting + render_on_display=False makes _ipython_display_ fall through to repr(). + """ + try: + import plotly.io as pio + + orig_rod = pio.renderers.render_on_display + pio.renderers.render_on_display = False + yield + pio.renderers.render_on_display = orig_rod + except ImportError: + yield + + @pytest.fixture def sample_water_xyz(): """Simple water molecule XYZ coordinates.""" diff --git a/tests/test_app.py b/tests/test_app.py index 7fd7fc2..1bcaad3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -117,13 +117,21 @@ def test_multiplicity_default(self): class TestTabStructure: """root_tab has the correct number and titles of tabs.""" - def test_five_tabs(self): + def test_seven_tabs(self): app = QuantUIApp() - assert len(app.root_tab.children) == 5 + assert len(app.root_tab.children) == 7 def test_tab_titles(self): app = QuantUIApp() - expected = ["Calculate", "History", "Compare", "Output", "Help"] + expected = [ + "Calculate", + "Results", + "Analysis", + "History", + "Compare", + "Log", + "Status", + ] for i, title in enumerate(expected): assert app.root_tab.get_title(i) == title @@ -340,3 +348,906 @@ def test_preopt_flag_mirrors_module_level(self): app = QuantUIApp() assert app._preopt_available == _PREOPT_AVAILABLE + + +# --------------------------------------------------------------------------- +# M3.3 — result log accordion and directory label +# --------------------------------------------------------------------------- + + +class TestResultLogAccordion: + """_result_log_accordion and _result_dir_label exist and start hidden.""" + + def test_log_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_result_log_accordion") + assert isinstance(app._result_log_accordion, widgets.Accordion) + + def test_log_accordion_initially_hidden(self): + app = QuantUIApp() + assert app._result_log_accordion.layout.display == "none" + + def test_log_accordion_initially_collapsed(self): + app = QuantUIApp() + assert app._result_log_accordion.selected_index is None + + def test_result_dir_label_exists(self): + app = QuantUIApp() + assert hasattr(app, "_result_dir_label") + assert isinstance(app._result_dir_label, widgets.HTML) + + def test_result_dir_label_initially_hidden(self): + app = QuantUIApp() + assert app._result_dir_label.layout.display == "none" + + def test_last_result_dir_initially_none(self): + app = QuantUIApp() + assert app._last_result_dir is None + + def test_on_run_clicked_clears_log(self): + """_on_run_clicked must hide log accordion and clear dir label.""" + app = QuantUIApp() + # Simulate a previous result being present + app._result_log_accordion.layout.display = "" + app._result_dir_label.layout.display = "" + app._result_dir_label.value = "Saved to: /some/path" + + with patch.object(app, "_do_run"): + app._on_run_clicked(None) + + assert app._result_log_accordion.layout.display == "none" + assert app._result_dir_label.layout.display == "none" + assert app._result_dir_label.value == "" + + def test_show_result_log_populates_widgets(self, tmp_path): + """_show_result_log() sets dir label and reveals accordion.""" + log_text = "SCF converged in 10 cycles." + log_file = tmp_path / "pyscf.log" + log_file.write_text(log_text, encoding="utf-8") + + app = QuantUIApp() + app._show_result_log(tmp_path, log_text) + + assert str(tmp_path) in app._result_dir_label.value + assert app._result_dir_label.layout.display == "" + assert app._result_log_accordion.layout.display == "" + + def test_show_result_log_falls_back_to_string(self, tmp_path): + """_show_result_log() uses in-memory log_text if pyscf.log absent.""" + log_text = "fallback log content" + app = QuantUIApp() + empty_dir = tmp_path / "no_log_here" + empty_dir.mkdir() + app._show_result_log(empty_dir, log_text) + + assert app._result_log_accordion.layout.display == "" + + +# --------------------------------------------------------------------------- +# M3.4 — Structure file exports (XYZ, MOL/SDF, PDB) +# --------------------------------------------------------------------------- + + +class TestStructureExportButtons: + """export_xyz_btn, export_mol_btn, export_pdb_btn exist and start disabled.""" + + def test_export_xyz_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_xyz_btn") + assert isinstance(app.export_xyz_btn, widgets.Button) + + def test_export_mol_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_mol_btn") + assert isinstance(app.export_mol_btn, widgets.Button) + + def test_export_pdb_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_pdb_btn") + assert isinstance(app.export_pdb_btn, widgets.Button) + + def test_struct_export_status_exists(self): + app = QuantUIApp() + assert hasattr(app, "struct_export_status") + + def test_export_xyz_btn_disabled_initially(self): + app = QuantUIApp() + assert app.export_xyz_btn.disabled is True + + def test_export_xyz_btn_enabled_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + assert app.export_xyz_btn.disabled is False + + def test_export_accordion_title_is_export(self): + app = QuantUIApp() + assert app.advanced_accordion.get_title(0) == "Export" + + +class TestExportXYZCallback: + """_on_export_xyz writes a valid XYZ file.""" + + def test_xyz_file_written_to_result_dir(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + xyz_files = list(tmp_path.glob("*.xyz")) + assert len(xyz_files) == 1 + + def test_xyz_file_contains_atom_count(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + content = list(tmp_path.glob("*.xyz"))[0].read_text() + first_line = content.splitlines()[0].strip() + assert first_line == "3" # water has 3 atoms + + def test_xyz_status_shows_saved_path(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + assert "Saved" in app.struct_export_status.value + + def test_xyz_no_molecule_shows_error(self): + app = QuantUIApp() + app._on_export_xyz(None) + assert "molecule" in app.struct_export_status.value.lower() + + +class TestExportMoleculeAndLabel: + """_export_molecule_and_label returns correct molecule and labels.""" + + def test_returns_current_molecule_when_no_result(self): + app = QuantUIApp() + water = _water() + app._set_molecule(water) + mol, method, basis = app._export_molecule_and_label() + assert mol is water + + def test_method_falls_back_to_dropdown(self): + app = QuantUIApp() + app._set_molecule(_water()) + _, method, _ = app._export_molecule_and_label() + assert method == app.method_dd.value + + +class TestMoleculeToRdkit: + """_molecule_to_rdkit does not raise; returns RDKit mol or None.""" + + def test_does_not_raise_for_water(self): + result = QuantUIApp._molecule_to_rdkit(_water()) + # Either succeeds or returns None — must not raise + assert result is None or result is not None + + +# --------------------------------------------------------------------------- +# M4.1 — Extended DFT functional list +# --------------------------------------------------------------------------- + + +class TestExtendedDFTFunctionals: + """New functionals appear in method_dd options.""" + + def test_wb97xd_in_dropdown(self): + app = QuantUIApp() + assert "wB97X-D" in app.method_dd.options + + def test_cam_b3lyp_in_dropdown(self): + app = QuantUIApp() + assert "CAM-B3LYP" in app.method_dd.options + + def test_m06l_in_dropdown(self): + app = QuantUIApp() + assert "M06-L" in app.method_dd.options + + def test_hse06_in_dropdown(self): + app = QuantUIApp() + assert "HSE06" in app.method_dd.options + + def test_pbe_d3_in_dropdown(self): + app = QuantUIApp() + assert "PBE-D3" in app.method_dd.options + + def test_mp2_in_dropdown(self): + app = QuantUIApp() + assert "MP2" in app.method_dd.options + + +# --------------------------------------------------------------------------- +# M4.2 — MP2 energy +# --------------------------------------------------------------------------- + + +class TestMP2SessionResult: + """mp2_correlation_hartree field on SessionResult.""" + + def test_mp2_corr_defaults_to_none(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + ) + assert r.mp2_correlation_hartree is None + + def test_mp2_corr_stored(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.3, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + mp2_correlation_hartree=-0.3, + ) + assert r.mp2_correlation_hartree == pytest.approx(-0.3) + + +class TestMP2FormatResult: + """_format_result shows HF reference and MP2 correlation when present.""" + + def test_hf_reference_shown_when_mp2(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.3, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + mp2_correlation_hartree=-0.3, + ) + app = QuantUIApp() + html = app._format_result(r) + assert "HF reference" in html + assert "MP2 correlation" in html + + +# --------------------------------------------------------------------------- +# M4.3 — Implicit solvent (PCM) +# --------------------------------------------------------------------------- + + +class TestSolventWidgets: + """solvent_cb and solvent_dd exist and behave correctly.""" + + def test_solvent_cb_exists(self): + app = QuantUIApp() + assert hasattr(app, "solvent_cb") + assert isinstance(app.solvent_cb, widgets.Checkbox) + + def test_solvent_dd_exists(self): + app = QuantUIApp() + assert hasattr(app, "solvent_dd") + assert isinstance(app.solvent_dd, widgets.Dropdown) + + def test_solvent_dd_hidden_initially(self): + app = QuantUIApp() + assert app.solvent_dd.layout.display == "none" + + def test_solvent_dd_revealed_when_cb_checked(self): + app = QuantUIApp() + app.solvent_cb.value = True + assert app.solvent_dd.layout.display == "" + + def test_solvent_dd_hidden_when_cb_unchecked(self): + app = QuantUIApp() + app.solvent_cb.value = True + app.solvent_cb.value = False + assert app.solvent_dd.layout.display == "none" + + def test_water_is_solvent_option(self): + app = QuantUIApp() + assert "Water" in app.solvent_dd.options + + def test_solvent_field_on_session_result(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="RHF", + basis="STO-3G", + formula="H2O", + solvent="Water", + ) + assert r.solvent == "Water" + + def test_solvent_shown_in_format_result(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="RHF", + basis="STO-3G", + formula="H2O", + solvent="Ethanol", + ) + app = QuantUIApp() + html = app._format_result(r) + assert "Ethanol" in html + assert "PCM" in html + + +# --------------------------------------------------------------------------- +# M-CAL — Calibration UI widgets +# --------------------------------------------------------------------------- + + +class TestCalibrationWidgets: + """Calibration accordion and its child widgets exist in correct initial state.""" + + def test_cal_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_cal_accordion") + assert isinstance(app._cal_accordion, widgets.Accordion) + + def test_cal_run_btn_exists(self): + app = QuantUIApp() + assert isinstance(app._cal_run_btn, widgets.Button) + + def test_cal_stop_btn_hidden_initially(self): + app = QuantUIApp() + assert app._cal_stop_btn.layout.display == "none" + + def test_cal_progress_hidden_initially(self): + app = QuantUIApp() + assert app._cal_progress.layout.display == "none" + + def test_cal_step_label_hidden_initially(self): + app = QuantUIApp() + assert app._cal_step_label.layout.display == "none" + + def test_cal_run_btn_disabled_when_pyscf_unavailable(self): + from quantui.app import _PYSCF_AVAILABLE + + app = QuantUIApp() + # Button state must match module-level availability flag + assert app._cal_run_btn.disabled == (not _PYSCF_AVAILABLE) + + def test_cal_progress_max_equals_suite_length(self): + from quantui.benchmarks import BENCHMARK_SUITE + + app = QuantUIApp() + assert app._cal_progress.max == len(BENCHMARK_SUITE) + + def test_on_cal_stop_sets_event(self): + import threading + + app = QuantUIApp() + app._cal_stop_event = threading.Event() + app._on_cal_stop(None) + assert app._cal_stop_event.is_set() + + +# --------------------------------------------------------------------------- +# M5 — NMR Shielding widgets +# --------------------------------------------------------------------------- + + +class TestNMRWidgets: + """NMR Shielding option exists and callback wires correctly.""" + + def test_nmr_in_calc_type_options(self): + app = QuantUIApp() + assert "NMR Shielding" in app.calc_type_dd.options + + def test_calc_type_dd_has_six_options(self): + app = QuantUIApp() + assert len(app.calc_type_dd.options) == 6 + + def test_nmr_calc_type_shows_note(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + # calc_extra_opts should contain an HTML note about basis recommendations + assert len(app.calc_extra_opts.children) == 1 + note = app.calc_extra_opts.children[0] + assert isinstance(note, widgets.HTML) + assert "6-31G*" in note.value + + def test_nmr_note_mentions_sto3g_warning(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + note = app.calc_extra_opts.children[0] + assert "STO-3G" in note.value + + def test_switching_away_from_nmr_clears_opts(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + app.calc_type_dd.value = "Single Point" + assert len(app.calc_extra_opts.children) == 0 + + +class TestFormatNMRResult: + """_format_nmr_result produces correct HTML.""" + + def _make_nmr(self, basis="6-31G*", converged=True): + from quantui.nmr_calc import NMRResult + + return NMRResult( + atom_symbols=["O", "H", "H"], + shielding_iso_ppm=[320.1, 28.5, 28.5], + chemical_shifts_ppm={1: 3.22, 2: 3.22}, + method="B3LYP", + basis=basis, + formula="H2O", + converged=converged, + ) + + def test_returns_string(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert isinstance(html, str) + + def test_contains_formula(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "H2O" in html + + def test_contains_method_and_basis(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "B3LYP" in html + assert "6-31G*" in html + + def test_h_shifts_table_present(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "¹H" in html + assert "3.22" in html + + def test_sto3g_warning_shown(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(basis="STO-3G")) + assert "STO-3G" in html + assert "qualitative" in html + + def test_no_sto3g_warning_for_631g(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(basis="6-31G*")) + assert "qualitative" not in html + + def test_not_converged_shows_warning(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(converged=False)) + assert "caution" in html + + def test_no_hc_atoms_shows_empty_message(self): + + from quantui.nmr_calc import NMRResult + + r = NMRResult( + atom_symbols=["N", "N"], + shielding_iso_ppm=[100.0, 100.0], + chemical_shifts_ppm={}, + method="RHF", + basis="STO-3G", + formula="N2", + ) + app = QuantUIApp() + html = app._format_nmr_result(r) + assert "No ¹H or ¹³C" in html + + +# --------------------------------------------------------------------------- +# M-IR — IR Spectrum accordion widgets +# --------------------------------------------------------------------------- + + +class TestIRSpectrumWidgets: + """IR Spectrum accordion and controls exist in correct initial state.""" + + def test_ir_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_ir_accordion") + assert isinstance(app._ir_accordion, widgets.Accordion) + + def test_ir_accordion_hidden_initially(self): + app = QuantUIApp() + assert app._ir_accordion.layout.display == "none" + + def test_ir_mode_toggle_exists(self): + app = QuantUIApp() + assert isinstance(app._ir_mode_toggle, widgets.ToggleButtons) + + def test_ir_mode_toggle_default_stick(self): + app = QuantUIApp() + assert app._ir_mode_toggle.value == "Stick" + + def test_ir_mode_toggle_has_two_options(self): + app = QuantUIApp() + assert set(app._ir_mode_toggle.options) == {"Stick", "Broadened"} + + def test_fwhm_slider_hidden_initially(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.layout.display == "none" + + def test_fwhm_slider_default_20(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.value == 20.0 + + def test_fwhm_slider_range(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.min == 5.0 + assert app._ir_fwhm_slider.max == 100.0 + + +class TestShowIRSpectrum: + """_show_ir_spectrum reveals accordion and wires mode toggle.""" + + def _make_freq_result(self): + from unittest.mock import MagicMock + + r = MagicMock() + r.frequencies_cm1 = [500.0, 1000.0, 3000.0] + r.ir_intensities = [10.0, 50.0, 5.0] + return r + + def test_show_ir_spectrum_returns_true_with_data(self): + app = QuantUIApp() + app._last_ir_freqs = [] + app._last_ir_ints = [] + ok = app._show_ir_spectrum(self._make_freq_result()) + assert ok is True + + def test_accordion_revealed_via_activate(self): + # _show_ir_spectrum populates widget; _activate_ana_panel reveals it. + app = QuantUIApp() + app._show_ir_spectrum(self._make_freq_result()) + assert app._ir_accordion.layout.display == "none" # still hidden + app._activate_ana_panel("IR Spectrum") + assert app._ir_accordion.layout.display == "" + + def test_fwhm_slider_shown_when_broadened(self): + app = QuantUIApp() + app._show_ir_spectrum(self._make_freq_result()) + app._ir_mode_toggle.value = "Broadened" + assert app._ir_fwhm_slider.layout.display == "" + + def test_fwhm_slider_hidden_when_stick(self): + app = QuantUIApp() + app._show_ir_spectrum(self._make_freq_result()) + app._ir_mode_toggle.value = "Broadened" + app._ir_mode_toggle.value = "Stick" + assert app._ir_fwhm_slider.layout.display == "none" + + +# --------------------------------------------------------------------------- +# M6 — Orbital Diagram accordion +# --------------------------------------------------------------------------- + + +class TestOrbitalAccordionWidgets: + """Orbital accordion widgets exist and have the correct initial state.""" + + def test_orb_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_orb_accordion") + + def test_orb_accordion_hidden_initially(self): + app = QuantUIApp() + assert app._orb_accordion.layout.display == "none" + + def test_orb_diagram_html_exists(self): + app = QuantUIApp() + assert hasattr(app, "_orb_diagram_html") + + def test_orb_toggle_has_four_options(self): + app = QuantUIApp() + assert set(app._orb_toggle.options) == {"HOMO-1", "HOMO", "LUMO", "LUMO+1"} + + def test_orb_toggle_default_homo(self): + app = QuantUIApp() + assert app._orb_toggle.value == "HOMO" + + def test_orb_iso_controls_hidden_initially(self): + app = QuantUIApp() + assert app._orb_iso_controls.layout.display == "none" + + def test_orb_accordion_hidden_after_run_clicked(self): + app = QuantUIApp() + app._orb_accordion.layout.display = "" + app._on_run_clicked(None) + assert app._orb_accordion.layout.display == "none" + + +class TestShowOrbitalDiagram: + """_show_orbital_diagram reveals accordion when MO data is present.""" + + def _make_result_with_mo(self): + from unittest.mock import MagicMock + + import numpy as np + + r = MagicMock() + r.formula = "H2O" + r.mo_energy_hartree = np.array([-1.5, -0.8, 0.2, 0.9]) + r.mo_occ = np.array([2.0, 2.0, 0.0, 0.0]) + r.mo_coeff = None + r.pyscf_mol_atom = None + r.pyscf_mol_basis = None + return r + + def test_show_orbital_diagram_returns_true_with_mo_data(self): + app = QuantUIApp() + ok = app._show_orbital_diagram(self._make_result_with_mo()) + assert ok is True + + def test_accordion_revealed_via_activate(self): + # _show_orbital_diagram populates widget; _activate_ana_panel reveals it. + app = QuantUIApp() + app._show_orbital_diagram(self._make_result_with_mo()) + assert app._orb_accordion.layout.display == "none" # still hidden + app._activate_ana_panel("Energies") + assert app._orb_accordion.layout.display == "" + + def test_accordion_stays_hidden_when_no_mo_data(self): + from unittest.mock import MagicMock + + app = QuantUIApp() + r = MagicMock() + r.mo_energy_hartree = None + r.mo_occ = None + app._show_orbital_diagram(r) + assert app._orb_accordion.layout.display == "none" + + def test_diagram_html_populated(self): + app = QuantUIApp() + app._show_orbital_diagram(self._make_result_with_mo()) + # plotly renders an interactive
    ; matplotlib fallback renders + val = app._orb_diagram_html.value + assert "