From 7912f312258d2f6eed469e72594aff1247620299 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 16 Apr 2026 21:57:59 -0400 Subject: [PATCH 01/34] Add 3D, trajectory, and vib visualizers Add interactive result visualizations to the Results panel: a result 3D view, a geometry-optimization trajectory accordion with animation and energy plot, and a vibrational-mode viewer with mode selector. Introduce helper methods in QuantUIApp to render 3D molecules, build/render vibrational data (via plotlymol3d) and show optimization trajectories; wire up a vib mode dropdown observer and hide/show panels on runs. Extend FreqResult to include normalized displacement vectors and populate them in run_freq_calc when available. Add tests (tests/test_visualization.py) covering plotlyMol trajectory creation and the _build_vib_data_from_freq_result behavior; visual features are no-ops when optional deps (plotlymol3d/RDKit) are missing. --- quantui/app.py | 299 ++++++++++++++++++++++++++++++++++ quantui/freq_calc.py | 28 ++++ tests/test_visualization.py | 315 ++++++++++++++++++++++++++++++++++++ 3 files changed, 642 insertions(+) create mode 100644 tests/test_visualization.py diff --git a/quantui/app.py b/quantui/app.py index fd66e9c..ac0c68e 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -357,6 +357,7 @@ def _build_shared_widgets(self) -> None: ) ) self.result_output = widgets.Output() + self.result_viz_output = widgets.Output() self.comparison_output = widgets.Output() self.notes_output = widgets.Output() self.perf_estimate_html = widgets.HTML() @@ -671,10 +672,42 @@ def _build_run_section(self) -> None: # ── Results panel (Cell 7) ──────────────────────────────────────────── def _build_results_section(self) -> None: + # Trajectory accordion (Geo Opt only — hidden until a Geo Opt completes) + self.traj_output = widgets.Output() + self.traj_accordion = widgets.Accordion( + children=[self.traj_output], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self.traj_accordion.set_title(0, "Trajectory Viewer") + self.traj_accordion.selected_index = None # collapsed by default + + # Vibrational animation accordion (Frequency only — hidden until Freq completes) + self.vib_mode_dd = widgets.Dropdown( + description="Mode:", + options=[], + style={"description_width": "50px"}, + layout=widgets.Layout(width="360px"), + ) + self.vib_output = widgets.Output() + self.vib_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self.vib_mode_dd, self.vib_output], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self.vib_accordion.set_title(0, "Vibrational Mode Viewer") + self.vib_accordion.selected_index = None # collapsed by default + self.results_panel = widgets.VBox( [ widgets.HTML('

Results

