diff --git a/README.md b/README.md index e870ce6..634c7d0 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ Built for classroom teaching at the - **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) +- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, NMR + shielding, TD-DFT UV-Vis, and 1D PES scans 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 @@ -148,6 +149,7 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): | 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 | +| PES Scan | 1D potential energy surface along a bond, angle, or dihedral; energy profile chart; geometry animation at each scan point | ### Basis sets @@ -184,6 +186,7 @@ quantui/ Main package 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 + pes_scan.py 1D potential energy surface scan optimizer.py QM geometry optimization with trajectory visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) pubchem.py PubChem molecule search @@ -197,7 +200,7 @@ quantui/ Main package notebooks/ molecule_computations.ipynb Main student-facing interface tutorials/ Step-by-step guided notebooks (01–05) -tests/ pytest test suite (575+ tests) +tests/ pytest test suite (860+ tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config diff --git a/launch-native.bat b/launch-native.bat index 47aec47..97890c6 100644 --- a/launch-native.bat +++ b/launch-native.bat @@ -12,7 +12,11 @@ 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" +REM Clears quantui/__pycache__ on every launch to prevent stale .pyc bytecode +REM (WSL2 DrvFs does not reliably propagate Windows-side mtime changes, so Python +REM may load pre-edit bytecode even after source changes — see GOTCHAS.md). +REM PYTHONDONTWRITEBYTECODE=1 prevents a new stale cache from accumulating. +start "QuantUI [native]" 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 && rm -rf quantui/__pycache__ && PYTHONDONTWRITEBYTECODE=1 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 diff --git a/local-setup/environment.yml b/local-setup/environment.yml index b289385..930bc5f 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -46,6 +46,10 @@ dependencies: - black>=24.0.0 - ruff>=0.4.0 # 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 + # On Linux/WSL also run: conda install -c conda-forge pyscf + # (pyscf-properties is pip-only; installed here automatically) + # PyPI pyscf-properties 0.1.0 is missing the infrared module and has an NMR + # reshape bug fixed in commit 4eee5a4 (2024-11-07, unreleased). Install + # from GitHub source until a new release lands on PyPI. + - pyscf-properties @ git+https://github.com/pyscf/properties.git - -e .. diff --git a/pyproject.toml b/pyproject.toml index fb9c928..640a024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,9 +40,12 @@ packages = ["quantui"] [project.optional-dependencies] # PySCF requires Linux/macOS/WSL — not available on Windows natively. # 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>=2.13.0: pyscf.prop.infrared is in pyscf 2.13.0+ core (creates the +# pyscf.prop namespace that infrared lives under). +# pyscf-properties: provides additional pyscf.prop.* modules (EFG, IR, etc.). +# NMR is accessed via pyscf.nmr (core, not overwritten by pyscf-properties). pyscf = [ - "pyscf>=2.3.0", + "pyscf>=2.13.0", "pyscf-properties", ] diff --git a/quantui/app.py b/quantui/app.py index e52993c..cbc57df 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -312,6 +312,7 @@ def __init__(self) -> None: # ── Instance state ──────────────────────────────────────────────── self._molecule: Optional[Molecule] = None self._last_result: Any = None + self._last_calc_type: Optional[str] = None # e.g. "frequency", "single_point" self._results: List = [] self._pending_traj_result: Any = None self.root_tab: widgets.Tab @@ -1537,6 +1538,13 @@ def _select_ana_panel(self, name: str) -> None: else: acc.layout.display = "none" btn.button_style = "" + # Plotly charts inside hidden accordions render with 0 dimensions and appear + # blank. Re-render the IR figure whenever its panel is brought into view so + # the chart always paints into a visible, correctly-sized container. + if name == "IR Spectrum" and getattr(self, "_last_ir_freqs", None): + self._update_ir_figure( + self._ir_mode_toggle.value, self._ir_fwhm_slider.value + ) def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: """Mark a panel as available (full opacity) and optionally select it.""" @@ -1714,14 +1722,32 @@ def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool: 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 ctx.source == "live": + pre = ctx.preopt_result + if pre is None: + return False + traj = getattr(pre, "trajectory", None) + energies = list(getattr(pre, "energies_hartree", [])) + else: + if ctx.result_dir is None: + return False + preopt_path = ctx.result_dir / "preopt_trajectory.json" + if not preopt_path.exists(): + return False + try: + from quantui.results_storage import load_trajectory + + traj, energies = load_trajectory( + ctx.result_dir, filename="preopt_trajectory.json" + ) + except Exception as _exc: + from quantui import calc_log as _clog + + _clog.log_event( + "pop_preopt_trajectory_error", + f"{type(_exc).__name__}: {_exc}"[:300], + ) + return False if not traj or len(traj) < 2: return False stub = _types_mod.SimpleNamespace( @@ -1743,7 +1769,7 @@ def _pop_vibrational(self, ctx: _AnalysisContext) -> bool: 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")): + if not (freqs and disps and mol_data.get("atoms")): return False from quantui.molecule import Molecule as _Mol @@ -1817,7 +1843,10 @@ def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: ) self._apply_plotly_theme(_fig) self._tddft_fig.value = _pio.to_html( - _fig, include_plotlyjs="cdn", full_html=False + _fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) return True except Exception: @@ -1892,7 +1921,40 @@ def _shift_table(label: str, shifts: list, sym: str) -> str: 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 + scan = ctx.spectra_data.get("pes_scan", {}) + if not scan or not scan.get("energies_hartree"): + return False + energies_ha = scan["energies_hartree"] + atom_indices = scan.get("atom_indices", []) + scan_type = scan.get("scan_type", "bond") + x_vals = scan.get("scan_parameter_values", []) + e_min = min(energies_ha) + _HARTREE_TO_KCAL = 627.5094740631 + e_rel = [(e - e_min) * _HARTREE_TO_KCAL for e in energies_ha] + idx = [i + 1 for i in atom_indices] + if scan_type == "bond": + label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å" + elif scan_type == "angle": + label = ( + f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °" + if len(idx) >= 3 + else "Angle / °" + ) + else: + label = ( + f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °" + if len(idx) >= 4 + else "Dihedral / °" + ) + result = _types_mod.SimpleNamespace( + scan_type=scan_type, + atom_indices=atom_indices, + scan_parameter_values=x_vals, + energies_hartree=energies_ha, + energies_relative_kcal=e_rel, + scan_coordinate_label=label, + converged_all=True, + ) return self._show_pes_scan_result(result) def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool: @@ -3486,6 +3548,7 @@ def _build_issue_context(self) -> dict: "method": self.method_dd.value, "basis": self.basis_dd.value, "calc_type": self.calc_type_dd.value, + "last_calc_type": getattr(self, "_last_calc_type", None), } except Exception: pass @@ -3503,7 +3566,16 @@ def _build_issue_context(self) -> dict: except Exception: pass try: - ctx["recent_events"] = _calc_log.get_recent_events(15) + _all_ev = _calc_log.get_recent_events(60) + # Always include the 10 most recent non-startup events so that calc + # events are not starved out by a burst of startup entries (e.g. + # rapid notebook restarts). Merge with the 5 most recent events of + # any type to preserve immediate context, then re-sort by timestamp. + _non_startup = [e for e in _all_ev if e.get("event") != "startup"] + _keep_ids = {id(e) for e in _non_startup[-10:]} | { + id(e) for e in _all_ev[-5:] + } + ctx["recent_events"] = [e for e in _all_ev if id(e) in _keep_ids] except Exception: pass return ctx @@ -4191,6 +4263,22 @@ def _build_vib_data_from_freq_result(self, freq_result, molecule): except ImportError: return None + try: + return self._build_vib_data_inner( + freq_result, molecule, np, VibrationalData, VibrationalMode + ) + except Exception as _e: + try: + from quantui import calc_log as _clog + + _clog.log_event("vib_data_error", f"{type(_e).__name__}: {_e}"[:300]) + except Exception: + pass + return None + + def _build_vib_data_inner( + self, freq_result, molecule, np, VibrationalData, VibrationalMode + ): displacements = getattr(freq_result, "displacements", None) if displacements is None: return None @@ -4294,15 +4382,17 @@ def _show_vib_animation(self, freq_result, molecule) -> bool: # Show loading indicator and render in a background thread so _do_run # is not blocked while the animation is generated (can take several seconds). + # append_display_data is used instead of display() because this method is + # called from the _do_run background thread; display(HTML(...)) is not + # thread-safe for plain HTML but append_display_data is. _first_label, _first_mode = options[0] self.vib_output.clear_output() - with self.vib_output: - display( - HTML( - f'
' - f"⏳ Rendering vibrational animation ({_first_label})…
" - ) + self.vib_output.append_display_data( + HTML( + f'' + f"⏳ Rendering vibrational animation ({_first_label})…
" ) + ) threading.Thread( target=self._render_vib_mode, args=(vib_data, molecule, _first_mode), @@ -4388,10 +4478,18 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: ) self._apply_plotly_theme(fig) self._ir_fig.value = _pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) - except Exception: - pass + except Exception as _e: + try: + from quantui import calc_log as _clog + + _clog.log_event("ir_fig_error", f"{type(_e).__name__}: {_e}"[:300]) + except Exception: + pass def _show_orbital_diagram(self, result) -> bool: """Build and reveal the interactive orbital diagram accordion. @@ -4431,7 +4529,12 @@ def _show_orbital_diagram(self, result) -> bool: 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) + html_str = _pio.to_html( + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, + ) self._orb_diagram_html.value = html_str _plotly_rendered = True except Exception: @@ -4522,7 +4625,10 @@ def _on_orb_range_changed(self, _change=None) -> None: ) self._apply_plotly_theme(fig) self._orb_diagram_html.value = _pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) except Exception: pass @@ -4592,12 +4698,12 @@ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: 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 def _err(msg: str) -> None: self.vib_output.clear_output() - with self.vib_output: - _ipy_display(_H(f'⚠ {msg}
')) + self.vib_output.append_display_data( + _H(f'⚠ {msg}
') + ) try: from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol @@ -4619,6 +4725,12 @@ def _err(msg: str) -> None: _err(f"Could not parse molecule for bond connectivity: {exc}") return + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_start", f"mode {mode_number}") + except Exception: + pass try: anim_fig = create_vibration_animation( vib_data=vib_data, @@ -4631,12 +4743,34 @@ def _err(msg: str) -> None: ) anim_fig.update_layout(height=420) except Exception as exc: + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event( + "vib_render_error", + f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300], + ) + except Exception: + pass _err(f"Animation generation failed: {exc}") return + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_done", f"mode {mode_number}") + except Exception: + pass + + import plotly.io as _pio + _anim_html = _pio.to_html( + anim_fig, + full_html=False, + include_plotlyjs="cdn", + config={"responsive": True}, + ) self.vib_output.clear_output() - with self.vib_output: - _ipy_display(anim_fig) + self.vib_output.append_display_data(_H(_anim_html)) def _on_vib_mode_changed(self, change) -> None: """Re-render vib animation when the mode dropdown changes.""" @@ -4652,13 +4786,12 @@ def _on_vib_mode_changed(self, change) -> None: f"mode {mode_number}", ) self.vib_output.clear_output() - with self.vib_output: - display( - HTML( - f'' - f"⏳ Rendering vibrational animation ({_label})…
" - ) + self.vib_output.append_display_data( + HTML( + f'' + f"⏳ Rendering vibrational animation ({_label})…
" ) + ) threading.Thread( target=self._render_vib_mode, args=(vib_data, molecule, mode_number), @@ -4887,7 +5020,15 @@ def _do_run(self) -> None: progress_stream=log, # type: ignore[arg-type] ) result_html = self._format_pes_scan_result(result) - save_spectra, save_type = {}, "pes_scan" + save_spectra = { + "pes_scan": { + "scan_type": result.scan_type, + "atom_indices": result.atom_indices, + "scan_parameter_values": result.scan_parameter_values, + "energies_hartree": result.energies_hartree, + } + } + save_type = "pes_scan" else: # Single Point self.run_status.value = "Calculating..." from quantui import run_in_session @@ -4919,6 +5060,7 @@ def _do_run(self) -> None: _elapsed = time.perf_counter() - _run_wall_t _elapsed_cpu = time.process_time() - _run_cpu_t self._last_result = result + self._last_calc_type = save_type self.accumulate_btn.disabled = False self.result_output.append_display_data(HTML(result_html)) @@ -4946,7 +5088,6 @@ def _do_run(self) -> None: preopt_result=_pre_opt, source="live", ) - self._apply_analysis_context(_ana_ctx) self.step_progress.complete(2) self.step_progress.complete(3) @@ -5002,6 +5143,17 @@ def _do_run(self) -> None: _e_list = getattr(result, "energies_hartree", []) if _traj: save_trajectory(_saved_dir, _traj, _e_list or []) + # Persist pre-opt geometry trajectory for Frequency runs (DEC-007). + if ct == "Frequency" and _pre_opt is not None: + _pre_traj = getattr(_pre_opt, "trajectory", None) + _pre_e = list(getattr(_pre_opt, "energies_hartree", [])) + if _pre_traj: + save_trajectory( + _saved_dir, + _pre_traj, + _pre_e, + filename="preopt_trajectory.json", + ) # Persist MO data for orbital diagram + isosurface replay. if ct in ("Single Point", "Geometry Opt", "Frequency"): save_orbitals(_saved_dir, result) @@ -5023,6 +5175,13 @@ def _do_run(self) -> None: except Exception: pass + # Activate analysis panels AFTER saving/refreshing the results browser. + # _refresh_results_browser (above) sets past_dd.options, which fires its + # observer and calls _deactivate_all_ana_panels. Placing this call here + # means that observer has already run (harmlessly, panels not yet active) + # by the time we activate them. + self._apply_analysis_context(_ana_ctx) + # Log performance try: _calc_log.log_calculation( @@ -5805,7 +5964,10 @@ def _show_pes_scan_result(self, result) -> bool: hovermode="closest", ) self._pes_plot_html.value = pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) except Exception: pass diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index ec4f0a9..cc90f5f 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -248,6 +248,7 @@ def run_freq_calc( 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) @@ -285,12 +286,69 @@ def run_freq_calc( except Exception: displacements = None - # IR intensities — best-effort; silently omitted if unavailable - try: - ir_info = pyscf_thermo.ir_spectrum(mf, h) - ir_intensities = [float(x) for x in ir_info["ir_inten"]] - except Exception: - ir_intensities = [] + # Numerical IR intensities via finite-difference dipole derivatives. + # pyscf.prop.infrared is absent from released pyscf/pyscf-properties; + # we compute ∂μ/∂R by displacing each atom ±DELTA, then project onto + # the harmonic normal modes. + # Reference: Porezag & Pederson, Phys. Rev. B 54, 7830 (1996). + if displacements is not None and frequencies_cm1: + try: + import numpy as _np_ir + + _DELTA = 0.01 # Bohr + _BOHR_TO_ANG = 0.52917721092 + _KM_MOL_FAC = 42.255 # (D/Å)²/amu → km/mol + + _n_ir = mol.natm + _coords0 = mol.atom_coords().copy() + _dm0 = mf.make_rdm1() + _dpdx = _np_ir.zeros((_n_ir * 3, 3)) + _xc = getattr(mf, "xc", None) + + _mol_v = mol.verbose + mol.verbose = 0 + try: + for _I in range(_n_ir): + for _ax in range(3): + _cp = _coords0.copy() + _cp[_I, _ax] += _DELTA + mol.set_geom_(_cp, unit="Bohr") + if _xc is not None: + _mf_d = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + _mf_d.xc = _xc + else: + _mf_d = scf.RHF(mol) if mol.spin == 0 else scf.UHF(mol) + _mf_d.verbose = 0 + _mf_d.stdout = stream + _mf_d.kernel(dm0=_dm0) + _mu_p = _np_ir.array(_mf_d.dip_moment(verbose=0)) + + _cm = _coords0.copy() + _cm[_I, _ax] -= _DELTA + mol.set_geom_(_cm, unit="Bohr") + if _xc is not None: + _mf_d = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + _mf_d.xc = _xc + else: + _mf_d = scf.RHF(mol) if mol.spin == 0 else scf.UHF(mol) + _mf_d.verbose = 0 + _mf_d.stdout = stream + _mf_d.kernel(dm0=_dm0) + _mu_m = _np_ir.array(_mf_d.dip_moment(verbose=0)) + + _dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA) + finally: + mol.set_geom_(_coords0, unit="Bohr") + mol.verbose = _mol_v + + _dpdx_AA = _dpdx / _BOHR_TO_ANG + _nm_flat = _np_ir.array(displacements).reshape(len(frequencies_cm1), -1) + _dpdQ = _nm_flat @ _dpdx_AA + _ir = (_KM_MOL_FAC * (_dpdQ**2).sum(axis=1)).tolist() + if len(_ir) == len(frequencies_cm1): + ir_intensities = _ir + except Exception as _ir_exc: + logger.warning("Numerical IR intensities failed: %s", _ir_exc) # Thermochemistry at 298.15 K / 1 atm — best-effort try: diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index 0c13a67..ec44710 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -17,7 +17,7 @@ import sys from dataclasses import dataclass -from typing import Dict, List, Tuple +from typing import Any, Dict, List, Tuple from .molecule import Molecule @@ -120,14 +120,142 @@ def run_nmr_calc( converged = bool(getattr(mf, "converged", False)) + # pyscf.nmr does not exist in released pyscf; use pyscf.prop.nmr (pyscf-properties). + _pyscf_nmr: Any = None try: - from pyscf.prop import nmr as _pyscf_nmr + import pyscf.prop.nmr + + _pyscf_nmr = pyscf.prop.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'" + "PySCF NMR module not found. " + "Install pyscf-properties: pip install pyscf-properties" ) from exc + # pyscf-properties 0.1.0 gen_vind hardcodes reshape(3, nmo, nocc). + # pyscf 2.x krylov reduces the batch below 3 via linear-dependency masking, + # causing "cannot reshape array of size N into shape (3,nmo,nocc)". + # Patch gen_vind to use reshape(-1, nmo, nocc) so any batch size works. + try: + from functools import reduce as _reduce_nmr + + import pyscf.prop.nmr.rhf as _prop_nmr_rhf + from pyscf import lib as _pyscf_lib_nmr + + def _fixed_gen_vind(mf_arg, mo_coeff, mo_occ): + vresp = mf_arg.gen_response(singlet=True, hermi=2) + occidx = mo_occ > 0 + orbo = mo_coeff[:, occidx] + nocc = orbo.shape[1] + _nao, nmo = mo_coeff.shape + + def vind(mo1): + _mo1 = _np.asarray(mo1).reshape(-1, nmo, nocc) + dm1 = _np.asarray( + [ + _reduce_nmr(_np.dot, (mo_coeff, x * 2, orbo.T.conj())) + for x in _mo1 + ] + ) + dm1 = dm1 - dm1.transpose(0, 2, 1).conj() + v1mo = _pyscf_lib_nmr.einsum( + "xpq,pi,qj->xij", vresp(dm1), mo_coeff.conj(), orbo + ) + return v1mo.ravel() + + return vind + + _prop_nmr_rhf.gen_vind = _fixed_gen_vind + except Exception: + pass + + # pyscf-properties 0.1.0 get_vxc_giao computes + # blksize = min(int(X*BLKSIZE)*BLKSIZE, ngrids) + # which equals ngrids when ngrids < X*BLKSIZE, and ngrids may not be + # divisible by BLKSIZE. pyscf 2.x block_loop asserts blksize%BLKSIZE==0. + # Patch get_vxc_giao to round blksize down to the nearest BLKSIZE multiple. + try: + import numpy as _np_rks + import pyscf.prop.nmr.rks as _prop_nmr_rks + from pyscf.dft import numint as _numint_rks + + def _fixed_get_vxc_giao( + ni, mol, grids, xc_code, dms, max_memory=2000, verbose=None + ): + xctype = ni._xc_type(xc_code) + make_rho, nset, nao = ni._gen_rho_evaluator(mol, dms, hermi=1) + ngrids = len(grids.weights) + _BLKSIZE = _numint_rks.BLKSIZE + _raw_blk = int(max_memory / 12 * 1e6 / 8 / nao / _BLKSIZE) * _BLKSIZE + blksize = max(_BLKSIZE, (min(_raw_blk, ngrids) // _BLKSIZE) * _BLKSIZE) + shls_slice = (0, mol.nbas) + ao_loc = mol.ao_loc_nr() + + vmat = _np_rks.zeros((3, nao, nao)) + if xctype == "LDA": + buf = _np_rks.empty((4, blksize, nao)) + ao_deriv = 0 + for ao, mask, weight, coords in ni.block_loop( + mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf + ): + rho = make_rho(0, ao, mask, "LDA") + vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1] + vrho = vxc[0] + aow = _np_rks.einsum("pi,p->pi", ao, weight * vrho) + giao = mol.eval_gto( + "GTOval_ig", coords, comp=3, non0tab=mask, out=buf[1:] + ) + vmat[0] += _numint_rks._dot_ao_ao( + mol, aow, giao[0], mask, shls_slice, ao_loc + ) + vmat[1] += _numint_rks._dot_ao_ao( + mol, aow, giao[1], mask, shls_slice, ao_loc + ) + vmat[2] += _numint_rks._dot_ao_ao( + mol, aow, giao[2], mask, shls_slice, ao_loc + ) + rho = vxc = vrho = aow = None + elif xctype == "GGA": + buf = _np_rks.empty((10, blksize, nao)) + ao_deriv = 1 + for ao, mask, weight, coords in ni.block_loop( + mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf + ): + rho = make_rho(0, ao, mask, "GGA") + vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1] + vrho, vsigma = vxc[:2] + wv = _np_rks.empty_like(rho) + wv[0] = weight * vrho + wv[1:] = rho[1:] * (weight * vsigma * 2) + aow = _np_rks.einsum("npi,np->pi", ao[:4], wv) + giao = mol.eval_gto( + "GTOval_ig", coords, 3, non0tab=mask, out=buf[4:] + ) + vmat[0] += _numint_rks._dot_ao_ao( + mol, aow, giao[0], mask, shls_slice, ao_loc + ) + vmat[1] += _numint_rks._dot_ao_ao( + mol, aow, giao[1], mask, shls_slice, ao_loc + ) + vmat[2] += _numint_rks._dot_ao_ao( + mol, aow, giao[2], mask, shls_slice, ao_loc + ) + giao = mol.eval_gto( + "GTOval_ipig", coords, 9, non0tab=mask, out=buf[1:] + ) + _prop_nmr_rks._gga_sum_( + vmat, mol, ao, giao, wv, mask, shls_slice, ao_loc + ) + rho = vxc = vrho = vsigma = wv = aow = None + elif xctype == "MGGA": + raise NotImplementedError("meta-GGA") + + return vmat - vmat.transpose(0, 2, 1) + + _prop_nmr_rks.get_vxc_giao = _fixed_get_vxc_giao + except Exception: + pass + try: if method_upper == "RHF": nmr_obj = _pyscf_nmr.RHF(mf) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index fbb9639..50bf396 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -96,7 +96,13 @@ def save_result( ] ) dest = base / dirname - dest.mkdir(parents=True, exist_ok=True) + # Windows timer resolution can produce identical microsecond timestamps for + # back-to-back calls; append a counter to guarantee a unique directory. + _collision = 1 + while dest.exists(): + dest = base / f"{dirname}_{_collision}" + _collision += 1 + dest.mkdir(parents=True) _e_ha = getattr(result, "energy_hartree", float("nan")) # energy_ev may be a property (SessionResult) or absent (OptimizationResult @@ -222,8 +228,13 @@ def load_orbitals(result_dir: Path): return stub -def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: - """Persist geometry-optimisation trajectory to *result_dir*/trajectory.json. +def save_trajectory( + result_dir: Path, + trajectory: list, + energies: list, + filename: str = "trajectory.json", +) -> None: + """Persist geometry-optimisation trajectory to *result_dir*/*filename*. Parameters ---------- @@ -233,6 +244,9 @@ def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: List of ``Molecule`` objects (one per optimisation step). energies: List of total energies in Hartree, parallel to *trajectory*. + filename: + Output filename inside *result_dir*. Defaults to ``trajectory.json``. + Pass ``preopt_trajectory.json`` for pre-optimisation steps. """ if not trajectory: return @@ -249,10 +263,10 @@ def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: for i, mol in enumerate(trajectory) ], } - (result_dir / "trajectory.json").write_text(json.dumps(data)) + (result_dir / filename).write_text(json.dumps(data)) -def load_trajectory(result_dir: Path): +def load_trajectory(result_dir: Path, filename: str = "trajectory.json"): """Reload a saved trajectory as (molecules, energies). Returns @@ -269,7 +283,7 @@ def load_trajectory(result_dir: Path): """ from quantui.molecule import Molecule - raw = json.loads((result_dir / "trajectory.json").read_text()) + raw = json.loads((result_dir / filename).read_text()) atoms = raw["atoms"] charge = raw.get("charge", 0) mult = raw.get("multiplicity", 1) diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 47c0a03..34a18b5 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -323,7 +323,10 @@ def run_in_session( _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_atom = [ + (atom, list(map(float, coords))) + for atom, coords in zip(molecule.atoms, molecule.coordinates) + ] _pyscf_mol_basis = basis except Exception: pass diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index 4291a59..1487031 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -210,7 +210,7 @@ def run_tddft_calc( oscillator_strengths: List[float] = [] try: - td = mf.TDDFT() + td = mf.TDHF() if using_hf else mf.TDDFT() td.nstates = nstates td.verbose = 3 td.stdout = stream diff --git a/tests/test_freq_analysis_history.py b/tests/test_freq_analysis_history.py new file mode 100644 index 0000000..33df680 --- /dev/null +++ b/tests/test_freq_analysis_history.py @@ -0,0 +1,432 @@ +"""Integration tests for the frequency-analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with the correct + keys that history-load needs (ir, molecule, + displacements). + (2) History context — _build_history_context loads calc_type + spectra + correctly from disk. + (3) Panel activation — _apply_analysis_context activates Vibrational and + IR Spectrum panels from a history context. + (4) _do_run end-to-end — Full path: patched run_freq_calc → disk write → + _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) + +These tests are the canary for BUG-FREQ-ANA class failures — any break in the +data pipeline will show up here before a user notices in the UI. +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_freq_result(): + """Realistic SimpleNamespace mirroring a FreqResult from run_freq_calc. + + Covers all attributes that _do_run reads when building save_spectra, + calling save_result, save_orbitals, and _apply_analysis_context. + """ + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + # Frequency-specific + frequencies_cm1=[100.0, 1500.0, 3800.0], + ir_intensities=[5.0, 50.0, 10.0], + zpve_hartree=0.021, + displacements=[ + [[0.0, 0.0, 0.1], [0.0, 0.07, -0.05], [0.0, -0.07, -0.05]], + [[0.0, 0.0, 0.1], [0.07, 0.0, -0.05], [-0.07, 0.0, -0.05]], + [[0.0, 0.1, 0.0], [0.0, -0.05, 0.07], [0.0, -0.05, -0.07]], + ], + thermo=None, + # MO data for orbital diagram / save_orbitals + mo_energy_hartree=np.array([-20.0, -1.3, -0.7, -0.5, -0.3]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0]), + mo_coeff=None, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.757, 0.586, 0.0]), + ("H", [-0.757, 0.586, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +def _make_freq_spectra(result, mol): + """Build the spectra dict exactly as _do_run does for a Frequency calc.""" + disps = None + if result.displacements is not None: + disps = np.asarray(result.displacements).tolist() + return { + "ir": { + "frequencies_cm1": result.frequencies_cm1, + "ir_intensities": result.ir_intensities, + "zpve_hartree": result.zpve_hartree, + "displacements": disps, + }, + "molecule": { + "atoms": list(mol.atoms), + "coords": [list(map(float, row)) for row in mol.coordinates], + "charge": mol.charge, + "multiplicity": mol.multiplicity, + }, + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def freq_result(): + return _make_freq_result() + + +@pytest.fixture +def water_mol(): + return _water() + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for frequency +# --------------------------------------------------------------------------- + + +class TestFreqSpectraStructure: + def test_result_json_has_frequency_calc_type( + self, tmp_path, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "frequency" + + def test_spectra_ir_frequencies_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["ir"]["frequencies_cm1"] == pytest.approx( + [100.0, 1500.0, 3800.0] + ) + + def test_spectra_ir_displacements_shape(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + disps = data["spectra"]["ir"]["displacements"] + assert ( + disps is not None + ), "displacements must be stored — _pop_vibrational needs them" + assert len(disps) == 3 # 3 modes for H2O (3N-6) + assert len(disps[0]) == 3 # 3 atoms + assert len(disps[0][0]) == 3 # x, y, z + + def test_spectra_molecule_atoms_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["molecule"]["atoms"] == ["O", "H", "H"] + + def test_spectra_molecule_coords_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + coords = data["spectra"]["molecule"]["coords"] + assert len(coords) == 3 + assert coords[0] == pytest.approx([0.0, 0.0, 0.0]) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestFreqHistoryContext: + def _save(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + return save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "frequency" + + def test_context_spectra_data_has_ir_key( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ( + "ir" in ctx.spectra_data + ), "spectra_data must have 'ir' key for panel dispatch" + + def test_context_spectra_data_has_molecule_key( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ( + "molecule" in ctx.spectra_data + ), "spectra_data must have 'molecule' key for _pop_vibrational" + + def test_context_spectra_ir_has_displacements( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + disps = ctx.spectra_data.get("ir", {}).get("displacements") + assert disps is not None, "displacements must survive disk roundtrip" + + def test_context_live_result_is_none(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestFreqAnalysisPanelActivation: + def _save(self, tmp_path, freq_result, water_mol, spectra=None): + if spectra is None: + spectra = _make_freq_spectra(freq_result, water_mol) + return save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + + def test_vibrational_panel_activates(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" in app._ana_available + + def test_ir_spectrum_panel_activates(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "IR Spectrum" in app._ana_available + + def test_navigate_button_visible_when_panels_activate( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + def test_no_vibrational_when_displacements_missing( + self, tmp_path, app, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + spectra["ir"]["displacements"] = None + saved = self._save(tmp_path, freq_result, water_mol, spectra=spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" not in app._ana_available + + def test_ir_spectrum_still_activates_when_displacements_missing( + self, tmp_path, app, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + spectra["ir"]["displacements"] = None + saved = self._save(tmp_path, freq_result, water_mol, spectra=spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert ( + "IR Spectrum" in app._ana_available + ), "IR Spectrum only needs frequencies_cm1, not displacements" + + def test_no_panels_when_spectra_empty(self, tmp_path, app, freq_result): + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" not in app._ana_available + assert "IR Spectrum" not in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, freq_result): + saved = save_result(freq_result, results_dir=tmp_path, calc_type="", spectra={}) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + def test_empty_html_hidden_when_panels_activate( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._analysis_empty_html.layout.display == "none" + + # 3d: Trajectory panel stays dark when preopt_trajectory.json is absent + def test_trajectory_dark_when_no_preopt_trajectory_file( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + # save_result never writes preopt_trajectory.json — confirm absent + assert not (saved / "preopt_trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) +class TestFreqDoRunEndToEnd: + """Full pipeline: patched run_freq_calc → disk → _history_load_analysis → panels. + + Does NOT mock save_result so real disk writes happen. + Uses QUANTUI_RESULTS_DIR env var to redirect writes to tmp_path. + """ + + def _run_freq(self, app, tmp_dir, monkeypatch): + """Run a real Frequency calc via _do_run, redirecting saves to tmp_dir.""" + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Frequency" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_frequency(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "frequency" + + def test_do_run_saves_ir_frequencies(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + freqs = data.get("spectra", {}).get("ir", {}).get("frequencies_cm1") + assert ( + freqs is not None and len(freqs) > 0 + ), "frequencies_cm1 must be saved in spectra.ir" + + def test_do_run_saves_molecule_atoms(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + atoms = data.get("spectra", {}).get("molecule", {}).get("atoms") + assert atoms == [ + "O", + "H", + "H", + ], "molecule.atoms must be saved for history replay" + + def test_do_run_saves_displacements(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + disps = data.get("spectra", {}).get("ir", {}).get("displacements") + assert ( + disps is not None + ), "displacements must be saved — _pop_vibrational needs them" + assert len(disps) == 3 + + def test_history_load_activates_vibrational_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Vibrational" in running_app._ana_available + + def test_history_load_activates_ir_spectrum_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "IR Spectrum" in running_app._ana_available + + def test_history_load_shows_navigate_button( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert running_app._to_analysis_btn.layout.display == "" + + # 3c: Trajectory panel activates on history load when pre-opt was enabled + def test_history_load_activates_trajectory_panel_when_preopt_enabled( + self, tmp_path, running_app, monkeypatch + ): + running_app._freq_preopt_cb.value = True + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + result_dir = saved[0] + assert ( + result_dir / "preopt_trajectory.json" + ).exists(), "preopt_trajectory.json must be written when pre-opt runs" + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(result_dir) + assert "Trajectory" in running_app._ana_available diff --git a/tests/test_freq_calc.py b/tests/test_freq_calc.py index e2139c9..38ac54a 100644 --- a/tests/test_freq_calc.py +++ b/tests/test_freq_calc.py @@ -175,5 +175,54 @@ def test_thermo_g_less_than_h(self): assert result.thermo.G_hartree < result.thermo.H_hartree +# ============================================================================ +# IR intensities — PySCF required +# ============================================================================ + + +class TestIRIntensities: + """make_ir_intensity() should return real km/mol values for H₂O / RHF. + + H₂O has 3 vibrational modes: bending (~1600 cm⁻¹), symmetric stretch + (~3700 cm⁻¹), antisymmetric stretch (~3800 cm⁻¹). All three are + IR-active (A1 and B2 symmetry), so all intensities must be positive. + """ + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_non_empty(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + assert result.ir_intensities, "ir_intensities should be non-empty for H₂O/RHF" + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_length_matches_frequencies(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + assert len(result.ir_intensities) == len(result.frequencies_cm1) + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_all_non_negative(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + for i, inten in enumerate(result.ir_intensities): + assert inten >= 0, f"mode {i}: intensity {inten:.3f} < 0" + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_physically_reasonable(self): + """All H₂O modes are IR-active; max intensity should be > 1 km/mol.""" + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + if result.ir_intensities: + assert max(result.ir_intensities) > 1.0 + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/test_geo_opt_analysis_history.py b/tests/test_geo_opt_analysis_history.py new file mode 100644 index 0000000..b438379 --- /dev/null +++ b/tests/test_geo_opt_analysis_history.py @@ -0,0 +1,306 @@ +"""Integration tests for the geometry-optimisation analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result writes result.json with calc_type + "geometry_opt"; save_trajectory writes trajectory.json; + save_orbitals writes orbitals.npz. + (2) History context — _build_history_context reconstructs the context. + (3) Panel activation — _apply_analysis_context activates Trajectory, Energies, + and Isosurface panels from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G geometry opt → disk write → + _history_load_analysis → all three panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_orbitals, + save_result, + save_trajectory, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_geo_opt_result(with_coeff: bool = True): + """Minimal namespace mirroring a real RHF/STO-3G geometry opt on water. + + Includes two trajectory steps so _pop_geo_trajectory passes the len >= 2 check. + """ + water_initial = Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + water_final = Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.96, 0.0, 0.0]], + ) + mo_coeff = np.eye(7) if with_coeff else None + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.1, + energy_ev=-2043.0, + homo_lumo_gap_ev=10.2, + converged=True, + n_steps=2, + n_iterations=10, + trajectory=[water_initial, water_final], + energies_hartree=[-75.0, -75.1], + molecule=water_final, + mo_energy_hartree=np.array([-20.5, -1.3, -0.7, -0.5, -0.3, 0.5, 0.7]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0]), + mo_coeff=mo_coeff, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.96, 0.0, 0.0]), + ("H", [-0.96, 0.0, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def geo_opt_result(): + return _make_geo_opt_result() + + +# --------------------------------------------------------------------------- +# Part 1: save_result + save_trajectory + save_orbitals write the correct files +# --------------------------------------------------------------------------- + + +class TestGeoOptSpectraStructure: + def _save_all(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="geometry_opt", spectra={} + ) + save_trajectory(saved, result.trajectory, result.energies_hartree) + save_orbitals(saved, result) + return saved + + def test_result_json_has_geo_opt_calc_type(self, tmp_path, geo_opt_result): + saved = save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "geometry_opt" + + def test_trajectory_json_written(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + assert (saved / "trajectory.json").exists() + + def test_orbitals_npz_written(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + assert (saved / "orbitals.npz").exists() + + def test_trajectory_has_multiple_steps(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + raw = json.loads((saved / "trajectory.json").read_text()) + assert len(raw["steps"]) >= 2, "trajectory must have >= 2 steps" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestGeoOptHistoryContext: + def _save(self, tmp_path, geo_opt_result): + return save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "geometry_opt" + + def test_context_result_dir_set(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx.result_dir == saved + + def test_context_live_result_is_none(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestGeoOptPanelActivation: + def _save_all(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="geometry_opt", spectra={} + ) + save_trajectory(saved, result.trajectory, result.energies_hartree) + save_orbitals(saved, result) + return saved + + def test_trajectory_panel_activates(self, tmp_path, app, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" in app._ana_available + + def test_energies_panel_activates(self, tmp_path, app, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" in app._ana_available + + def test_isosurface_activates(self, tmp_path, app): + result_with_coeff = _make_geo_opt_result(with_coeff=True) + saved = self._save_all(tmp_path, result_with_coeff) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Isosurface" in app._ana_available + + def test_trajectory_absent_when_trajectory_json_missing( + self, tmp_path, app, geo_opt_result + ): + # save_result + save_orbitals, but NOT save_trajectory + saved = save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + save_orbitals(saved, geo_opt_result) + assert not (saved / "trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, geo_opt_result): + saved = save_result( + geo_opt_result, results_dir=tmp_path, calc_type="", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) +class TestGeoOptDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G geometry opt → disk → history → panels.""" + + def _run_geo_opt(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Geometry Opt" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_geometry_opt( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "geometry_opt" + + def test_do_run_saves_trajectory_json(self, tmp_path, running_app, monkeypatch): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved + result_dir = saved[0] + assert ( + result_dir / "trajectory.json" + ).exists(), "trajectory.json must be written by _do_run for history replay" + raw = json.loads((result_dir / "trajectory.json").read_text()) + assert raw["atoms"] == ["O", "H", "H"] + assert len(raw["steps"]) >= 1 + assert any( + s["energy"] is not None for s in raw["steps"] + ), "energies must be non-empty in trajectory" + + def test_history_load_activates_trajectory_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Trajectory" in running_app._ana_available + + def test_history_load_activates_energies_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Energies" in running_app._ana_available + + def test_history_load_activates_isosurface_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Isosurface" in running_app._ana_available diff --git a/tests/test_nmr_analysis_history.py b/tests/test_nmr_analysis_history.py new file mode 100644 index 0000000..39491f3 --- /dev/null +++ b/tests/test_nmr_analysis_history.py @@ -0,0 +1,269 @@ +"""Integration tests for the NMR shielding analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "nmr" + and the correct nmr spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the NMR panel + from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G NMR → disk write → + _history_load_analysis → panel activates. + (PySCF-gated; requires pyscf-properties.) +""" + +from __future__ import annotations + +import json +import math +import sys +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + +# NMR additionally requires pyscf-properties (pip install pyscf-properties). +# Attempt a lightweight import check without running a calculation. +try: + from quantui.nmr_calc import run_nmr_calc # noqa: F401 + + _NMR_AVAILABLE = _PYSCF_AVAILABLE +except ImportError: + _NMR_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_nmr_result(): + """Minimal namespace mirroring a real RHF/STO-3G NMR result on water. + + Water has 3 atoms (1 O + 2 H) so shielding values should have length 3. + Chemical shift keys use string indices (as _do_run serialises them). + """ + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + atom_symbols=["O", "H", "H"], + shielding_iso_ppm=[320.5, 28.1, 28.1], + chemical_shifts_ppm={1: -1.5, 2: -1.5}, # int keys, serialised to str + reference_compound="TMS", + ) + + +def _make_nmr_spectra(result=None): + """Build the spectra dict as _do_run does for an NMR calculation.""" + if result is None: + result = _make_nmr_result() + return { + "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, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def nmr_result(): + return _make_nmr_result() + + +@pytest.fixture +def nmr_spectra(nmr_result): + return _make_nmr_spectra(nmr_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for NMR +# --------------------------------------------------------------------------- + + +class TestNMRSpectraStructure: + def test_result_json_has_nmr_calc_type(self, tmp_path, nmr_result, nmr_spectra): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "nmr" + + def test_atom_symbols_and_shielding_present( + self, tmp_path, nmr_result, nmr_spectra + ): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + nmr = data["spectra"]["nmr"] + assert "atom_symbols" in nmr + assert "shielding_iso_ppm" in nmr + + def test_atom_symbols_and_shielding_same_length( + self, tmp_path, nmr_result, nmr_spectra + ): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + nmr = data["spectra"]["nmr"] + assert len(nmr["atom_symbols"]) == len( + nmr["shielding_iso_ppm"] + ), "atom_symbols and shielding_iso_ppm must have the same length" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestNMRHistoryContext: + def _save(self, tmp_path, nmr_result, nmr_spectra): + return save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "nmr" + + def test_context_spectra_data_has_nmr_keys( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + nmr = ctx.spectra_data.get("nmr", {}) + assert nmr.get("atom_symbols"), "spectra_data must have nmr.atom_symbols" + assert nmr.get( + "shielding_iso_ppm" + ), "spectra_data must have nmr.shielding_iso_ppm" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestNMRPanelActivation: + def _save(self, tmp_path, nmr_result, spectra): + return save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=spectra + ) + + def test_nmr_panel_activates_with_data( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "NMR" in app._ana_available + + def test_nmr_absent_when_spectra_missing(self, tmp_path, app, nmr_result): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "NMR" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF + pyscf-properties gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _NMR_AVAILABLE or sys.platform == "win32", + reason="PySCF/pyscf-properties not available or not supported on native Windows", +) +class TestNMRDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G NMR → disk → history → panel.""" + + def _run_nmr(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "NMR Shielding" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_nmr(self, tmp_path, running_app, monkeypatch): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "nmr" + + def test_do_run_saves_shieldings_for_all_atoms( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + nmr = data.get("spectra", {}).get("nmr", {}) + symbols = nmr.get("atom_symbols", []) + shieldings = nmr.get("shielding_iso_ppm", []) + assert symbols == ["O", "H", "H"], "water must have 3 atoms: O, H, H" + assert len(shieldings) == 3, "must have one shielding value per atom" + assert all( + math.isfinite(s) for s in shieldings + ), "all shielding values must be finite floats" + + def test_history_load_activates_nmr_panel(self, tmp_path, running_app, monkeypatch): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "NMR" in running_app._ana_available diff --git a/tests/test_nmr_calc.py b/tests/test_nmr_calc.py index 2c7f3b2..1578100 100644 --- a/tests/test_nmr_calc.py +++ b/tests/test_nmr_calc.py @@ -28,19 +28,6 @@ not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)" ) -# pyscf-properties 0.1.0 (PyPI) has a reshape bug in nmr/rhf.py that -# was fixed on the pyscf/properties master branch (commit 4eee5a4, -# "fix nmr", 2024-11-07) but not yet released. Mark the integration -# tests xfail until a fixed release lands on PyPI. -# Fix: pip install git+https://github.com/pyscf/properties.git -_nmr_xfail = pytest.mark.xfail( - reason=( - "pyscf-properties 0.1.0 incompatible with pyscf>=2.13.0: " - "rhf.py reshapes mo1 to (3,nmo,nocc) but krylov returns (nmo*nocc,). " - "Fixed on pyscf/properties master (commit 4eee5a4) — awaiting PyPI release." - ), - strict=False, -) # --------------------------------------------------------------------------- # Helpers @@ -179,28 +166,24 @@ def test_raises_importerror_without_pyscf(self, monkeypatch): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_returns_nmr_result(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert isinstance(result, NMRResult) @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_has_two_h_shifts(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert len(result.h_shifts()) == 2 @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_no_c_shifts(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert result.c_shifts() == [] @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_methane_has_c_and_h_shifts(self): result = run_nmr_calc(_methane(), method="RHF", basis="STO-3G") assert len(result.c_shifts()) == 1 @@ -208,7 +191,6 @@ def test_methane_has_c_and_h_shifts(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_h_shifts_reasonable_range(self): result = run_nmr_calc(_water(), method="B3LYP", basis="6-31G*") for _i, delta in result.h_shifts(): @@ -217,7 +199,6 @@ def test_water_h_shifts_reasonable_range(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_formula_matches_molecule(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert "O" in result.formula @@ -225,7 +206,6 @@ def test_formula_matches_molecule(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_shielding_iso_length_matches_atoms(self): mol = _water() result = run_nmr_calc(mol, method="RHF", basis="STO-3G") diff --git a/tests/test_pes_scan_analysis_history.py b/tests/test_pes_scan_analysis_history.py new file mode 100644 index 0000000..6518889 --- /dev/null +++ b/tests/test_pes_scan_analysis_history.py @@ -0,0 +1,311 @@ +"""Integration tests for the PES Scan analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "pes_scan" + and the correct pes_scan spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the PES Scan panel + from a history context; Trajectory activates when + trajectory.json is present. + (4) _do_run end-to-end — Full path: real RHF/STO-3G bond scan on H2 → + disk write → _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_result, + save_trajectory, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _h2(): + return Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]]) + + +def _make_pes_result(): + """Minimal namespace mirroring a real RHF/STO-3G bond scan on H2.""" + scan_values = [0.60, 0.70, 0.74, 0.80, 0.90] + energies = [-1.060, -1.115, -1.117, -1.100, -1.060] + mol_at_0 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.60]]) + mol_at_1 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.70]]) + mol_at_2 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]]) + mol_at_3 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.80]]) + mol_at_4 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.90]]) + return SimpleNamespace( + formula="H2", + method="RHF", + basis="STO-3G", + energy_hartree=min(energies), + energy_ev=min(energies) * 27.211, + homo_lumo_gap_ev=8.0, + converged=True, + n_iterations=10, + scan_type="bond", + atom_indices=[0, 1], + scan_parameter_values=scan_values, + energies_hartree=energies, + coordinates_list=[mol_at_0, mol_at_1, mol_at_2, mol_at_3, mol_at_4], + converged_all=True, + ) + + +def _make_pes_spectra(result=None): + """Build the spectra dict as _do_run does for a PES scan.""" + if result is None: + result = _make_pes_result() + return { + "pes_scan": { + "scan_type": result.scan_type, + "atom_indices": result.atom_indices, + "scan_parameter_values": result.scan_parameter_values, + "energies_hartree": result.energies_hartree, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def pes_result(): + return _make_pes_result() + + +@pytest.fixture +def pes_spectra(pes_result): + return _make_pes_spectra(pes_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for pes_scan +# --------------------------------------------------------------------------- + + +class TestPESScanSpectraStructure: + def test_result_json_has_pes_scan_calc_type( + self, tmp_path, pes_result, pes_spectra + ): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "pes_scan" + + def test_pes_scan_keys_present(self, tmp_path, pes_result, pes_spectra): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + scan = data["spectra"]["pes_scan"] + assert "scan_type" in scan + assert "atom_indices" in scan + assert "scan_parameter_values" in scan + assert "energies_hartree" in scan + + def test_scan_values_non_empty(self, tmp_path, pes_result, pes_spectra): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + scan = data["spectra"]["pes_scan"] + assert len(scan["scan_parameter_values"]) >= 2 + assert len(scan["energies_hartree"]) == len(scan["scan_parameter_values"]) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestPESScanHistoryContext: + def _save(self, tmp_path, pes_result, pes_spectra): + return save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "pes_scan" + + def test_context_spectra_data_has_pes_scan( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + scan = ctx.spectra_data.get("pes_scan", {}) + assert scan.get( + "energies_hartree" + ), "spectra_data must have pes_scan.energies_hartree for panel dispatch" + assert scan.get( + "scan_parameter_values" + ), "spectra_data must have pes_scan.scan_parameter_values" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestPESScanPanelActivation: + def _save(self, tmp_path, pes_result, pes_spectra): + return save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + + def test_pes_scan_panel_activates_with_data( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "PES Scan" in app._ana_available + + def test_pes_scan_absent_when_spectra_empty(self, tmp_path, app, pes_result): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "PES Scan" not in app._ana_available + + def test_trajectory_panel_activates_with_trajectory_json( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + save_trajectory(saved, pes_result.coordinates_list, pes_result.energies_hartree) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" in app._ana_available + + def test_trajectory_absent_when_no_trajectory_json( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + assert not (saved / "trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) +class TestPESScanDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G H2 bond scan → disk → history → panels.""" + + def _run_pes_scan(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "PES Scan" + app._scan_type_dd.value = "Bond" + app._scan_atom1.value = 1 + app._scan_atom2.value = 2 + app._scan_start.value = 0.6 + app._scan_stop.value = 0.9 + app._scan_steps.value = 3 + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_h2()) + return a + + def test_do_run_saves_calc_type_pes_scan(self, tmp_path, running_app, monkeypatch): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "pes_scan" + + def test_do_run_saves_scan_parameter_values( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + vals = data.get("spectra", {}).get("pes_scan", {}).get("scan_parameter_values") + assert ( + vals is not None and len(vals) >= 1 + ), "at least 1 scan point must be saved" + + def test_do_run_saves_energies_hartree(self, tmp_path, running_app, monkeypatch): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + energies = data.get("spectra", {}).get("pes_scan", {}).get("energies_hartree") + assert energies is not None and len(energies) >= 1 + + def test_history_load_activates_pes_scan_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "PES Scan" in running_app._ana_available + + def test_history_load_activates_trajectory_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Trajectory" in running_app._ana_available diff --git a/tests/test_results_storage.py b/tests/test_results_storage.py new file mode 100644 index 0000000..d024780 --- /dev/null +++ b/tests/test_results_storage.py @@ -0,0 +1,333 @@ +"""Tests for quantui/results_storage.py. + +Covers save_result, load_result, list_results, save_orbitals, load_orbitals, +save_trajectory, load_trajectory, and save_thumbnail end-to-end using +tmp_path fixtures (no mocking of the storage layer itself). +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_orbitals, + load_result, + load_trajectory, + save_orbitals, + save_result, + save_thumbnail, + save_trajectory, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_result(**overrides): + defaults = dict( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def _water_traj(): + mol = Molecule(["O", "H", "H"], [[0, 0, 0], [0.757, 0.586, 0], [-0.757, 0.586, 0]]) + return [mol, mol], [-75.0, -75.1] + + +# --------------------------------------------------------------------------- +# save_result / load_result +# --------------------------------------------------------------------------- + + +class TestSaveResult: + def test_creates_result_directory(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert saved.is_dir() + + def test_writes_result_json(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert (saved / "result.json").exists() + + def test_result_json_fields(self, tmp_path): + saved = save_result( + _make_result(), results_dir=tmp_path, calc_type="single_point" + ) + data = json.loads((saved / "result.json").read_text()) + assert data["formula"] == "H2O" + assert data["method"] == "RHF" + assert data["basis"] == "STO-3G" + assert data["energy_hartree"] == pytest.approx(-75.0) + assert data["converged"] is True + assert data["calc_type"] == "single_point" + assert data["_schema_version"] == 2 + + def test_spectra_stored_in_json(self, tmp_path): + spectra = { + "ir": {"frequencies_cm1": [1000.0, 2000.0], "ir_intensities": [1.0, 2.0]} + } + saved = save_result(_make_result(), results_dir=tmp_path, spectra=spectra) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["ir"]["frequencies_cm1"] == [1000.0, 2000.0] + + def test_pyscf_log_written_when_provided(self, tmp_path): + saved = save_result( + _make_result(), + pyscf_log="converged SCF energy = -75.0", + results_dir=tmp_path, + ) + log_path = saved / "pyscf.log" + assert log_path.exists() + assert "converged" in log_path.read_text() + + def test_no_pyscf_log_when_empty(self, tmp_path): + saved = save_result(_make_result(), pyscf_log="", results_dir=tmp_path) + assert not (saved / "pyscf.log").exists() + + def test_directory_name_contains_formula_method_basis(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert "H2O" in saved.name + assert "RHF" in saved.name + assert "STO-3G" in saved.name + + def test_missing_optional_fields_use_defaults(self, tmp_path): + minimal = SimpleNamespace( + formula="H2", method="RHF", basis="STO-3G", energy_hartree=-1.0 + ) + saved = save_result(minimal, results_dir=tmp_path) + data = json.loads((saved / "result.json").read_text()) + assert data["homo_lumo_gap_ev"] is None + assert data["converged"] is None + assert data["n_iterations"] == -1 + + def test_returns_path_to_created_directory(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert isinstance(saved, type(tmp_path)) + assert saved.parent == tmp_path + + def test_each_call_creates_unique_directory(self, tmp_path): + d1 = save_result(_make_result(), results_dir=tmp_path) + d2 = save_result(_make_result(), results_dir=tmp_path) + assert d1 != d2 + + +class TestLoadResult: + def test_roundtrip(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path, calc_type="frequency") + data = load_result(saved) + assert data["formula"] == "H2O" + assert data["calc_type"] == "frequency" + assert data["_schema_version"] == 2 + + def test_spectra_roundtrip(self, tmp_path): + spectra = { + "nmr": {"atom_symbols": ["H", "H"], "shielding_iso_ppm": [30.1, 30.2]} + } + saved = save_result(_make_result(), results_dir=tmp_path, spectra=spectra) + data = load_result(saved) + assert data["spectra"]["nmr"]["atom_symbols"] == ["H", "H"] + + +# --------------------------------------------------------------------------- +# list_results +# --------------------------------------------------------------------------- + + +class TestListResults: + def test_empty_when_directory_missing(self, tmp_path): + assert list_results(tmp_path / "nonexistent") == [] + + def test_empty_when_directory_is_empty(self, tmp_path): + assert list_results(tmp_path) == [] + + def test_returns_directories_that_have_result_json(self, tmp_path): + r1 = save_result(_make_result(), results_dir=tmp_path) + r2 = save_result(_make_result(), results_dir=tmp_path) + found = list_results(tmp_path) + assert r1 in found + assert r2 in found + + def test_excludes_directories_without_result_json(self, tmp_path): + empty_dir = tmp_path / "2026-no-json" + empty_dir.mkdir() + found = list_results(tmp_path) + assert empty_dir not in found + + def test_sorted_newest_first(self, tmp_path): + r1 = save_result(_make_result(), results_dir=tmp_path) + r2 = save_result(_make_result(), results_dir=tmp_path) + found = list_results(tmp_path) + assert found.index(r2) < found.index(r1) + + +# --------------------------------------------------------------------------- +# save_orbitals / load_orbitals +# --------------------------------------------------------------------------- + + +class TestSaveOrbitals: + def test_creates_npz_file(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0, -0.5, 0.2]), + mo_occ=np.array([2.0, 2.0, 0.0]), + mo_coeff=None, + pyscf_mol_atom=None, + pyscf_mol_basis=None, + ) + save_orbitals(tmp_path, result) + assert (tmp_path / "orbitals.npz").exists() + + def test_skips_when_no_mo_data(self, tmp_path): + result = SimpleNamespace(mo_energy_hartree=None, mo_occ=None) + save_orbitals(tmp_path, result) + assert not (tmp_path / "orbitals.npz").exists() + + def test_writes_meta_json_when_mol_data_present(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0]), + mo_occ=np.array([2.0]), + mo_coeff=None, + pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], + pyscf_mol_basis="sto-3g", + ) + save_orbitals(tmp_path, result) + assert (tmp_path / "orbitals_meta.json").exists() + meta = json.loads((tmp_path / "orbitals_meta.json").read_text()) + assert meta["mol_basis"] == "sto-3g" + + +class TestLoadOrbitals: + def test_roundtrip(self, tmp_path): + mo_e = np.array([-1.0, -0.5, 0.2]) + result = SimpleNamespace( + mo_energy_hartree=mo_e, + mo_occ=np.array([2.0, 2.0, 0.0]), + mo_coeff=None, + pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], + pyscf_mol_basis="sto-3g", + ) + save_orbitals(tmp_path, result) + loaded = load_orbitals(tmp_path) + np.testing.assert_array_almost_equal(loaded.mo_energy_hartree, mo_e) + assert loaded.pyscf_mol_basis == "sto-3g" + + def test_raises_file_not_found_when_missing(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_orbitals(tmp_path / "nonexistent") + + def test_handles_missing_meta_json_gracefully(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0]), + mo_occ=np.array([2.0]), + mo_coeff=None, + pyscf_mol_atom=None, + pyscf_mol_basis=None, + ) + save_orbitals(tmp_path, result) + loaded = load_orbitals(tmp_path) + assert loaded.pyscf_mol_atom is None + assert loaded.pyscf_mol_basis is None + + +# --------------------------------------------------------------------------- +# save_trajectory / load_trajectory +# --------------------------------------------------------------------------- + + +class TestSaveTrajectory: + def test_creates_trajectory_json(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + assert (tmp_path / "trajectory.json").exists() + + def test_skips_empty_trajectory(self, tmp_path): + save_trajectory(tmp_path, [], []) + assert not (tmp_path / "trajectory.json").exists() + + def test_stores_atom_symbols_and_coords(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + data = json.loads((tmp_path / "trajectory.json").read_text()) + assert data["atoms"] == ["O", "H", "H"] + assert len(data["steps"]) == 2 + + +class TestLoadTrajectory: + def test_roundtrip(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + loaded_traj, loaded_e = load_trajectory(tmp_path) + assert len(loaded_traj) == 2 + assert loaded_e[0] == pytest.approx(-75.0) + assert loaded_e[1] == pytest.approx(-75.1) + + def test_loaded_molecules_have_correct_atoms(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + loaded_traj, _ = load_trajectory(tmp_path) + assert list(loaded_traj[0].atoms) == ["O", "H", "H"] + + def test_raises_file_not_found_when_missing(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_trajectory(tmp_path / "nonexistent") + + def test_all_none_energies_returns_empty_list(self, tmp_path): + mol = Molecule(["H", "H"], [[0, 0, 0], [0.74, 0, 0]]) + traj = [mol] + save_trajectory(tmp_path, traj, [None]) + _, energies = load_trajectory(tmp_path) + assert energies == [] + + +# --------------------------------------------------------------------------- +# save_thumbnail +# --------------------------------------------------------------------------- + + +class TestSaveThumbnail: + def test_creates_png(self, tmp_path): + data = { + "calc_type": "single_point", + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + "energy_hartree": -75.0, + "converged": True, + } + save_thumbnail(tmp_path, data) + assert (tmp_path / "thumbnail.png").exists() + + def test_does_not_raise_for_unknown_calc_type(self, tmp_path): + save_thumbnail(tmp_path, {"calc_type": "unknown", "formula": "X"}) + + @pytest.mark.parametrize( + "calc_type", ["single_point", "geometry_opt", "frequency", "tddft", "nmr"] + ) + def test_creates_png_for_all_calc_types(self, tmp_path, calc_type): + result_dir = tmp_path / calc_type + result_dir.mkdir() + data = { + "calc_type": calc_type, + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + "energy_hartree": -75.0, + "converged": True, + } + save_thumbnail(result_dir, data) + assert (result_dir / "thumbnail.png").exists() diff --git a/tests/test_sp_analysis_history.py b/tests/test_sp_analysis_history.py new file mode 100644 index 0000000..a0e70bf --- /dev/null +++ b/tests/test_sp_analysis_history.py @@ -0,0 +1,253 @@ +"""Integration tests for the single-point analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type + "single_point"; save_orbitals writes orbitals.npz. + (2) History context — _build_history_context loads calc_type correctly + from disk. + (3) Panel activation — _apply_analysis_context activates Energies and + Isosurface panels from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G run → disk write → + _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_orbitals, + save_result, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_sp_result(with_coeff: bool = True): + """Minimal namespace mirroring a real RHF/STO-3G result on water. + + 7 MOs (matching STO-3G water: 5 occupied + 2 virtual) so that + orbital_info_from_arrays does not raise on the n_occ >= n_total check. + """ + mo_coeff = np.eye(7) if with_coeff else None + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + mo_energy_hartree=np.array([-20.5, -1.3, -0.7, -0.5, -0.3, 0.5, 0.7]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0]), + mo_coeff=mo_coeff, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.757, 0.586, 0.0]), + ("H", [-0.757, 0.586, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def sp_result(): + return _make_sp_result() + + +# --------------------------------------------------------------------------- +# Part 1: save_result + save_orbitals write the correct files +# --------------------------------------------------------------------------- + + +class TestSPSpectraStructure: + def test_result_json_has_single_point_calc_type(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "single_point" + + def test_save_orbitals_writes_npz(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + save_orbitals(saved, sp_result) + assert (saved / "orbitals.npz").exists() + + def test_no_spectra_keys_for_single_point(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + data = json.loads((saved / "result.json").read_text()) + assert ( + data.get("spectra", {}) == {} + ), "single_point produces no spectra data — only orbitals.npz" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestSPHistoryContext: + def _save(self, tmp_path, sp_result): + return save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "single_point" + + def test_context_result_dir_set(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx.result_dir == saved + + def test_context_live_result_is_none(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestSPPanelActivation: + def _save_with_orbitals(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + save_orbitals(saved, result) + return saved + + def test_energies_panel_activates_with_orbitals(self, tmp_path, app, sp_result): + saved = self._save_with_orbitals(tmp_path, sp_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" in app._ana_available + + def test_energies_absent_when_orbitals_missing(self, tmp_path, app, sp_result): + # save_result only — no save_orbitals call, so orbitals.npz is absent + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + assert not (saved / "orbitals.npz").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" not in app._ana_available + + def test_isosurface_activates_when_mo_coeff_present(self, tmp_path, app): + result_with_coeff = _make_sp_result(with_coeff=True) + saved = self._save_with_orbitals(tmp_path, result_with_coeff) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Isosurface" in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, sp_result): + saved = save_result(sp_result, results_dir=tmp_path, calc_type="", spectra={}) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) +class TestSPDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G single point → disk → history → panels.""" + + def _run_sp(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Single Point" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_single_point( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "single_point" + + def test_do_run_saves_orbitals_npz(self, tmp_path, running_app, monkeypatch): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved + assert ( + saved[0] / "orbitals.npz" + ).exists(), "orbitals.npz must be written by _do_run for history replay" + + def test_history_load_activates_energies_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Energies" in running_app._ana_available + + def test_history_load_activates_isosurface_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Isosurface" in running_app._ana_available diff --git a/tests/test_tddft_analysis_history.py b/tests/test_tddft_analysis_history.py new file mode 100644 index 0000000..c6a9f32 --- /dev/null +++ b/tests/test_tddft_analysis_history.py @@ -0,0 +1,251 @@ +"""Integration tests for the TD-DFT (UV-Vis) analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "tddft" + and the correct uv_vis spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the UV-Vis panel + from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G + 3 excited states → + disk write → _history_load_analysis → panel activates. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_tddft_result(): + """Minimal namespace mirroring a real RHF/STO-3G TD-DFT result on water.""" + energies = [6.5, 7.2, 8.1] + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + excitation_energies_ev=energies, + oscillator_strengths=[0.05, 0.12, 0.03], + wavelengths_nm=[1240.0 / e for e in energies], + ) + + +def _make_tddft_spectra(result=None): + """Build the spectra dict as _do_run does for a TD-DFT calculation.""" + if result is None: + result = _make_tddft_result() + return { + "uv_vis": { + "excitation_energies_ev": result.excitation_energies_ev, + "oscillator_strengths": result.oscillator_strengths, + "wavelengths_nm": result.wavelengths_nm, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def tddft_result(): + return _make_tddft_result() + + +@pytest.fixture +def tddft_spectra(tddft_result): + return _make_tddft_spectra(tddft_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for tddft +# --------------------------------------------------------------------------- + + +class TestTDDFTSpectraStructure: + def test_result_json_has_tddft_calc_type( + self, tmp_path, tddft_result, tddft_spectra + ): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "tddft" + + def test_all_uv_vis_keys_present(self, tmp_path, tddft_result, tddft_spectra): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + uv = data["spectra"]["uv_vis"] + assert "excitation_energies_ev" in uv + assert "oscillator_strengths" in uv + assert "wavelengths_nm" in uv + + def test_wavelengths_non_empty(self, tmp_path, tddft_result, tddft_spectra): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + wl = data["spectra"]["uv_vis"]["wavelengths_nm"] + assert len(wl) > 0 + assert all(w > 0 for w in wl) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestTDDFTHistoryContext: + def _save(self, tmp_path, tddft_result, tddft_spectra): + return save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "tddft" + + def test_context_spectra_data_has_excitation_energies( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + uv = ctx.spectra_data.get("uv_vis", {}) + assert uv.get( + "excitation_energies_ev" + ), "spectra_data must have uv_vis.excitation_energies_ev for panel dispatch" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestTDDFTPanelActivation: + def _save(self, tmp_path, tddft_result, spectra): + return save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=spectra + ) + + def test_uv_vis_panel_activates_with_data( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "UV-Vis" in app._ana_available + + def test_uv_vis_absent_when_spectra_empty(self, tmp_path, app, tddft_result): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "UV-Vis" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) +class TestTDDFTDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G TD-DFT (3 states) → disk → history → panel.""" + + def _run_tddft(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "UV-Vis (TD-DFT)" + app.method_dd.value = ( + "B3LYP" # RHF lacks TDDFT; B3LYP is the canonical TD-DFT choice + ) + app.nstates_si.value = 3 + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_tddft(self, tmp_path, running_app, monkeypatch): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "tddft" + + def test_do_run_saves_excitation_energies(self, tmp_path, running_app, monkeypatch): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + energies = ( + data.get("spectra", {}).get("uv_vis", {}).get("excitation_energies_ev") + ) + assert ( + energies is not None and len(energies) >= 1 + ), "at least 1 excitation energy must be saved" + + def test_history_load_activates_uv_vis_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "UV-Vis" in running_app._ana_available