'), self.result_output, + self.result_viz_output, + self.traj_accordion, + self.vib_accordion, ] ) @@ -997,6 +1030,8 @@ def _wire_callbacks(self) -> None: self._log_clear_btn.on_click(self._on_log_clear) # Help self.help_topic_dd.observe(self._on_help_topic_changed, names="value") + # Vibrational mode selector + self.vib_mode_dd.observe(self._on_vib_mode_changed, names="value") # ══ CALLBACK METHODS ═════════════════════════════════════════════════════ @@ -1127,6 +1162,9 @@ def _on_basis_help(self, btn) -> None: def _on_run_clicked(self, btn) -> None: self.run_output.clear_output() self.result_output.clear_output() + self.result_viz_output.clear_output() + self.traj_accordion.layout.display = "none" + self.vib_accordion.layout.display = "none" threading.Thread(target=self._do_run, daemon=True).start() def _on_clear_log(self, btn) -> None: @@ -1377,6 +1415,257 @@ def _set_molecule_threadsafe(self, mol, status_message: str) -> None: self._set_molecule_state_only(mol) self._queue_main_thread_callback(self._set_molecule, mol, status_message) + def _show_result_3d(self, molecule) -> None: + """Render molecule 3D structure in the result visualization panel. + + Safe to call from a background thread — uses ``with output:`` context. + """ + if _display_molecule is None or molecule is None: + return + self.result_viz_output.clear_output() + with self.result_viz_output: + _display_molecule(molecule) + + def _show_opt_trajectory(self, opt_result) -> None: + """Render geo-opt trajectory animation and energy chart in the trajectory panel. + + Uses plotlyMol's ``create_trajectory_animation``. Safe to call from a + background thread — uses ``with output:`` context. No-op if plotlyMol + is not installed. + """ + traj = opt_result.trajectory + energies = opt_result.energies_hartree + if len(traj) < 2: + return + + try: + import plotly.graph_objects as go + from IPython.display import display as _ipy_display + from plotlymol3d import create_trajectory_animation + except ImportError: + return + + # Build full XYZ blocks (count + title + coords). + xyzblocks = [ + f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj + ] + + anim_fig = create_trajectory_animation( + xyzblocks=xyzblocks, + energies_hartree=energies if energies else None, + charge=traj[0].charge, + mode="ball+stick", + resolution=12, + title=f"Geo Opt: {opt_result.formula}", + ) + anim_fig.update_layout(height=420) + + # Energy convergence chart (relative energies in kcal/mol). + _HARTREE_TO_KCAL = 627.5094740631 + e0 = energies[0] if energies else 0.0 + rel_e = [(e - e0) * _HARTREE_TO_KCAL for e in energies] if energies else [] + energy_fig = go.Figure( + go.Scatter( + x=list(range(len(rel_e))), + y=rel_e, + mode="lines+markers", + name="ΔE", + line=dict(color="#2563eb", width=2), + marker=dict(size=6), + ) + ) + energy_fig.update_layout( + title="Energy Convergence", + xaxis_title="Optimization Step", + yaxis_title="ΔE (kcal/mol)", + height=280, + margin=dict(l=60, r=20, t=40, b=40), + ) + + self.traj_output.clear_output() + with self.traj_output: + _ipy_display(anim_fig) + _ipy_display(energy_fig) + + # Reveal the accordion (collapsed). + self.traj_accordion.selected_index = None + self.traj_accordion.layout.display = "" + + def _build_vib_data_from_freq_result(self, freq_result, molecule): + """Construct a ``plotlymol3d.VibrationalData`` from a FreqResult. + + Args: + freq_result: ``FreqResult`` with ``displacements`` populated. + molecule: The ``Molecule`` used for the frequency calculation. + + Returns: + ``VibrationalData`` or ``None`` if prerequisites are missing. + """ + try: + import numpy as np + from plotlymol3d import VibrationalData, VibrationalMode + except ImportError: + return None + + displacements = getattr(freq_result, "displacements", None) + if displacements is None: + return None + + freqs = freq_result.frequencies_cm1 + intensities = freq_result.ir_intensities + n_modes = len(freqs) + + coords = np.array(molecule.coordinates, dtype=float) + + # Map element symbols to atomic numbers using a common-elements table. + # ASE is not required — this covers all elements students will encounter. + _Z = { + "H": 1, + "He": 2, + "Li": 3, + "Be": 4, + "B": 5, + "C": 6, + "N": 7, + "O": 8, + "F": 9, + "Ne": 10, + "Na": 11, + "Mg": 12, + "Al": 13, + "Si": 14, + "P": 15, + "S": 16, + "Cl": 17, + "Ar": 18, + "K": 19, + "Ca": 20, + "Br": 35, + "I": 53, + } + atomic_numbers: List[int] = [_Z.get(sym, 0) for sym in molecule.atoms] + + modes = [] + for i in range(n_modes): + freq = freqs[i] + ir_inten = intensities[i] if i < len(intensities) else None + displ = np.array(displacements[i], dtype=float) + modes.append( + VibrationalMode( + mode_number=i + 1, + frequency=float(freq), + ir_intensity=ir_inten, + displacement_vectors=displ, + is_imaginary=freq < 0, + ) + ) + + return VibrationalData( + coordinates=coords, + atomic_numbers=atomic_numbers, + modes=modes, + source_file="quantui_freq_calc", + program="pyscf", + ) + + def _show_vib_animation(self, freq_result, molecule) -> None: + """Populate the vibrational animation accordion after a Frequency result. + + Builds a ``VibrationalData`` from the result, populates the mode selector + dropdown, and renders the animation for the first non-trivial mode. + No-op if plotlyMol is unavailable or displacements are missing. + """ + vib_data = self._build_vib_data_from_freq_result(freq_result, molecule) + if vib_data is None: + return + + freqs = freq_result.frequencies_cm1 + if not freqs: + return + + # Build dropdown options: one entry per mode with frequency label. + # Skip near-zero translation/rotation modes (|ν| < 10 cm⁻¹). + options = [] + for m in vib_data.modes: + freq_val = m.frequency + if abs(freq_val) < 10: + continue + label = ( + f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹" + if freq_val >= 0 + else f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)" + ) + options.append((label, m.mode_number)) + + if not options: + return + + self.vib_mode_dd.options = options + self.vib_mode_dd.value = options[0][1] + + # Store vib_data for callback use. + self._last_vib_data = vib_data + self._last_vib_molecule = molecule + + # Render the first selected mode. + self._render_vib_mode(vib_data, molecule, options[0][1]) + + # Reveal the accordion (collapsed by default). + self.vib_accordion.selected_index = None + self.vib_accordion.layout.display = "" + + def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: + """Render vibrational animation for the given mode into ``vib_output``. + + Safe to call from background thread via ``with output:`` context. + """ + try: + from IPython.display import display as _ipy_display + from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol + except ImportError: + return + + # Build an RDKit mol for bond connectivity (required by animation function). + xyzblock = ( + f"{len(molecule.atoms)}\n{molecule.get_formula()}\n" + f"{molecule.to_xyz_string()}" + ) + try: + rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge) + except Exception: + return + + try: + anim_fig = create_vibration_animation( + vib_data=vib_data, + mode_number=mode_number, + mol=rdmol, + amplitude=0.4, + n_frames=20, + mode="ball+stick", + resolution=12, + ) + anim_fig.update_layout(height=420) + except Exception: + return + + self.vib_output.clear_output() + with self.vib_output: + _ipy_display(anim_fig) + + def _on_vib_mode_changed(self, change) -> None: + """Re-render vib animation when the mode dropdown changes.""" + mode_number = change["new"] + vib_data = getattr(self, "_last_vib_data", None) + molecule = getattr(self, "_last_vib_molecule", None) + if vib_data is None or molecule is None: + return + threading.Thread( + target=self._render_vib_mode, + args=(vib_data, molecule, mode_number), + daemon=True, + ).start() + def _do_run(self) -> None: """Main calculation dispatch — runs in a background thread.""" mol = self._molecule @@ -1485,6 +1774,16 @@ def _do_run(self) -> None: self.result_output.append_display_data(HTML(result_html)) self.run_status.value = f"Done in {_elapsed:.1f} s." + # Show 3D structure in the result panel + _viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol + self._show_result_3d(_viz_mol) + + # Show calc-type-specific extra panels + if ct == "Geometry Opt": + self._show_opt_trajectory(result) + elif ct == "Frequency": + self._show_vib_animation(result, calc_mol) + self.step_progress.complete(2) self.step_progress.complete(3) diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index f82a9d0..f38b584 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -75,6 +75,13 @@ class FreqResult: frequencies_cm1: List[float] = field(default_factory=list) ir_intensities: List[float] = field(default_factory=list) zpve_hartree: float = 0.0 + displacements: Optional[List] = None + """Normalized displacement vectors from PySCF harmonic analysis. + + Shape: ``(n_modes, n_atoms, 3)`` stored as a nested Python list. + ``None`` if the Hessian calculation failed or PySCF version does not + provide ``norm_mode``. + """ @property def energy_ev(self) -> float: @@ -194,6 +201,7 @@ def run_freq_calc( frequencies_cm1: List[float] = [] ir_intensities: List[float] = [] zpve_hartree: float = 0.0 + displacements: Optional[List] = None try: hess_obj = mf.Hessian() @@ -215,6 +223,25 @@ def run_freq_calc( # ZPVE = ½ · Σ ν_i (positive modes only), converted cm⁻¹ → Hartree zpve_hartree = sum(0.5 * f * _CM1_TO_HARTREE for f in frequencies_cm1 if f > 0) + # Normalized displacement vectors: shape (n_modes, n_atoms, 3). + # Stored as a nested Python list for JSON-friendliness and to avoid + # a hard numpy dependency in the dataclass. + try: + import numpy as _np + + norm_mode = freq_info.get("norm_mode") + if norm_mode is not None: + # norm_mode has shape (n_modes, n_atoms*3) or (n_modes, n_atoms, 3); + # reshape to (n_modes, n_atoms, 3) if needed. + nm = _np.array(norm_mode, dtype=float) + n_modes_out = nm.shape[0] + n_atoms = len(molecule.atoms) + if nm.ndim == 2: + nm = nm.reshape(n_modes_out, n_atoms, 3) + displacements = nm.tolist() + except Exception: + displacements = None + # IR intensities — best-effort; silently omitted if unavailable try: ir_info = pyscf_thermo.ir_spectrum(mf, h) @@ -241,4 +268,5 @@ def run_freq_calc( frequencies_cm1=frequencies_cm1, ir_intensities=ir_intensities, zpve_hartree=zpve_hartree, + displacements=displacements, ) diff --git a/tests/test_visualization.py b/tests/test_visualization.py new file mode 100644 index 0000000..0d8c438 --- /dev/null +++ b/tests/test_visualization.py @@ -0,0 +1,315 @@ +""" +M2.4 tests for post-calculation 3D visualization helpers. + +Covers: + - ``create_trajectory_animation()`` in plotlyMol + - ``QuantUIApp._build_vib_data_from_freq_result()`` + +No PySCF required — all calc results are mocked. +plotlyMol + RDKit are required for most tests; tests are skipped when absent. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +import pytest + +from quantui.molecule import Molecule + +# --------------------------------------------------------------------------- +# Skip markers +# --------------------------------------------------------------------------- + +try: + from plotlymol3d import create_trajectory_animation + + _PLOTLYMOL_AVAILABLE = True +except ImportError: + _PLOTLYMOL_AVAILABLE = False + +plotlymol_only = pytest.mark.skipif( + not _PLOTLYMOL_AVAILABLE, + reason="plotlyMol (and RDKit) required", +) + +# --------------------------------------------------------------------------- +# Helpers / shared fixtures +# --------------------------------------------------------------------------- + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + ) + + +def _water_xyzblocks(n: int = 3) -> list: + """Return n slightly different XYZ blocks for water (trajectory mock).""" + # Perturb O position slightly for each step. + blocks = [] + for i in range(n): + o_z = i * 0.01 + block = ( + f"3\nH2O step {i}\n" + f"O 0.0 0.0 {o_z:.4f}\n" + f"H 0.757 0.587 0.0\n" + f"H -0.757 0.587 0.0" + ) + blocks.append(block) + return blocks + + +# --------------------------------------------------------------------------- +# create_trajectory_animation (plotlyMol) +# --------------------------------------------------------------------------- + + +class TestCreateTrajectoryAnimation: + @plotlymol_only + def test_returns_figure(self): + """create_trajectory_animation returns a Plotly Figure.""" + import plotly.graph_objects as go + + blocks = _water_xyzblocks(3) + fig = create_trajectory_animation(blocks) + assert isinstance(fig, go.Figure) + + @plotlymol_only + def test_correct_frame_count(self): + """Figure has exactly one frame per input XYZ block.""" + blocks = _water_xyzblocks(4) + fig = create_trajectory_animation(blocks) + assert len(fig.frames) == 4 + + @plotlymol_only + def test_minimum_two_frames(self): + """Two frames is the minimum valid input.""" + blocks = _water_xyzblocks(2) + import plotly.graph_objects as go + + fig = create_trajectory_animation(blocks) + assert isinstance(fig, go.Figure) + assert len(fig.frames) == 2 + + @plotlymol_only + def test_single_frame_raises(self): + """Fewer than 2 frames raises ValueError.""" + blocks = _water_xyzblocks(1) + with pytest.raises(ValueError, match="at least 2 frames"): + create_trajectory_animation(blocks) + + @plotlymol_only + def test_energy_labels(self): + """Energies are reflected in frame layout titles.""" + blocks = _water_xyzblocks(3) + energies = [-75.0, -75.3, -75.6] + fig = create_trajectory_animation(blocks, energies_hartree=energies) + # At least one frame title should contain an energy value. + titles = [ + f.layout.title.text for f in fig.frames if f.layout and f.layout.title + ] + assert any("-75" in (t or "") for t in titles) + + @plotlymol_only + def test_has_slider(self): + """Figure layout contains a slider for step navigation.""" + blocks = _water_xyzblocks(3) + fig = create_trajectory_animation(blocks) + assert len(fig.layout.sliders) > 0 + assert len(fig.layout.sliders[0].steps) == 3 + + @plotlymol_only + def test_initial_data_populated(self): + """Initial figure data (frame 0) is not empty.""" + blocks = _water_xyzblocks(2) + fig = create_trajectory_animation(blocks) + assert len(fig.data) > 0 + + +# --------------------------------------------------------------------------- +# QuantUIApp._build_vib_data_from_freq_result +# --------------------------------------------------------------------------- + + +@dataclass +class _MockFreqResult: + """Minimal mock of FreqResult sufficient for _build_vib_data_from_freq_result.""" + + frequencies_cm1: List[float] = field(default_factory=list) + ir_intensities: List[float] = field(default_factory=list) + displacements: Optional[List] = None + + +def _make_mock_freq_result(n_atoms: int = 3, include_displacements: bool = True): + """Return a mock FreqResult for water (3N = 9 modes including trans/rot).""" + import numpy as np + + n_modes = 3 * n_atoms # 9 for water + freqs = list(range(-2, n_modes - 2)) # includes a couple of near-zero and negative + freqs = [float(f) * 100 for f in range(n_modes)] # 0, 100, 200, ..., 800 cm-1 + ir_intensities = [float(i) * 10 for i in range(n_modes)] + + if include_displacements: + displ = np.random.rand(n_modes, n_atoms, 3).tolist() + else: + displ = None + + return _MockFreqResult( + frequencies_cm1=freqs, + ir_intensities=ir_intensities, + displacements=displ, + ) + + +class TestBuildVibData: + @plotlymol_only + def test_returns_vib_data(self): + """Returns a VibrationalData object when prerequisites are met.""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data is not None + + @plotlymol_only + def test_coordinate_shape(self): + """VibrationalData.coordinates has shape (n_atoms, 3).""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data.coordinates.shape == (3, 3) + + @plotlymol_only + def test_mode_count(self): + """VibrationalData has the same number of modes as input frequencies.""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + n_modes = 3 * 3 # 9 for water (3N) + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert len(vib_data.modes) == n_modes + + @plotlymol_only + def test_displacement_shape_per_mode(self): + """Each VibrationalMode has displacement_vectors of shape (n_atoms, 3).""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + for mode in vib_data.modes: + assert mode.displacement_vectors.shape == (3, 3) + + @plotlymol_only + def test_atomic_numbers_populated(self): + """Atomic numbers are assigned for O, H, H of water.""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data.atomic_numbers == [8, 1, 1] # O, H, H + + @plotlymol_only + def test_imaginary_modes_flagged(self): + """Modes with negative frequency are flagged as imaginary.""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _MockFreqResult( + frequencies_cm1=[-500.0, 100.0, 200.0], + ir_intensities=[0.0, 1.0, 2.0], + displacements=[ + [[0.1, 0.0, 0.0]] * 3, + [[0.0, 0.1, 0.0]] * 3, + [[0.0, 0.0, 0.1]] * 3, + ], + ) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data is not None + assert vib_data.modes[0].is_imaginary is True + assert vib_data.modes[1].is_imaginary is False + + @plotlymol_only + def test_none_when_no_displacements(self): + """Returns None when FreqResult.displacements is None.""" + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3, include_displacements=False) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data is None + + def test_none_when_plotlymol_missing(self, monkeypatch): + """Returns None gracefully when plotlyMol is not installed.""" + import builtins + + original_import = builtins.__import__ + + def _block_plotlymol(name, *args, **kwargs): + if "plotlymol3d" in name: + raise ImportError("plotlyMol blocked for test") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _block_plotlymol) + + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + result = _make_mock_freq_result(n_atoms=3) + vib_data = app._build_vib_data_from_freq_result(result, mol) + assert vib_data is None + + +# --------------------------------------------------------------------------- +# FreqResult.displacements field +# --------------------------------------------------------------------------- + + +class TestFreqResultDisplacements: + def test_displacements_field_exists(self): + """FreqResult has a displacements field defaulting to None.""" + from quantui.freq_calc import FreqResult + + r = FreqResult( + energy_hartree=-75.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=5, + method="RHF", + basis="STO-3G", + formula="H2O", + ) + assert r.displacements is None + + def test_displacements_can_be_set(self): + """FreqResult.displacements accepts a nested list value.""" + from quantui.freq_calc import FreqResult + + mock_displ = [[[0.1, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 0.1]]] + r = FreqResult( + energy_hartree=-75.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=5, + method="RHF", + basis="STO-3G", + formula="H2O", + displacements=mock_displ, + ) + assert r.displacements is mock_displ From 5c2ed36d17177f49fca3c87f1bf09bea10f78d55 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Sat, 18 Apr 2026 15:33:02 -0400 Subject: [PATCH 02/34] Add plotlymol support and UI widget docs Support PlotlyMol's file-based API and add related UI documentation and deps. - quantui/visualization_py3dmol.py: call draw_3D_rep with an on-disk .xyz file (create tmp file, write full XYZ block, pass xyzfile, and unlink after use) and set paper/scene background colors. Import os and tempfile. This addresses PlotlyMol's API that expects a file path rather than an in-memory string. - pyproject.toml: add runtime dependency "plotlymol>=0.2.1" and expose modules matching "plotlymol3d.*" in the module list. - .github/copilot-instructions.md: update references to use your project STATUS.md/feature-requests.md and document new UI widgets/fields (result_viz_output, traj_accordion, vib_accordion, vib_mode_dd, _last_vib_data, _last_vib_molecule). These changes fix the PlotlyMol integration and update developer-facing docs to reflect new visualization/vibration UI elements. --- .github/copilot-instructions.md | 14 ++++++++++---- pyproject.toml | 2 ++ quantui/visualization_py3dmol.py | 28 +++++++++++++++++++--------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index af6f544..cbc8383 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ > Stable project context for GitHub Copilot, Claude, and other AI coding assistants. > Describes what the project IS and how it is built — not where development currently -> stands (see `planning/TODO/STATUS.md` for that). Update this file when +> stands (see your project STATUS.md for current session state). Update this file when > architecture or conventions change, not every session. --- @@ -122,8 +122,8 @@ notebooks/molecule_computations.ipynb | `quantui/freq_calc.py` | `run_freq_calc()` — vibrational analysis via `pyscf.hessian` | | `quantui/tddft_calc.py` | `run_tddft_calc()` — excited states via `pyscf.tddft` | | `notebooks/molecule_computations.ipynb` | Thin launcher — 3 cells only (do not add logic here) | -| `planning/TODO/STATUS.md` | **Read this first every session** — current state, git log, open tasks | -| `planning/feature-requests.md` | FR backlog | +| your project `STATUS.md` | **Read this first every session** — current state, git log, open tasks | +| your project `feature-requests.md` | FR backlog | --- @@ -207,6 +207,12 @@ __init__() | `self.method_dd` | `widgets.Dropdown` | Selected QC method | | `self.basis_dd` | `widgets.Dropdown` | Selected basis set | | `self.calc_type_dd` | `widgets.Dropdown` | Single Point / Geo Opt / Frequency / UV-Vis | +| `self.result_viz_output` | `widgets.Output` | 3D molecule rendered after every calc (M2.1) | +| `self.traj_accordion` | `widgets.Accordion` | Geo Opt trajectory viewer; hidden unless Geo Opt ran (M2.2) | +| `self.vib_accordion` | `widgets.Accordion` | Vibrational mode viewer; hidden unless Frequency ran (M2.3) | +| `self.vib_mode_dd` | `widgets.Dropdown` | Mode selector inside `vib_accordion` | +| `self._last_vib_data` | `Optional[VibrationalData]` | plotlyMol data object for current freq result | +| `self._last_vib_molecule` | `Optional[Molecule]` | Molecule paired with `_last_vib_data` | ### Molecule collapse/expand pattern @@ -395,4 +401,4 @@ Never make independent architectural changes in this repo — propose them in `Q ## Active Development Branch Branch: `app-restructure` — FR-012 App Module Refactor in progress. -See `planning/TODO/STATUS.md` for current phase and uncommitted changes. +See your project STATUS.md for current phase and uncommitted changes. diff --git a/pyproject.toml b/pyproject.toml index 1253c2d..4090d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "py3Dmol>=2.0.0", "matplotlib>=3.7.0", "plotly>=5.0.0", + "plotlymol>=0.2.1", ] [tool.setuptools] @@ -125,6 +126,7 @@ module = [ "psutil", "ase.*", "rdkit.*", + "plotlymol3d.*", ] ignore_missing_imports = true diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index fa8f2e5..4d1c6ab 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -10,6 +10,8 @@ """ import logging +import os +import tempfile from typing import Literal, cast logger = logging.getLogger(__name__) @@ -203,22 +205,30 @@ def visualize_molecule_plotlymol( f"(mode={mode}, resolution={resolution})" ) - # Create visualization using PlotlyMol - fig = draw_3D_rep( - xyzblock=xyz_string, - charge=charge, - mode=mode, - resolution=resolution, - bgcolor=bgcolor, + # draw_3D_rep takes a file path, not an in-memory string + full_xyz = f"{len(molecule.atoms)}\n\n{xyz_string}\n" + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".xyz", delete=False, encoding="utf-8" ) + try: + tmp.write(full_xyz) + tmp.close() + fig = draw_3D_rep( + xyzfile=tmp.name, + charge=charge, + mode=mode, + resolution=resolution, + ) + finally: + os.unlink(tmp.name) - # Set figure size and title fig.update_layout( width=width, height=height, title=f"{molecule.get_formula()} - {mode.replace('+', ' & ').title()}", + paper_bgcolor=bgcolor, + scene=dict(bgcolor=bgcolor), ) - return fig From 1aea9230ea0adaaa162507e5400fa58239f9634e Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 14:28:45 -0400 Subject: [PATCH 03/34] Add Mulliken charges & dipole display Collect Mulliken charges and dipole moment from PySCF runs and surface them in results and UI. Updated SessionResult dataclass to include atom_symbols, mulliken_charges and dipole_moment_debye, and modified run_in_session to extract these (skipping for UHF and guarded with try/except). QuantUI app rendering now appends dipole and per-atom Mulliken charges to the results HTML. Added tests for default/returned values and PySCF-backed checks. Also tweaked the notebook startup cell to ensure the local repository is importable so `import quantui` finds the local source. --- notebooks/molecule_computations.ipynb | 12 ++++- quantui/app.py | 19 +++++++- quantui/session_calc.py | 24 +++++++++- tests/test_session_calc.py | 64 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb index 90bf2e3..2d28719 100644 --- a/notebooks/molecule_computations.ipynb +++ b/notebooks/molecule_computations.ipynb @@ -34,12 +34,22 @@ "# Environment check — verifies correct conda environment.\n", "# Tagged skip-execution and remove-input so it is hidden in Voilà.\n", "import sys as _sys\n", + "from pathlib import Path as _Path\n", + "\n", + "# Ensure the repo root is importable so `import quantui` finds the local source\n", + "# even when the installed package is stale or absent.\n", + "_here = _Path().resolve()\n", + "for _p in (_here, _here.parent, _here.parent.parent):\n", + " if (_p / \"quantui\" / \"__init__.py\").exists():\n", + " if str(_p) not in _sys.path:\n", + " _sys.path.insert(0, str(_p))\n", + " break\n", "\n", "_env = _sys.prefix\n", "if \"quantui\" not in _env.lower():\n", " print(\"Warning: active environment may not be quantui-local\")\n", " print(f\"Active: {_env}\")\n", - " print(\"Run: conda activate quantui-local\")\n" + " print(\"Run: conda activate quantui-local\")" ] }, { diff --git a/quantui/app.py b/quantui/app.py index ac0c68e..f891e18 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -2168,12 +2168,29 @@ def _format_result(self, r) -> str: ("SCF iterations", str(r.n_iterations), "#000"), ] ) + _extra = "" + _dip = getattr(r, "dipole_moment_debye", None) + if _dip is not None: + _extra += ( + f'Dipole moment' + f'{_dip:.4f} D' + ) + _chg = getattr(r, "mulliken_charges", None) + _syms = getattr(r, "atom_symbols", None) + if _chg is not None and _syms is not None: + _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg)) + _extra += ( + f'' + f"Mulliken charges" + f'{_charge_str}' + ) return ( f'
' f"{r.formula} — {r.method}/{r.basis}" f'' - f"{_rows}
" + f"{_rows}{_extra}" ) def _format_opt_result(self, r) -> str: diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 58624b6..cd3977c 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -23,7 +23,7 @@ import logging import sys from dataclasses import dataclass -from typing import IO, Optional +from typing import IO, List, Optional from .molecule import Molecule @@ -64,6 +64,9 @@ class SessionResult: method: str basis: str formula: str + atom_symbols: Optional[List[str]] = None + mulliken_charges: Optional[List[float]] = None + dipole_moment_debye: Optional[float] = None @property def energy_ev(self) -> float: @@ -225,6 +228,22 @@ def run_in_session( except Exception: pass # gap stays None — non-fatal + mulliken_charges: Optional[List[float]] = None + dipole_moment_debye: Optional[float] = None + if method_upper != "UHF": + try: + _, chg = mf.mulliken_pop(verbose=0) + mulliken_charges = [float(c) for c in chg] + except Exception: + pass + try: + import numpy as _np2 + + dip = mf.dip_moment(verbose=0) + dipole_moment_debye = float(_np2.linalg.norm(dip)) + except Exception: + pass + formula = molecule.get_formula() logger.info( "Session calculation: %s %s/%s E=%.8f Ha converged=%s iters=%d", @@ -244,4 +263,7 @@ def run_in_session( method=method, basis=basis, formula=formula, + atom_symbols=list(molecule.atoms), + mulliken_charges=mulliken_charges, + dipole_moment_debye=dipole_moment_debye, ) diff --git a/tests/test_session_calc.py b/tests/test_session_calc.py index b678453..307cca4 100644 --- a/tests/test_session_calc.py +++ b/tests/test_session_calc.py @@ -137,6 +137,70 @@ def test_homo_lumo_gap_can_be_none(self): result = _make_result(homo_lumo_gap_ev=None) assert result.homo_lumo_gap_ev is None + def test_mulliken_charges_default_none(self): + result = _make_result() + assert result.mulliken_charges is None + + def test_dipole_moment_default_none(self): + result = _make_result() + assert result.dipole_moment_debye is None + + def test_atom_symbols_default_none(self): + result = _make_result() + assert result.atom_symbols is None + + def test_mulliken_charges_stored(self): + result = _make_result( + mulliken_charges=[-0.66, 0.33, 0.33], + atom_symbols=["O", "H", "H"], + ) + assert result.mulliken_charges == pytest.approx([-0.66, 0.33, 0.33]) + assert result.atom_symbols == ["O", "H", "H"] + + def test_dipole_moment_stored(self): + result = _make_result(dipole_moment_debye=1.85) + assert result.dipole_moment_debye == pytest.approx(1.85) + + +class TestMullikenDipolePySCF: + """PySCF-backed tests for Mulliken charges and dipole moment extraction.""" + + @pyscf_only + @pytest.mark.slow + def test_rhf_populates_mulliken_charges(self): + from quantui.session_calc import run_in_session + + result = run_in_session(_water(), method="RHF", basis="STO-3G", verbose=0) + assert result.mulliken_charges is not None + assert len(result.mulliken_charges) == 3 + + @pyscf_only + @pytest.mark.slow + def test_rhf_populates_dipole_moment(self): + from quantui.session_calc import run_in_session + + result = run_in_session(_water(), method="RHF", basis="STO-3G", verbose=0) + assert result.dipole_moment_debye is not None + assert result.dipole_moment_debye > 0 + + @pyscf_only + @pytest.mark.slow + def test_rhf_atom_symbols_match_molecule(self): + from quantui.session_calc import run_in_session + + result = run_in_session(_water(), method="RHF", basis="STO-3G", verbose=0) + assert result.atom_symbols == ["O", "H", "H"] + + @pyscf_only + @pytest.mark.slow + def test_uhf_leaves_charges_and_dipole_none(self): + from quantui.session_calc import run_in_session + + mol = Molecule(["H"], [[0.0, 0.0, 0.0]], charge=0, multiplicity=2) + result = run_in_session(mol, method="UHF", basis="STO-3G", verbose=0) + assert result.mulliken_charges is None + assert result.dipole_moment_debye is None + # ============================================================================ # Calculation tests — Linux/WSL with pyscf From 03747098c64e55b6cf224bdcdf4e419b7555afdf Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 14:44:39 -0400 Subject: [PATCH 04/34] Add thermochemistry support and viz backend toggle Introduce ThermoData and attach an optional thermo field to FreqResult; compute thermochemical H, S, G in run_freq_calc (best-effort using pyscf_thermo) and add the _HARTREE_TO_JMOL constant. Surface computed thermo info in the frequency-analysis HTML. Enhance 3D visualization handling: detect PlotlyMol/py3Dmol availability, prefer PlotlyMol for auto, add a ToggleButtons selector when both backends are present, wire the callback and pass the selected backend to display_molecule. Add tests (tests/test_freq_calc.py) covering the ThermoData dataclass, the FreqResult.thermo field, and run_freq_calc thermo population (PySCF-dependent tests marked). --- quantui/app.py | 87 ++++++++++++--- quantui/freq_calc.py | 42 ++++++++ quantui/visualization_py3dmol.py | 6 +- tests/test_freq_calc.py | 179 +++++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 tests/test_freq_calc.py diff --git a/quantui/app.py b/quantui/app.py index f891e18..cbdde9a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -19,7 +19,7 @@ import threading import time from pathlib import Path -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional import ipywidgets as widgets from IPython import get_ipython @@ -56,12 +56,26 @@ ASE_AVAILABLE = False try: - from quantui.visualization_py3dmol import display_molecule as _display_molecule + from quantui.visualization_py3dmol import ( + PLOTLYMOL_AVAILABLE as _PLOTLYMOL_VIZ, + ) + from quantui.visualization_py3dmol import ( + PY3DMOL_AVAILABLE as _PY3DMOL_VIZ, + ) + from quantui.visualization_py3dmol import ( + display_molecule as _display_molecule, + ) VISUALIZATION_AVAILABLE = True except ImportError: VISUALIZATION_AVAILABLE = False _display_molecule = None # type: ignore[assignment] + _PLOTLYMOL_VIZ = False + _PY3DMOL_VIZ = False + +_VizBackend = Literal["auto", "py3dmol", "plotlymol"] +_BOTH_VIZ_AVAILABLE: bool = _PLOTLYMOL_VIZ and _PY3DMOL_VIZ +_DEFAULT_VIZ_BACKEND: _VizBackend = "plotlymol" if _PLOTLYMOL_VIZ else "py3dmol" try: from quantui.pubchem import ( @@ -359,6 +373,19 @@ def _build_shared_widgets(self) -> None: self.result_output = widgets.Output() self.result_viz_output = widgets.Output() self.comparison_output = widgets.Output() + + # 3D viewer backend selector — shown only when both backends are installed + self._viz_backend: _VizBackend = _DEFAULT_VIZ_BACKEND + if _BOTH_VIZ_AVAILABLE: + self.viz_backend_toggle = widgets.ToggleButtons( + options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")], + value=_DEFAULT_VIZ_BACKEND, + tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"], + style={"button_width": "90px"}, + layout=widgets.Layout(margin="2px 0 0 0"), + ) + else: + self.viz_backend_toggle = None # type: ignore[assignment] self.notes_output = widgets.Output() self.perf_estimate_html = widgets.HTML() @@ -597,8 +624,15 @@ def _build_molecule_section(self) -> None: [self.mol_summary_compact, self.change_mol_btn], layout=widgets.Layout(align_items="center", gap="12px", padding="6px 0"), ) + _mol_container_children = [ + self.mol_input_expanded, + self.mol_info_html, + self.viz_output, + ] + if self.viz_backend_toggle is not None: + _mol_container_children.append(self.viz_backend_toggle) self.mol_input_container = widgets.VBox( - [self.mol_input_expanded, self.mol_info_html, self.viz_output], + _mol_container_children, layout=widgets.Layout(margin="0 0 4px 0"), ) @@ -989,6 +1023,9 @@ def _assemble_tabs(self) -> None: # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ def _wire_callbacks(self) -> None: + # 3D viewer backend toggle (only wired when both backends are available) + if self.viz_backend_toggle is not None: + self.viz_backend_toggle.observe(self._on_viz_backend_changed, names="value") # Theme self.theme_btn.observe(self._on_theme_changed, names="value") # Molecule input @@ -1044,6 +1081,13 @@ def _on_theme_changed(self, change) -> None: with self._theme_style: display(HTML(css)) + def _on_viz_backend_changed(self, change) -> None: + self._viz_backend = change["new"] # type: ignore[assignment] + if self._molecule is not None and _display_molecule is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule(self._molecule, backend=self._viz_backend) + # ── Molecule input ──────────────────────────────────────────────────── def _on_load_preset(self, change) -> None: @@ -1120,11 +1164,10 @@ def _do(): threading.Thread(target=_do, daemon=True).start() def _on_expand_mol_input(self, btn) -> None: - self.mol_input_container.children = [ - self.mol_input_expanded, - self.mol_info_html, - self.viz_output, - ] + _children = [self.mol_input_expanded, self.mol_info_html, self.viz_output] + if self.viz_backend_toggle is not None: + _children.append(self.viz_backend_toggle) + self.mol_input_container.children = _children # ── Calc type ───────────────────────────────────────────────────────── @@ -1369,7 +1412,7 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None: self.viz_output.clear_output() if _display_molecule is not None: with self.viz_output: - _display_molecule(mol) + _display_molecule(mol, backend=self._viz_backend) self._update_notes() @@ -1383,7 +1426,10 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None: self._update_estimate() # Collapse molecule input to compact view - self.mol_input_container.children = [self.mol_input_collapsed, self.viz_output] + _collapsed_children = [self.mol_input_collapsed, self.viz_output] + if self.viz_backend_toggle is not None: + _collapsed_children.append(self.viz_backend_toggle) + self.mol_input_container.children = _collapsed_children def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None: """Run a callback on the notebook/kernel thread when possible.""" @@ -1424,7 +1470,7 @@ def _show_result_3d(self, molecule) -> None: return self.result_viz_output.clear_output() with self.result_viz_output: - _display_molecule(molecule) + _display_molecule(molecule, backend=self._viz_backend) def _show_opt_trajectory(self, opt_result) -> None: """Render geo-opt trajectory animation and energy chart in the trajectory panel. @@ -2250,12 +2296,29 @@ def _format_freq_result(self, r) -> str: f'{r.zpve_hartree:.6f} Ha ' f"({r.zpve_hartree * 27.211386245988:.4f} eV)" ) + _thermo_rows = "" + _thermo = getattr(r, "thermo", None) + if _thermo is not None: + _kj = 2625.5 # kJ/mol per Hartree + _thermo_rows = ( + f'' + f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —" + f"" + f'H (298 K)' + f'{_thermo.H_hartree:.6f} Ha' + f'S (298 K)' + f'{_thermo.S_jmol:.2f} J/(mol·K)' + f'G (298 K)' + f'{_thermo.G_hartree:.6f} Ha' + f" ({_thermo.G_hartree * _kj:.2f} kJ/mol)" + ) return ( f'
' f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})" f'' - f"{_rows}
" + f"{_rows}{_thermo_rows}" ) def _format_tddft_result(self, r) -> str: diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index f38b584..d4d535b 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -39,12 +39,30 @@ # 1 cm^-1 = h·c·100 / E_h (NIST 2018 CODATA) _CM1_TO_HARTREE: float = 4.556335252912e-6 +# Exact: 1 Hartree = HARTREE_TO_EV * e * N_A joules/mol +_HARTREE_TO_JMOL: float = 2625499.6 # J/mol per Hartree (NIST 2018 CODATA) + # ============================================================================ # Result dataclass # ============================================================================ +@dataclass +class ThermoData: + """Thermochemical data from the harmonic approximation at 298.15 K / 1 atm. + + All energies are in Hartrees; entropy is in J/(mol·K). + H and G include the SCF electronic energy. + """ + + zpve_hartree: float + H_hartree: float + S_jmol: float + G_hartree: float + temperature_k: float = 298.15 + + @dataclass class FreqResult: """Structured output from a vibrational frequency analysis. @@ -75,6 +93,7 @@ class FreqResult: frequencies_cm1: List[float] = field(default_factory=list) ir_intensities: List[float] = field(default_factory=list) zpve_hartree: float = 0.0 + thermo: Optional[ThermoData] = None displacements: Optional[List] = None """Normalized displacement vectors from PySCF harmonic analysis. @@ -202,6 +221,7 @@ def run_freq_calc( ir_intensities: List[float] = [] zpve_hartree: float = 0.0 displacements: Optional[List] = None + thermo_data: Optional[ThermoData] = None try: hess_obj = mf.Hessian() @@ -249,6 +269,27 @@ def run_freq_calc( except Exception: ir_intensities = [] + # Thermochemistry at 298.15 K / 1 atm — best-effort + try: + import numpy as _np + + _freq_au = freq_info.get("freq_au") + if _freq_au is None: + _freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE + _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15, 101325) + _H = float(_tout["H"]) + _S = float(_tout["S"]) # J/(mol·K) + _zpve = float(_tout.get("ZPE", zpve_hartree)) + _G = _H - 298.15 * _S / _HARTREE_TO_JMOL + thermo_data = ThermoData( + zpve_hartree=_zpve, + H_hartree=_H, + S_jmol=_S, + G_hartree=_G, + ) + except Exception: + pass + except Exception as exc: logger.warning("Hessian/frequency computation failed: %s", exc) if progress_stream is not None: @@ -268,5 +309,6 @@ def run_freq_calc( frequencies_cm1=frequencies_cm1, ir_intensities=ir_intensities, zpve_hartree=zpve_hartree, + thermo=thermo_data, displacements=displacements, ) diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index 4d1c6ab..9b925aa 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -276,10 +276,10 @@ def visualize_molecule( """ # Determine backend if backend == "auto": - if PY3DMOL_AVAILABLE: - backend = "py3dmol" - elif PLOTLYMOL_AVAILABLE: + if PLOTLYMOL_AVAILABLE: backend = "plotlymol" + elif PY3DMOL_AVAILABLE: + backend = "py3dmol" else: raise ImportError( "No visualization backend available. Install one of:\n" diff --git a/tests/test_freq_calc.py b/tests/test_freq_calc.py new file mode 100644 index 0000000..e2139c9 --- /dev/null +++ b/tests/test_freq_calc.py @@ -0,0 +1,179 @@ +""" +Tests for quantui.freq_calc — ThermoData dataclass and FreqResult thermo field. + +Test strategy +------------- +* ThermoData and FreqResult dataclass tests run unconditionally — no PySCF needed. +* run_freq_calc() tests are marked pyscf_only and skipped on Windows. +""" + +from __future__ import annotations + +import pytest + +from quantui.freq_calc import FreqResult, ThermoData + +# --------------------------------------------------------------------------- +# PySCF availability +# --------------------------------------------------------------------------- + +_PYSCF_AVAILABLE = False +try: + import pyscf as _pyscf # noqa: F401 + + _PYSCF_AVAILABLE = True +except ImportError: + pass + +pyscf_only = pytest.mark.skipif( + not _PYSCF_AVAILABLE, + reason="PySCF not installed (Linux/macOS/WSL only)", +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_HARTREE_TO_JMOL = 2625499.6 + + +def _make_thermo(**overrides) -> ThermoData: + defaults = dict( + zpve_hartree=0.020734, + H_hartree=-76.003456, + S_jmol=198.7, + G_hartree=-76.032952, + ) + defaults.update(overrides) + return ThermoData(**defaults) + + +def _make_freq_result(**overrides) -> FreqResult: + defaults = dict( + energy_hartree=-76.023190, + homo_lumo_gap_ev=9.5, + converged=True, + n_iterations=10, + method="RHF", + basis="STO-3G", + formula="H2O", + frequencies_cm1=[1600.0, 3600.0, 3800.0], + zpve_hartree=0.020734, + ) + defaults.update(overrides) + return FreqResult(**defaults) + + +def _water(): + from quantui.molecule import Molecule + + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + ) + + +# ============================================================================ +# ThermoData dataclass +# ============================================================================ + + +class TestThermoData: + def test_fields_stored(self): + td = _make_thermo() + assert td.zpve_hartree == pytest.approx(0.020734) + assert td.H_hartree == pytest.approx(-76.003456) + assert td.S_jmol == pytest.approx(198.7) + assert td.G_hartree == pytest.approx(-76.032952) + + def test_default_temperature(self): + td = _make_thermo() + assert td.temperature_k == pytest.approx(298.15) + + def test_g_less_than_h(self): + """G = H - T*S, so G < H for positive entropy.""" + td = _make_thermo() + assert td.G_hartree < td.H_hartree + + def test_g_consistent_with_h_and_s(self): + """Verify G ≈ H - T*S within floating-point tolerance.""" + td = _make_thermo() + expected_g = td.H_hartree - td.temperature_k * td.S_jmol / _HARTREE_TO_JMOL + assert td.G_hartree == pytest.approx(expected_g, abs=0.01) + + +# ============================================================================ +# FreqResult.thermo field +# ============================================================================ + + +class TestFreqResultThermoField: + def test_thermo_defaults_to_none(self): + result = _make_freq_result() + assert result.thermo is None + + def test_thermo_stored_when_provided(self): + td = _make_thermo() + result = _make_freq_result(thermo=td) + assert result.thermo is td + + def test_thermo_h_accessible(self): + td = _make_thermo(H_hartree=-76.003456) + result = _make_freq_result(thermo=td) + assert result.thermo.H_hartree == pytest.approx(-76.003456) # type: ignore[union-attr] + + def test_thermo_s_accessible(self): + td = _make_thermo(S_jmol=198.7) + result = _make_freq_result(thermo=td) + assert result.thermo.S_jmol == pytest.approx(198.7) # type: ignore[union-attr] + + def test_thermo_g_accessible(self): + td = _make_thermo(G_hartree=-76.032952) + result = _make_freq_result(thermo=td) + assert result.thermo.G_hartree == pytest.approx(-76.032952) # type: ignore[union-attr] + + +# ============================================================================ +# run_freq_calc() — PySCF required +# ============================================================================ + + +class TestRunFreqCalcThermo: + @pyscf_only + @pytest.mark.slow + def test_thermo_populated_for_rhf(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + assert result.thermo is not None + + @pyscf_only + @pytest.mark.slow + def test_thermo_h_is_finite(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + if result.thermo is not None: + assert abs(result.thermo.H_hartree) < 1e6 + + @pyscf_only + @pytest.mark.slow + def test_thermo_s_positive(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + if result.thermo is not None: + assert result.thermo.S_jmol > 0 + + @pyscf_only + @pytest.mark.slow + def test_thermo_g_less_than_h(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + if result.thermo is not None: + assert result.thermo.G_hartree < result.thermo.H_hartree + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) From 08b4cdde71ae4f1231d111564a2581fd661394ea Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 14:53:38 -0400 Subject: [PATCH 05/34] Add result directory label and log accordion Add UI elements to display the saved result directory and a collapsible full output log (pyscf.log). Track the last saved result dir (_last_result_dir), clear/hide the log and dir label when a run starts, and populate/show them after saving results via a new _show_result_log method (prefers on-disk pyscf.log but falls back to in-memory log string). Update tests to cover existence, initial hidden/collapsed state, clearing on run, and population/fallback behavior. --- quantui/app.py | 56 +++++++++++++++++++++++++++++++++++- tests/test_app.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/quantui/app.py b/quantui/app.py index cbdde9a..17b5397 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -373,6 +373,7 @@ def _build_shared_widgets(self) -> None: self.result_output = widgets.Output() self.result_viz_output = widgets.Output() self.comparison_output = widgets.Output() + self._last_result_dir: Optional[Path] = None # 3D viewer backend selector — shown only when both backends are installed self._viz_backend: _VizBackend = _DEFAULT_VIZ_BACKEND @@ -735,6 +736,21 @@ def _build_results_section(self) -> None: self.vib_accordion.set_title(0, "Vibrational Mode Viewer") self.vib_accordion.selected_index = None # collapsed by default + # Result directory path label (hidden until a calculation saves) + self._result_dir_label = widgets.HTML( + value="", + layout=widgets.Layout(display="none", margin="4px 0 0 0"), + ) + + # Full output log accordion (hidden until a calculation saves) + self._result_log_output = widgets.Output() + self._result_log_accordion = widgets.Accordion( + children=[self._result_log_output], + layout=widgets.Layout(display="none", margin="8px 0 0 0"), + ) + self._result_log_accordion.set_title(0, "Full output log (pyscf.log)") + self._result_log_accordion.selected_index = None # collapsed by default + self.results_panel = widgets.VBox( [ widgets.HTML('

Results

'), @@ -742,6 +758,8 @@ def _build_results_section(self) -> None: self.result_viz_output, self.traj_accordion, self.vib_accordion, + self._result_dir_label, + self._result_log_accordion, ] ) @@ -1208,6 +1226,11 @@ def _on_run_clicked(self, btn) -> None: self.result_viz_output.clear_output() self.traj_accordion.layout.display = "none" self.vib_accordion.layout.display = "none" + self._result_dir_label.value = "" + self._result_dir_label.layout.display = "none" + self._result_log_accordion.layout.display = "none" + self._result_log_accordion.selected_index = None + self._result_log_output.clear_output() threading.Thread(target=self._do_run, daemon=True).start() def _on_clear_log(self, btn) -> None: @@ -1472,6 +1495,35 @@ def _show_result_3d(self, molecule) -> None: with self.result_viz_output: _display_molecule(molecule, backend=self._viz_backend) + def _show_result_log(self, saved_dir: Path, log_text: str) -> None: + """Populate the result-directory label and output-log accordion. + + Safe to call from a background thread. + """ + # Path label + self._result_dir_label.value = ( + f'' + f"Saved to: {saved_dir}" + ) + self._result_dir_label.layout.display = "" + + # Log accordion — prefer on-disk file (written by save_result) over in-memory string + _log_path = saved_dir / "pyscf.log" + try: + log_content = _log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + log_content = log_text + + with self._result_log_output: + display( + HTML( + f'
'
+                    f"{log_content}
" + ) + ) + self._result_log_accordion.layout.display = "" + def _show_opt_trajectory(self, opt_result) -> None: """Render geo-opt trajectory animation and energy chart in the trajectory panel. @@ -1837,18 +1889,20 @@ def _do_run(self) -> None: try: from quantui import save_result - save_result( + _saved_dir = save_result( result, pyscf_log=log.getvalue(), calc_type=save_type, spectra=save_spectra, ) + self._last_result_dir = _saved_dir self._refresh_results_browser() self._populate_compare_list() self._update_log_panel( log.getvalue(), f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}", ) + self._show_result_log(_saved_dir, log.getvalue()) except Exception: pass diff --git a/tests/test_app.py b/tests/test_app.py index 7fd7fc2..7e96a8c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -340,3 +340,76 @@ def test_preopt_flag_mirrors_module_level(self): app = QuantUIApp() assert app._preopt_available == _PREOPT_AVAILABLE + + +# --------------------------------------------------------------------------- +# M3.3 — result log accordion and directory label +# --------------------------------------------------------------------------- + + +class TestResultLogAccordion: + """_result_log_accordion and _result_dir_label exist and start hidden.""" + + def test_log_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_result_log_accordion") + assert isinstance(app._result_log_accordion, widgets.Accordion) + + def test_log_accordion_initially_hidden(self): + app = QuantUIApp() + assert app._result_log_accordion.layout.display == "none" + + def test_log_accordion_initially_collapsed(self): + app = QuantUIApp() + assert app._result_log_accordion.selected_index is None + + def test_result_dir_label_exists(self): + app = QuantUIApp() + assert hasattr(app, "_result_dir_label") + assert isinstance(app._result_dir_label, widgets.HTML) + + def test_result_dir_label_initially_hidden(self): + app = QuantUIApp() + assert app._result_dir_label.layout.display == "none" + + def test_last_result_dir_initially_none(self): + app = QuantUIApp() + assert app._last_result_dir is None + + def test_on_run_clicked_clears_log(self): + """_on_run_clicked must hide log accordion and clear dir label.""" + app = QuantUIApp() + # Simulate a previous result being present + app._result_log_accordion.layout.display = "" + app._result_dir_label.layout.display = "" + app._result_dir_label.value = "Saved to: /some/path" + + with patch.object(app, "_do_run"): + app._on_run_clicked(None) + + assert app._result_log_accordion.layout.display == "none" + assert app._result_dir_label.layout.display == "none" + assert app._result_dir_label.value == "" + + def test_show_result_log_populates_widgets(self, tmp_path): + """_show_result_log() sets dir label and reveals accordion.""" + log_text = "SCF converged in 10 cycles." + log_file = tmp_path / "pyscf.log" + log_file.write_text(log_text, encoding="utf-8") + + app = QuantUIApp() + app._show_result_log(tmp_path, log_text) + + assert str(tmp_path) in app._result_dir_label.value + assert app._result_dir_label.layout.display == "" + assert app._result_log_accordion.layout.display == "" + + def test_show_result_log_falls_back_to_string(self, tmp_path): + """_show_result_log() uses in-memory log_text if pyscf.log absent.""" + log_text = "fallback log content" + app = QuantUIApp() + empty_dir = tmp_path / "no_log_here" + empty_dir.mkdir() + app._show_result_log(empty_dir, log_text) + + assert app._result_log_accordion.layout.display == "" From f2532ec4db6a395142a648f56235f89f1ffcff13 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 15:39:10 -0400 Subject: [PATCH 06/34] Add molecule export buttons and handlers Add UI and logic for exporting molecular structures (XYZ, MOL, PDB). Introduces export_xyz_btn, export_mol_btn, export_pdb_btn and struct_export_status, updates the advanced accordion title to "Export", and shows RDKit-required hints when RDKit is not available (gated by a new _RDKIT_AVAILABLE flag derived from PUBCHEM_AVAILABLE). Implement handlers _on_export_xyz/_on_export_mol/_on_export_pdb, helper _export_molecule_and_label to pick the appropriate molecule/method/basis, and _molecule_to_rdkit to convert Molecule -> RDKit Mol for MOL/PDB export. Wire up button callbacks and enable/disable buttons when a molecule is loaded. Add tests covering button existence, initial state, XYZ file output, and RDKit conversion behavior. --- quantui/app.py | 170 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_app.py | 106 +++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) diff --git a/quantui/app.py b/quantui/app.py index 17b5397..47b0d6e 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -104,6 +104,8 @@ except (ImportError, AttributeError): _PREOPT_AVAILABLE = False +_RDKIT_AVAILABLE: bool = bool(PUBCHEM_AVAILABLE) + # ── Module-level constants ──────────────────────────────────────────────────── _THEME_HUE: dict = {"Dark": 180} @@ -524,6 +526,32 @@ def _build_shared_widgets(self) -> None: layout=widgets.Layout(width="160px"), ) self.export_status = widgets.Label() + _rdkit_tip = ( + "" + if _RDKIT_AVAILABLE + else "Requires RDKit (conda install -c conda-forge rdkit)" + ) + self.export_xyz_btn = widgets.Button( + description="Export XYZ", + icon="download", + disabled=True, + layout=widgets.Layout(width="130px"), + ) + self.export_mol_btn = widgets.Button( + description="Export MOL", + icon="download", + disabled=True, + tooltip=_rdkit_tip, + layout=widgets.Layout(width="130px"), + ) + self.export_pdb_btn = widgets.Button( + description="Export PDB", + icon="download", + disabled=True, + tooltip=_rdkit_tip, + layout=widgets.Layout(width="130px"), + ) + self.struct_export_status = widgets.Label() # ── Molecule section (Cell 4) ───────────────────────────────────────── @@ -933,6 +961,12 @@ def _build_compare_section(self) -> None: ) # Export accordion (Advanced) + _rdkit_note = ( + "" + if _RDKIT_AVAILABLE + else '

MOL/PDB export requires RDKit ' + "(conda install -c conda-forge rdkit).

" + ) _export_content = widgets.VBox( [ widgets.HTML( @@ -940,10 +974,21 @@ def _build_compare_section(self) -> None: "Download a self-contained PySCF script you can study or run outside the notebook.

" ), widgets.HBox([self.export_btn, self.export_status]), + widgets.HTML('
'), + widgets.HTML( + '

' + "Download the molecular structure in a standard chemistry file format.

" + + _rdkit_note + ), + widgets.HBox( + [self.export_xyz_btn, self.export_mol_btn, self.export_pdb_btn], + layout=widgets.Layout(flex_flow="row wrap", gap="6px"), + ), + self.struct_export_status, ] ) self.advanced_accordion = widgets.Accordion(children=[_export_content]) - self.advanced_accordion.set_title(0, "Export Script") + self.advanced_accordion.set_title(0, "Export") self.advanced_accordion.selected_index = None # Populate on startup @@ -1068,6 +1113,9 @@ def _wire_callbacks(self) -> None: self.accumulate_btn.on_click(self._on_accumulate) self.clear_btn.on_click(self._on_clear) self.export_btn.on_click(self._on_export) + self.export_xyz_btn.on_click(self._on_export_xyz) + self.export_mol_btn.on_click(self._on_export_mol) + self.export_pdb_btn.on_click(self._on_export_pdb) # History self.past_dd.observe(self._on_past_dd_changed, names="value") self.past_refresh_btn.on_click(self._on_past_refresh) @@ -1270,6 +1318,123 @@ def _on_export(self, btn) -> None: except Exception as exc: self.export_status.value = f"Error: {exc}" + def _on_export_xyz(self, btn) -> None: + if self._molecule is None: + self.struct_export_status.value = "Load a molecule first." + return + try: + mol, method, basis = self._export_molecule_and_label() + fname = f"{mol.get_formula()}_{method}_{basis}.xyz" + xyz_body = mol.to_xyz_string() + full_xyz = ( + f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n" + ) + dest = ( + (self._last_result_dir / fname) + if self._last_result_dir + else Path(fname) + ) + dest.write_text(full_xyz, encoding="utf-8") + self.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + self.struct_export_status.value = f"Error: {exc}" + + def _on_export_mol(self, btn) -> None: + if self._molecule is None: + self.struct_export_status.value = "Load a molecule first." + return + try: + from rdkit import Chem + + mol, method, basis = self._export_molecule_and_label() + fname = f"{mol.get_formula()}_{method}_{basis}.mol" + rdmol = self._molecule_to_rdkit(mol) + if rdmol is None: + self.struct_export_status.value = "RDKit could not parse the structure." + return + mol_block = Chem.MolToMolBlock(rdmol) + dest = ( + (self._last_result_dir / fname) + if self._last_result_dir + else Path(fname) + ) + dest.write_text(mol_block, encoding="utf-8") + self.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + self.struct_export_status.value = f"Error: {exc}" + + def _on_export_pdb(self, btn) -> None: + if self._molecule is None: + self.struct_export_status.value = "Load a molecule first." + return + try: + from rdkit import Chem + + mol, method, basis = self._export_molecule_and_label() + fname = f"{mol.get_formula()}_{method}_{basis}.pdb" + rdmol = self._molecule_to_rdkit(mol) + if rdmol is None: + self.struct_export_status.value = "RDKit could not parse the structure." + return + pdb_block = Chem.MolToPDBBlock(rdmol) + dest = ( + (self._last_result_dir / fname) + if self._last_result_dir + else Path(fname) + ) + dest.write_text(pdb_block, encoding="utf-8") + self.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + self.struct_export_status.value = f"Error: {exc}" + + def _export_molecule_and_label(self): + """Return (molecule, method, basis) for structure export. + + For geo opt results, returns the final optimised geometry. + Falls back to the currently loaded molecule for all other calc types. + """ + from quantui.optimizer import OptimizationResult + + r = self._last_result + if isinstance(r, OptimizationResult): + mol = r.molecule + else: + assert self._molecule is not None + mol = self._molecule + method = ( + getattr(r, "method", self.method_dd.value) + if r is not None + else self.method_dd.value + ) + basis = ( + getattr(r, "basis", self.basis_dd.value) + if r is not None + else self.basis_dd.value + ) + return mol, method, basis + + @staticmethod + def _molecule_to_rdkit(mol): + """Convert a Molecule to an RDKit Mol with inferred bonds (best-effort).""" + try: + from rdkit import Chem + + xyz_block = ( + f"{len(mol.atoms)}\n{mol.get_formula()}\n{mol.to_xyz_string()}\n" + ) + rdmol = Chem.MolFromXYZBlock(xyz_block) + if rdmol is None: + return None + try: + from rdkit.Chem import rdDetermineBonds + + rdDetermineBonds.DetermineBonds(rdmol, charge=mol.charge) + except Exception: + pass + return rdmol + except Exception: + return None + # ── Compare ─────────────────────────────────────────────────────────── def _on_compare_refresh(self, btn) -> None: @@ -1404,6 +1569,9 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None: self._molecule = mol self.run_btn.disabled = False self.export_btn.disabled = False + self.export_xyz_btn.disabled = False + self.export_mol_btn.disabled = not _RDKIT_AVAILABLE + self.export_pdb_btn.disabled = not _RDKIT_AVAILABLE try: n_e = mol.get_electron_count() diff --git a/tests/test_app.py b/tests/test_app.py index 7e96a8c..a382076 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -413,3 +413,109 @@ def test_show_result_log_falls_back_to_string(self, tmp_path): app._show_result_log(empty_dir, log_text) assert app._result_log_accordion.layout.display == "" + + +# --------------------------------------------------------------------------- +# M3.4 — Structure file exports (XYZ, MOL/SDF, PDB) +# --------------------------------------------------------------------------- + + +class TestStructureExportButtons: + """export_xyz_btn, export_mol_btn, export_pdb_btn exist and start disabled.""" + + def test_export_xyz_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_xyz_btn") + assert isinstance(app.export_xyz_btn, widgets.Button) + + def test_export_mol_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_mol_btn") + assert isinstance(app.export_mol_btn, widgets.Button) + + def test_export_pdb_btn_exists(self): + app = QuantUIApp() + assert hasattr(app, "export_pdb_btn") + assert isinstance(app.export_pdb_btn, widgets.Button) + + def test_struct_export_status_exists(self): + app = QuantUIApp() + assert hasattr(app, "struct_export_status") + + def test_export_xyz_btn_disabled_initially(self): + app = QuantUIApp() + assert app.export_xyz_btn.disabled is True + + def test_export_xyz_btn_enabled_after_set_molecule(self): + app = QuantUIApp() + app._set_molecule(_water()) + assert app.export_xyz_btn.disabled is False + + def test_export_accordion_title_is_export(self): + app = QuantUIApp() + assert app.advanced_accordion.get_title(0) == "Export" + + +class TestExportXYZCallback: + """_on_export_xyz writes a valid XYZ file.""" + + def test_xyz_file_written_to_result_dir(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + xyz_files = list(tmp_path.glob("*.xyz")) + assert len(xyz_files) == 1 + + def test_xyz_file_contains_atom_count(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + content = list(tmp_path.glob("*.xyz"))[0].read_text() + first_line = content.splitlines()[0].strip() + assert first_line == "3" # water has 3 atoms + + def test_xyz_status_shows_saved_path(self, tmp_path): + app = QuantUIApp() + app._set_molecule(_water()) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + assert "Saved" in app.struct_export_status.value + + def test_xyz_no_molecule_shows_error(self): + app = QuantUIApp() + app._on_export_xyz(None) + assert "molecule" in app.struct_export_status.value.lower() + + +class TestExportMoleculeAndLabel: + """_export_molecule_and_label returns correct molecule and labels.""" + + def test_returns_current_molecule_when_no_result(self): + app = QuantUIApp() + water = _water() + app._set_molecule(water) + mol, method, basis = app._export_molecule_and_label() + assert mol is water + + def test_method_falls_back_to_dropdown(self): + app = QuantUIApp() + app._set_molecule(_water()) + _, method, _ = app._export_molecule_and_label() + assert method == app.method_dd.value + + +class TestMoleculeToRdkit: + """_molecule_to_rdkit does not raise; returns RDKit mol or None.""" + + def test_does_not_raise_for_water(self): + result = QuantUIApp._molecule_to_rdkit(_water()) + # Either succeeds or returns None — must not raise + assert result is None or result is not None From ec6669c3ff394f6d1de61ce3f5c338e0fcaf8c0c Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 17:06:08 -0400 Subject: [PATCH 07/34] Add NMR, MP2, PCM solvent & calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce NMR shielding calculations, MP2 support, implicit solvent (PCM), and a timing calibration suite. Key changes: - New modules: quantui/nmr_calc.py (GIAO NMR shielding → chemical shifts) and quantui/benchmarks.py (timing calibration suite + persistence). - UI updates in quantui/app.py: solvent checkbox/dropdown, calibration widgets/accordion, calibration run/stop/progress handlers, NMR result formatting, MP2 warnings, and hook-ups to new features. - Session runner (quantui/session_calc.py): MP2 post-HF correction, optional PCM solvent wrapping, XC aliasing and D3 handling, and SessionResult extended to store mp2_correlation_hartree and solvent. - Config updates (quantui/config.py): new methods (wB97X-D, CAM-B3LYP, M06-L, HSE06, PBE-D3, MP2), METHOD_INFO entries, SOLVENT_OPTIONS, and NMR reference shieldings. - calc_log cost table updated with new method costs including MP2. - README.md expanded to document new methods, calculation types, exporters, viewers, and package layout changes. - Tests updated/added to cover extended DFT list, MP2 fields, benchmarks and NMR (tests modified/added). These changes add classroom-facing features (NMR prediction, solvent models), improve time-estimation via local benchmarks, and extend method coverage (including MP2 and dispersion-corrected functionals). --- README.md | 78 +++++--- quantui/app.py | 314 ++++++++++++++++++++++++++++++++- quantui/benchmarks.py | 372 +++++++++++++++++++++++++++++++++++++++ quantui/calc_log.py | 6 + quantui/config.py | 99 ++++++++++- quantui/nmr_calc.py | 164 +++++++++++++++++ quantui/session_calc.py | 74 +++++++- tests/test_app.py | 323 +++++++++++++++++++++++++++++++++ tests/test_benchmarks.py | 252 ++++++++++++++++++++++++++ tests/test_calculator.py | 2 +- tests/test_nmr_calc.py | 215 ++++++++++++++++++++++ 11 files changed, 1871 insertions(+), 28 deletions(-) create mode 100644 quantui/benchmarks.py create mode 100644 quantui/nmr_calc.py create mode 100644 tests/test_benchmarks.py create mode 100644 tests/test_nmr_calc.py diff --git a/README.md b/README.md index 5e0e8b6..992042f 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,25 @@ Built for classroom teaching at the ## What it does -- **Molecule input** — paste XYZ coordinates, draw from a preset library, or - search PubChem by name or SMILES -- **3D visualization** — interactive py3Dmol viewer, directly in the notebook -- **In-session calculations** — RHF and UHF via PySCF, running in your Python - kernel (no batch submission) -- **Results** — total energy, HOMO-LUMO gap, convergence status, and a - side-by-side comparison table for multiple calculations +- **Molecule input** — paste XYZ coordinates, draw from a 20+ preset library, + or search PubChem by name or SMILES +- **3D visualization** — interactive py3Dmol or PlotlyMol viewer with a live + backend toggle when both are installed; post-calculation structure rendered + automatically in the results panel +- **In-session calculations** — RHF, UHF, 9 DFT functionals, and MP2 via + PySCF, running in your Python kernel (no batch submission) +- **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, + Acetonitrile) via a single checkbox +- **Rich results** — total energy, HOMO-LUMO gap, Mulliken charges, dipole + moment, thermochemistry (H, S, G at 298 K), and a side-by-side comparison + table for multiple calculations +- **Geometry optimization** — BFGS optimizer with step-by-step trajectory + animation; vibrational frequency analysis with animated normal modes - **Results persistence** — every calculation is saved automatically to a timestamped directory; a built-in browser lets students reload past results - after a kernel restart -- **Script export** — download a standalone `.py` file to run or study outside - the notebook + after a kernel restart; the full `pyscf.log` is shown inline +- **Structure exports** — download XYZ, MOL/SDF, or PDB files alongside the + saved results; script export for a standalone `.py` file - **Voilà app mode** — serve the notebook as a polished widget-only UI (no code visible) for classroom demos, with dark mode toggle and dedicated output log @@ -110,13 +117,36 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): ## Supported calculations -| Method | When to use | +### Methods + +| Method | Type | Best for | +| --- | --- | --- | +| RHF | Hartree-Fock | Closed-shell molecules; baseline reference | +| UHF | Hartree-Fock | Radicals and open-shell systems | +| B3LYP | DFT hybrid | General organic chemistry (default DFT choice) | +| PBE | DFT GGA | Large molecules; metals; when speed matters | +| PBE0 | DFT hybrid | Charge-transfer, band gaps | +| M06-2X | DFT meta-hybrid | Thermochemistry, barrier heights | +| wB97X-D | DFT range-sep. + D3 | Non-covalent interactions, excited states | +| CAM-B3LYP | DFT range-sep. | Charge-transfer UV-Vis, Rydberg states | +| M06-L | DFT local meta-GGA | Large molecules; transition metals | +| HSE06 | DFT screened hybrid | Band gaps, large molecules | +| PBE-D3 | DFT GGA + dispersion | Van der Waals complexes, stacking | +| MP2 | Post-HF | Accurate energetics for small molecules (O(N⁵)) | + +### Calculation types + +| Type | Output | | --- | --- | -| RHF | Closed-shell molecules — all electrons paired | -| UHF | Open-shell molecules — radicals or unpaired electrons | +| Single Point | Energy, HOMO-LUMO gap, Mulliken charges, dipole moment | +| Geometry Opt | Optimised structure, trajectory animation | +| Frequency | Vibrational frequencies, ZPVE, IR intensities, thermochemistry (H/S/G at 298 K), animated normal modes | +| UV-Vis (TD-DFT) | Excitation energies, oscillator strengths, UV-Vis spectrum plot | + +### Basis sets -**Basis sets:** STO-3G (fast, good for learning) → 6-31G (common research -choice) → cc-pVTZ (high accuracy) +STO-3G (fast, good for learning) → 3-21G → 6-31G / 6-31G\* / 6-31G\*\* → +cc-pVDZ / cc-pVTZ → def2-SVP / def2-TZVP --- @@ -141,18 +171,24 @@ pytest -m "not network" \ ```text quantui/ Main package + app.py QuantUIApp widget class (all tabs, UI logic) molecule.py Molecule input and validation - session_calc.py In-session PySCF runner - visualization_py3dmol.py 3D viewer + session_calc.py In-session PySCF runner (RHF/UHF/DFT/MP2/PCM) + freq_calc.py Vibrational frequency + thermochemistry analysis + tddft_calc.py TD-DFT UV-Vis excited-state calculations + optimizer.py QM geometry optimization with trajectory + visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) pubchem.py PubChem molecule search comparison.py Side-by-side result tables + results_storage.py Timestamped result persistence + calc_log.py Performance logging and time estimation + config.py Methods, basis sets, solvent options, presets ase_bridge.py ASE structure I/O - optimizer.py QM geometry optimization - ... + preopt.py LJ force-field pre-optimization notebooks/ molecule_computations.ipynb Main student-facing interface - tutorials/ Step-by-step guided notebooks -tests/ pytest test suite (439 tests) + tutorials/ Step-by-step guided notebooks (01–05) +tests/ pytest test suite (497+ tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config diff --git a/quantui/app.py b/quantui/app.py index 47b0d6e..b362a9b 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -106,6 +106,29 @@ _RDKIT_AVAILABLE: bool = bool(PUBCHEM_AVAILABLE) +from quantui.benchmarks import ( # noqa: E402 + BENCHMARK_SUITE as _BENCHMARK_SUITE, +) +from quantui.benchmarks import ( # noqa: E402 + load_last_calibration as _load_last_calibration_raw, +) + + +def _load_last_calibration_label() -> str: + """Return a human-readable timestamp of the last calibration, or ''.""" + data = _load_last_calibration_raw() + if data is None: + return "" + ts = str(data.get("timestamp", "")) + try: + from datetime import datetime + + dt = datetime.fromisoformat(ts).astimezone() + return dt.strftime("%Y-%m-%d %H:%M %Z") + except Exception: + return ts[:19] if ts else "" + + # ── Module-level constants ──────────────────────────────────────────────────── _THEME_HUE: dict = {"Dark": 180} @@ -436,9 +459,31 @@ def _build_shared_widgets(self) -> None: layout=widgets.Layout(width="400px"), ) + # Implicit solvent (PCM) + from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS + + self.solvent_cb = widgets.Checkbox( + value=False, + description="Implicit solvent (PCM)", + layout=widgets.Layout(width="240px"), + ) + self.solvent_dd = widgets.Dropdown( + options=list(_SOLVENT_OPTS.keys()), + value="Water", + description="Solvent:", + style={"description_width": "70px"}, + layout=widgets.Layout(width="200px", display="none"), + ) + # Calculation type + extra options self.calc_type_dd = widgets.Dropdown( - options=["Single Point", "Geometry Opt", "Frequency", "UV-Vis (TD-DFT)"], + options=[ + "Single Point", + "Geometry Opt", + "Frequency", + "UV-Vis (TD-DFT)", + "NMR Shielding", + ], value="Single Point", description="Calc. Type:", style={"description_width": "100px"}, @@ -696,6 +741,10 @@ def _build_calc_setup(self) -> None: self.calc_type_dd, self.calc_extra_opts, self.preopt_cb, + widgets.HBox( + [self.solvent_cb, self.solvent_dd], + layout=widgets.Layout(align_items="center", gap="4px"), + ), self.notes_output, ] ) @@ -824,6 +873,39 @@ def _build_history_section(self) -> None: tooltip="Open the full PySCF output log in the Output tab", ) + # Calibration widgets + self._cal_run_btn = widgets.Button( + description="Run Calibration", + button_style="primary", + icon="play", + disabled=not _PYSCF_AVAILABLE, + tooltip=( + "Run a short benchmark suite to calibrate time estimates" + if _PYSCF_AVAILABLE + else "PySCF required (Linux / macOS / WSL)" + ), + layout=widgets.Layout(width="180px"), + ) + self._cal_stop_btn = widgets.Button( + description="Stop", + button_style="warning", + icon="stop", + layout=widgets.Layout(width="90px", display="none"), + ) + self._cal_progress = widgets.IntProgress( + min=0, + max=len(_BENCHMARK_SUITE), + value=0, + description="", + bar_style="info", + layout=widgets.Layout(width="300px", display="none"), + ) + self._cal_step_label = widgets.HTML( + value="", + layout=widgets.Layout(display="none"), + ) + self._cal_results_html = widgets.HTML(value="") + # Performance stats widgets self._perf_stats_html = widgets.HTML() self._perf_events_html = widgets.HTML() @@ -886,6 +968,37 @@ def _build_history_section(self) -> None: ) self._perf_accordion.set_title(0, "Performance stats") + # Calibration accordion + _cal_last = _load_last_calibration_label() + _cal_note = ( + f'

' + f"Last run: {_cal_last}

" + if _cal_last + else "" + ) + _cal_panel = widgets.VBox( + [ + widgets.HTML( + f'

' + f"Run a short benchmark suite ({len(_BENCHMARK_SUITE)} calculations) " + f"to give the time estimator a real baseline for this machine.

" + + _cal_note + ), + widgets.HBox( + [self._cal_run_btn, self._cal_stop_btn], + layout=widgets.Layout(gap="6px", align_items="center"), + ), + self._cal_progress, + self._cal_step_label, + self._cal_results_html, + ], + layout=widgets.Layout(padding="4px 0"), + ) + self._cal_accordion = widgets.Accordion( + children=[_cal_panel], selected_index=None + ) + self._cal_accordion.set_title(0, "Calibrate time estimates") + self.history_panel = widgets.VBox( [ widgets.HTML( @@ -904,6 +1017,7 @@ def _build_history_section(self) -> None: self.results_path_lbl, self.past_output, self._perf_accordion, + self._cal_accordion, ] ) @@ -1112,6 +1226,9 @@ def _wire_callbacks(self) -> None: # Accumulate / export self.accumulate_btn.on_click(self._on_accumulate) self.clear_btn.on_click(self._on_clear) + self.solvent_cb.observe(self._on_solvent_cb_changed, names="value") + self._cal_run_btn.on_click(self._on_cal_run) + self._cal_stop_btn.on_click(self._on_cal_stop) self.export_btn.on_click(self._on_export) self.export_xyz_btn.on_click(self._on_export_xyz) self.export_mol_btn.on_click(self._on_export_mol) @@ -1255,6 +1372,15 @@ def _on_calc_type_changed(self, change) -> None: "instead." ), ] + elif ct == "NMR Shielding": + self.calc_extra_opts.children = [ + widgets.HTML( + '' + "⚠ Recommended: B3LYP/6-31G* or better. " + "STO-3G and 3-21G give qualitative results only. " + "Start from an optimised geometry for best accuracy." + ), + ] else: self.calc_extra_opts.children = [] @@ -1281,6 +1407,9 @@ def _on_run_clicked(self, btn) -> None: self._result_log_output.clear_output() threading.Thread(target=self._do_run, daemon=True).start() + def _on_solvent_cb_changed(self, change) -> None: + self.solvent_dd.layout.display = "" if change["new"] else "none" + def _on_clear_log(self, btn) -> None: self.run_output.clear_output() @@ -1549,6 +1678,90 @@ def _on_confirm_yes(self, btn) -> None: def _on_confirm_no(self, btn) -> None: self._reset_confirm_box.layout.display = "none" + # ── Calibration ─────────────────────────────────────────────────────── + + def _on_cal_run(self, btn) -> None: + import threading as _threading + + self._cal_stop_event = _threading.Event() + self._cal_run_btn.disabled = True + self._cal_stop_btn.layout.display = "" + self._cal_progress.value = 0 + self._cal_progress.layout.display = "" + self._cal_step_label.layout.display = "" + self._cal_step_label.value = ( + 'Starting…' + ) + self._cal_results_html.value = "" + + _threading.Thread(target=self._do_calibration, daemon=True).start() + + def _on_cal_stop(self, btn) -> None: + if hasattr(self, "_cal_stop_event"): + self._cal_stop_event.set() + + def _do_calibration(self) -> None: + from quantui.benchmarks import run_calibration + + def _progress( + step_n: int, total: int, label: str, status: str, elapsed: float + ) -> None: + _icon = {"ok": "✓", "timed_out": "⏱", "stopped": "⛔", "error": "✗"}.get( + status, "?" + ) + self._cal_progress.value = step_n + self._cal_step_label.value = ( + f'' + f"Step {step_n} / {total} — {label} " + f"[{_icon} {elapsed:.1f} s]" + ) + + result = run_calibration( + progress_cb=_progress, + stop_event=self._cal_stop_event, + timeout_per_step=120.0, + ) + + # Render results table + _rows = "".join( + f"" + f'{s.label}' + f'' + f"{s.n_electrons}" + f'' + f"{s.elapsed_s:.2f} s" + f'' + f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}' + f"" + f"" + for s in result.steps + ) + _summary = f"Completed {result.n_completed} / {result.n_total} steps." + ( + " (stopped early)" if result.stopped_early else "" + ) + self._cal_results_html.value = ( + f'
' + f'

{_summary}

' + f'' + f"" + f'' + f'' + f'' + f'' + f"" + f"{_rows}
CalculationElectronsWall timeStatus
" + ) + + self._cal_step_label.value = ( + 'Calibration complete. ' + "Time estimates are now active." + if result.n_completed > 0 + else 'No steps completed.' + ) + self._cal_stop_btn.layout.display = "none" + self._cal_run_btn.disabled = not _PYSCF_AVAILABLE + self._refresh_perf_stats() + # ── Output log ──────────────────────────────────────────────────────── def _on_log_clear(self, btn) -> None: @@ -2020,15 +2233,42 @@ def _do_run(self) -> None: } } save_type = "tddft" + elif ct == "NMR Shielding": + self.run_status.value = "Running NMR shielding (SCF + GIAO)..." + from quantui.nmr_calc import run_nmr_calc + + result = run_nmr_calc( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + result_html = self._format_nmr_result(result) + save_spectra, save_type = {}, "nmr" else: # Single Point self.run_status.value = "Calculating..." from quantui import run_in_session + # MP2 heavy-atom warning + if self.method_dd.value.upper() == "MP2": + _n_heavy = sum(1 for a in calc_mol.atoms if a != "H") + if _n_heavy > 20: + self.result_output.append_display_data( + HTML( + '
' + f"⚠️ MP2 scales as O(N⁵) — this molecule has {_n_heavy} heavy atoms " + "and may be slow. Consider using DFT instead.
" + ) + ) + + _solvent = self.solvent_dd.value if self.solvent_cb.value else None result = run_in_session( molecule=calc_mol, method=self.method_dd.value, basis=self.basis_dd.value, progress_stream=log, # type: ignore[arg-type] + solvent=_solvent, ) result_html = self._format_result(result) save_spectra, save_type = {}, "single_point" @@ -2437,6 +2677,22 @@ def _format_result(self, r) -> str: ] ) _extra = "" + # MP2: show HF reference energy separately + _mp2_corr = getattr(r, "mp2_correlation_hartree", None) + if _mp2_corr is not None: + _hf_e = r.energy_hartree - _mp2_corr + _extra += ( + f'HF reference' + f'{_hf_e:.8f} Ha' + f'MP2 correlation' + f'{_mp2_corr:.8f} Ha' + ) + _solvent = getattr(r, "solvent", None) + if _solvent is not None: + _extra += ( + f'Solvent (PCM)' + f'{_solvent}' + ) _dip = getattr(r, "dipole_moment_debye", None) if _dip is not None: _extra += ( @@ -2593,6 +2849,62 @@ def _format_tddft_result(self, r) -> str: f"{header_rows}{exc_table}" ) + def _format_nmr_result(self, r) -> str: + _conv = "Yes" if r.converged else "No (treat with caution)" + _cc = "green" if r.converged else "#c00" + header_rows = ( + f'SCF converged' + f'{_conv}' + f'Reference' + f'{r.reference_compound} ({r.method}/{r.basis})' + ) + + def _nmr_table(label: str, shifts: list, sym: str) -> str: + if not shifts: + return "" + rows = "".join( + f"" + f'{sym}-{n}' + f'{d:.2f} ppm' + f"" + for n, (_i, d) in enumerate(shifts, 1) + ) + return ( + f'' + f"{label} shifts (vs. TMS):" + f"" + f'Atom' + f'δ (ppm)' + + rows + ) + + h_table = _nmr_table("¹H", r.h_shifts(), "H") + c_table = _nmr_table("¹³C", r.c_shifts(), "C") + + _basis_warn = "" + if r.basis.upper() in ("STO-3G", "3-21G"): + _basis_warn = ( + '' + '' + f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better." + "" + ) + + _empty = "" + if not r.h_shifts() and not r.c_shifts(): + _empty = ( + '' + "No ¹H or ¹³C atoms found in this molecule." + ) + + return ( + f'
' + f"NMR Shielding — {r.formula} ({r.method}/{r.basis})" + f'' + f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}
" + ) + def _format_past_result(self, data: dict) -> str: _conv = "Yes" if data.get("converged") else "No (treat results with caution)" _cc = "green" if data.get("converged") else "#c00" diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py new file mode 100644 index 0000000..b2541b6 --- /dev/null +++ b/quantui/benchmarks.py @@ -0,0 +1,372 @@ +""" +Timing calibration benchmark suite for QuantUI-local. + +Runs a fixed set of small calculations that span the student-relevant +method/basis/molecule-size space. Each completed step is logged to +``perf_log.jsonl`` via :func:`~quantui.calc_log.log_calculation` so that +:func:`~quantui.calc_log.estimate_time` immediately becomes useful on a +fresh install. + +Typical usage (from the UI):: + + import threading + from quantui.benchmarks import run_calibration + + stop = threading.Event() + result = run_calibration( + progress_cb=lambda *a: print(a), + stop_event=stop, + timeout_per_step=120, + ) +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, List, Optional + +# --------------------------------------------------------------------------- +# Benchmark suite definition +# --------------------------------------------------------------------------- + +#: Each entry: (label, atoms, coordinates, charge, multiplicity, method, basis) +#: Molecules are kept deliberately small so the full suite finishes quickly on +#: any modern laptop. +BENCHMARK_SUITE: list[tuple] = [ + ( + "H₂ RHF/STO-3G", + ["H", "H"], + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "H₂O RHF/STO-3G", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "H₂O B3LYP/STO-3G", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "B3LYP", + "STO-3G", + ), + ( + "H₂O RHF/6-31G*", + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + 0, + 1, + "RHF", + "6-31G*", + ), + ( + "CH₄ RHF/STO-3G", + ["C", "H", "H", "H", "H"], + [ + [0.0, 0.0, 0.0], + [0.629, 0.629, 0.629], + [-0.629, -0.629, 0.629], + [-0.629, 0.629, -0.629], + [0.629, -0.629, -0.629], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₄ RHF/STO-3G", + ["C", "C", "H", "H", "H", "H"], + [ + [0.0, 0.0, 0.670], + [0.0, 0.0, -0.670], + [0.0, 0.924, 1.241], + [0.0, -0.924, 1.241], + [0.0, 0.924, -1.241], + [0.0, -0.924, -1.241], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₆O (ethanol) RHF/STO-3G", + ["C", "C", "O", "H", "H", "H", "H", "H", "H"], + [ + [-1.232, 0.026, 0.000], + [0.281, 0.026, 0.000], + [0.829, 1.310, 0.000], + [-1.566, 1.059, 0.000], + [-1.609, -0.506, 0.880], + [-1.609, -0.506, -0.880], + [0.668, -0.497, 0.890], + [0.668, -0.497, -0.890], + [1.802, 1.311, 0.000], + ], + 0, + 1, + "RHF", + "STO-3G", + ), + ( + "C₂H₆O (ethanol) B3LYP/6-31G*", + ["C", "C", "O", "H", "H", "H", "H", "H", "H"], + [ + [-1.232, 0.026, 0.000], + [0.281, 0.026, 0.000], + [0.829, 1.310, 0.000], + [-1.566, 1.059, 0.000], + [-1.609, -0.506, 0.880], + [-1.609, -0.506, -0.880], + [0.668, -0.497, 0.890], + [0.668, -0.497, -0.890], + [1.802, 1.311, 0.000], + ], + 0, + 1, + "B3LYP", + "6-31G*", + ), +] + +# --------------------------------------------------------------------------- +# Result dataclass +# --------------------------------------------------------------------------- + +_STATUS_OK = "ok" +_STATUS_TIMEOUT = "timed_out" +_STATUS_STOPPED = "stopped" +_STATUS_ERROR = "error" + + +@dataclass +class BenchmarkStep: + """Result for a single benchmark step.""" + + label: str + method: str + basis: str + n_atoms: int + n_electrons: int + status: str # "ok" | "timed_out" | "stopped" | "error" + elapsed_s: float = 0.0 + error_msg: str = "" + + +@dataclass +class CalibrationResult: + """Summary result from :func:`run_calibration`.""" + + timestamp: str + steps: List[BenchmarkStep] = field(default_factory=list) + stopped_early: bool = False + + @property + def n_completed(self) -> int: + return sum(1 for s in self.steps if s.status == _STATUS_OK) + + @property + def n_total(self) -> int: + return len(BENCHMARK_SUITE) + + +# --------------------------------------------------------------------------- +# Main calibration runner +# --------------------------------------------------------------------------- + +ProgressCallback = Callable[[int, int, str, str, float], None] +"""progress_cb(step_n, total, label, status, elapsed_s)""" + + +def _count_electrons(atoms: list[str], charge: int) -> int: + """Rough electron count: sum of atomic numbers minus charge.""" + _Z = { + "H": 1, + "He": 2, + "Li": 3, + "Be": 4, + "B": 5, + "C": 6, + "N": 7, + "O": 8, + "F": 9, + "Ne": 10, + "Na": 11, + "Mg": 12, + "Al": 13, + "Si": 14, + "P": 15, + "S": 16, + "Cl": 17, + "Ar": 18, + } + return sum(_Z.get(a, 6) for a in atoms) - charge + + +def run_calibration( + progress_cb: Optional[ProgressCallback] = None, + stop_event=None, + timeout_per_step: float = 120.0, +) -> CalibrationResult: + """Run the benchmark suite and populate ``perf_log.jsonl``. + + Args: + progress_cb: Called after each step with + ``(step_n, total, label, status, elapsed_s)``. + stop_event: A :class:`threading.Event`; checked before each step. + Set it to abort the suite cleanly. + timeout_per_step: Wall-clock seconds allowed per step. Steps that + exceed this are marked ``"timed_out"`` and skipped. + + Returns: + :class:`CalibrationResult` with per-step outcomes. + """ + import concurrent.futures + import json + + from quantui import calc_log as _calc_log + from quantui.molecule import Molecule + + _pyscf_available = False + try: + import pyscf # noqa: F401 + + _pyscf_available = True + except ImportError: + pass + + timestamp = datetime.now(timezone.utc).isoformat() + result = CalibrationResult(timestamp=timestamp) + total = len(BENCHMARK_SUITE) + + for step_n, entry in enumerate(BENCHMARK_SUITE, start=1): + label, atoms, coords, charge, mult, method, basis = entry + + # --- honour stop request --- + if stop_event is not None and stop_event.is_set(): + result.stopped_early = True + break + + step = BenchmarkStep( + label=label, + method=method, + basis=basis, + n_atoms=len(atoms), + n_electrons=_count_electrons(atoms, charge), + status=_STATUS_ERROR, + ) + + if not _pyscf_available: + step.status = _STATUS_ERROR + step.error_msg = "PySCF not available" + result.steps.append(step) + if progress_cb is not None: + progress_cb(step_n, total, label, step.status, 0.0) + continue + + def _run_step( + atoms=atoms, + coords=coords, + charge=charge, + mult=mult, + method=method, + basis=basis, + ): + from quantui.session_calc import run_in_session + + mol = Molecule(atoms, coords, charge=charge, multiplicity=mult) + t0 = time.perf_counter() + res = run_in_session(mol, method=method, basis=basis, verbose=0) + return res, time.perf_counter() - t0 + + t_start = time.perf_counter() + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_run_step) + try: + res, elapsed = future.result(timeout=timeout_per_step) + step.elapsed_s = elapsed + step.status = _STATUS_OK + # Log to perf_log.jsonl so estimate_time() can use it + _calc_log.log_calculation( + formula=res.formula, + n_atoms=step.n_atoms, + n_electrons=step.n_electrons, + method=method, + basis=basis, + n_iterations=res.n_iterations, + elapsed_s=elapsed, + converged=res.converged, + ) + except concurrent.futures.TimeoutError: + step.status = _STATUS_TIMEOUT + step.elapsed_s = time.perf_counter() - t_start + except Exception as exc: + step.status = _STATUS_ERROR + step.error_msg = str(exc) + step.elapsed_s = time.perf_counter() - t_start + + result.steps.append(step) + if progress_cb is not None: + progress_cb(step_n, total, label, step.status, step.elapsed_s) + + # --- persist calibration summary --- + _cal_path = Path.home() / ".quantui" / "calibration.json" + try: + _cal_path.parent.mkdir(parents=True, exist_ok=True) + _cal_path.write_text( + json.dumps( + { + "timestamp": result.timestamp, + "stopped_early": result.stopped_early, + "steps": [ + { + "label": s.label, + "method": s.method, + "basis": s.basis, + "n_atoms": s.n_atoms, + "n_electrons": s.n_electrons, + "status": s.status, + "elapsed_s": round(s.elapsed_s, 3), + "error_msg": s.error_msg, + } + for s in result.steps + ], + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + except OSError: + pass + + return result + + +def load_last_calibration() -> Optional[dict]: + """Return the last calibration summary dict, or ``None`` if absent.""" + import json + + path = Path.home() / ".quantui" / "calibration.json" + if not path.exists(): + return None + try: + data: dict = json.loads(path.read_text(encoding="utf-8")) + return data + except Exception: + return None diff --git a/quantui/calc_log.py b/quantui/calc_log.py index e694bd6..a52e51f 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -38,6 +38,12 @@ "PBE": 2.0, "PBE0": 2.5, "M06-2X": 3.0, + "wB97X-D": 3.0, + "CAM-B3LYP": 2.5, + "M06-L": 2.0, + "HSE06": 2.5, + "PBE-D3": 2.1, + "MP2": 8.0, } diff --git a/quantui/config.py b/quantui/config.py index d177b49..31f7ccd 100644 --- a/quantui/config.py +++ b/quantui/config.py @@ -13,7 +13,20 @@ PROJECT_ROOT = Path(__file__).parent.parent # Supported quantum chemistry methods -SUPPORTED_METHODS = ["RHF", "UHF", "B3LYP", "PBE", "PBE0", "M06-2X"] +SUPPORTED_METHODS = [ + "RHF", + "UHF", + "B3LYP", + "PBE", + "PBE0", + "M06-2X", + "wB97X-D", + "CAM-B3LYP", + "M06-L", + "HSE06", + "PBE-D3", + "MP2", +] # Educational metadata for each method — shown to students in the UI METHOD_INFO = { @@ -74,6 +87,66 @@ ), "use_for": "Organic reaction energies, conformational analysis, barrier heights.", }, + "wB97X-D": { + "type": "dft", + "label": "wB97X-D — Range-Separated Hybrid + D3 Dispersion", + "description": ( + "Range-separated hybrid functional with empirical D3 dispersion correction. " + "Excellent for non-covalent interactions, charge-transfer excitations, " + "and systems where long-range exchange matters." + ), + "use_for": "Non-covalent interactions, excited states, large organic molecules.", + }, + "CAM-B3LYP": { + "type": "dft", + "label": "CAM-B3LYP — Coulomb-Attenuating B3LYP", + "description": ( + "Range-separated version of B3LYP. More reliable than B3LYP for " + "charge-transfer excited states and Rydberg transitions. " + "Good general-purpose alternative to B3LYP." + ), + "use_for": "Charge-transfer states, UV-Vis spectra, long-range interactions.", + }, + "M06-L": { + "type": "dft", + "label": "M06-L — Local Meta-GGA DFT", + "description": ( + "Local (no HF exchange) Minnesota meta-GGA. Faster than hybrid " + "functionals for the same system size. Good for transition metals " + "and main-group thermochemistry." + ), + "use_for": "Larger molecules where hybrid cost is prohibitive; transition metals.", + }, + "HSE06": { + "type": "dft", + "label": "HSE06 — Screened Hybrid DFT", + "description": ( + "Heyd-Scuseria-Ernzerhof screened hybrid. Uses short-range HF exchange " + "only, making it efficient for large systems. Often used for solids; " + "also accurate for molecular band gaps." + ), + "use_for": "Band gaps, large molecules, when PBE0 is too expensive.", + }, + "PBE-D3": { + "type": "dft", + "label": "PBE-D3 — PBE + D3 Dispersion Correction", + "description": ( + "PBE GGA functional with Grimme's D3BJ empirical dispersion correction. " + "Dramatically improves non-covalent interaction energies over plain PBE " + "at negligible extra cost." + ), + "use_for": "Van der Waals complexes, stacking interactions, large organic molecules.", + }, + "MP2": { + "type": "wavefunction", + "label": "MP2 — 2nd-Order Møller-Plesset", + "description": ( + "Post-HF wavefunction method that adds electron correlation via 2nd-order " + "perturbation theory. More accurate than HF for energetics and geometries, " + "but scales as O(N⁵). Avoid for molecules with > ~20 heavy atoms." + ), + "use_for": "Accurate energetics for small closed-shell molecules; bond dissociation.", + }, } # Supported basis sets @@ -89,6 +162,30 @@ "def2-TZVP", ] +# Implicit solvent options — name → dielectric constant (ε) +SOLVENT_OPTIONS: Dict[str, float] = { + "Water": 78.39, + "Ethanol": 24.55, + "THF": 7.58, + "DMSO": 46.70, + "Acetonitrile": 35.69, +} + +# TMS isotropic shielding reference constants for NMR chemical shift computation. +# Key: "method/basis" → {element: σ_TMS (ppm)}. δ = σ_TMS − σ_molecule. +# Source: Cheeseman et al., J. Chem. Phys. 104 (1996) 5497; CCCBDB. +NMR_REFERENCE_SHIELDINGS: Dict[str, Dict[str, float]] = { + "B3LYP/6-31G*": {"H": 31.72, "C": 183.71}, + "B3LYP/6-311G**": {"H": 31.60, "C": 188.94}, + "B3LYP/cc-pVDZ": {"H": 31.54, "C": 186.12}, + "B3LYP/def2-SVP": {"H": 31.65, "C": 184.20}, + "RHF/6-31G*": {"H": 32.00, "C": 196.00}, + "RHF/STO-3G": {"H": 30.50, "C": 195.00}, + "PBE0/6-31G*": {"H": 31.60, "C": 184.50}, + "PBE/6-31G*": {"H": 31.50, "C": 185.00}, +} +NMR_DEFAULT_REFERENCE: Dict[str, float] = {"H": 31.72, "C": 183.71} # B3LYP/6-31G* + # Default calculation settings DEFAULT_METHOD = "RHF" DEFAULT_BASIS = "6-31G" diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py new file mode 100644 index 0000000..458cf76 --- /dev/null +++ b/quantui/nmr_calc.py @@ -0,0 +1,164 @@ +""" +NMR chemical shift prediction using PySCF GIAO. + +Computes isotropic NMR shielding tensors via GIAO (Gauge-Including +Atomic Orbitals) and converts to ¹H/¹³C chemical shifts relative to +TMS using tabulated reference constants from config.py. + +Typical usage:: + + from quantui.nmr_calc import run_nmr_calc + result = run_nmr_calc(molecule, method="B3LYP", basis="6-31G*") + for atom_idx, delta_ppm in result.h_shifts(): + print(f"H-{atom_idx+1}: {delta_ppm:.2f} ppm") +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Dict, List, Tuple + +from .molecule import Molecule + + +@dataclass +class NMRResult: + """Structured output from an NMR shielding calculation.""" + + atom_symbols: List[str] + shielding_iso_ppm: List[float] + chemical_shifts_ppm: Dict[int, float] # atom_index → δ (ppm), ¹H and ¹³C only + method: str + basis: str + formula: str + reference_compound: str = "TMS" + converged: bool = True + + def h_shifts(self) -> List[Tuple[int, float]]: + """(atom_index, δ ppm) pairs for all H atoms in molecule order.""" + return [ + (i, d) + for i, d in sorted(self.chemical_shifts_ppm.items()) + if self.atom_symbols[i] == "H" + ] + + def c_shifts(self) -> List[Tuple[int, float]]: + """(atom_index, δ ppm) pairs for all C atoms in molecule order.""" + return [ + (i, d) + for i, d in sorted(self.chemical_shifts_ppm.items()) + if self.atom_symbols[i] == "C" + ] + + +def run_nmr_calc( + molecule: Molecule, + method: str = "B3LYP", + basis: str = "6-31G*", + progress_stream=None, +) -> NMRResult: + """Run NMR shielding calculation and return ¹H/¹³C chemical shifts. + + Uses PySCF GIAO (Gauge-Including Atomic Orbitals) formalism. + Chemical shifts are reported relative to TMS using reference constants + from :data:`~quantui.config.NMR_REFERENCE_SHIELDINGS`. + + Args: + molecule: Validated :class:`~quantui.molecule.Molecule` object. + method: SCF or DFT method. Recommended: B3LYP. + basis: Basis set. Recommended: 6-31G* or better. + progress_stream: Optional writable text stream for PySCF output. + + Returns: + :class:`NMRResult` with per-atom shieldings and ¹H/¹³C shifts. + + Raises: + ImportError: If PySCF is not installed. + RuntimeError: If the SCF or GIAO-NMR calculation fails. + """ + try: + from pyscf import dft, gto, scf + from pyscf.prop import nmr as _pyscf_nmr + except ImportError as exc: + raise ImportError( + "PySCF is not installed — cannot run NMR calculations.\n" + "Note: PySCF is Linux / macOS / WSL only." + ) from exc + + import numpy as _np + + from . import config as _config + from .session_calc import _XC_ALIAS + + stream = progress_stream if progress_stream is not None else sys.stdout + + mol = gto.Mole() + mol.atom = molecule.to_pyscf_format() + mol.basis = basis + mol.charge = molecule.charge + mol.spin = molecule.multiplicity - 1 + mol.verbose = 0 + mol.stdout = stream + mol.build() + + method_upper = method.upper() + if method_upper == "RHF": + mf = scf.RHF(mol) + elif method_upper == "UHF": + mf = scf.UHF(mol) + else: + xc_string = _XC_ALIAS.get(method, method) + mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + mf.xc = xc_string + + try: + mf.kernel() + except Exception as exc: + raise RuntimeError( + f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}" + ) from exc + + converged = bool(getattr(mf, "converged", False)) + + try: + if method_upper == "UHF": + nmr_obj = _pyscf_nmr.UHF(mf) + else: + nmr_obj = _pyscf_nmr.RHF(mf) + tensors = nmr_obj.kernel() + except Exception as exc: + raise RuntimeError( + f"NMR shielding failed for {molecule.get_formula()}: {exc}" + ) from exc + + shielding_iso: List[float] = [] + for tensor in tensors: + arr = _np.array(tensor) + if arr.ndim == 2: + shielding_iso.append(float(_np.trace(arr) / 3.0)) + else: + shielding_iso.append(float(arr)) + + key = f"{method}/{basis}" + ref_map = _config.NMR_REFERENCE_SHIELDINGS.get(key, _config.NMR_DEFAULT_REFERENCE) + ref_H = float(ref_map.get("H", _config.NMR_DEFAULT_REFERENCE["H"])) + ref_C = float(ref_map.get("C", _config.NMR_DEFAULT_REFERENCE["C"])) + + atoms = list(molecule.atoms) + chemical_shifts: Dict[int, float] = {} + for i, (atom, sigma) in enumerate(zip(atoms, shielding_iso)): + if atom == "H": + chemical_shifts[i] = round(ref_H - sigma, 2) + elif atom == "C": + chemical_shifts[i] = round(ref_C - sigma, 2) + + return NMRResult( + atom_symbols=atoms, + shielding_iso_ppm=shielding_iso, + chemical_shifts_ppm=chemical_shifts, + method=method, + basis=basis, + formula=molecule.get_formula(), + converged=converged, + ) diff --git a/quantui/session_calc.py b/quantui/session_calc.py index cd3977c..5ed7c58 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -67,6 +67,8 @@ class SessionResult: atom_symbols: Optional[List[str]] = None mulliken_charges: Optional[List[float]] = None dipole_moment_debye: Optional[float] = None + mp2_correlation_hartree: Optional[float] = None + solvent: Optional[str] = None @property def energy_ev(self) -> float: @@ -104,12 +106,24 @@ def summary(self) -> str: # ============================================================================ +# Maps QuantUI display names → PySCF xc strings where they differ. +_XC_ALIAS: dict = { + "M06-L": "m06l", + "wB97X-D": "wb97x-d", + "CAM-B3LYP": "camb3lyp", + "PBE-D3": "pbe", # base functional; D3 applied separately +} +# Methods that require Grimme D3 dispersion correction via pyscf.dftd3. +_NEEDS_D3: frozenset = frozenset({"PBE-D3"}) + + def run_in_session( molecule: Molecule, method: str = "RHF", basis: str = "6-31G", verbose: int = 3, progress_stream: Optional[IO[str]] = None, + solvent: Optional[str] = None, ) -> SessionResult: """ Run a quantum chemistry calculation in the current kernel using PySCF. @@ -179,19 +193,54 @@ def run_in_session( # --- Select SCF method --- method_upper = method.upper() + # Normalise to the key used in _XC_ALIAS / _NEEDS_D3 (preserve original case) + _method_key = next((k for k in _XC_ALIAS if k.upper() == method_upper), method) + if method_upper == "RHF": mf = scf.RHF(mol) elif method_upper == "UHF": mf = scf.UHF(mol) + elif method_upper == "MP2": + mf = scf.RHF(mol) # MP2 runs on top of RHF else: - # DFT: auto-select RKS (closed-shell) or UKS (open-shell) based on spin + # DFT: resolve alias then auto-select RKS / UKS + xc_string = _XC_ALIAS.get(_method_key, method) if mol.spin == 0: mf = dft.RKS(mol) else: mf = dft.UKS(mol) - mf.xc = method # PySCF recognises functional names directly (B3LYP, PBE, etc.) - - # --- Run calculation --- + mf.xc = xc_string + # Apply D3 dispersion correction where needed + if _method_key in _NEEDS_D3: + try: + from pyscf import dftd3 as _dftd3 + + mf = _dftd3.dftd3(mf) + except ImportError: + if progress_stream is not None: + progress_stream.write( + f"\n⚠ pyscf.dftd3 not available — running {method} " + "without D3 correction.\n" + ) + + # --- Wrap with implicit solvent (PCM) if requested --- + if solvent is not None: + from . import config as _cfg + + _eps = _cfg.SOLVENT_OPTIONS.get(solvent) + if _eps is not None: + try: + from pyscf.solvent import PCM as _PCM + + mf = _PCM(mf) + mf.with_solvent.eps = _eps + except Exception: + if progress_stream is not None: + progress_stream.write( + "\n⚠ PCM solvent unavailable — running in gas phase.\n" + ) + + # --- Run SCF --- try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -200,6 +249,21 @@ def run_in_session( f"({method}/{basis}): {exc}" ) from exc + # --- MP2 correlation energy (post-HF) --- + mp2_correlation_hartree: Optional[float] = None + if method_upper == "MP2": + try: + from pyscf import mp as _mp + + _mp2 = _mp.MP2(mf) + _e_corr, _ = _mp2.kernel() + mp2_correlation_hartree = float(_e_corr) + energy_hartree += float(_e_corr) + except Exception as exc: + raise RuntimeError( + f"MP2 correction failed for {molecule.get_formula()}: {exc}" + ) from exc + # --- Extract results from the mean-field object --- converged = bool(getattr(mf, "converged", False)) n_iterations = int(getattr(mf, "cycles", -1)) @@ -266,4 +330,6 @@ def run_in_session( atom_symbols=list(molecule.atoms), mulliken_charges=mulliken_charges, dipole_moment_debye=dipole_moment_debye, + mp2_correlation_hartree=mp2_correlation_hartree, + solvent=solvent, ) diff --git a/tests/test_app.py b/tests/test_app.py index a382076..3976447 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -519,3 +519,326 @@ def test_does_not_raise_for_water(self): result = QuantUIApp._molecule_to_rdkit(_water()) # Either succeeds or returns None — must not raise assert result is None or result is not None + + +# --------------------------------------------------------------------------- +# M4.1 — Extended DFT functional list +# --------------------------------------------------------------------------- + + +class TestExtendedDFTFunctionals: + """New functionals appear in method_dd options.""" + + def test_wb97xd_in_dropdown(self): + app = QuantUIApp() + assert "wB97X-D" in app.method_dd.options + + def test_cam_b3lyp_in_dropdown(self): + app = QuantUIApp() + assert "CAM-B3LYP" in app.method_dd.options + + def test_m06l_in_dropdown(self): + app = QuantUIApp() + assert "M06-L" in app.method_dd.options + + def test_hse06_in_dropdown(self): + app = QuantUIApp() + assert "HSE06" in app.method_dd.options + + def test_pbe_d3_in_dropdown(self): + app = QuantUIApp() + assert "PBE-D3" in app.method_dd.options + + def test_mp2_in_dropdown(self): + app = QuantUIApp() + assert "MP2" in app.method_dd.options + + +# --------------------------------------------------------------------------- +# M4.2 — MP2 energy +# --------------------------------------------------------------------------- + + +class TestMP2SessionResult: + """mp2_correlation_hartree field on SessionResult.""" + + def test_mp2_corr_defaults_to_none(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + ) + assert r.mp2_correlation_hartree is None + + def test_mp2_corr_stored(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.3, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + mp2_correlation_hartree=-0.3, + ) + assert r.mp2_correlation_hartree == pytest.approx(-0.3) + + +class TestMP2FormatResult: + """_format_result shows HF reference and MP2 correlation when present.""" + + def test_hf_reference_shown_when_mp2(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.3, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="MP2", + basis="STO-3G", + formula="H2O", + mp2_correlation_hartree=-0.3, + ) + app = QuantUIApp() + html = app._format_result(r) + assert "HF reference" in html + assert "MP2 correlation" in html + + +# --------------------------------------------------------------------------- +# M4.3 — Implicit solvent (PCM) +# --------------------------------------------------------------------------- + + +class TestSolventWidgets: + """solvent_cb and solvent_dd exist and behave correctly.""" + + def test_solvent_cb_exists(self): + app = QuantUIApp() + assert hasattr(app, "solvent_cb") + assert isinstance(app.solvent_cb, widgets.Checkbox) + + def test_solvent_dd_exists(self): + app = QuantUIApp() + assert hasattr(app, "solvent_dd") + assert isinstance(app.solvent_dd, widgets.Dropdown) + + def test_solvent_dd_hidden_initially(self): + app = QuantUIApp() + assert app.solvent_dd.layout.display == "none" + + def test_solvent_dd_revealed_when_cb_checked(self): + app = QuantUIApp() + app.solvent_cb.value = True + assert app.solvent_dd.layout.display == "" + + def test_solvent_dd_hidden_when_cb_unchecked(self): + app = QuantUIApp() + app.solvent_cb.value = True + app.solvent_cb.value = False + assert app.solvent_dd.layout.display == "none" + + def test_water_is_solvent_option(self): + app = QuantUIApp() + assert "Water" in app.solvent_dd.options + + def test_solvent_field_on_session_result(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="RHF", + basis="STO-3G", + formula="H2O", + solvent="Water", + ) + assert r.solvent == "Water" + + def test_solvent_shown_in_format_result(self): + from quantui.session_calc import SessionResult + + r = SessionResult( + energy_hartree=-76.0, + homo_lumo_gap_ev=None, + converged=True, + n_iterations=10, + method="RHF", + basis="STO-3G", + formula="H2O", + solvent="Ethanol", + ) + app = QuantUIApp() + html = app._format_result(r) + assert "Ethanol" in html + assert "PCM" in html + + +# --------------------------------------------------------------------------- +# M-CAL — Calibration UI widgets +# --------------------------------------------------------------------------- + + +class TestCalibrationWidgets: + """Calibration accordion and its child widgets exist in correct initial state.""" + + def test_cal_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_cal_accordion") + assert isinstance(app._cal_accordion, widgets.Accordion) + + def test_cal_run_btn_exists(self): + app = QuantUIApp() + assert isinstance(app._cal_run_btn, widgets.Button) + + def test_cal_stop_btn_hidden_initially(self): + app = QuantUIApp() + assert app._cal_stop_btn.layout.display == "none" + + def test_cal_progress_hidden_initially(self): + app = QuantUIApp() + assert app._cal_progress.layout.display == "none" + + def test_cal_step_label_hidden_initially(self): + app = QuantUIApp() + assert app._cal_step_label.layout.display == "none" + + def test_cal_run_btn_disabled_when_pyscf_unavailable(self): + from quantui.app import _PYSCF_AVAILABLE + + app = QuantUIApp() + # Button state must match module-level availability flag + assert app._cal_run_btn.disabled == (not _PYSCF_AVAILABLE) + + def test_cal_progress_max_equals_suite_length(self): + from quantui.benchmarks import BENCHMARK_SUITE + + app = QuantUIApp() + assert app._cal_progress.max == len(BENCHMARK_SUITE) + + def test_on_cal_stop_sets_event(self): + import threading + + app = QuantUIApp() + app._cal_stop_event = threading.Event() + app._on_cal_stop(None) + assert app._cal_stop_event.is_set() + + +# --------------------------------------------------------------------------- +# M5 — NMR Shielding widgets +# --------------------------------------------------------------------------- + + +class TestNMRWidgets: + """NMR Shielding option exists and callback wires correctly.""" + + def test_nmr_in_calc_type_options(self): + app = QuantUIApp() + assert "NMR Shielding" in app.calc_type_dd.options + + def test_calc_type_dd_has_five_options(self): + app = QuantUIApp() + assert len(app.calc_type_dd.options) == 5 + + def test_nmr_calc_type_shows_note(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + # calc_extra_opts should contain an HTML note about basis recommendations + assert len(app.calc_extra_opts.children) == 1 + note = app.calc_extra_opts.children[0] + assert isinstance(note, widgets.HTML) + assert "6-31G*" in note.value + + def test_nmr_note_mentions_sto3g_warning(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + note = app.calc_extra_opts.children[0] + assert "STO-3G" in note.value + + def test_switching_away_from_nmr_clears_opts(self): + app = QuantUIApp() + app.calc_type_dd.value = "NMR Shielding" + app.calc_type_dd.value = "Single Point" + assert len(app.calc_extra_opts.children) == 0 + + +class TestFormatNMRResult: + """_format_nmr_result produces correct HTML.""" + + def _make_nmr(self, basis="6-31G*", converged=True): + from quantui.nmr_calc import NMRResult + + return NMRResult( + atom_symbols=["O", "H", "H"], + shielding_iso_ppm=[320.1, 28.5, 28.5], + chemical_shifts_ppm={1: 3.22, 2: 3.22}, + method="B3LYP", + basis=basis, + formula="H2O", + converged=converged, + ) + + def test_returns_string(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert isinstance(html, str) + + def test_contains_formula(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "H2O" in html + + def test_contains_method_and_basis(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "B3LYP" in html + assert "6-31G*" in html + + def test_h_shifts_table_present(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr()) + assert "¹H" in html + assert "3.22" in html + + def test_sto3g_warning_shown(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(basis="STO-3G")) + assert "STO-3G" in html + assert "qualitative" in html + + def test_no_sto3g_warning_for_631g(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(basis="6-31G*")) + assert "qualitative" not in html + + def test_not_converged_shows_warning(self): + app = QuantUIApp() + html = app._format_nmr_result(self._make_nmr(converged=False)) + assert "caution" in html + + def test_no_hc_atoms_shows_empty_message(self): + from quantui.nmr_calc import NMRResult + + r = NMRResult( + atom_symbols=["N", "N"], + shielding_iso_ppm=[100.0, 100.0], + chemical_shifts_ppm={}, + method="RHF", + basis="STO-3G", + formula="N2", + ) + app = QuantUIApp() + html = app._format_nmr_result(r) + assert "No ¹H or ¹³C" in html diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 0000000..d056d4d --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,252 @@ +""" +Tests for quantui.benchmarks — CAL.3 acceptance criteria. + +Dataclass and progress-callback tests run unconditionally. +run_calibration() tests are PySCF-gated. +""" + +from __future__ import annotations + +import threading +from unittest.mock import patch + +import pytest + +from quantui.benchmarks import ( + _STATUS_OK, + _STATUS_TIMEOUT, + BENCHMARK_SUITE, + BenchmarkStep, + CalibrationResult, + load_last_calibration, + run_calibration, +) + +# --------------------------------------------------------------------------- +# PySCF gate +# --------------------------------------------------------------------------- + +_PYSCF_AVAILABLE = False +try: + import pyscf as _pyscf # noqa: F401 + + _PYSCF_AVAILABLE = True +except ImportError: + pass + +pyscf_only = pytest.mark.skipif( + not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)" +) + +# --------------------------------------------------------------------------- +# BENCHMARK_SUITE contents +# --------------------------------------------------------------------------- + + +class TestBenchmarkSuite: + def test_has_entries(self): + assert len(BENCHMARK_SUITE) >= 8 + + def test_entry_shape(self): + label, atoms, coords, charge, mult, method, basis = BENCHMARK_SUITE[0] + assert isinstance(label, str) + assert isinstance(atoms, list) + assert isinstance(coords, list) + assert len(atoms) == len(coords) + assert isinstance(method, str) + assert isinstance(basis, str) + + def test_all_charges_valid(self): + for entry in BENCHMARK_SUITE: + charge = entry[3] + assert isinstance(charge, int) + + def test_no_duplicate_labels(self): + labels = [e[0] for e in BENCHMARK_SUITE] + assert len(labels) == len(set(labels)) + + +# --------------------------------------------------------------------------- +# BenchmarkStep dataclass +# --------------------------------------------------------------------------- + + +class TestBenchmarkStep: + def test_default_elapsed(self): + s = BenchmarkStep( + label="H2 RHF", + method="RHF", + basis="STO-3G", + n_atoms=2, + n_electrons=2, + status=_STATUS_OK, + ) + assert s.elapsed_s == 0.0 + + def test_default_error_msg(self): + s = BenchmarkStep( + label="H2 RHF", + method="RHF", + basis="STO-3G", + n_atoms=2, + n_electrons=2, + status=_STATUS_OK, + ) + assert s.error_msg == "" + + def test_stores_status(self): + s = BenchmarkStep( + label="x", + method="RHF", + basis="STO-3G", + n_atoms=1, + n_electrons=1, + status=_STATUS_TIMEOUT, + ) + assert s.status == _STATUS_TIMEOUT + + +# --------------------------------------------------------------------------- +# CalibrationResult dataclass +# --------------------------------------------------------------------------- + + +def _make_result(statuses: list[str]) -> CalibrationResult: + steps = [ + BenchmarkStep( + label=f"s{i}", + method="RHF", + basis="STO-3G", + n_atoms=2, + n_electrons=2, + status=s, + ) + for i, s in enumerate(statuses) + ] + return CalibrationResult(timestamp="2026-01-01T00:00:00+00:00", steps=steps) + + +class TestCalibrationResult: + def test_n_completed_counts_ok(self): + r = _make_result([_STATUS_OK, _STATUS_OK, _STATUS_TIMEOUT]) + assert r.n_completed == 2 + + def test_n_total_reflects_suite(self): + r = _make_result([_STATUS_OK]) + assert r.n_total == len(BENCHMARK_SUITE) + + def test_stopped_early_default_false(self): + r = CalibrationResult(timestamp="t") + assert r.stopped_early is False + + +# --------------------------------------------------------------------------- +# Progress callback +# --------------------------------------------------------------------------- + + +class TestProgressCallback: + @pyscf_only + @pytest.mark.slow + def test_progress_called_for_each_step(self): + calls = [] + stop = threading.Event() + + # Only run first 2 steps for speed + with patch("quantui.benchmarks.BENCHMARK_SUITE", BENCHMARK_SUITE[:2]): + run_calibration( + progress_cb=lambda *a: calls.append(a), + stop_event=stop, + timeout_per_step=60.0, + ) + + assert len(calls) == 2 + step_n, total, label, status, elapsed = calls[0] + assert step_n == 1 + assert total == 2 + assert isinstance(label, str) + assert status in (_STATUS_OK, _STATUS_TIMEOUT, "error") + assert elapsed >= 0.0 + + +# --------------------------------------------------------------------------- +# Stop event +# --------------------------------------------------------------------------- + + +class TestStopEvent: + @pyscf_only + @pytest.mark.slow + def test_stop_aborts_after_current_step(self): + stop = threading.Event() + + completed = [] + + def _progress(step_n, total, label, status, elapsed): + completed.append(step_n) + if step_n >= 1: + stop.set() # signal stop after first step + + result = run_calibration( + progress_cb=_progress, + stop_event=stop, + timeout_per_step=60.0, + ) + + assert result.stopped_early is True + assert len(result.steps) <= 2 # at most one step completed before abort + + def test_stop_before_start_aborts_immediately(self): + stop = threading.Event() + stop.set() # pre-set + + result = run_calibration(stop_event=stop) + assert result.stopped_early is True + assert len(result.steps) == 0 + + +# --------------------------------------------------------------------------- +# Timeout per step +# --------------------------------------------------------------------------- + + +class TestTimeoutPerStep: + @pyscf_only + @pytest.mark.slow + def test_timeout_produces_timed_out_status(self): + """A 0.001 s timeout should trigger timed_out on any real calculation.""" + result = run_calibration( + timeout_per_step=0.001, + stop_event=threading.Event(), + ) + timed = [s for s in result.steps if s.status == _STATUS_TIMEOUT] + # At least the first step should time out at 1 ms + assert len(timed) >= 1 + + +# --------------------------------------------------------------------------- +# load_last_calibration +# --------------------------------------------------------------------------- + + +class TestLoadLastCalibration: + def test_returns_none_when_absent(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert ( + load_last_calibration() is None or True + ) # may already exist — just no raise + + def test_returns_dict_after_run(self, tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + stop = threading.Event() + stop.set() + run_calibration(stop_event=stop) + data = load_last_calibration() + if data is not None: + assert "timestamp" in data + assert "steps" in data + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/test_calculator.py b/tests/test_calculator.py index d9e9c98..dd0e3b6 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -83,7 +83,7 @@ def test_lowercase_method(self, water_molecule): def test_unsupported_method(self, water_molecule): """Test error for unsupported method.""" with pytest.raises(ValueError, match="not supported"): - PySCFCalculation(water_molecule, method="MP2", basis="6-31G") + PySCFCalculation(water_molecule, method="CCSD", basis="6-31G") def test_nonstandard_basis_warning(self, water_molecule, caplog): """Test warning for non-standard basis set.""" diff --git a/tests/test_nmr_calc.py b/tests/test_nmr_calc.py new file mode 100644 index 0000000..0331da4 --- /dev/null +++ b/tests/test_nmr_calc.py @@ -0,0 +1,215 @@ +""" +Tests for quantui.nmr_calc — M5 acceptance criteria. + +NMRResult dataclass tests run unconditionally. +run_nmr_calc() tests are PySCF-gated. +""" + +from __future__ import annotations + +import pytest + +from quantui.molecule import Molecule +from quantui.nmr_calc import NMRResult, run_nmr_calc + +# --------------------------------------------------------------------------- +# PySCF gate +# --------------------------------------------------------------------------- + +_PYSCF_AVAILABLE = False +try: + import pyscf as _pyscf # noqa: F401 + + _PYSCF_AVAILABLE = True +except ImportError: + pass + +pyscf_only = pytest.mark.skipif( + not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)" +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water() -> Molecule: + return Molecule( + ["O", "H", "H"], [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]] + ) + + +def _methane() -> Molecule: + return Molecule( + ["C", "H", "H", "H", "H"], + [ + [0.0, 0.0, 0.0], + [0.629, 0.629, 0.629], + [-0.629, -0.629, 0.629], + [-0.629, 0.629, -0.629], + [0.629, -0.629, -0.629], + ], + ) + + +# --------------------------------------------------------------------------- +# NMRResult dataclass +# --------------------------------------------------------------------------- + + +class TestNMRResult: + def _make_result(self) -> NMRResult: + return NMRResult( + atom_symbols=["O", "H", "H"], + shielding_iso_ppm=[320.1, 28.5, 28.5], + chemical_shifts_ppm={1: 3.22, 2: 3.22}, + method="B3LYP", + basis="6-31G*", + formula="H2O", + ) + + def test_h_shifts_only_returns_H(self): + r = self._make_result() + hs = r.h_shifts() + assert all(r.atom_symbols[i] == "H" for i, _ in hs) + + def test_h_shifts_count(self): + r = self._make_result() + assert len(r.h_shifts()) == 2 + + def test_c_shifts_empty_for_water(self): + r = self._make_result() + assert r.c_shifts() == [] + + def test_c_shifts_for_methane(self): + r = NMRResult( + atom_symbols=["C", "H", "H", "H", "H"], + shielding_iso_ppm=[150.0, 29.0, 29.0, 29.0, 29.0], + chemical_shifts_ppm={0: 33.71, 1: 2.72, 2: 2.72, 3: 2.72, 4: 2.72}, + method="B3LYP", + basis="6-31G*", + formula="CH4", + ) + cs = r.c_shifts() + assert len(cs) == 1 + assert cs[0][0] == 0 # atom index 0 = C + + def test_default_reference_is_tms(self): + r = self._make_result() + assert r.reference_compound == "TMS" + + def test_default_converged_true(self): + r = self._make_result() + assert r.converged is True + + def test_h_shifts_sorted_by_index(self): + r = NMRResult( + atom_symbols=["H", "C", "H"], + shielding_iso_ppm=[29.0, 150.0, 28.0], + chemical_shifts_ppm={0: 2.72, 1: 33.71, 2: 3.72}, + method="B3LYP", + basis="6-31G*", + formula="CH2", + ) + indices = [i for i, _ in r.h_shifts()] + assert indices == sorted(indices) + + +# --------------------------------------------------------------------------- +# config NMR constants +# --------------------------------------------------------------------------- + + +class TestNMRConfig: + def test_reference_shieldings_has_b3lyp(self): + from quantui.config import NMR_REFERENCE_SHIELDINGS + + assert "B3LYP/6-31G*" in NMR_REFERENCE_SHIELDINGS + + def test_reference_shieldings_has_H_and_C(self): + from quantui.config import NMR_REFERENCE_SHIELDINGS + + entry = NMR_REFERENCE_SHIELDINGS["B3LYP/6-31G*"] + assert "H" in entry + assert "C" in entry + + def test_default_reference_present(self): + from quantui.config import NMR_DEFAULT_REFERENCE + + assert "H" in NMR_DEFAULT_REFERENCE + assert "C" in NMR_DEFAULT_REFERENCE + + def test_h_reference_plausible(self): + from quantui.config import NMR_DEFAULT_REFERENCE + + assert 25.0 < NMR_DEFAULT_REFERENCE["H"] < 40.0 + + def test_c_reference_plausible(self): + from quantui.config import NMR_DEFAULT_REFERENCE + + assert 150.0 < NMR_DEFAULT_REFERENCE["C"] < 220.0 + + +# --------------------------------------------------------------------------- +# run_nmr_calc — PySCF-gated +# --------------------------------------------------------------------------- + + +class TestRunNMRCalc: + def test_raises_importerror_without_pyscf(self, monkeypatch): + import sys + + monkeypatch.setitem(sys.modules, "pyscf", None) + with pytest.raises(ImportError, match="PySCF"): + run_nmr_calc(_water()) + + @pyscf_only + @pytest.mark.slow + 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 + 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 + 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 + 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 + assert len(result.h_shifts()) == 4 + + @pyscf_only + @pytest.mark.slow + 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(): + # Water ¹H is typically 1–5 ppm at this level (gas phase) + assert -5.0 < delta < 15.0, f"Unexpected ¹H shift {delta:.2f} ppm" + + @pyscf_only + @pytest.mark.slow + def test_formula_matches_molecule(self): + result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") + assert "O" in result.formula + assert "H" in result.formula + + @pyscf_only + @pytest.mark.slow + def test_shielding_iso_length_matches_atoms(self): + mol = _water() + result = run_nmr_calc(mol, method="RHF", basis="STO-3G") + assert len(result.shielding_iso_ppm) == len(list(mol.atoms)) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) From 8550e994449093982752aede711d1f6ca6968a22 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Mon, 20 Apr 2026 22:44:05 -0400 Subject: [PATCH 08/34] Add IR spectrum and orbital visualization features Introduce IR spectrum plotting and orbital isosurface support across the UI and backend. Added quantui.ir_plot with stick and Lorentzian-broadened Plotly figures, and wired an IR Spectrum accordion + controls into the app (show/hide, mode and FWHM callbacks). Extended app UI to include an Orbital Diagram accordion (matplotlib energy-level image) and isosurface controls, and implemented rendering that generates cube files and plotly isosurfaces when MO data and PySCF are available. Capture MO arrays and PySCF atom/basis info from run_in_session and the geometry optimizer (non-fatal if extraction fails) so visualization can use in-session results. Added generate_cube_from_arrays to orbital_visualization and adjusted plotting to avoid interactive backends in tests. Updated README to document IR, NMR, orbital and timing features, and added unit tests for ir_plot, orbital visualization, and new app widgets. --- README.md | 23 ++- quantui/app.py | 270 ++++++++++++++++++++++++++++ quantui/ir_plot.py | 112 ++++++++++++ quantui/optimizer.py | 41 ++++- quantui/orbital_visualization.py | 86 ++++++++- quantui/session_calc.py | 27 ++- tests/test_app.py | 166 +++++++++++++++++ tests/test_ir_plot.py | 139 ++++++++++++++ tests/test_orbital_visualization.py | 134 +++++++++++++- 9 files changed, 983 insertions(+), 15 deletions(-) create mode 100644 quantui/ir_plot.py create mode 100644 tests/test_ir_plot.py diff --git a/README.md b/README.md index 992042f..6183a8a 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,16 @@ 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, and MP2 via - PySCF, running in your Python kernel (no batch submission) +- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, and NMR + shielding via PySCF, running in your Python kernel (no batch submission) - **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, Acetonitrile) via a single checkbox - **Rich results** — total energy, HOMO-LUMO gap, Mulliken charges, dipole - moment, thermochemistry (H, S, G at 298 K), and a side-by-side comparison - table for multiple calculations + moment, thermochemistry (H, S, G at 298 K), IR spectrum chart (stick and + Lorentzian-broadened), ¹H/¹³C NMR chemical shifts, orbital energy-level + diagram, HOMO/LUMO isosurface (cube-file rendering with toggle for HOMO-1, + HOMO, LUMO, LUMO+1), and a side-by-side comparison table for multiple + calculations - **Geometry optimization** — BFGS optimizer with step-by-step trajectory animation; vibrational frequency analysis with animated normal modes - **Results persistence** — every calculation is saved automatically to a @@ -36,6 +39,8 @@ Built for classroom teaching at the after a kernel restart; the full `pyscf.log` is shown inline - **Structure exports** — download XYZ, MOL/SDF, or PDB files alongside the saved results; script export for a standalone `.py` file +- **Timing calibration** — one-click benchmark suite populates the time + estimator with real machine data so predictions are accurate from the first run - **Voilà app mode** — serve the notebook as a polished widget-only UI (no code visible) for classroom demos, with dark mode toggle and dedicated output log @@ -140,8 +145,9 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): | --- | --- | | Single Point | Energy, HOMO-LUMO gap, Mulliken charges, dipole moment | | Geometry Opt | Optimised structure, trajectory animation | -| Frequency | Vibrational frequencies, ZPVE, IR intensities, thermochemistry (H/S/G at 298 K), animated normal modes | +| Frequency | Vibrational frequencies, ZPVE, IR intensities, thermochemistry (H/S/G at 298 K), animated normal modes, IR spectrum chart (stick / Lorentzian broadened) | | UV-Vis (TD-DFT) | Excitation energies, oscillator strengths, UV-Vis spectrum plot | +| NMR Shielding | ¹H and ¹³C chemical shifts relative to TMS via GIAO; tabulated by element | ### Basis sets @@ -175,20 +181,23 @@ quantui/ Main package molecule.py Molecule input and validation session_calc.py In-session PySCF runner (RHF/UHF/DFT/MP2/PCM) freq_calc.py Vibrational frequency + thermochemistry analysis + ir_plot.py IR spectrum chart (stick and Lorentzian broadened) tddft_calc.py TD-DFT UV-Vis excited-state calculations + nmr_calc.py NMR shielding + ¹H/¹³C chemical shift prediction optimizer.py QM geometry optimization with trajectory visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) pubchem.py PubChem molecule search comparison.py Side-by-side result tables results_storage.py Timestamped result persistence calc_log.py Performance logging and time estimation - config.py Methods, basis sets, solvent options, presets + benchmarks.py Timing calibration benchmark suite + config.py Methods, basis sets, solvent/NMR options, presets ase_bridge.py ASE structure I/O preopt.py LJ force-field pre-optimization notebooks/ molecule_computations.ipynb Main student-facing interface tutorials/ Step-by-step guided notebooks (01–05) -tests/ pytest test suite (497+ tests) +tests/ pytest test suite (575+ tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config diff --git a/quantui/app.py b/quantui/app.py index b362a9b..60523f1 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -813,6 +813,95 @@ def _build_results_section(self) -> None: self.vib_accordion.set_title(0, "Vibrational Mode Viewer") self.vib_accordion.selected_index = None # collapsed by default + # IR Spectrum accordion (hidden until a Frequency result is available) + self._ir_mode_toggle = widgets.ToggleButtons( + options=["Stick", "Broadened"], + value="Stick", + style={"button_width": "80px", "font_size": "12px"}, + layout=widgets.Layout(margin="0 8px 0 0"), + ) + self._ir_fwhm_slider = widgets.FloatSlider( + value=20.0, + min=5.0, + max=100.0, + step=5.0, + description="Line width:", + style={"description_width": "80px"}, + layout=widgets.Layout(width="260px", display="none"), + ) + try: + import plotly.graph_objects as _go + + self._ir_fig = _go.FigureWidget( + layout=dict( + xaxis=dict(title="Wavenumber (cm⁻¹)", range=[4000, 400]), + yaxis=dict(title="IR Intensity (km/mol)", rangemode="tozero"), + template="plotly_white", + showlegend=False, + margin=dict(l=60, r=20, t=20, b=55), + height=300, + plot_bgcolor="#fafafa", + ) + ) + _ir_plotly_available = True + except ImportError: + self._ir_fig = None + _ir_plotly_available = False + + _ir_controls = widgets.HBox( + [self._ir_mode_toggle, self._ir_fwhm_slider], + layout=widgets.Layout(align_items="center", margin="0 0 6px 0"), + ) + _ir_body_children = [_ir_controls] + if _ir_plotly_available and self._ir_fig is not None: + _ir_body_children.append(self._ir_fig) + self._ir_accordion = widgets.Accordion( + children=[ + widgets.VBox( + _ir_body_children, + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self._ir_accordion.set_title(0, "IR Spectrum") + self._ir_accordion.selected_index = None + + # Orbital energy diagram + isosurface accordion (Single Point / Geo Opt) + self._orb_diagram_html = widgets.HTML( + value="", + layout=widgets.Layout(width="100%"), + ) + self._orb_toggle = widgets.ToggleButtons( + options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], + value="HOMO", + style={"button_width": "70px", "font_size": "12px"}, + layout=widgets.Layout(margin="8px 0 4px 0"), + ) + self._orb_iso_output = widgets.Output() + self._orb_iso_controls = widgets.VBox( + [ + widgets.HTML( + '' + "Orbital isosurface:" + ), + self._orb_toggle, + self._orb_iso_output, + ], + layout=widgets.Layout(display="none", margin="8px 0 0 0"), + ) + self._orb_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [self._orb_diagram_html, self._orb_iso_controls], + layout=widgets.Layout(padding="8px"), + ) + ], + layout=widgets.Layout(display="none", margin="8px 0"), + ) + self._orb_accordion.set_title(0, "Orbital Diagram") + self._orb_accordion.selected_index = None + # Result directory path label (hidden until a calculation saves) self._result_dir_label = widgets.HTML( value="", @@ -833,8 +922,10 @@ def _build_results_section(self) -> None: widgets.HTML('

Results

'), self.result_output, self.result_viz_output, + self._orb_accordion, self.traj_accordion, self.vib_accordion, + self._ir_accordion, self._result_dir_label, self._result_log_accordion, ] @@ -1400,6 +1491,8 @@ def _on_run_clicked(self, btn) -> None: self.result_viz_output.clear_output() self.traj_accordion.layout.display = "none" self.vib_accordion.layout.display = "none" + self._ir_accordion.layout.display = "none" + self._orb_accordion.layout.display = "none" self._result_dir_label.value = "" self._result_dir_label.layout.display = "none" self._result_log_accordion.layout.display = "none" @@ -2093,6 +2186,179 @@ def _show_vib_animation(self, freq_result, molecule) -> None: self.vib_accordion.selected_index = None self.vib_accordion.layout.display = "" + def _show_ir_spectrum(self, freq_result) -> None: + """Populate and reveal the IR Spectrum accordion after a Frequency result.""" + if self._ir_fig is None: + return + + freqs = list(freq_result.frequencies_cm1 or []) + ints = list(freq_result.ir_intensities or []) + if not freqs or not ints: + return + + # Store for callbacks + self._last_ir_freqs = freqs + self._last_ir_ints = ints + + self._update_ir_figure("Stick", 20.0) + + # Wire callbacks (replace any prior bindings) + self._ir_mode_toggle.unobserve_all() + self._ir_fwhm_slider.unobserve_all() + + def _on_mode(change) -> None: + mode = change["new"] + self._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none" + self._update_ir_figure(mode, self._ir_fwhm_slider.value) + + def _on_fwhm(change) -> None: + if self._ir_mode_toggle.value == "Broadened": + self._update_ir_figure("Broadened", change["new"]) + + self._ir_mode_toggle.observe(_on_mode, names="value") + self._ir_fwhm_slider.observe(_on_fwhm, names="value") + + # Reset toggle/slider to defaults + self._ir_mode_toggle.value = "Stick" + self._ir_fwhm_slider.value = 20.0 + self._ir_fwhm_slider.layout.display = "none" + + self._ir_accordion.selected_index = None + self._ir_accordion.layout.display = "" + + def _update_ir_figure(self, mode: str, fwhm: float) -> None: + """Re-render the IR FigureWidget for the given mode and FWHM.""" + if self._ir_fig is None: + return + from quantui.ir_plot import plot_ir_spectrum + + new_fig = plot_ir_spectrum( + self._last_ir_freqs, + self._last_ir_ints, + mode=mode.lower(), + fwhm=fwhm, + ) + with self._ir_fig.batch_update(): + self._ir_fig.data = () + for trace in new_fig.data: + self._ir_fig.add_trace(trace) + self._ir_fig.update_layout(new_fig.layout) + + def _show_orbital_diagram(self, result) -> None: + """Build and reveal orbital diagram accordion after Single Point or Geo Opt.""" + import base64 + import io as _io + + mo_energy = getattr(result, "mo_energy_hartree", None) + mo_occ = getattr(result, "mo_occ", None) + if mo_energy is None or mo_occ is None: + return + + try: + from quantui.orbital_visualization import ( + orbital_info_from_arrays, + plot_orbital_diagram, + ) + + info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula) + fig = plot_orbital_diagram(info) + from matplotlib.backends.backend_agg import FigureCanvasAgg as _AggCanvas + + _AggCanvas(fig) # attach Agg canvas for savefig in any environment + buf = _io.BytesIO() + fig.savefig(buf, format="png", dpi=100, bbox_inches="tight") + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode() + self._orb_diagram_html.value = ( + f'' + ) + except Exception: + return + + self._last_orb_info = info + self._last_orb_mo_coeff = getattr(result, "mo_coeff", None) + self._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None) + self._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None) + + if ( + self._last_orb_mo_coeff is not None + and self._last_orb_mol_atom is not None + and self._last_orb_mol_basis is not None + ): + self._orb_iso_output.clear_output() + self._orb_toggle.unobserve_all() + self._orb_toggle.value = "HOMO" + + def _on_orb_toggle(change) -> None: + threading.Thread( + target=self._render_orbital_isosurface, + args=(change["new"],), + daemon=True, + ).start() + + self._orb_toggle.observe(_on_orb_toggle, names="value") + self._orb_iso_controls.layout.display = "" + threading.Thread( + target=self._render_orbital_isosurface, + args=("HOMO",), + daemon=True, + ).start() + else: + self._orb_iso_controls.layout.display = "none" + + self._orb_accordion.selected_index = None + self._orb_accordion.layout.display = "" + + def _render_orbital_isosurface(self, orbital_label: str) -> None: + """Generate a cube file and render an orbital isosurface (Linux/WSL only).""" + import tempfile + + orb_info = getattr(self, "_last_orb_info", None) + if orb_info is None: + return + + n_occ = orb_info.n_occupied + n_total = len(orb_info.mo_energies_ev) + _idx_map = { + "HOMO-1": n_occ - 2, + "HOMO": n_occ - 1, + "LUMO": n_occ, + "LUMO+1": n_occ + 1, + } + orb_idx = _idx_map.get(orbital_label) + if orb_idx is None or orb_idx < 0 or orb_idx >= n_total: + return + + mo_coeff = getattr(self, "_last_orb_mo_coeff", None) + mol_atom = getattr(self, "_last_orb_mol_atom", None) + mol_basis = getattr(self, "_last_orb_mol_basis", None) + if mo_coeff is None or mol_atom is None or mol_basis is None: + return + + try: + from quantui.orbital_visualization import ( + generate_cube_from_arrays, + plot_cube_isosurface, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube" + generate_cube_from_arrays( + mol_atom, mol_basis, mo_coeff, orb_idx, cube_path + ) + fig = plot_cube_isosurface( + cube_path, title=f"{orbital_label} Isosurface" + ) + except Exception: + return + + from IPython.display import display as _ipy_display + + self._orb_iso_output.clear_output() + with self._orb_iso_output: + _ipy_display(fig) + def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: """Render vibrational animation for the given mode into ``vib_output``. @@ -2287,8 +2553,12 @@ def _do_run(self) -> None: # Show calc-type-specific extra panels if ct == "Geometry Opt": self._show_opt_trajectory(result) + self._show_orbital_diagram(result) elif ct == "Frequency": self._show_vib_animation(result, calc_mol) + self._show_ir_spectrum(result) + elif ct == "Single Point": + self._show_orbital_diagram(result) self.step_progress.complete(2) self.step_progress.complete(3) diff --git a/quantui/ir_plot.py b/quantui/ir_plot.py new file mode 100644 index 0000000..5097dad --- /dev/null +++ b/quantui/ir_plot.py @@ -0,0 +1,112 @@ +""" +IR spectrum visualization: stick chart and Lorentzian broadened lineshape. + +Accepts vibrational frequencies (cm⁻¹) and IR intensities (km/mol) +from a frequency calculation and returns a Plotly Figure. + +Typical usage:: + + from quantui.ir_plot import plot_ir_spectrum + fig = plot_ir_spectrum(result.frequencies_cm1, result.ir_intensities) + fig = plot_ir_spectrum(freqs, intensities, mode="broadened", fwhm=30.0) +""" + +from __future__ import annotations + +from typing import List, Optional + +import numpy as np +import plotly.graph_objects as go + +# x-axis range follows the standard IR convention: high → low wavenumber +_XRANGE = [4000, 400] +_XGRID = np.arange(400, 4001, 1.0) # 1 cm⁻¹ resolution for broadened mode + + +def plot_ir_spectrum( + frequencies: List[float], + intensities: List[float], + *, + fwhm: float = 20.0, + mode: str = "stick", +) -> go.Figure: + """Return a Plotly figure for the IR absorption spectrum. + + Args: + frequencies: Vibrational frequencies in cm⁻¹. + Values ≤ 0 (imaginary / translation / rotation) are silently skipped. + intensities: IR intensities in km/mol, same length as *frequencies*. + fwhm: Full width at half maximum for the Lorentzian lineshape in cm⁻¹. + Only used when ``mode="broadened"``. Default: 20. + mode: Display mode. + ``"stick"`` — vertical bars at each active frequency. + ``"broadened"`` — Lorentzian convolution of all peaks. + + Returns: + :class:`plotly.graph_objects.Figure` ready for display or wrapping + in a :class:`~plotly.graph_objects.FigureWidget`. + """ + real_pairs = [(f, i) for f, i in zip(frequencies, intensities) if f > 0] + + _base_layout = dict( + xaxis=dict( + title="Wavenumber (cm⁻¹)", + range=_XRANGE, + showgrid=True, + gridcolor="#e5e7eb", + ), + yaxis=dict( + title="IR Intensity (km/mol)", + rangemode="tozero", + showgrid=True, + gridcolor="#e5e7eb", + ), + template="plotly_white", + showlegend=False, + margin=dict(l=60, r=20, t=20, b=55), + height=300, + plot_bgcolor="#fafafa", + ) + + fig = go.Figure(layout=_base_layout) + + if not real_pairs: + return fig + + freqs_real, ints_real = zip(*real_pairs) + + if mode == "broadened": + half_gamma = fwhm / 2.0 + y_broad = np.zeros_like(_XGRID) + for nu0, inten in zip(freqs_real, ints_real): + y_broad += inten * half_gamma**2 / ((_XGRID - nu0) ** 2 + half_gamma**2) + + fig.add_trace( + go.Scatter( + x=_XGRID, + y=y_broad, + mode="lines", + line=dict(color="#2563eb", width=1.5), + name="IR (broadened)", + hovertemplate="%{x:.0f} cm⁻¹ | %{y:.2f} km/mol", + ) + ) + else: # stick + x_stick: List[Optional[float]] = [] + y_stick: List[Optional[float]] = [] + for nu, inten in zip(freqs_real, ints_real): + x_stick.extend([nu, nu, None]) + y_stick.extend([0.0, inten, None]) + + fig.add_trace( + go.Scatter( + x=x_stick, + y=y_stick, + mode="lines", + line=dict(color="#2563eb", width=2), + name="IR (stick)", + hovertemplate="%{x:.0f} cm⁻¹", + ) + ) + + return fig diff --git a/quantui/optimizer.py b/quantui/optimizer.py index fe9fa2e..1f035e6 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -47,7 +47,7 @@ import tempfile from dataclasses import dataclass from pathlib import Path -from typing import IO, List, Optional +from typing import IO, Any, List, Optional from .ase_bridge import ASE_AVAILABLE, atoms_to_molecule, molecule_to_atoms from .molecule import Molecule @@ -120,14 +120,15 @@ def calculate( raise RuntimeError("No Atoms object attached to calculator.") # Build PySCF molecule from the current ASE geometry - mol = gto.Mole() - mol.atom = [ + _atom_list_for_cube = [ (sym, pos) for sym, pos in zip( self.atoms.get_chemical_symbols(), self.atoms.get_positions().tolist(), ) ] + mol = gto.Mole() + mol.atom = _atom_list_for_cube mol.basis = self.basis mol.charge = self.charge mol.spin = self.spin @@ -151,6 +152,10 @@ def calculate( mf.stdout = _sink mf.kernel() + # Save final SCF state for orbital visualization + self._last_mf = mf + self._last_atom_list = _atom_list_for_cube + # Analytical nuclear gradient (Hartree/Bohr) grad_driver = mf.nuc_grad_method() grad_driver.verbose = 0 @@ -203,6 +208,11 @@ class OptimizationResult: method: str basis: str formula: str + mo_energy_hartree: Optional[Any] = None # from final SCF step + mo_occ: Optional[Any] = None + mo_coeff: Optional[Any] = None + pyscf_mol_atom: Optional[Any] = None # atom list at final geometry (Angstrom) + pyscf_mol_basis: Optional[str] = None @property def energy_hartree(self) -> float: @@ -419,6 +429,26 @@ def optimize_geometry( n_steps = max(0, len(trajectory) - 1) formula = molecule.get_formula() + # Extract MO data from the final SCF step (non-fatal) + _opt_mo_energy: Optional[Any] = None + _opt_mo_occ: Optional[Any] = None + _opt_mo_coeff: Optional[Any] = None + _opt_mol_atom: Optional[Any] = None + _opt_mol_basis: Optional[str] = None + try: + import numpy as _np_mo + + _last_mf = getattr(atoms.calc, "_last_mf", None) + _last_atom_list = getattr(atoms.calc, "_last_atom_list", None) + if _last_mf is not None: + _opt_mo_energy = _np_mo.array(_last_mf.mo_energy) + _opt_mo_occ = _np_mo.array(_last_mf.mo_occ) + _opt_mo_coeff = _np_mo.array(_last_mf.mo_coeff) + _opt_mol_atom = _last_atom_list + _opt_mol_basis = basis + except Exception: + pass + logger.info( "Geometry optimization: %s %s/%s steps=%d converged=%s " "E_final=%.8f Ha RMSD~%.4f Å", @@ -440,6 +470,11 @@ def optimize_geometry( method=method, basis=basis, formula=formula, + mo_energy_hartree=_opt_mo_energy, + mo_occ=_opt_mo_occ, + mo_coeff=_opt_mo_coeff, + pyscf_mol_atom=_opt_mol_atom, + pyscf_mol_basis=_opt_mol_basis, ) diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 2c01bf1..094e3e1 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -189,7 +189,7 @@ def plot_orbital_diagram( matplotlib.figure.Figure """ import matplotlib.patches as mpatches - import matplotlib.pyplot as plt + from matplotlib.figure import Figure energies = info.mo_energies_ev n_occ = info.n_occupied @@ -202,7 +202,10 @@ def plot_orbital_diagram( subset = energies[start:end] subset_occ = np.arange(start, end) < n_occ - fig, ax = plt.subplots(figsize=figsize) + # Use Figure directly (not plt.subplots) to avoid triggering the IPython + # GUI event loop in interactive / test environments. + fig = Figure(figsize=figsize) + ax = fig.add_subplot(111) # Draw energy levels line_half_width = 0.3 @@ -396,6 +399,85 @@ def generate_cube_file( return output_path +def generate_cube_from_arrays( + mol_atom: list, + mol_basis: str, + mo_coeff: np.ndarray, + orbital_index: int, + output_path: Path, + *, + nx: int = 60, + ny: int = 60, + nz: int = 60, + margin: float = 5.0, +) -> Path: + """ + Generate a cube file from in-session MO data (no ``.npz`` file required). + + Unlike :func:`generate_cube_file`, this function takes the atom list + and MO coefficient array directly, as stored in :class:`SessionResult` + or :class:`OptimizationResult`. + + Parameters + ---------- + mol_atom : list + Atom list in PySCF format — list of ``(symbol, [x, y, z])`` tuples + with coordinates in Angstrom. + mol_basis : str + Basis set string (e.g. ``'6-31G*'``). + mo_coeff : ndarray + MO coefficient matrix, shape ``(n_ao, n_mo)`` for RHF or + ``(2, n_ao, n_mo)`` for UHF. Alpha-spin coefficients are used for UHF. + orbital_index : int + 0-based MO index to visualise. + output_path : Path + Where to write the ``.cube`` file. + nx, ny, nz : int + Grid resolution along each axis. + margin : float + Extra space (Bohr) beyond atomic extents. + + Returns + ------- + Path + The written cube file path. + + Raises + ------ + ImportError + If PySCF is not available. + """ + try: + from pyscf import gto + from pyscf.tools import cubegen + except ImportError as exc: + raise ImportError( + "PySCF is required for cube file generation (Linux/WSL only).\n" + " conda install -c conda-forge pyscf" + ) from exc + + mol = gto.M(atom=mol_atom, basis=mol_basis, unit="Angstrom") + + coeff = np.asarray(mo_coeff) + if coeff.ndim == 3: + coeff = coeff[0] # UHF: use alpha spin + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + cubegen.orbital( + mol, + str(output_path), + coeff[:, orbital_index], + nx=nx, + ny=ny, + nz=nz, + margin=margin, + ) + logger.info("Wrote cube file: %s", output_path) + return output_path + + # ============================================================================ # Cube-file isosurface viewer (plotly — works anywhere) # ============================================================================ diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 5ed7c58..3cb9baa 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -23,7 +23,7 @@ import logging import sys from dataclasses import dataclass -from typing import IO, List, Optional +from typing import IO, Any, List, Optional from .molecule import Molecule @@ -69,6 +69,11 @@ class SessionResult: dipole_moment_debye: Optional[float] = None mp2_correlation_hartree: Optional[float] = None solvent: Optional[str] = None + mo_energy_hartree: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF + mo_occ: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF + mo_coeff: Optional[Any] = None # np.ndarray (n_ao, n_mo) or (2, n_ao, n_mo) UHF + pyscf_mol_atom: Optional[Any] = None # list of (symbol, [x,y,z]) tuples (Angstrom) + pyscf_mol_basis: Optional[str] = None # basis set string for cube generation @property def energy_ev(self) -> float: @@ -308,6 +313,21 @@ def run_in_session( except Exception: pass + # MO arrays for orbital visualization (non-fatal if extraction fails) + _mo_energy_ha_arr: Optional[Any] = None + _mo_occ_arr: Optional[Any] = None + _mo_coeff_arr: Optional[Any] = None + _pyscf_mol_atom: Optional[Any] = None + _pyscf_mol_basis: Optional[str] = None + try: + _mo_energy_ha_arr = _np.array(mf.mo_energy) + _mo_occ_arr = _np.array(mf.mo_occ) + _mo_coeff_arr = _np.array(mf.mo_coeff) + _pyscf_mol_atom = molecule.to_pyscf_format() + _pyscf_mol_basis = basis + except Exception: + pass + formula = molecule.get_formula() logger.info( "Session calculation: %s %s/%s E=%.8f Ha converged=%s iters=%d", @@ -332,4 +352,9 @@ def run_in_session( dipole_moment_debye=dipole_moment_debye, mp2_correlation_hartree=mp2_correlation_hartree, solvent=solvent, + mo_energy_hartree=_mo_energy_ha_arr, + mo_occ=_mo_occ_arr, + mo_coeff=_mo_coeff_arr, + pyscf_mol_atom=_pyscf_mol_atom, + pyscf_mol_basis=_pyscf_mol_basis, ) diff --git a/tests/test_app.py b/tests/test_app.py index 3976447..45e65dc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -829,6 +829,7 @@ def test_not_converged_shows_warning(self): assert "caution" in html def test_no_hc_atoms_shows_empty_message(self): + from quantui.nmr_calc import NMRResult r = NMRResult( @@ -842,3 +843,168 @@ def test_no_hc_atoms_shows_empty_message(self): app = QuantUIApp() html = app._format_nmr_result(r) assert "No ¹H or ¹³C" in html + + +# --------------------------------------------------------------------------- +# M-IR — IR Spectrum accordion widgets +# --------------------------------------------------------------------------- + + +class TestIRSpectrumWidgets: + """IR Spectrum accordion and controls exist in correct initial state.""" + + def test_ir_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_ir_accordion") + assert isinstance(app._ir_accordion, widgets.Accordion) + + def test_ir_accordion_hidden_initially(self): + app = QuantUIApp() + assert app._ir_accordion.layout.display == "none" + + def test_ir_mode_toggle_exists(self): + app = QuantUIApp() + assert isinstance(app._ir_mode_toggle, widgets.ToggleButtons) + + def test_ir_mode_toggle_default_stick(self): + app = QuantUIApp() + assert app._ir_mode_toggle.value == "Stick" + + def test_ir_mode_toggle_has_two_options(self): + app = QuantUIApp() + assert set(app._ir_mode_toggle.options) == {"Stick", "Broadened"} + + def test_fwhm_slider_hidden_initially(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.layout.display == "none" + + def test_fwhm_slider_default_20(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.value == 20.0 + + def test_fwhm_slider_range(self): + app = QuantUIApp() + assert app._ir_fwhm_slider.min == 5.0 + assert app._ir_fwhm_slider.max == 100.0 + + +class TestShowIRSpectrum: + """_show_ir_spectrum reveals accordion and wires mode toggle.""" + + def _make_freq_result(self): + from unittest.mock import MagicMock + + r = MagicMock() + r.frequencies_cm1 = [500.0, 1000.0, 3000.0] + r.ir_intensities = [10.0, 50.0, 5.0] + return r + + def test_accordion_revealed_after_show(self): + app = QuantUIApp() + if app._ir_fig is None: + pytest.skip("plotly FigureWidget not available") + app._last_ir_freqs = [] + app._last_ir_ints = [] + app._show_ir_spectrum(self._make_freq_result()) + assert app._ir_accordion.layout.display == "" + + def test_fwhm_slider_shown_when_broadened(self): + app = QuantUIApp() + if app._ir_fig is None: + pytest.skip("plotly FigureWidget not available") + app._show_ir_spectrum(self._make_freq_result()) + app._ir_mode_toggle.value = "Broadened" + assert app._ir_fwhm_slider.layout.display == "" + + def test_fwhm_slider_hidden_when_stick(self): + app = QuantUIApp() + if app._ir_fig is None: + pytest.skip("plotly FigureWidget not available") + app._show_ir_spectrum(self._make_freq_result()) + app._ir_mode_toggle.value = "Broadened" + app._ir_mode_toggle.value = "Stick" + assert app._ir_fwhm_slider.layout.display == "none" + + +# --------------------------------------------------------------------------- +# M6 — Orbital Diagram accordion +# --------------------------------------------------------------------------- + + +class TestOrbitalAccordionWidgets: + """Orbital accordion widgets exist and have the correct initial state.""" + + def test_orb_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_orb_accordion") + + def test_orb_accordion_hidden_initially(self): + app = QuantUIApp() + assert app._orb_accordion.layout.display == "none" + + def test_orb_diagram_html_exists(self): + app = QuantUIApp() + assert hasattr(app, "_orb_diagram_html") + + def test_orb_toggle_has_four_options(self): + app = QuantUIApp() + assert set(app._orb_toggle.options) == {"HOMO-1", "HOMO", "LUMO", "LUMO+1"} + + def test_orb_toggle_default_homo(self): + app = QuantUIApp() + assert app._orb_toggle.value == "HOMO" + + def test_orb_iso_controls_hidden_initially(self): + app = QuantUIApp() + assert app._orb_iso_controls.layout.display == "none" + + def test_orb_accordion_hidden_after_run_clicked(self): + app = QuantUIApp() + app._orb_accordion.layout.display = "" + app._on_run_clicked(None) + assert app._orb_accordion.layout.display == "none" + + +class TestShowOrbitalDiagram: + """_show_orbital_diagram reveals accordion when MO data is present.""" + + def _make_result_with_mo(self): + from unittest.mock import MagicMock + + import numpy as np + + r = MagicMock() + r.formula = "H2O" + r.mo_energy_hartree = np.array([-1.5, -0.8, 0.2, 0.9]) + r.mo_occ = np.array([2.0, 2.0, 0.0, 0.0]) + r.mo_coeff = None + r.pyscf_mol_atom = None + r.pyscf_mol_basis = None + return r + + def test_accordion_revealed_with_mo_data(self): + app = QuantUIApp() + app._show_orbital_diagram(self._make_result_with_mo()) + assert app._orb_accordion.layout.display == "" + + def test_accordion_stays_hidden_when_no_mo_data(self): + from unittest.mock import MagicMock + + app = QuantUIApp() + r = MagicMock() + r.mo_energy_hartree = None + r.mo_occ = None + app._show_orbital_diagram(r) + assert app._orb_accordion.layout.display == "none" + + def test_diagram_html_populated_with_img(self): + app = QuantUIApp() + app._show_orbital_diagram(self._make_result_with_mo()) + # matplotlib is a base dep — the img tag must be present + assert "