From def3603325f197f823efc9c5ae94443e6b3e6144 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Wed, 29 Apr 2026 16:21:12 -0400 Subject: [PATCH 01/16] Clarify pyscf-properties install instructions Update environment.yml comments to recommend `conda install -c conda-forge pyscf` (without pyscf-properties) for Linux/WSL and note that pyscf-properties is pip-only and gets installed automatically via the environment. This avoids suggesting a conda package that doesn't exist and clarifies installation behavior. --- local-setup/environment.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/local-setup/environment.yml b/local-setup/environment.yml index b289385..c2cfb92 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -46,6 +46,7 @@ dependencies: - black>=24.0.0 - ruff>=0.4.0 # Install QuantUI in editable mode - # On Linux/WSL also run: conda install -c conda-forge pyscf pyscf-properties + # On Linux/WSL also run: conda install -c conda-forge pyscf + # (pyscf-properties is pip-only; installed here automatically) - pyscf-properties # NMR and other properties (pyscf.prop); moved out of pyscf core in v2.0 - -e .. From e37fb2ddd371246bb182566c7bdc00bbb306a708 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Wed, 29 Apr 2026 16:56:54 -0400 Subject: [PATCH 02/16] Add tests for results_storage Add comprehensive end-to-end tests for quantui/results_storage.py. The new tests (tests/test_results_storage.py) exercise save_result/load_result (JSON contents, spectra, pyscf log handling, directory naming, defaults and uniqueness), list_results (filtering and newest-first sorting), save_orbitals/load_orbitals (orbitals.npz, orbitals_meta.json, and missing-file handling), save_trajectory/load_trajectory (trajectory.json roundtrip, molecule atoms, energy handling), and save_thumbnail (PNG generation across calc types). Tests use pytest tmp_path fixtures and do not mock the storage layer. --- tests/test_results_storage.py | 333 ++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 tests/test_results_storage.py diff --git a/tests/test_results_storage.py b/tests/test_results_storage.py new file mode 100644 index 0000000..d024780 --- /dev/null +++ b/tests/test_results_storage.py @@ -0,0 +1,333 @@ +"""Tests for quantui/results_storage.py. + +Covers save_result, load_result, list_results, save_orbitals, load_orbitals, +save_trajectory, load_trajectory, and save_thumbnail end-to-end using +tmp_path fixtures (no mocking of the storage layer itself). +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_orbitals, + load_result, + load_trajectory, + save_orbitals, + save_result, + save_thumbnail, + save_trajectory, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_result(**overrides): + defaults = dict( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def _water_traj(): + mol = Molecule(["O", "H", "H"], [[0, 0, 0], [0.757, 0.586, 0], [-0.757, 0.586, 0]]) + return [mol, mol], [-75.0, -75.1] + + +# --------------------------------------------------------------------------- +# save_result / load_result +# --------------------------------------------------------------------------- + + +class TestSaveResult: + def test_creates_result_directory(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert saved.is_dir() + + def test_writes_result_json(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert (saved / "result.json").exists() + + def test_result_json_fields(self, tmp_path): + saved = save_result( + _make_result(), results_dir=tmp_path, calc_type="single_point" + ) + data = json.loads((saved / "result.json").read_text()) + assert data["formula"] == "H2O" + assert data["method"] == "RHF" + assert data["basis"] == "STO-3G" + assert data["energy_hartree"] == pytest.approx(-75.0) + assert data["converged"] is True + assert data["calc_type"] == "single_point" + assert data["_schema_version"] == 2 + + def test_spectra_stored_in_json(self, tmp_path): + spectra = { + "ir": {"frequencies_cm1": [1000.0, 2000.0], "ir_intensities": [1.0, 2.0]} + } + saved = save_result(_make_result(), results_dir=tmp_path, spectra=spectra) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["ir"]["frequencies_cm1"] == [1000.0, 2000.0] + + def test_pyscf_log_written_when_provided(self, tmp_path): + saved = save_result( + _make_result(), + pyscf_log="converged SCF energy = -75.0", + results_dir=tmp_path, + ) + log_path = saved / "pyscf.log" + assert log_path.exists() + assert "converged" in log_path.read_text() + + def test_no_pyscf_log_when_empty(self, tmp_path): + saved = save_result(_make_result(), pyscf_log="", results_dir=tmp_path) + assert not (saved / "pyscf.log").exists() + + def test_directory_name_contains_formula_method_basis(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert "H2O" in saved.name + assert "RHF" in saved.name + assert "STO-3G" in saved.name + + def test_missing_optional_fields_use_defaults(self, tmp_path): + minimal = SimpleNamespace( + formula="H2", method="RHF", basis="STO-3G", energy_hartree=-1.0 + ) + saved = save_result(minimal, results_dir=tmp_path) + data = json.loads((saved / "result.json").read_text()) + assert data["homo_lumo_gap_ev"] is None + assert data["converged"] is None + assert data["n_iterations"] == -1 + + def test_returns_path_to_created_directory(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path) + assert isinstance(saved, type(tmp_path)) + assert saved.parent == tmp_path + + def test_each_call_creates_unique_directory(self, tmp_path): + d1 = save_result(_make_result(), results_dir=tmp_path) + d2 = save_result(_make_result(), results_dir=tmp_path) + assert d1 != d2 + + +class TestLoadResult: + def test_roundtrip(self, tmp_path): + saved = save_result(_make_result(), results_dir=tmp_path, calc_type="frequency") + data = load_result(saved) + assert data["formula"] == "H2O" + assert data["calc_type"] == "frequency" + assert data["_schema_version"] == 2 + + def test_spectra_roundtrip(self, tmp_path): + spectra = { + "nmr": {"atom_symbols": ["H", "H"], "shielding_iso_ppm": [30.1, 30.2]} + } + saved = save_result(_make_result(), results_dir=tmp_path, spectra=spectra) + data = load_result(saved) + assert data["spectra"]["nmr"]["atom_symbols"] == ["H", "H"] + + +# --------------------------------------------------------------------------- +# list_results +# --------------------------------------------------------------------------- + + +class TestListResults: + def test_empty_when_directory_missing(self, tmp_path): + assert list_results(tmp_path / "nonexistent") == [] + + def test_empty_when_directory_is_empty(self, tmp_path): + assert list_results(tmp_path) == [] + + def test_returns_directories_that_have_result_json(self, tmp_path): + r1 = save_result(_make_result(), results_dir=tmp_path) + r2 = save_result(_make_result(), results_dir=tmp_path) + found = list_results(tmp_path) + assert r1 in found + assert r2 in found + + def test_excludes_directories_without_result_json(self, tmp_path): + empty_dir = tmp_path / "2026-no-json" + empty_dir.mkdir() + found = list_results(tmp_path) + assert empty_dir not in found + + def test_sorted_newest_first(self, tmp_path): + r1 = save_result(_make_result(), results_dir=tmp_path) + r2 = save_result(_make_result(), results_dir=tmp_path) + found = list_results(tmp_path) + assert found.index(r2) < found.index(r1) + + +# --------------------------------------------------------------------------- +# save_orbitals / load_orbitals +# --------------------------------------------------------------------------- + + +class TestSaveOrbitals: + def test_creates_npz_file(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0, -0.5, 0.2]), + mo_occ=np.array([2.0, 2.0, 0.0]), + mo_coeff=None, + pyscf_mol_atom=None, + pyscf_mol_basis=None, + ) + save_orbitals(tmp_path, result) + assert (tmp_path / "orbitals.npz").exists() + + def test_skips_when_no_mo_data(self, tmp_path): + result = SimpleNamespace(mo_energy_hartree=None, mo_occ=None) + save_orbitals(tmp_path, result) + assert not (tmp_path / "orbitals.npz").exists() + + def test_writes_meta_json_when_mol_data_present(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0]), + mo_occ=np.array([2.0]), + mo_coeff=None, + pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], + pyscf_mol_basis="sto-3g", + ) + save_orbitals(tmp_path, result) + assert (tmp_path / "orbitals_meta.json").exists() + meta = json.loads((tmp_path / "orbitals_meta.json").read_text()) + assert meta["mol_basis"] == "sto-3g" + + +class TestLoadOrbitals: + def test_roundtrip(self, tmp_path): + mo_e = np.array([-1.0, -0.5, 0.2]) + result = SimpleNamespace( + mo_energy_hartree=mo_e, + mo_occ=np.array([2.0, 2.0, 0.0]), + mo_coeff=None, + pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], + pyscf_mol_basis="sto-3g", + ) + save_orbitals(tmp_path, result) + loaded = load_orbitals(tmp_path) + np.testing.assert_array_almost_equal(loaded.mo_energy_hartree, mo_e) + assert loaded.pyscf_mol_basis == "sto-3g" + + def test_raises_file_not_found_when_missing(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_orbitals(tmp_path / "nonexistent") + + def test_handles_missing_meta_json_gracefully(self, tmp_path): + result = SimpleNamespace( + mo_energy_hartree=np.array([-1.0]), + mo_occ=np.array([2.0]), + mo_coeff=None, + pyscf_mol_atom=None, + pyscf_mol_basis=None, + ) + save_orbitals(tmp_path, result) + loaded = load_orbitals(tmp_path) + assert loaded.pyscf_mol_atom is None + assert loaded.pyscf_mol_basis is None + + +# --------------------------------------------------------------------------- +# save_trajectory / load_trajectory +# --------------------------------------------------------------------------- + + +class TestSaveTrajectory: + def test_creates_trajectory_json(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + assert (tmp_path / "trajectory.json").exists() + + def test_skips_empty_trajectory(self, tmp_path): + save_trajectory(tmp_path, [], []) + assert not (tmp_path / "trajectory.json").exists() + + def test_stores_atom_symbols_and_coords(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + data = json.loads((tmp_path / "trajectory.json").read_text()) + assert data["atoms"] == ["O", "H", "H"] + assert len(data["steps"]) == 2 + + +class TestLoadTrajectory: + def test_roundtrip(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + loaded_traj, loaded_e = load_trajectory(tmp_path) + assert len(loaded_traj) == 2 + assert loaded_e[0] == pytest.approx(-75.0) + assert loaded_e[1] == pytest.approx(-75.1) + + def test_loaded_molecules_have_correct_atoms(self, tmp_path): + traj, energies = _water_traj() + save_trajectory(tmp_path, traj, energies) + loaded_traj, _ = load_trajectory(tmp_path) + assert list(loaded_traj[0].atoms) == ["O", "H", "H"] + + def test_raises_file_not_found_when_missing(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_trajectory(tmp_path / "nonexistent") + + def test_all_none_energies_returns_empty_list(self, tmp_path): + mol = Molecule(["H", "H"], [[0, 0, 0], [0.74, 0, 0]]) + traj = [mol] + save_trajectory(tmp_path, traj, [None]) + _, energies = load_trajectory(tmp_path) + assert energies == [] + + +# --------------------------------------------------------------------------- +# save_thumbnail +# --------------------------------------------------------------------------- + + +class TestSaveThumbnail: + def test_creates_png(self, tmp_path): + data = { + "calc_type": "single_point", + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + "energy_hartree": -75.0, + "converged": True, + } + save_thumbnail(tmp_path, data) + assert (tmp_path / "thumbnail.png").exists() + + def test_does_not_raise_for_unknown_calc_type(self, tmp_path): + save_thumbnail(tmp_path, {"calc_type": "unknown", "formula": "X"}) + + @pytest.mark.parametrize( + "calc_type", ["single_point", "geometry_opt", "frequency", "tddft", "nmr"] + ) + def test_creates_png_for_all_calc_types(self, tmp_path, calc_type): + result_dir = tmp_path / calc_type + result_dir.mkdir() + data = { + "calc_type": calc_type, + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + "energy_hartree": -75.0, + "converged": True, + } + save_thumbnail(result_dir, data) + assert (result_dir / "thumbnail.png").exists() From 52a87b3b6a5a78f0fd135821fc21a53c9d5a17c5 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Wed, 29 Apr 2026 19:26:23 -0400 Subject: [PATCH 03/16] Add freq analysis history tests; relax check Add comprehensive integration tests for the frequency-analysis history roundtrip (tests/test_freq_analysis_history.py) covering saving spectra, history context reconstruction, panel activation, and end-to-end _do_run behavior. Adjust quantui/app.py to stop requiring ir_intensities when validating saved IR spectra (now only checks frequencies, displacements and molecule atoms), allowing history/panel activation even if intensities are missing. --- quantui/app.py | 2 +- tests/test_freq_analysis_history.py | 402 ++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 tests/test_freq_analysis_history.py diff --git a/quantui/app.py b/quantui/app.py index e52993c..2318a08 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1743,7 +1743,7 @@ def _pop_vibrational(self, ctx: _AnalysisContext) -> bool: freqs = ir.get("frequencies_cm1") ints = ir.get("ir_intensities") disps = ir.get("displacements") - if not (freqs and ints and disps and mol_data.get("atoms")): + if not (freqs and disps and mol_data.get("atoms")): return False from quantui.molecule import Molecule as _Mol diff --git a/tests/test_freq_analysis_history.py b/tests/test_freq_analysis_history.py new file mode 100644 index 0000000..b1ad396 --- /dev/null +++ b/tests/test_freq_analysis_history.py @@ -0,0 +1,402 @@ +"""Integration tests for the frequency-analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with the correct + keys that history-load needs (ir, molecule, + displacements). + (2) History context — _build_history_context loads calc_type + spectra + correctly from disk. + (3) Panel activation — _apply_analysis_context activates Vibrational and + IR Spectrum panels from a history context. + (4) _do_run end-to-end — Full path: patched run_freq_calc → disk write → + _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) + +These tests are the canary for BUG-FREQ-ANA class failures — any break in the +data pipeline will show up here before a user notices in the UI. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_freq_result(): + """Realistic SimpleNamespace mirroring a FreqResult from run_freq_calc. + + Covers all attributes that _do_run reads when building save_spectra, + calling save_result, save_orbitals, and _apply_analysis_context. + """ + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + # Frequency-specific + frequencies_cm1=[100.0, 1500.0, 3800.0], + ir_intensities=[5.0, 50.0, 10.0], + zpve_hartree=0.021, + displacements=[ + [[0.0, 0.0, 0.1], [0.0, 0.07, -0.05], [0.0, -0.07, -0.05]], + [[0.0, 0.0, 0.1], [0.07, 0.0, -0.05], [-0.07, 0.0, -0.05]], + [[0.0, 0.1, 0.0], [0.0, -0.05, 0.07], [0.0, -0.05, -0.07]], + ], + thermo=None, + # MO data for orbital diagram / save_orbitals + mo_energy_hartree=np.array([-20.0, -1.3, -0.7, -0.5, -0.3]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0]), + mo_coeff=None, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.757, 0.586, 0.0]), + ("H", [-0.757, 0.586, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +def _make_freq_spectra(result, mol): + """Build the spectra dict exactly as _do_run does for a Frequency calc.""" + disps = None + if result.displacements is not None: + disps = np.asarray(result.displacements).tolist() + return { + "ir": { + "frequencies_cm1": result.frequencies_cm1, + "ir_intensities": result.ir_intensities, + "zpve_hartree": result.zpve_hartree, + "displacements": disps, + }, + "molecule": { + "atoms": list(mol.atoms), + "coords": [list(map(float, row)) for row in mol.coordinates], + "charge": mol.charge, + "multiplicity": mol.multiplicity, + }, + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def freq_result(): + return _make_freq_result() + + +@pytest.fixture +def water_mol(): + return _water() + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for frequency +# --------------------------------------------------------------------------- + + +class TestFreqSpectraStructure: + def test_result_json_has_frequency_calc_type( + self, tmp_path, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "frequency" + + def test_spectra_ir_frequencies_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["ir"]["frequencies_cm1"] == pytest.approx( + [100.0, 1500.0, 3800.0] + ) + + def test_spectra_ir_displacements_shape(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + disps = data["spectra"]["ir"]["displacements"] + assert ( + disps is not None + ), "displacements must be stored — _pop_vibrational needs them" + assert len(disps) == 3 # 3 modes for H2O (3N-6) + assert len(disps[0]) == 3 # 3 atoms + assert len(disps[0][0]) == 3 # x, y, z + + def test_spectra_molecule_atoms_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["spectra"]["molecule"]["atoms"] == ["O", "H", "H"] + + def test_spectra_molecule_coords_present(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + data = json.loads((saved / "result.json").read_text()) + coords = data["spectra"]["molecule"]["coords"] + assert len(coords) == 3 + assert coords[0] == pytest.approx([0.0, 0.0, 0.0]) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestFreqHistoryContext: + def _save(self, tmp_path, freq_result, water_mol): + spectra = _make_freq_spectra(freq_result, water_mol) + return save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "frequency" + + def test_context_spectra_data_has_ir_key( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ( + "ir" in ctx.spectra_data + ), "spectra_data must have 'ir' key for panel dispatch" + + def test_context_spectra_data_has_molecule_key( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ( + "molecule" in ctx.spectra_data + ), "spectra_data must have 'molecule' key for _pop_vibrational" + + def test_context_spectra_ir_has_displacements( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + disps = ctx.spectra_data.get("ir", {}).get("displacements") + assert disps is not None, "displacements must survive disk roundtrip" + + def test_context_live_result_is_none(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestFreqAnalysisPanelActivation: + def _save(self, tmp_path, freq_result, water_mol, spectra=None): + if spectra is None: + spectra = _make_freq_spectra(freq_result, water_mol) + return save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra=spectra + ) + + def test_vibrational_panel_activates(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" in app._ana_available + + def test_ir_spectrum_panel_activates(self, tmp_path, app, freq_result, water_mol): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "IR Spectrum" in app._ana_available + + def test_navigate_button_visible_when_panels_activate( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + def test_no_vibrational_when_displacements_missing( + self, tmp_path, app, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + spectra["ir"]["displacements"] = None + saved = self._save(tmp_path, freq_result, water_mol, spectra=spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" not in app._ana_available + + def test_ir_spectrum_still_activates_when_displacements_missing( + self, tmp_path, app, freq_result, water_mol + ): + spectra = _make_freq_spectra(freq_result, water_mol) + spectra["ir"]["displacements"] = None + saved = self._save(tmp_path, freq_result, water_mol, spectra=spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert ( + "IR Spectrum" in app._ana_available + ), "IR Spectrum only needs frequencies_cm1, not displacements" + + def test_no_panels_when_spectra_empty(self, tmp_path, app, freq_result): + saved = save_result( + freq_result, results_dir=tmp_path, calc_type="frequency", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Vibrational" not in app._ana_available + assert "IR Spectrum" not in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, freq_result): + saved = save_result(freq_result, results_dir=tmp_path, calc_type="", spectra={}) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + def test_empty_html_hidden_when_panels_activate( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._analysis_empty_html.layout.display == "none" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +class TestFreqDoRunEndToEnd: + """Full pipeline: patched run_freq_calc → disk → _history_load_analysis → panels. + + Does NOT mock save_result so real disk writes happen. + Uses QUANTUI_RESULTS_DIR env var to redirect writes to tmp_path. + """ + + def _run_freq(self, app, tmp_dir, monkeypatch): + """Run a real Frequency calc via _do_run, redirecting saves to tmp_dir.""" + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Frequency" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_frequency(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "frequency" + + def test_do_run_saves_ir_frequencies(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + freqs = data.get("spectra", {}).get("ir", {}).get("frequencies_cm1") + assert ( + freqs is not None and len(freqs) > 0 + ), "frequencies_cm1 must be saved in spectra.ir" + + def test_do_run_saves_molecule_atoms(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + atoms = data.get("spectra", {}).get("molecule", {}).get("atoms") + assert atoms == [ + "O", + "H", + "H", + ], "molecule.atoms must be saved for history replay" + + def test_do_run_saves_displacements(self, tmp_path, running_app, monkeypatch): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + data = load_result(saved[0]) + disps = data.get("spectra", {}).get("ir", {}).get("displacements") + assert ( + disps is not None + ), "displacements must be saved — _pop_vibrational needs them" + assert len(disps) == 3 + + def test_history_load_activates_vibrational_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Vibrational" in running_app._ana_available + + def test_history_load_activates_ir_spectrum_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "IR Spectrum" in running_app._ana_available + + def test_history_load_shows_navigate_button( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_freq(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert running_app._to_analysis_btn.layout.display == "" From d96305fab8e057329d8c00d9b797c95edef5c630 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 19:04:53 -0400 Subject: [PATCH 04/16] Improve vib rendering robustness & WSL launch launch-native.bat: clear quantui/__pycache__ on every start and set PYTHONDONTWRITEBYTECODE=1 to avoid stale .pyc being loaded under WSL/DrvFs; added explanatory comments. quantui/app.py: harden vibrational/IR rendering paths with additional try/except handlers and non-failing logging (calls to calc_log where available). Replace unsafe with self.vib_output context usage with append_display_data for thread-safety, convert Plotly animation to HTML and append it (avoids displaying Plotly from background threads), and add vib_render_start/done/error logging. Also move _apply_analysis_context to after refreshing the results browser to avoid a race that deactivates panels. --- launch-native.bat | 6 +++- quantui/app.py | 91 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/launch-native.bat b/launch-native.bat index 47aec47..97890c6 100644 --- a/launch-native.bat +++ b/launch-native.bat @@ -12,7 +12,11 @@ REM last install (.dev_install_stamp). quantui/*.py changes are always live in REM editable mode — reinstall is only needed after pyproject.toml changes or on REM first use. REM Uses port 8867 to avoid conflict with container-based launchers on 8866. -start "QuantUI [native]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && source ~/miniconda3/etc/profile.d/conda.sh && conda activate quantui && if [ pyproject.toml -nt .dev_install_stamp ] || ! python -c 'import quantui' 2>/dev/null; then pip install -e . -q && touch .dev_install_stamp; fi && voila notebooks/molecule_computations.ipynb --no-browser --port=8867 --ServerApp.disable_check_xsrf=True" +REM Clears quantui/__pycache__ on every launch to prevent stale .pyc bytecode +REM (WSL2 DrvFs does not reliably propagate Windows-side mtime changes, so Python +REM may load pre-edit bytecode even after source changes — see GOTCHAS.md). +REM PYTHONDONTWRITEBYTECODE=1 prevents a new stale cache from accumulating. +start "QuantUI [native]" wsl -d Ubuntu -- bash -c "cd '%WSLPATH%' && source ~/miniconda3/etc/profile.d/conda.sh && conda activate quantui && if [ pyproject.toml -nt .dev_install_stamp ] || ! python -c 'import quantui' 2>/dev/null; then pip install -e . -q && touch .dev_install_stamp; fi && rm -rf quantui/__pycache__ && PYTHONDONTWRITEBYTECODE=1 voila notebooks/molecule_computations.ipynb --no-browser --port=8867 --ServerApp.disable_check_xsrf=True" echo Waiting for Voila to start... timeout /t 6 /nobreak > nul diff --git a/quantui/app.py b/quantui/app.py index 2318a08..0292088 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -4191,6 +4191,22 @@ def _build_vib_data_from_freq_result(self, freq_result, molecule): except ImportError: return None + try: + return self._build_vib_data_inner( + freq_result, molecule, np, VibrationalData, VibrationalMode + ) + except Exception as _e: + try: + from quantui import calc_log as _clog + + _clog.log_event("vib_data_error", f"{type(_e).__name__}: {_e}"[:300]) + except Exception: + pass + return None + + def _build_vib_data_inner( + self, freq_result, molecule, np, VibrationalData, VibrationalMode + ): displacements = getattr(freq_result, "displacements", None) if displacements is None: return None @@ -4294,15 +4310,17 @@ def _show_vib_animation(self, freq_result, molecule) -> bool: # Show loading indicator and render in a background thread so _do_run # is not blocked while the animation is generated (can take several seconds). + # append_display_data is used instead of display() because this method is + # called from the _do_run background thread; display(HTML(...)) is not + # thread-safe for plain HTML but append_display_data is. _first_label, _first_mode = options[0] self.vib_output.clear_output() - with self.vib_output: - display( - HTML( - f'

' - f"⏳ Rendering vibrational animation ({_first_label})…

" - ) + self.vib_output.append_display_data( + HTML( + f'

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

" ) + ) threading.Thread( target=self._render_vib_mode, args=(vib_data, molecule, _first_mode), @@ -4390,8 +4408,13 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: self._ir_fig.value = _pio.to_html( fig, include_plotlyjs="cdn", full_html=False ) - except Exception: - pass + except Exception as _e: + try: + from quantui import calc_log as _clog + + _clog.log_event("ir_fig_error", f"{type(_e).__name__}: {_e}"[:300]) + except Exception: + pass def _show_orbital_diagram(self, result) -> bool: """Build and reveal the interactive orbital diagram accordion. @@ -4592,12 +4615,12 @@ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: Safe to call from background thread via ``with output:`` context. """ from IPython.display import HTML as _H - from IPython.display import display as _ipy_display def _err(msg: str) -> None: self.vib_output.clear_output() - with self.vib_output: - _ipy_display(_H(f'

⚠ {msg}

')) + self.vib_output.append_display_data( + _H(f'

⚠ {msg}

') + ) try: from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol @@ -4619,6 +4642,12 @@ def _err(msg: str) -> None: _err(f"Could not parse molecule for bond connectivity: {exc}") return + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_start", f"mode {mode_number}") + except Exception: + pass try: anim_fig = create_vibration_animation( vib_data=vib_data, @@ -4631,12 +4660,29 @@ def _err(msg: str) -> None: ) anim_fig.update_layout(height=420) except Exception as exc: + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event( + "vib_render_error", + f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300], + ) + except Exception: + pass _err(f"Animation generation failed: {exc}") return + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_done", f"mode {mode_number}") + except Exception: + pass + + import plotly.io as _pio + _anim_html = _pio.to_html(anim_fig, full_html=False, include_plotlyjs="cdn") self.vib_output.clear_output() - with self.vib_output: - _ipy_display(anim_fig) + self.vib_output.append_display_data(_H(_anim_html)) def _on_vib_mode_changed(self, change) -> None: """Re-render vib animation when the mode dropdown changes.""" @@ -4652,13 +4698,12 @@ def _on_vib_mode_changed(self, change) -> None: f"mode {mode_number}", ) self.vib_output.clear_output() - with self.vib_output: - display( - HTML( - f'

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

" - ) + self.vib_output.append_display_data( + HTML( + f'

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

" ) + ) threading.Thread( target=self._render_vib_mode, args=(vib_data, molecule, mode_number), @@ -4946,7 +4991,6 @@ def _do_run(self) -> None: preopt_result=_pre_opt, source="live", ) - self._apply_analysis_context(_ana_ctx) self.step_progress.complete(2) self.step_progress.complete(3) @@ -5023,6 +5067,13 @@ def _do_run(self) -> None: except Exception: pass + # Activate analysis panels AFTER saving/refreshing the results browser. + # _refresh_results_browser (above) sets past_dd.options, which fires its + # observer and calls _deactivate_all_ana_panels. Placing this call here + # means that observer has already run (harmlessly, panels not yet active) + # by the time we activate them. + self._apply_analysis_context(_ana_ctx) + # Log performance try: _calc_log.log_calculation( From b0c69843f6cd48f5b9e466592ead549116717f63 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 19:12:54 -0400 Subject: [PATCH 05/16] Re-render IR figure on accordion show Plotly charts inside hidden accordions can render with zero dimensions and appear blank. This change triggers an IR figure re-render when the "IR Spectrum" panel is made visible (if IR data exists), using the current IR mode and FWHM slider values so the chart paints correctly into the visible container. --- quantui/app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/quantui/app.py b/quantui/app.py index 0292088..c80c14e 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1537,6 +1537,13 @@ def _select_ana_panel(self, name: str) -> None: else: acc.layout.display = "none" btn.button_style = "" + # Plotly charts inside hidden accordions render with 0 dimensions and appear + # blank. Re-render the IR figure whenever its panel is brought into view so + # the chart always paints into a visible, correctly-sized container. + if name == "IR Spectrum" and getattr(self, "_last_ir_freqs", None): + self._update_ir_figure( + self._ir_mode_toggle.value, self._ir_fwhm_slider.value + ) def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: """Mark a panel as available (full opacity) and optionally select it.""" From f71f77277bfbfb502537be3f065b69f3f0e37253 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 21:00:08 -0400 Subject: [PATCH 06/16] Use pyscf.prop.infrared; add IR tests Install pyscf-properties from the GitHub repo until a fixed PyPI release is available, because the PyPI 0.1.0 package is missing the infrared module and has an NMR reshape bug. Update freq_calc.py to prefer pyscf.prop.infrared.proc_hessian_ when present so mo1_grad/h1ao_grad can be reused for IR intensities (with a fallback to Hessian.kernel()), and compute IR intensities via the infrared helper classes with warnings on failure. Add integration tests for IR intensities (H2O/RHF) and remove the earlier xfail workaround in the NMR tests now that the package is sourced from GitHub. --- local-setup/environment.yml | 5 +++- quantui/freq_calc.py | 58 ++++++++++++++++++++++++++++++++----- tests/test_freq_calc.py | 49 +++++++++++++++++++++++++++++++ tests/test_nmr_calc.py | 20 ------------- 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/local-setup/environment.yml b/local-setup/environment.yml index c2cfb92..930bc5f 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -48,5 +48,8 @@ dependencies: # Install QuantUI in editable mode # On Linux/WSL also run: conda install -c conda-forge pyscf # (pyscf-properties is pip-only; installed here automatically) - - pyscf-properties # NMR and other properties (pyscf.prop); moved out of pyscf core in v2.0 + # PyPI pyscf-properties 0.1.0 is missing the infrared module and has an NMR + # reshape bug fixed in commit 4eee5a4 (2024-11-07, unreleased). Install + # from GitHub source until a new release lands on PyPI. + - pyscf-properties @ git+https://github.com/pyscf/properties.git - -e .. diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index ec4f0a9..188daaa 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -248,7 +248,30 @@ def run_freq_calc( hess_obj = mf.Hessian() hess_obj.verbose = mol.verbose hess_obj.stdout = stream - h = hess_obj.kernel() + + # Use pyscf.prop.infrared.proc_hessian_ when available so the CPHF + # mo1_grad computed here can be reused for IR intensities at no extra + # cost. Falls back to hess_obj.kernel() if the module is absent. + _mo1_grad = None + _h1ao_grad = None + try: + from pyscf.prop.infrared.rhf import ( + Infrared as _IRCls, + ) + from pyscf.prop.infrared.rhf import ( + kernel_dipderiv as _kdd, + ) + from pyscf.prop.infrared.rhf import ( + kernel_ir as _kir, + ) + from pyscf.prop.infrared.rhf import ( # type: ignore[import] + proc_hessian_ as _proc_hess, + ) + + _, _h1ao_grad, _mo1_grad, _ = _proc_hess(hess_obj) + h = hess_obj.de + except ImportError: + h = hess_obj.kernel() freq_info = pyscf_thermo.harmonic_analysis(mol, h) @@ -285,12 +308,33 @@ def run_freq_calc( except Exception: displacements = None - # IR intensities — best-effort; silently omitted if unavailable - try: - ir_info = pyscf_thermo.ir_spectrum(mf, h) - ir_intensities = [float(x) for x in ir_info["ir_inten"]] - except Exception: - ir_intensities = [] + # IR intensities — free alongside the Hessian when pyscf.prop.infrared + # is available: mo1_grad from proc_hessian_ is reused, so no second + # CPHF solve is needed. + if _mo1_grad is not None: + try: + import numpy as _np_ir + + _mf_ir = _IRCls(mf) + _mf_ir.mf_hess = hess_obj + _mf_ir._h1ao_grad = _h1ao_grad + _mf_ir._mo1_grad = _mo1_grad + _mf_ir.vib_dict = freq_info + _kdd(_mf_ir) + _kir(_mf_ir) + _ir_arr = ( + _np_ir.asarray(_mf_ir.ir_inten, dtype=float).real.ravel().tolist() + ) + if len(_ir_arr) == len(frequencies_cm1): + ir_intensities = _ir_arr + else: + logger.warning( + "IR intensity count (%d) != frequency count (%d); discarding", + len(_ir_arr), + len(frequencies_cm1), + ) + except Exception as _ir_exc: + logger.warning("IR intensities unavailable: %s", _ir_exc) # Thermochemistry at 298.15 K / 1 atm — best-effort try: diff --git a/tests/test_freq_calc.py b/tests/test_freq_calc.py index e2139c9..38ac54a 100644 --- a/tests/test_freq_calc.py +++ b/tests/test_freq_calc.py @@ -175,5 +175,54 @@ def test_thermo_g_less_than_h(self): assert result.thermo.G_hartree < result.thermo.H_hartree +# ============================================================================ +# IR intensities — PySCF required +# ============================================================================ + + +class TestIRIntensities: + """make_ir_intensity() should return real km/mol values for H₂O / RHF. + + H₂O has 3 vibrational modes: bending (~1600 cm⁻¹), symmetric stretch + (~3700 cm⁻¹), antisymmetric stretch (~3800 cm⁻¹). All three are + IR-active (A1 and B2 symmetry), so all intensities must be positive. + """ + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_non_empty(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + assert result.ir_intensities, "ir_intensities should be non-empty for H₂O/RHF" + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_length_matches_frequencies(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + assert len(result.ir_intensities) == len(result.frequencies_cm1) + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_all_non_negative(self): + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + for i, inten in enumerate(result.ir_intensities): + assert inten >= 0, f"mode {i}: intensity {inten:.3f} < 0" + + @pyscf_only + @pytest.mark.slow + def test_ir_intensities_physically_reasonable(self): + """All H₂O modes are IR-active; max intensity should be > 1 km/mol.""" + from quantui.freq_calc import run_freq_calc + + result = run_freq_calc(_water(), method="RHF", basis="STO-3G") + if result.ir_intensities: + assert max(result.ir_intensities) > 1.0 + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/test_nmr_calc.py b/tests/test_nmr_calc.py index 2c7f3b2..1578100 100644 --- a/tests/test_nmr_calc.py +++ b/tests/test_nmr_calc.py @@ -28,19 +28,6 @@ not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)" ) -# pyscf-properties 0.1.0 (PyPI) has a reshape bug in nmr/rhf.py that -# was fixed on the pyscf/properties master branch (commit 4eee5a4, -# "fix nmr", 2024-11-07) but not yet released. Mark the integration -# tests xfail until a fixed release lands on PyPI. -# Fix: pip install git+https://github.com/pyscf/properties.git -_nmr_xfail = pytest.mark.xfail( - reason=( - "pyscf-properties 0.1.0 incompatible with pyscf>=2.13.0: " - "rhf.py reshapes mo1 to (3,nmo,nocc) but krylov returns (nmo*nocc,). " - "Fixed on pyscf/properties master (commit 4eee5a4) — awaiting PyPI release." - ), - strict=False, -) # --------------------------------------------------------------------------- # Helpers @@ -179,28 +166,24 @@ def test_raises_importerror_without_pyscf(self, monkeypatch): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_returns_nmr_result(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert isinstance(result, NMRResult) @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_has_two_h_shifts(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert len(result.h_shifts()) == 2 @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_no_c_shifts(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert result.c_shifts() == [] @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_methane_has_c_and_h_shifts(self): result = run_nmr_calc(_methane(), method="RHF", basis="STO-3G") assert len(result.c_shifts()) == 1 @@ -208,7 +191,6 @@ def test_methane_has_c_and_h_shifts(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_water_h_shifts_reasonable_range(self): result = run_nmr_calc(_water(), method="B3LYP", basis="6-31G*") for _i, delta in result.h_shifts(): @@ -217,7 +199,6 @@ def test_water_h_shifts_reasonable_range(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_formula_matches_molecule(self): result = run_nmr_calc(_water(), method="RHF", basis="STO-3G") assert "O" in result.formula @@ -225,7 +206,6 @@ def test_formula_matches_molecule(self): @pyscf_only @pytest.mark.slow - @_nmr_xfail def test_shielding_iso_length_matches_atoms(self): mol = _water() result = run_nmr_calc(mol, method="RHF", basis="STO-3G") From ab932cabab91fb1106a663b001a8d16a5efb474a Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 22:17:55 -0400 Subject: [PATCH 07/16] Add last_calc_type, responsive Plotly, event cull Add a _last_calc_type attribute and persist it in the saved context; set it when a run completes to track the type of the last calculation. Update multiple plotly.io.to_html calls to include config={"responsive": True} so the generated HTML is responsive. Improve recent_events selection by fetching a larger window (60), ensuring the 10 most recent non-startup events plus the 5 most recent events of any type are always included (to avoid startup bursts drowning out calculation events), and then filtering the original event list to preserve ordering. --- quantui/app.py | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index c80c14e..c1c1f6c 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -312,6 +312,7 @@ def __init__(self) -> None: # ── Instance state ──────────────────────────────────────────────── self._molecule: Optional[Molecule] = None self._last_result: Any = None + self._last_calc_type: Optional[str] = None # e.g. "frequency", "single_point" self._results: List = [] self._pending_traj_result: Any = None self.root_tab: widgets.Tab @@ -1824,7 +1825,10 @@ def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: ) self._apply_plotly_theme(_fig) self._tddft_fig.value = _pio.to_html( - _fig, include_plotlyjs="cdn", full_html=False + _fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) return True except Exception: @@ -3493,6 +3497,7 @@ def _build_issue_context(self) -> dict: "method": self.method_dd.value, "basis": self.basis_dd.value, "calc_type": self.calc_type_dd.value, + "last_calc_type": getattr(self, "_last_calc_type", None), } except Exception: pass @@ -3510,7 +3515,16 @@ def _build_issue_context(self) -> dict: except Exception: pass try: - ctx["recent_events"] = _calc_log.get_recent_events(15) + _all_ev = _calc_log.get_recent_events(60) + # Always include the 10 most recent non-startup events so that calc + # events are not starved out by a burst of startup entries (e.g. + # rapid notebook restarts). Merge with the 5 most recent events of + # any type to preserve immediate context, then re-sort by timestamp. + _non_startup = [e for e in _all_ev if e.get("event") != "startup"] + _keep_ids = {id(e) for e in _non_startup[-10:]} | { + id(e) for e in _all_ev[-5:] + } + ctx["recent_events"] = [e for e in _all_ev if id(e) in _keep_ids] except Exception: pass return ctx @@ -4413,7 +4427,10 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: ) self._apply_plotly_theme(fig) self._ir_fig.value = _pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) except Exception as _e: try: @@ -4461,7 +4478,12 @@ def _show_orbital_diagram(self, result) -> bool: self._orb_ymin_input.value = round(float(yr[0]), 2) self._orb_ymax_input.value = round(float(yr[1]), 2) self._apply_plotly_theme(fig) - html_str = _pio.to_html(fig, include_plotlyjs="cdn", full_html=False) + html_str = _pio.to_html( + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, + ) self._orb_diagram_html.value = html_str _plotly_rendered = True except Exception: @@ -4552,7 +4574,10 @@ def _on_orb_range_changed(self, _change=None) -> None: ) self._apply_plotly_theme(fig) self._orb_diagram_html.value = _pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) except Exception: pass @@ -4687,7 +4712,12 @@ def _err(msg: str) -> None: import plotly.io as _pio - _anim_html = _pio.to_html(anim_fig, full_html=False, include_plotlyjs="cdn") + _anim_html = _pio.to_html( + anim_fig, + full_html=False, + include_plotlyjs="cdn", + config={"responsive": True}, + ) self.vib_output.clear_output() self.vib_output.append_display_data(_H(_anim_html)) @@ -4971,6 +5001,7 @@ def _do_run(self) -> None: _elapsed = time.perf_counter() - _run_wall_t _elapsed_cpu = time.process_time() - _run_cpu_t self._last_result = result + self._last_calc_type = save_type self.accumulate_btn.disabled = False self.result_output.append_display_data(HTML(result_html)) @@ -5863,7 +5894,10 @@ def _show_pes_scan_result(self, result) -> bool: hovermode="closest", ) self._pes_plot_html.value = pio.to_html( - fig, include_plotlyjs="cdn", full_html=False + fig, + include_plotlyjs="cdn", + full_html=False, + config={"responsive": True}, ) except Exception: pass From 0d66649c801eb548060978047290c06c78cb67ea Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 22:32:21 -0400 Subject: [PATCH 08/16] Persist and load preopt trajectory Save pre-optimization geometry trajectories for Frequency runs to disk and load them during history replay. save_trajectory and load_trajectory now accept a filename (defaulting to trajectory.json) so preopt_trajectory.json can be written/read; QuantUIApp writes preopt_trajectory.json when a Frequency run included a pre-opt and will load it for non-live history contexts (with error logging on failure). Tests updated to verify missing preopt file keeps the Trajectory panel inactive and that a saved preopt file activates it on history load. --- quantui/app.py | 45 ++++++++++++++++++++++++----- quantui/results_storage.py | 18 ++++++++---- tests/test_freq_analysis_history.py | 26 +++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index c1c1f6c..06efef6 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1722,14 +1722,32 @@ def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool: return True def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool: - # Pre-opt trajectory is only available for live Frequency runs that - # had the pre-opt checkbox enabled. Not stored to disk, so history - # replay cannot show it. - pre = ctx.preopt_result - if pre is None: - return False - traj = getattr(pre, "trajectory", None) - energies = list(getattr(pre, "energies_hartree", [])) + if ctx.source == "live": + pre = ctx.preopt_result + if pre is None: + return False + traj = getattr(pre, "trajectory", None) + energies = list(getattr(pre, "energies_hartree", [])) + else: + if ctx.result_dir is None: + return False + preopt_path = ctx.result_dir / "preopt_trajectory.json" + if not preopt_path.exists(): + return False + try: + from quantui.results_storage import load_trajectory + + traj, energies = load_trajectory( + ctx.result_dir, filename="preopt_trajectory.json" + ) + except Exception as _exc: + from quantui import calc_log as _clog + + _clog.log_event( + "pop_preopt_trajectory_error", + f"{type(_exc).__name__}: {_exc}"[:300], + ) + return False if not traj or len(traj) < 2: return False stub = _types_mod.SimpleNamespace( @@ -5084,6 +5102,17 @@ def _do_run(self) -> None: _e_list = getattr(result, "energies_hartree", []) if _traj: save_trajectory(_saved_dir, _traj, _e_list or []) + # Persist pre-opt geometry trajectory for Frequency runs (DEC-007). + if ct == "Frequency" and _pre_opt is not None: + _pre_traj = getattr(_pre_opt, "trajectory", None) + _pre_e = list(getattr(_pre_opt, "energies_hartree", [])) + if _pre_traj: + save_trajectory( + _saved_dir, + _pre_traj, + _pre_e, + filename="preopt_trajectory.json", + ) # Persist MO data for orbital diagram + isosurface replay. if ct in ("Single Point", "Geometry Opt", "Frequency"): save_orbitals(_saved_dir, result) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index fbb9639..1290dae 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -222,8 +222,13 @@ def load_orbitals(result_dir: Path): return stub -def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: - """Persist geometry-optimisation trajectory to *result_dir*/trajectory.json. +def save_trajectory( + result_dir: Path, + trajectory: list, + energies: list, + filename: str = "trajectory.json", +) -> None: + """Persist geometry-optimisation trajectory to *result_dir*/*filename*. Parameters ---------- @@ -233,6 +238,9 @@ def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: List of ``Molecule`` objects (one per optimisation step). energies: List of total energies in Hartree, parallel to *trajectory*. + filename: + Output filename inside *result_dir*. Defaults to ``trajectory.json``. + Pass ``preopt_trajectory.json`` for pre-optimisation steps. """ if not trajectory: return @@ -249,10 +257,10 @@ def save_trajectory(result_dir: Path, trajectory: list, energies: list) -> None: for i, mol in enumerate(trajectory) ], } - (result_dir / "trajectory.json").write_text(json.dumps(data)) + (result_dir / filename).write_text(json.dumps(data)) -def load_trajectory(result_dir: Path): +def load_trajectory(result_dir: Path, filename: str = "trajectory.json"): """Reload a saved trajectory as (molecules, energies). Returns @@ -269,7 +277,7 @@ def load_trajectory(result_dir: Path): """ from quantui.molecule import Molecule - raw = json.loads((result_dir / "trajectory.json").read_text()) + raw = json.loads((result_dir / filename).read_text()) atoms = raw["atoms"] charge = raw.get("charge", 0) mult = raw.get("multiplicity", 1) diff --git a/tests/test_freq_analysis_history.py b/tests/test_freq_analysis_history.py index b1ad396..25eea56 100644 --- a/tests/test_freq_analysis_history.py +++ b/tests/test_freq_analysis_history.py @@ -316,6 +316,17 @@ def test_empty_html_hidden_when_panels_activate( app._apply_analysis_context(ctx) assert app._analysis_empty_html.layout.display == "none" + # 3d: Trajectory panel stays dark when preopt_trajectory.json is absent + def test_trajectory_dark_when_no_preopt_trajectory_file( + self, tmp_path, app, freq_result, water_mol + ): + saved = self._save(tmp_path, freq_result, water_mol) + # save_result never writes preopt_trajectory.json — confirm absent + assert not (saved / "preopt_trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + # --------------------------------------------------------------------------- # Part 4: _do_run end-to-end (PySCF-gated) @@ -400,3 +411,18 @@ def test_history_load_shows_navigate_button( running_app._deactivate_all_ana_panels() running_app._history_load_analysis(saved[0]) assert running_app._to_analysis_btn.layout.display == "" + + # 3c: Trajectory panel activates on history load when pre-opt was enabled + def test_history_load_activates_trajectory_panel_when_preopt_enabled( + self, tmp_path, running_app, monkeypatch + ): + running_app._freq_preopt_cb.value = True + saved = self._run_freq(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + result_dir = saved[0] + assert ( + result_dir / "preopt_trajectory.json" + ).exists(), "preopt_trajectory.json must be written when pre-opt runs" + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(result_dir) + assert "Trajectory" in running_app._ana_available From e86f8ea10a98923ace85e2c7816dbea998d77e89 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 30 Apr 2026 23:04:14 -0400 Subject: [PATCH 09/16] Build pyscf mol atom list and add SP tests Replace molecule.to_pyscf_format() with an explicit construction of the PySCF atom list (atom, [float coords]) in quantui/session_calc.py to ensure coordinates are proper floats and avoid depending on the Molecule helper. Add integration tests (tests/test_sp_analysis_history.py) that exercise single-point result storage and orbitals, history context reconstruction, analysis panel activation, and an end-to-end PySCF-gated run to validate the history replay path. --- quantui/session_calc.py | 5 +- tests/test_sp_analysis_history.py | 249 ++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/test_sp_analysis_history.py diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 47c0a03..34a18b5 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -323,7 +323,10 @@ def run_in_session( _mo_energy_ha_arr = _np.array(mf.mo_energy) _mo_occ_arr = _np.array(mf.mo_occ) _mo_coeff_arr = _np.array(mf.mo_coeff) - _pyscf_mol_atom = molecule.to_pyscf_format() + _pyscf_mol_atom = [ + (atom, list(map(float, coords))) + for atom, coords in zip(molecule.atoms, molecule.coordinates) + ] _pyscf_mol_basis = basis except Exception: pass diff --git a/tests/test_sp_analysis_history.py b/tests/test_sp_analysis_history.py new file mode 100644 index 0000000..ce07036 --- /dev/null +++ b/tests/test_sp_analysis_history.py @@ -0,0 +1,249 @@ +"""Integration tests for the single-point analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type + "single_point"; save_orbitals writes orbitals.npz. + (2) History context — _build_history_context loads calc_type correctly + from disk. + (3) Panel activation — _apply_analysis_context activates Energies and + Isosurface panels from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G run → disk write → + _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_orbitals, + save_result, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_sp_result(with_coeff: bool = True): + """Minimal namespace mirroring a real RHF/STO-3G result on water. + + 7 MOs (matching STO-3G water: 5 occupied + 2 virtual) so that + orbital_info_from_arrays does not raise on the n_occ >= n_total check. + """ + mo_coeff = np.eye(7) if with_coeff else None + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + mo_energy_hartree=np.array([-20.5, -1.3, -0.7, -0.5, -0.3, 0.5, 0.7]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0]), + mo_coeff=mo_coeff, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.757, 0.586, 0.0]), + ("H", [-0.757, 0.586, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def sp_result(): + return _make_sp_result() + + +# --------------------------------------------------------------------------- +# Part 1: save_result + save_orbitals write the correct files +# --------------------------------------------------------------------------- + + +class TestSPSpectraStructure: + def test_result_json_has_single_point_calc_type(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "single_point" + + def test_save_orbitals_writes_npz(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + save_orbitals(saved, sp_result) + assert (saved / "orbitals.npz").exists() + + def test_no_spectra_keys_for_single_point(self, tmp_path, sp_result): + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + data = json.loads((saved / "result.json").read_text()) + assert ( + data.get("spectra", {}) == {} + ), "single_point produces no spectra data — only orbitals.npz" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestSPHistoryContext: + def _save(self, tmp_path, sp_result): + return save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "single_point" + + def test_context_result_dir_set(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx.result_dir == saved + + def test_context_live_result_is_none(self, tmp_path, app, sp_result): + saved = self._save(tmp_path, sp_result) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestSPPanelActivation: + def _save_with_orbitals(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + save_orbitals(saved, result) + return saved + + def test_energies_panel_activates_with_orbitals(self, tmp_path, app, sp_result): + saved = self._save_with_orbitals(tmp_path, sp_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" in app._ana_available + + def test_energies_absent_when_orbitals_missing(self, tmp_path, app, sp_result): + # save_result only — no save_orbitals call, so orbitals.npz is absent + saved = save_result( + sp_result, results_dir=tmp_path, calc_type="single_point", spectra={} + ) + assert not (saved / "orbitals.npz").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" not in app._ana_available + + def test_isosurface_activates_when_mo_coeff_present(self, tmp_path, app): + result_with_coeff = _make_sp_result(with_coeff=True) + saved = self._save_with_orbitals(tmp_path, result_with_coeff) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Isosurface" in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, sp_result): + saved = save_result(sp_result, results_dir=tmp_path, calc_type="", spectra={}) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +class TestSPDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G single point → disk → history → panels.""" + + def _run_sp(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Single Point" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_single_point( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "single_point" + + def test_do_run_saves_orbitals_npz(self, tmp_path, running_app, monkeypatch): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved + assert ( + saved[0] / "orbitals.npz" + ).exists(), "orbitals.npz must be written by _do_run for history replay" + + def test_history_load_activates_energies_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Energies" in running_app._ana_available + + def test_history_load_activates_isosurface_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_sp(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Isosurface" in running_app._ana_available From b0e76fcf7ad5040a56f3dfd33d76033753366feb Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 10:15:19 -0400 Subject: [PATCH 10/16] Add history tests and PES/TD-DFT support Introduce integration tests for analysis history (geometry optimisation, NMR, PES scan, TD-DFT) and update app behavior and docs. README: document PES Scan and TD-DFT UV-Vis support, add pes_scan entry and update test suite count. quantui/app.py: reconstruct PES scan results from saved spectra when live_result is absent (compute relative energies in kcal, build scan labels) and persist pes_scan spectra on scan runs. quantui/tddft_calc.py: select TDHF when using_hf is set, otherwise use TDDFT. Adds four new test modules under tests/ to validate history roundtrips and panel activation. --- README.md | 9 +- quantui/app.py | 45 +++- quantui/tddft_calc.py | 2 +- tests/test_geo_opt_analysis_history.py | 302 +++++++++++++++++++++++ tests/test_nmr_analysis_history.py | 268 +++++++++++++++++++++ tests/test_pes_scan_analysis_history.py | 307 ++++++++++++++++++++++++ tests/test_tddft_analysis_history.py | 247 +++++++++++++++++++ 7 files changed, 1174 insertions(+), 6 deletions(-) create mode 100644 tests/test_geo_opt_analysis_history.py create mode 100644 tests/test_nmr_analysis_history.py create mode 100644 tests/test_pes_scan_analysis_history.py create mode 100644 tests/test_tddft_analysis_history.py diff --git a/README.md b/README.md index e870ce6..634c7d0 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ Built for classroom teaching at the - **3D visualization** — interactive py3Dmol or PlotlyMol viewer with a live backend toggle when both are installed; post-calculation structure rendered automatically in the results panel -- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, and NMR - shielding via PySCF, running in your Python kernel (no batch submission) +- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, NMR + shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running in your + Python kernel (no batch submission) - **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, Acetonitrile) via a single checkbox - **Rich results** — total energy, HOMO-LUMO gap, Mulliken charges, dipole @@ -148,6 +149,7 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): | Frequency | Vibrational frequencies, ZPVE, IR intensities, thermochemistry (H/S/G at 298 K), animated normal modes, IR spectrum chart (stick / Lorentzian broadened) | | UV-Vis (TD-DFT) | Excitation energies, oscillator strengths, UV-Vis spectrum plot | | NMR Shielding | ¹H and ¹³C chemical shifts relative to TMS via GIAO; tabulated by element | +| PES Scan | 1D potential energy surface along a bond, angle, or dihedral; energy profile chart; geometry animation at each scan point | ### Basis sets @@ -184,6 +186,7 @@ quantui/ Main package ir_plot.py IR spectrum chart (stick and Lorentzian broadened) tddft_calc.py TD-DFT UV-Vis excited-state calculations nmr_calc.py NMR shielding + ¹H/¹³C chemical shift prediction + pes_scan.py 1D potential energy surface scan optimizer.py QM geometry optimization with trajectory visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) pubchem.py PubChem molecule search @@ -197,7 +200,7 @@ quantui/ Main package notebooks/ molecule_computations.ipynb Main student-facing interface tutorials/ Step-by-step guided notebooks (01–05) -tests/ pytest test suite (575+ tests) +tests/ pytest test suite (860+ tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config diff --git a/quantui/app.py b/quantui/app.py index 06efef6..cbc57df 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1921,7 +1921,40 @@ def _shift_table(label: str, shifts: list, sym: str) -> str: def _pop_pes_plot(self, ctx: _AnalysisContext) -> bool: result = ctx.live_result if result is None: - return False # PES energy data not stored to disk; live-only for now + scan = ctx.spectra_data.get("pes_scan", {}) + if not scan or not scan.get("energies_hartree"): + return False + energies_ha = scan["energies_hartree"] + atom_indices = scan.get("atom_indices", []) + scan_type = scan.get("scan_type", "bond") + x_vals = scan.get("scan_parameter_values", []) + e_min = min(energies_ha) + _HARTREE_TO_KCAL = 627.5094740631 + e_rel = [(e - e_min) * _HARTREE_TO_KCAL for e in energies_ha] + idx = [i + 1 for i in atom_indices] + if scan_type == "bond": + label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å" + elif scan_type == "angle": + label = ( + f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °" + if len(idx) >= 3 + else "Angle / °" + ) + else: + label = ( + f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °" + if len(idx) >= 4 + else "Dihedral / °" + ) + result = _types_mod.SimpleNamespace( + scan_type=scan_type, + atom_indices=atom_indices, + scan_parameter_values=x_vals, + energies_hartree=energies_ha, + energies_relative_kcal=e_rel, + scan_coordinate_label=label, + converged_all=True, + ) return self._show_pes_scan_result(result) def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool: @@ -4987,7 +5020,15 @@ def _do_run(self) -> None: progress_stream=log, # type: ignore[arg-type] ) result_html = self._format_pes_scan_result(result) - save_spectra, save_type = {}, "pes_scan" + save_spectra = { + "pes_scan": { + "scan_type": result.scan_type, + "atom_indices": result.atom_indices, + "scan_parameter_values": result.scan_parameter_values, + "energies_hartree": result.energies_hartree, + } + } + save_type = "pes_scan" else: # Single Point self.run_status.value = "Calculating..." from quantui import run_in_session diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index 4291a59..1487031 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -210,7 +210,7 @@ def run_tddft_calc( oscillator_strengths: List[float] = [] try: - td = mf.TDDFT() + td = mf.TDHF() if using_hf else mf.TDDFT() td.nstates = nstates td.verbose = 3 td.stdout = stream diff --git a/tests/test_geo_opt_analysis_history.py b/tests/test_geo_opt_analysis_history.py new file mode 100644 index 0000000..c50a29e --- /dev/null +++ b/tests/test_geo_opt_analysis_history.py @@ -0,0 +1,302 @@ +"""Integration tests for the geometry-optimisation analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result writes result.json with calc_type + "geometry_opt"; save_trajectory writes trajectory.json; + save_orbitals writes orbitals.npz. + (2) History context — _build_history_context reconstructs the context. + (3) Panel activation — _apply_analysis_context activates Trajectory, Energies, + and Isosurface panels from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G geometry opt → disk write → + _history_load_analysis → all three panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_orbitals, + save_result, + save_trajectory, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_geo_opt_result(with_coeff: bool = True): + """Minimal namespace mirroring a real RHF/STO-3G geometry opt on water. + + Includes two trajectory steps so _pop_geo_trajectory passes the len >= 2 check. + """ + water_initial = Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + water_final = Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.96, 0.0, 0.0]], + ) + mo_coeff = np.eye(7) if with_coeff else None + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.1, + energy_ev=-2043.0, + homo_lumo_gap_ev=10.2, + converged=True, + n_steps=2, + n_iterations=10, + trajectory=[water_initial, water_final], + energies_hartree=[-75.0, -75.1], + molecule=water_final, + mo_energy_hartree=np.array([-20.5, -1.3, -0.7, -0.5, -0.3, 0.5, 0.7]), + mo_occ=np.array([2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0]), + mo_coeff=mo_coeff, + pyscf_mol_atom=[ + ("O", [0.0, 0.0, 0.0]), + ("H", [0.96, 0.0, 0.0]), + ("H", [-0.96, 0.0, 0.0]), + ], + pyscf_mol_basis="sto-3g", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def geo_opt_result(): + return _make_geo_opt_result() + + +# --------------------------------------------------------------------------- +# Part 1: save_result + save_trajectory + save_orbitals write the correct files +# --------------------------------------------------------------------------- + + +class TestGeoOptSpectraStructure: + def _save_all(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="geometry_opt", spectra={} + ) + save_trajectory(saved, result.trajectory, result.energies_hartree) + save_orbitals(saved, result) + return saved + + def test_result_json_has_geo_opt_calc_type(self, tmp_path, geo_opt_result): + saved = save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "geometry_opt" + + def test_trajectory_json_written(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + assert (saved / "trajectory.json").exists() + + def test_orbitals_npz_written(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + assert (saved / "orbitals.npz").exists() + + def test_trajectory_has_multiple_steps(self, tmp_path, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + raw = json.loads((saved / "trajectory.json").read_text()) + assert len(raw["steps"]) >= 2, "trajectory must have >= 2 steps" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestGeoOptHistoryContext: + def _save(self, tmp_path, geo_opt_result): + return save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + + def test_context_has_correct_calc_type(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "geometry_opt" + + def test_context_result_dir_set(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx.result_dir == saved + + def test_context_live_result_is_none(self, tmp_path, app, geo_opt_result): + saved = self._save(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + assert ctx.live_result is None + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestGeoOptPanelActivation: + def _save_all(self, tmp_path, result): + saved = save_result( + result, results_dir=tmp_path, calc_type="geometry_opt", spectra={} + ) + save_trajectory(saved, result.trajectory, result.energies_hartree) + save_orbitals(saved, result) + return saved + + def test_trajectory_panel_activates(self, tmp_path, app, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" in app._ana_available + + def test_energies_panel_activates(self, tmp_path, app, geo_opt_result): + saved = self._save_all(tmp_path, geo_opt_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Energies" in app._ana_available + + def test_isosurface_activates(self, tmp_path, app): + result_with_coeff = _make_geo_opt_result(with_coeff=True) + saved = self._save_all(tmp_path, result_with_coeff) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Isosurface" in app._ana_available + + def test_trajectory_absent_when_trajectory_json_missing( + self, tmp_path, app, geo_opt_result + ): + # save_result + save_orbitals, but NOT save_trajectory + saved = save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + save_orbitals(saved, geo_opt_result) + assert not (saved / "trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, geo_opt_result): + saved = save_result( + geo_opt_result, results_dir=tmp_path, calc_type="", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert len(app._ana_available) == 0 + assert app._to_analysis_btn.layout.display == "none" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +class TestGeoOptDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G geometry opt → disk → history → panels.""" + + def _run_geo_opt(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "Geometry Opt" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_geometry_opt( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "geometry_opt" + + def test_do_run_saves_trajectory_json(self, tmp_path, running_app, monkeypatch): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved + result_dir = saved[0] + assert ( + result_dir / "trajectory.json" + ).exists(), "trajectory.json must be written by _do_run for history replay" + raw = json.loads((result_dir / "trajectory.json").read_text()) + assert raw["atoms"] == ["O", "H", "H"] + assert len(raw["steps"]) >= 1 + assert any( + s["energy"] is not None for s in raw["steps"] + ), "energies must be non-empty in trajectory" + + def test_history_load_activates_trajectory_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Trajectory" in running_app._ana_available + + def test_history_load_activates_energies_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Energies" in running_app._ana_available + + def test_history_load_activates_isosurface_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_geo_opt(running_app, tmp_path, monkeypatch) + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Isosurface" in running_app._ana_available diff --git a/tests/test_nmr_analysis_history.py b/tests/test_nmr_analysis_history.py new file mode 100644 index 0000000..acbb1a2 --- /dev/null +++ b/tests/test_nmr_analysis_history.py @@ -0,0 +1,268 @@ +"""Integration tests for the NMR shielding analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "nmr" + and the correct nmr spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the NMR panel + from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G NMR → disk write → + _history_load_analysis → panel activates. + (PySCF-gated; requires pyscf-properties.) +""" + +from __future__ import annotations + +import json +import math +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + +# NMR additionally requires pyscf-properties (pip install pyscf-properties). +# Attempt a lightweight import check without running a calculation. +try: + from quantui.nmr_calc import run_nmr_calc # noqa: F401 + + _NMR_AVAILABLE = _PYSCF_AVAILABLE +except ImportError: + _NMR_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_nmr_result(): + """Minimal namespace mirroring a real RHF/STO-3G NMR result on water. + + Water has 3 atoms (1 O + 2 H) so shielding values should have length 3. + Chemical shift keys use string indices (as _do_run serialises them). + """ + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + atom_symbols=["O", "H", "H"], + shielding_iso_ppm=[320.5, 28.1, 28.1], + chemical_shifts_ppm={1: -1.5, 2: -1.5}, # int keys, serialised to str + reference_compound="TMS", + ) + + +def _make_nmr_spectra(result=None): + """Build the spectra dict as _do_run does for an NMR calculation.""" + if result is None: + result = _make_nmr_result() + return { + "nmr": { + "atom_symbols": list(result.atom_symbols), + "shielding_iso_ppm": list(result.shielding_iso_ppm), + "chemical_shifts_ppm": { + str(k): v for k, v in result.chemical_shifts_ppm.items() + }, + "reference_compound": result.reference_compound, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def nmr_result(): + return _make_nmr_result() + + +@pytest.fixture +def nmr_spectra(nmr_result): + return _make_nmr_spectra(nmr_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for NMR +# --------------------------------------------------------------------------- + + +class TestNMRSpectraStructure: + def test_result_json_has_nmr_calc_type(self, tmp_path, nmr_result, nmr_spectra): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "nmr" + + def test_atom_symbols_and_shielding_present( + self, tmp_path, nmr_result, nmr_spectra + ): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + nmr = data["spectra"]["nmr"] + assert "atom_symbols" in nmr + assert "shielding_iso_ppm" in nmr + + def test_atom_symbols_and_shielding_same_length( + self, tmp_path, nmr_result, nmr_spectra + ): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + data = json.loads((saved / "result.json").read_text()) + nmr = data["spectra"]["nmr"] + assert len(nmr["atom_symbols"]) == len( + nmr["shielding_iso_ppm"] + ), "atom_symbols and shielding_iso_ppm must have the same length" + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestNMRHistoryContext: + def _save(self, tmp_path, nmr_result, nmr_spectra): + return save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=nmr_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "nmr" + + def test_context_spectra_data_has_nmr_keys( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + nmr = ctx.spectra_data.get("nmr", {}) + assert nmr.get("atom_symbols"), "spectra_data must have nmr.atom_symbols" + assert nmr.get( + "shielding_iso_ppm" + ), "spectra_data must have nmr.shielding_iso_ppm" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestNMRPanelActivation: + def _save(self, tmp_path, nmr_result, spectra): + return save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra=spectra + ) + + def test_nmr_panel_activates_with_data( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "NMR" in app._ana_available + + def test_nmr_absent_when_spectra_missing(self, tmp_path, app, nmr_result): + saved = save_result( + nmr_result, results_dir=tmp_path, calc_type="nmr", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "NMR" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, nmr_result, nmr_spectra + ): + saved = self._save(tmp_path, nmr_result, nmr_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF + pyscf-properties gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _NMR_AVAILABLE, + reason="PySCF or pyscf-properties not available on this platform", +) +class TestNMRDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G NMR → disk → history → panel.""" + + def _run_nmr(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "NMR Shielding" + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_nmr(self, tmp_path, running_app, monkeypatch): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "nmr" + + def test_do_run_saves_shieldings_for_all_atoms( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + nmr = data.get("spectra", {}).get("nmr", {}) + symbols = nmr.get("atom_symbols", []) + shieldings = nmr.get("shielding_iso_ppm", []) + assert symbols == ["O", "H", "H"], "water must have 3 atoms: O, H, H" + assert len(shieldings) == 3, "must have one shielding value per atom" + assert all( + math.isfinite(s) for s in shieldings + ), "all shielding values must be finite floats" + + def test_history_load_activates_nmr_panel(self, tmp_path, running_app, monkeypatch): + saved = self._run_nmr(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "NMR" in running_app._ana_available diff --git a/tests/test_pes_scan_analysis_history.py b/tests/test_pes_scan_analysis_history.py new file mode 100644 index 0000000..0de0d8a --- /dev/null +++ b/tests/test_pes_scan_analysis_history.py @@ -0,0 +1,307 @@ +"""Integration tests for the PES Scan analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "pes_scan" + and the correct pes_scan spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the PES Scan panel + from a history context; Trajectory activates when + trajectory.json is present. + (4) _do_run end-to-end — Full path: real RHF/STO-3G bond scan on H2 → + disk write → _history_load_analysis → panels activate. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import ( + list_results, + load_result, + save_result, + save_trajectory, +) + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _h2(): + return Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]]) + + +def _make_pes_result(): + """Minimal namespace mirroring a real RHF/STO-3G bond scan on H2.""" + scan_values = [0.60, 0.70, 0.74, 0.80, 0.90] + energies = [-1.060, -1.115, -1.117, -1.100, -1.060] + mol_at_0 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.60]]) + mol_at_1 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.70]]) + mol_at_2 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]]) + mol_at_3 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.80]]) + mol_at_4 = Molecule(["H", "H"], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.90]]) + return SimpleNamespace( + formula="H2", + method="RHF", + basis="STO-3G", + energy_hartree=min(energies), + energy_ev=min(energies) * 27.211, + homo_lumo_gap_ev=8.0, + converged=True, + n_iterations=10, + scan_type="bond", + atom_indices=[0, 1], + scan_parameter_values=scan_values, + energies_hartree=energies, + coordinates_list=[mol_at_0, mol_at_1, mol_at_2, mol_at_3, mol_at_4], + converged_all=True, + ) + + +def _make_pes_spectra(result=None): + """Build the spectra dict as _do_run does for a PES scan.""" + if result is None: + result = _make_pes_result() + return { + "pes_scan": { + "scan_type": result.scan_type, + "atom_indices": result.atom_indices, + "scan_parameter_values": result.scan_parameter_values, + "energies_hartree": result.energies_hartree, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def pes_result(): + return _make_pes_result() + + +@pytest.fixture +def pes_spectra(pes_result): + return _make_pes_spectra(pes_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for pes_scan +# --------------------------------------------------------------------------- + + +class TestPESScanSpectraStructure: + def test_result_json_has_pes_scan_calc_type( + self, tmp_path, pes_result, pes_spectra + ): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "pes_scan" + + def test_pes_scan_keys_present(self, tmp_path, pes_result, pes_spectra): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + scan = data["spectra"]["pes_scan"] + assert "scan_type" in scan + assert "atom_indices" in scan + assert "scan_parameter_values" in scan + assert "energies_hartree" in scan + + def test_scan_values_non_empty(self, tmp_path, pes_result, pes_spectra): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + data = json.loads((saved / "result.json").read_text()) + scan = data["spectra"]["pes_scan"] + assert len(scan["scan_parameter_values"]) >= 2 + assert len(scan["energies_hartree"]) == len(scan["scan_parameter_values"]) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestPESScanHistoryContext: + def _save(self, tmp_path, pes_result, pes_spectra): + return save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "pes_scan" + + def test_context_spectra_data_has_pes_scan( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + scan = ctx.spectra_data.get("pes_scan", {}) + assert scan.get( + "energies_hartree" + ), "spectra_data must have pes_scan.energies_hartree for panel dispatch" + assert scan.get( + "scan_parameter_values" + ), "spectra_data must have pes_scan.scan_parameter_values" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestPESScanPanelActivation: + def _save(self, tmp_path, pes_result, pes_spectra): + return save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra=pes_spectra + ) + + def test_pes_scan_panel_activates_with_data( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "PES Scan" in app._ana_available + + def test_pes_scan_absent_when_spectra_empty(self, tmp_path, app, pes_result): + saved = save_result( + pes_result, results_dir=tmp_path, calc_type="pes_scan", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "PES Scan" not in app._ana_available + + def test_trajectory_panel_activates_with_trajectory_json( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + save_trajectory(saved, pes_result.coordinates_list, pes_result.energies_hartree) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" in app._ana_available + + def test_trajectory_absent_when_no_trajectory_json( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + assert not (saved / "trajectory.json").exists() + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "Trajectory" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, pes_result, pes_spectra + ): + saved = self._save(tmp_path, pes_result, pes_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +class TestPESScanDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G H2 bond scan → disk → history → panels.""" + + def _run_pes_scan(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "PES Scan" + app._scan_type_dd.value = "Bond" + app._scan_atom1.value = 1 + app._scan_atom2.value = 2 + app._scan_start.value = 0.6 + app._scan_stop.value = 0.9 + app._scan_steps.value = 3 + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_h2()) + return a + + def test_do_run_saves_calc_type_pes_scan(self, tmp_path, running_app, monkeypatch): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "pes_scan" + + def test_do_run_saves_scan_parameter_values( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + vals = data.get("spectra", {}).get("pes_scan", {}).get("scan_parameter_values") + assert ( + vals is not None and len(vals) >= 1 + ), "at least 1 scan point must be saved" + + def test_do_run_saves_energies_hartree(self, tmp_path, running_app, monkeypatch): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + energies = data.get("spectra", {}).get("pes_scan", {}).get("energies_hartree") + assert energies is not None and len(energies) >= 1 + + def test_history_load_activates_pes_scan_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "PES Scan" in running_app._ana_available + + def test_history_load_activates_trajectory_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_pes_scan(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "Trajectory" in running_app._ana_available diff --git a/tests/test_tddft_analysis_history.py b/tests/test_tddft_analysis_history.py new file mode 100644 index 0000000..653a2d5 --- /dev/null +++ b/tests/test_tddft_analysis_history.py @@ -0,0 +1,247 @@ +"""Integration tests for the TD-DFT (UV-Vis) analysis history roundtrip. + +Covers the complete path from "calculation finishes" to "history panels activate": + + (1) Spectra structure — save_result stores result.json with calc_type "tddft" + and the correct uv_vis spectra keys. + (2) History context — _build_history_context loads the spectra correctly. + (3) Panel activation — _apply_analysis_context activates the UV-Vis panel + from a history context. + (4) _do_run end-to-end — Full path: real RHF/STO-3G + 3 excited states → + disk write → _history_load_analysis → panel activates. + (PySCF-gated; skipped on Windows.) +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from quantui.app import QuantUIApp +from quantui.molecule import Molecule +from quantui.results_storage import list_results, load_result, save_result + +try: + from quantui.app import _PYSCF_AVAILABLE +except ImportError: + _PYSCF_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _water(): + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]], + ) + + +def _make_tddft_result(): + """Minimal namespace mirroring a real RHF/STO-3G TD-DFT result on water.""" + energies = [6.5, 7.2, 8.1] + return SimpleNamespace( + formula="H2O", + method="RHF", + basis="STO-3G", + energy_hartree=-75.0, + energy_ev=-2040.5, + homo_lumo_gap_ev=10.5, + converged=True, + n_iterations=8, + excitation_energies_ev=energies, + oscillator_strengths=[0.05, 0.12, 0.03], + wavelengths_nm=[1240.0 / e for e in energies], + ) + + +def _make_tddft_spectra(result=None): + """Build the spectra dict as _do_run does for a TD-DFT calculation.""" + if result is None: + result = _make_tddft_result() + return { + "uv_vis": { + "excitation_energies_ev": result.excitation_energies_ev, + "oscillator_strengths": result.oscillator_strengths, + "wavelengths_nm": result.wavelengths_nm, + } + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + +@pytest.fixture +def tddft_result(): + return _make_tddft_result() + + +@pytest.fixture +def tddft_spectra(tddft_result): + return _make_tddft_spectra(tddft_result) + + +# --------------------------------------------------------------------------- +# Part 1: save_result stores the correct JSON structure for tddft +# --------------------------------------------------------------------------- + + +class TestTDDFTSpectraStructure: + def test_result_json_has_tddft_calc_type( + self, tmp_path, tddft_result, tddft_spectra + ): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + assert data["calc_type"] == "tddft" + + def test_all_uv_vis_keys_present(self, tmp_path, tddft_result, tddft_spectra): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + uv = data["spectra"]["uv_vis"] + assert "excitation_energies_ev" in uv + assert "oscillator_strengths" in uv + assert "wavelengths_nm" in uv + + def test_wavelengths_non_empty(self, tmp_path, tddft_result, tddft_spectra): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + data = json.loads((saved / "result.json").read_text()) + wl = data["spectra"]["uv_vis"]["wavelengths_nm"] + assert len(wl) > 0 + assert all(w > 0 for w in wl) + + +# --------------------------------------------------------------------------- +# Part 2: _build_history_context reconstructs the context correctly +# --------------------------------------------------------------------------- + + +class TestTDDFTHistoryContext: + def _save(self, tmp_path, tddft_result, tddft_spectra): + return save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=tddft_spectra + ) + + def test_context_has_correct_calc_type( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + assert ctx is not None + assert ctx.calc_type == "tddft" + + def test_context_spectra_data_has_excitation_energies( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + uv = ctx.spectra_data.get("uv_vis", {}) + assert uv.get( + "excitation_energies_ev" + ), "spectra_data must have uv_vis.excitation_energies_ev for panel dispatch" + + +# --------------------------------------------------------------------------- +# Part 3: _apply_analysis_context activates the correct panels +# --------------------------------------------------------------------------- + + +class TestTDDFTPanelActivation: + def _save(self, tmp_path, tddft_result, spectra): + return save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra=spectra + ) + + def test_uv_vis_panel_activates_with_data( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "UV-Vis" in app._ana_available + + def test_uv_vis_absent_when_spectra_empty(self, tmp_path, app, tddft_result): + saved = save_result( + tddft_result, results_dir=tmp_path, calc_type="tddft", spectra={} + ) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert "UV-Vis" not in app._ana_available + + def test_navigate_button_visible_when_panel_activates( + self, tmp_path, app, tddft_result, tddft_spectra + ): + saved = self._save(tmp_path, tddft_result, tddft_spectra) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + assert app._to_analysis_btn.layout.display == "" + + +# --------------------------------------------------------------------------- +# Part 4: _do_run end-to-end (PySCF-gated) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +class TestTDDFTDoRunEndToEnd: + """Full pipeline: real RHF/STO-3G TD-DFT (3 states) → disk → history → panel.""" + + def _run_tddft(self, app, tmp_dir, monkeypatch): + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_dir)) + app.calc_type_dd.value = "UV-Vis (TD-DFT)" + app.method_dd.value = ( + "B3LYP" # RHF lacks TDDFT; B3LYP is the canonical TD-DFT choice + ) + app.nstates_si.value = 3 + app._do_run() + return list_results(tmp_dir) + + @pytest.fixture + def running_app(self): + a = QuantUIApp() + a._set_molecule(_water()) + return a + + def test_do_run_saves_calc_type_tddft(self, tmp_path, running_app, monkeypatch): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved, "No result saved to disk" + data = load_result(saved[0]) + assert data["calc_type"] == "tddft" + + def test_do_run_saves_excitation_energies(self, tmp_path, running_app, monkeypatch): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved + data = load_result(saved[0]) + energies = ( + data.get("spectra", {}).get("uv_vis", {}).get("excitation_energies_ev") + ) + assert ( + energies is not None and len(energies) >= 1 + ), "at least 1 excitation energy must be saved" + + def test_history_load_activates_uv_vis_panel( + self, tmp_path, running_app, monkeypatch + ): + saved = self._run_tddft(running_app, tmp_path, monkeypatch) + assert saved + running_app._deactivate_all_ana_panels() + running_app._history_load_analysis(saved[0]) + assert "UV-Vis" in running_app._ana_available From 34ee803ebcf0cf8e09c11b95a7b704fd5cb5ec00 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 11:06:45 -0400 Subject: [PATCH 11/16] Avoid result dirs colliding; skip PySCF on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused 30 failures on windows-latest: 1. save_result used datetime microseconds for unique directory names, butWindows timer resolution can return the same microsecond for back-to-back calls. Added a collision counter (_1, _2, ...) suffix so directories are always unique regardless of platform clock granularity. 2. PySCF now ships Windows wheels and imports successfully on the CI runner even though actual quantum calculations fail at runtime. The DoRunEndToEnd test classes were skipif(not _PYSCF_AVAILABLE), but import succeeding ≠ calculations working. Added sys.platform == 'win32' to the skipif condition so these tests are skipped on native Windows unconditionally. --- quantui/results_storage.py | 8 +++++++- tests/test_freq_analysis_history.py | 6 +++++- tests/test_geo_opt_analysis_history.py | 6 +++++- tests/test_nmr_analysis_history.py | 5 +++-- tests/test_pes_scan_analysis_history.py | 6 +++++- tests/test_sp_analysis_history.py | 6 +++++- tests/test_tddft_analysis_history.py | 6 +++++- 7 files changed, 35 insertions(+), 8 deletions(-) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index 1290dae..50bf396 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -96,7 +96,13 @@ def save_result( ] ) dest = base / dirname - dest.mkdir(parents=True, exist_ok=True) + # Windows timer resolution can produce identical microsecond timestamps for + # back-to-back calls; append a counter to guarantee a unique directory. + _collision = 1 + while dest.exists(): + dest = base / f"{dirname}_{_collision}" + _collision += 1 + dest.mkdir(parents=True) _e_ha = getattr(result, "energy_hartree", float("nan")) # energy_ev may be a property (SessionResult) or absent (OptimizationResult diff --git a/tests/test_freq_analysis_history.py b/tests/test_freq_analysis_history.py index 25eea56..33df680 100644 --- a/tests/test_freq_analysis_history.py +++ b/tests/test_freq_analysis_history.py @@ -20,6 +20,7 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace import numpy as np @@ -333,7 +334,10 @@ def test_trajectory_dark_when_no_preopt_trajectory_file( # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) class TestFreqDoRunEndToEnd: """Full pipeline: patched run_freq_calc → disk → _history_load_analysis → panels. diff --git a/tests/test_geo_opt_analysis_history.py b/tests/test_geo_opt_analysis_history.py index c50a29e..b438379 100644 --- a/tests/test_geo_opt_analysis_history.py +++ b/tests/test_geo_opt_analysis_history.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace import numpy as np @@ -238,7 +239,10 @@ def test_no_panels_when_calc_type_wrong(self, tmp_path, app, geo_opt_result): # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) class TestGeoOptDoRunEndToEnd: """Full pipeline: real RHF/STO-3G geometry opt → disk → history → panels.""" diff --git a/tests/test_nmr_analysis_history.py b/tests/test_nmr_analysis_history.py index acbb1a2..39491f3 100644 --- a/tests/test_nmr_analysis_history.py +++ b/tests/test_nmr_analysis_history.py @@ -16,6 +16,7 @@ import json import math +import sys from types import SimpleNamespace import pytest @@ -221,8 +222,8 @@ def test_navigate_button_visible_when_panel_activates( @pytest.mark.skipif( - not _NMR_AVAILABLE, - reason="PySCF or pyscf-properties not available on this platform", + not _NMR_AVAILABLE or sys.platform == "win32", + reason="PySCF/pyscf-properties not available or not supported on native Windows", ) class TestNMRDoRunEndToEnd: """Full pipeline: real RHF/STO-3G NMR → disk → history → panel.""" diff --git a/tests/test_pes_scan_analysis_history.py b/tests/test_pes_scan_analysis_history.py index 0de0d8a..6518889 100644 --- a/tests/test_pes_scan_analysis_history.py +++ b/tests/test_pes_scan_analysis_history.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace import pytest @@ -242,7 +243,10 @@ def test_navigate_button_visible_when_panel_activates( # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) class TestPESScanDoRunEndToEnd: """Full pipeline: real RHF/STO-3G H2 bond scan → disk → history → panels.""" diff --git a/tests/test_sp_analysis_history.py b/tests/test_sp_analysis_history.py index ce07036..a0e70bf 100644 --- a/tests/test_sp_analysis_history.py +++ b/tests/test_sp_analysis_history.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace import numpy as np @@ -200,7 +201,10 @@ def test_no_panels_when_calc_type_wrong(self, tmp_path, app, sp_result): # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) class TestSPDoRunEndToEnd: """Full pipeline: real RHF/STO-3G single point → disk → history → panels.""" diff --git a/tests/test_tddft_analysis_history.py b/tests/test_tddft_analysis_history.py index 653a2d5..c6a9f32 100644 --- a/tests/test_tddft_analysis_history.py +++ b/tests/test_tddft_analysis_history.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace import pytest @@ -200,7 +201,10 @@ def test_navigate_button_visible_when_panel_activates( # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available on this platform") +@pytest.mark.skipif( + not _PYSCF_AVAILABLE or sys.platform == "win32", + reason="PySCF not available or not supported on native Windows", +) class TestTDDFTDoRunEndToEnd: """Full pipeline: real RHF/STO-3G TD-DFT (3 states) → disk → history → panel.""" From 441fb939ab3f3e90151d984da159bf52afdcb01b Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 11:35:31 -0400 Subject: [PATCH 12/16] Support multiple pyscf IR APIs; constrain pyscf Pin pyscf to <=2.13.0 in pyproject.toml and make frequency calculation robust to pyscf API changes. freq_calc.py now prefers proc_hessian_ (when available), falls back to Infrared.kernel(), and finally to hess_obj.kernel(); it captures IR intensities from either path, validates counts against frequencies, and logs failures. Adds a small helper to normalize/store IR intensities and improved exception handling to maintain compatibility across pyscf versions. --- pyproject.toml | 2 +- quantui/freq_calc.py | 94 +++++++++++++++++++++++++++----------------- 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb9c928..33af09f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ packages = ["quantui"] # 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>=2.3.0,<=2.13.0", "pyscf-properties", ] diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 188daaa..25b9e8b 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -29,7 +29,7 @@ import logging import sys from dataclasses import dataclass, field -from typing import IO, List, Optional +from typing import IO, Any, List, Optional from .molecule import Molecule from .session_calc import HARTREE_TO_EV @@ -249,29 +249,45 @@ def run_freq_calc( hess_obj.verbose = mol.verbose hess_obj.stdout = stream - # Use pyscf.prop.infrared.proc_hessian_ when available so the CPHF - # mo1_grad computed here can be reused for IR intensities at no extra - # cost. Falls back to hess_obj.kernel() if the module is absent. + # Primary: proc_hessian_ (pyscf-properties ≤ 0.1.0 with pyscf 2.13.x) + # computes the Hessian + CPHF in one pass; mo1_grad is reused for IR. + # Fallback A: Infrared.kernel() (newer pyscf where proc_hessian_ was + # removed) runs the same combined computation via a different API. + # Fallback B: plain hess_obj.kernel() if infrared is unavailable. _mo1_grad = None _h1ao_grad = None + _IRCls: Any = None + _kdd: Any = None + _kir: Any = None + _ir_inten_raw = None # pre-computed intensities from Fallback A try: - from pyscf.prop.infrared.rhf import ( - Infrared as _IRCls, - ) - from pyscf.prop.infrared.rhf import ( - kernel_dipderiv as _kdd, - ) - from pyscf.prop.infrared.rhf import ( - kernel_ir as _kir, - ) - from pyscf.prop.infrared.rhf import ( # type: ignore[import] - proc_hessian_ as _proc_hess, - ) + import pyscf.prop.infrared.rhf as _ir_rhf_primary - _, _h1ao_grad, _mo1_grad, _ = _proc_hess(hess_obj) + _IRCls = _ir_rhf_primary.Infrared + _kdd = _ir_rhf_primary.kernel_dipderiv + _kir = _ir_rhf_primary.kernel_ir + _, _h1ao_grad, _mo1_grad, _ = _ir_rhf_primary.proc_hessian_(hess_obj) h = hess_obj.de - except ImportError: - h = hess_obj.kernel() + except (ImportError, AttributeError): + # proc_hessian_ absent — try Infrared.kernel() which gives both + # Hessian and IR intensities via a single CPHF solve. + try: + from pyscf.prop.infrared import rhf as _ir_rhf_mod + + _ir_obj = _ir_rhf_mod.Infrared(mf) + _ir_obj.verbose = mol.verbose + _ir_obj.stdout = stream + _ir_obj.kernel() + h = _ir_obj.de + _ir_inten_raw = getattr(_ir_obj, "ir_inten", None) + except ImportError: + h = hess_obj.kernel() + except Exception as _ir_exc: + logger.warning( + "Infrared.kernel() failed (%s); using Hessian.kernel() only", + _ir_exc, + ) + h = hess_obj.kernel() freq_info = pyscf_thermo.harmonic_analysis(mol, h) @@ -308,13 +324,25 @@ def run_freq_calc( except Exception: displacements = None - # IR intensities — free alongside the Hessian when pyscf.prop.infrared - # is available: mo1_grad from proc_hessian_ is reused, so no second - # CPHF solve is needed. + # IR intensities — two code paths depending on which Hessian route ran. + def _store_ir(arr): + """Validate length and store IR intensities.""" + nonlocal ir_intensities + import numpy as _np_ir2 + + _ir_arr = _np_ir2.asarray(arr, dtype=float).real.ravel().tolist() + if len(_ir_arr) == len(frequencies_cm1): + ir_intensities = _ir_arr + else: + logger.warning( + "IR intensity count (%d) != frequency count (%d); discarding", + len(_ir_arr), + len(frequencies_cm1), + ) + if _mo1_grad is not None: + # proc_hessian_ path: assemble IRCls manually with stashed gradients. try: - import numpy as _np_ir - _mf_ir = _IRCls(mf) _mf_ir.mf_hess = hess_obj _mf_ir._h1ao_grad = _h1ao_grad @@ -322,17 +350,13 @@ def run_freq_calc( _mf_ir.vib_dict = freq_info _kdd(_mf_ir) _kir(_mf_ir) - _ir_arr = ( - _np_ir.asarray(_mf_ir.ir_inten, dtype=float).real.ravel().tolist() - ) - if len(_ir_arr) == len(frequencies_cm1): - ir_intensities = _ir_arr - else: - logger.warning( - "IR intensity count (%d) != frequency count (%d); discarding", - len(_ir_arr), - len(frequencies_cm1), - ) + _store_ir(_mf_ir.ir_inten) + except Exception as _ir_exc: + logger.warning("IR intensities unavailable: %s", _ir_exc) + elif _ir_inten_raw is not None: + # Infrared.kernel() path: intensities already computed above. + try: + _store_ir(_ir_inten_raw) except Exception as _ir_exc: logger.warning("IR intensities unavailable: %s", _ir_exc) From f2387ed2082a2ad39417540dbd8c3a3ee8f731ec Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 11:50:17 -0400 Subject: [PATCH 13/16] Prefer Infrared.kernel(); tighten pyscf range Update pyproject to require pyscf < 2.13.0 (replace <=2.13.0 with <2.13.0). Refactor quantui/freq_calc.py to remove legacy proc_hessian_ compatibility code and related temporaries, simplify IR computation to use pyscf.prop.infrared.Infrared.kernel() with a fallback to hess_obj.kernel(), and remove an unused typing.Any import. This streamlines the Hessian/IR code path to match newer pyscf/pyscf-properties APIs while preserving IR intensity capture and logging failures. --- pyproject.toml | 2 +- quantui/freq_calc.py | 76 +++++++++++++------------------------------- 2 files changed, 23 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 33af09f..aedffd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ packages = ["quantui"] # 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,<=2.13.0", + "pyscf>=2.3.0,<2.13.0", "pyscf-properties", ] diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 25b9e8b..44900bb 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -29,7 +29,7 @@ import logging import sys from dataclasses import dataclass, field -from typing import IO, Any, List, Optional +from typing import IO, List, Optional from .molecule import Molecule from .session_calc import HARTREE_TO_EV @@ -249,45 +249,27 @@ def run_freq_calc( hess_obj.verbose = mol.verbose hess_obj.stdout = stream - # Primary: proc_hessian_ (pyscf-properties ≤ 0.1.0 with pyscf 2.13.x) - # computes the Hessian + CPHF in one pass; mo1_grad is reused for IR. - # Fallback A: Infrared.kernel() (newer pyscf where proc_hessian_ was - # removed) runs the same combined computation via a different API. - # Fallback B: plain hess_obj.kernel() if infrared is unavailable. - _mo1_grad = None - _h1ao_grad = None - _IRCls: Any = None - _kdd: Any = None - _kir: Any = None - _ir_inten_raw = None # pre-computed intensities from Fallback A + # Primary: Infrared.kernel() (pyscf-properties) computes the Hessian + + # CPHF in one pass and stores IR intensities in ir_inten. + # Fallback: plain hess_obj.kernel() when pyscf-properties is absent. + _ir_inten_raw = None try: - import pyscf.prop.infrared.rhf as _ir_rhf_primary - - _IRCls = _ir_rhf_primary.Infrared - _kdd = _ir_rhf_primary.kernel_dipderiv - _kir = _ir_rhf_primary.kernel_ir - _, _h1ao_grad, _mo1_grad, _ = _ir_rhf_primary.proc_hessian_(hess_obj) - h = hess_obj.de - except (ImportError, AttributeError): - # proc_hessian_ absent — try Infrared.kernel() which gives both - # Hessian and IR intensities via a single CPHF solve. - try: - from pyscf.prop.infrared import rhf as _ir_rhf_mod - - _ir_obj = _ir_rhf_mod.Infrared(mf) - _ir_obj.verbose = mol.verbose - _ir_obj.stdout = stream - _ir_obj.kernel() - h = _ir_obj.de - _ir_inten_raw = getattr(_ir_obj, "ir_inten", None) - except ImportError: - h = hess_obj.kernel() - except Exception as _ir_exc: - logger.warning( - "Infrared.kernel() failed (%s); using Hessian.kernel() only", - _ir_exc, - ) - h = hess_obj.kernel() + from pyscf.prop.infrared import rhf as _ir_rhf_mod + + _ir_obj = _ir_rhf_mod.Infrared(mf) + _ir_obj.verbose = mol.verbose + _ir_obj.stdout = stream + _ir_obj.kernel() + h = _ir_obj.de + _ir_inten_raw = getattr(_ir_obj, "ir_inten", None) + except ImportError: + h = hess_obj.kernel() + except Exception as _ir_exc: + logger.warning( + "Infrared.kernel() failed (%s); using Hessian.kernel() only", + _ir_exc, + ) + h = hess_obj.kernel() freq_info = pyscf_thermo.harmonic_analysis(mol, h) @@ -340,21 +322,7 @@ def _store_ir(arr): len(frequencies_cm1), ) - if _mo1_grad is not None: - # proc_hessian_ path: assemble IRCls manually with stashed gradients. - try: - _mf_ir = _IRCls(mf) - _mf_ir.mf_hess = hess_obj - _mf_ir._h1ao_grad = _h1ao_grad - _mf_ir._mo1_grad = _mo1_grad - _mf_ir.vib_dict = freq_info - _kdd(_mf_ir) - _kir(_mf_ir) - _store_ir(_mf_ir.ir_inten) - except Exception as _ir_exc: - logger.warning("IR intensities unavailable: %s", _ir_exc) - elif _ir_inten_raw is not None: - # Infrared.kernel() path: intensities already computed above. + if _ir_inten_raw is not None: try: _store_ir(_ir_inten_raw) except Exception as _ir_exc: From b1242455e914ce3fd939577982a27b2c4494c158 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 12:00:47 -0400 Subject: [PATCH 14/16] Update pyscf dependency in pyproject.toml Require pyscf>=2.13.0 and remove the pyscf-properties package from optional dependencies. PySCF 2.13+ restores NMR and pyscf.prop.infrared to the core, while pyscf-properties 0.1.0 overwrites core NMR with a CPHF-incompatible implementation and lacks infrared support; the comments were updated to document this. --- pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aedffd2..5fe6843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +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 pyscf.prop (NMR, etc.) — moved out of core in PySCF 2.0. +# pyscf>=2.13.0: NMR and pyscf.prop.infrared are in pyscf core. +# pyscf-properties 0.1.0 is NOT required — it overwrites pyscf core's NMR +# with a CPHF-incompatible version and does not include infrared. pyscf = [ - "pyscf>=2.3.0,<2.13.0", - "pyscf-properties", + "pyscf>=2.13.0", ] # ASE: structure I/O, extended molecule library, geometry optimisation From df6bbbf944bac840023f7787cdabc29a27e66e85 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 13:50:51 -0400 Subject: [PATCH 15/16] Prefer core pyscf.nmr; add pyscf-properties Clarify PySCF dependency notes in pyproject.toml and add pyscf-properties to the pyscf optional deps. In quantui/nmr_calc.py, prefer importing the core pyscf.nmr module first and only fall back to pyscf.prop.nmr (from pyscf-properties) to avoid incompatible CPHF interfaces and runtime reshape errors. Improved ImportError handling/messages to guide installing a compatible PySCF (>=2.13.0). --- pyproject.toml | 8 +++++--- quantui/nmr_calc.py | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fe6843..640a024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,11 +40,13 @@ 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>=2.13.0: NMR and pyscf.prop.infrared are in pyscf core. -# pyscf-properties 0.1.0 is NOT required — it overwrites pyscf core's NMR -# with a CPHF-incompatible version and does not include infrared. +# pyscf>=2.13.0: pyscf.prop.infrared is in pyscf 2.13.0+ core (creates the +# pyscf.prop namespace that infrared lives under). +# pyscf-properties: provides additional pyscf.prop.* modules (EFG, IR, etc.). +# NMR is accessed via pyscf.nmr (core, not overwritten by pyscf-properties). pyscf = [ "pyscf>=2.13.0", + "pyscf-properties", ] # ASE: structure I/O, extended molecule library, geometry optimisation diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index 0c13a67..a0ce10a 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -17,7 +17,7 @@ import sys from dataclasses import dataclass -from typing import Dict, List, Tuple +from typing import Any, Dict, List, Tuple from .molecule import Molecule @@ -120,13 +120,24 @@ def run_nmr_calc( converged = bool(getattr(mf, "converged", False)) + # pyscf.nmr (core) is NOT overwritten by pyscf-properties; always prefer it. + # pyscf.prop.nmr (pyscf-properties) uses an old CPHF interface incompatible + # with pyscf 2.x and will produce a reshape error at runtime. + _pyscf_nmr: Any = None try: - from pyscf.prop import nmr as _pyscf_nmr - except ImportError as exc: - raise ImportError( - "PySCF NMR module (pyscf.prop.nmr) not found. " - "Ensure PySCF>=2.0 is installed: pip install 'pyscf>=2.0'" - ) from exc + import pyscf.nmr + + _pyscf_nmr = pyscf.nmr + except ImportError: + try: + import pyscf.prop.nmr + + _pyscf_nmr = pyscf.prop.nmr + except ImportError as exc: + raise ImportError( + "PySCF NMR module not found. " + "Install PySCF>=2.13.0: pip install 'pyscf>=2.13.0'" + ) from exc try: if method_upper == "RHF": From 1dea519db905e2ca8ba5b6843c1b60de80d2e6c4 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 15:16:27 -0400 Subject: [PATCH 16/16] Numerical IR calc + pyscf NMR compatibility fixes Replace use of pyscf.prop.infrared fallback: always compute Hessian via hess_obj.kernel() and add a numerical IR intensity path that computes dipole derivatives by finite-difference displacements of each atom, projects onto normal modes and converts to km/mol. Improve robustness by restoring molecular geometry and verbosity after displacements and updating the warning message for failed IR intensity computation. In NMR code, prefer pyscf.prop.nmr (pyscf-properties) and update the install hint accordingly. Apply runtime patches for pyscf-properties compatibility: fix gen_vind to reshape with -1 (avoids reshape errors with smaller Krylov batches) and patch get_vxc_giao to round blksize down to a BLKSIZE multiple (avoids block_loop asserts). These changes improve compatibility with PySCF/pyscf-properties versions and prevent runtime reshape/blocksize errors. --- quantui/freq_calc.py | 102 +++++++++++++++++------------ quantui/nmr_calc.py | 149 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 56 deletions(-) diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 44900bb..cc90f5f 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -249,27 +249,7 @@ def run_freq_calc( hess_obj.verbose = mol.verbose hess_obj.stdout = stream - # Primary: Infrared.kernel() (pyscf-properties) computes the Hessian + - # CPHF in one pass and stores IR intensities in ir_inten. - # Fallback: plain hess_obj.kernel() when pyscf-properties is absent. - _ir_inten_raw = None - try: - from pyscf.prop.infrared import rhf as _ir_rhf_mod - - _ir_obj = _ir_rhf_mod.Infrared(mf) - _ir_obj.verbose = mol.verbose - _ir_obj.stdout = stream - _ir_obj.kernel() - h = _ir_obj.de - _ir_inten_raw = getattr(_ir_obj, "ir_inten", None) - except ImportError: - h = hess_obj.kernel() - except Exception as _ir_exc: - logger.warning( - "Infrared.kernel() failed (%s); using Hessian.kernel() only", - _ir_exc, - ) - h = hess_obj.kernel() + h = hess_obj.kernel() freq_info = pyscf_thermo.harmonic_analysis(mol, h) @@ -306,27 +286,69 @@ def run_freq_calc( except Exception: displacements = None - # IR intensities — two code paths depending on which Hessian route ran. - def _store_ir(arr): - """Validate length and store IR intensities.""" - nonlocal ir_intensities - import numpy as _np_ir2 - - _ir_arr = _np_ir2.asarray(arr, dtype=float).real.ravel().tolist() - if len(_ir_arr) == len(frequencies_cm1): - ir_intensities = _ir_arr - else: - logger.warning( - "IR intensity count (%d) != frequency count (%d); discarding", - len(_ir_arr), - len(frequencies_cm1), - ) - - if _ir_inten_raw is not None: + # Numerical IR intensities via finite-difference dipole derivatives. + # pyscf.prop.infrared is absent from released pyscf/pyscf-properties; + # we compute ∂μ/∂R by displacing each atom ±DELTA, then project onto + # the harmonic normal modes. + # Reference: Porezag & Pederson, Phys. Rev. B 54, 7830 (1996). + if displacements is not None and frequencies_cm1: try: - _store_ir(_ir_inten_raw) + import numpy as _np_ir + + _DELTA = 0.01 # Bohr + _BOHR_TO_ANG = 0.52917721092 + _KM_MOL_FAC = 42.255 # (D/Å)²/amu → km/mol + + _n_ir = mol.natm + _coords0 = mol.atom_coords().copy() + _dm0 = mf.make_rdm1() + _dpdx = _np_ir.zeros((_n_ir * 3, 3)) + _xc = getattr(mf, "xc", None) + + _mol_v = mol.verbose + mol.verbose = 0 + try: + for _I in range(_n_ir): + for _ax in range(3): + _cp = _coords0.copy() + _cp[_I, _ax] += _DELTA + mol.set_geom_(_cp, unit="Bohr") + if _xc is not None: + _mf_d = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + _mf_d.xc = _xc + else: + _mf_d = scf.RHF(mol) if mol.spin == 0 else scf.UHF(mol) + _mf_d.verbose = 0 + _mf_d.stdout = stream + _mf_d.kernel(dm0=_dm0) + _mu_p = _np_ir.array(_mf_d.dip_moment(verbose=0)) + + _cm = _coords0.copy() + _cm[_I, _ax] -= _DELTA + mol.set_geom_(_cm, unit="Bohr") + if _xc is not None: + _mf_d = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol) + _mf_d.xc = _xc + else: + _mf_d = scf.RHF(mol) if mol.spin == 0 else scf.UHF(mol) + _mf_d.verbose = 0 + _mf_d.stdout = stream + _mf_d.kernel(dm0=_dm0) + _mu_m = _np_ir.array(_mf_d.dip_moment(verbose=0)) + + _dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA) + finally: + mol.set_geom_(_coords0, unit="Bohr") + mol.verbose = _mol_v + + _dpdx_AA = _dpdx / _BOHR_TO_ANG + _nm_flat = _np_ir.array(displacements).reshape(len(frequencies_cm1), -1) + _dpdQ = _nm_flat @ _dpdx_AA + _ir = (_KM_MOL_FAC * (_dpdQ**2).sum(axis=1)).tolist() + if len(_ir) == len(frequencies_cm1): + ir_intensities = _ir except Exception as _ir_exc: - logger.warning("IR intensities unavailable: %s", _ir_exc) + logger.warning("Numerical IR intensities failed: %s", _ir_exc) # Thermochemistry at 298.15 K / 1 atm — best-effort try: diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index a0ce10a..ec44710 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -120,24 +120,141 @@ def run_nmr_calc( converged = bool(getattr(mf, "converged", False)) - # pyscf.nmr (core) is NOT overwritten by pyscf-properties; always prefer it. - # pyscf.prop.nmr (pyscf-properties) uses an old CPHF interface incompatible - # with pyscf 2.x and will produce a reshape error at runtime. + # pyscf.nmr does not exist in released pyscf; use pyscf.prop.nmr (pyscf-properties). _pyscf_nmr: Any = None try: - import pyscf.nmr - - _pyscf_nmr = pyscf.nmr - except ImportError: - try: - import pyscf.prop.nmr - - _pyscf_nmr = pyscf.prop.nmr - except ImportError as exc: - raise ImportError( - "PySCF NMR module not found. " - "Install PySCF>=2.13.0: pip install 'pyscf>=2.13.0'" - ) from exc + import pyscf.prop.nmr + + _pyscf_nmr = pyscf.prop.nmr + except ImportError as exc: + raise ImportError( + "PySCF NMR module not found. " + "Install pyscf-properties: pip install pyscf-properties" + ) from exc + + # pyscf-properties 0.1.0 gen_vind hardcodes reshape(3, nmo, nocc). + # pyscf 2.x krylov reduces the batch below 3 via linear-dependency masking, + # causing "cannot reshape array of size N into shape (3,nmo,nocc)". + # Patch gen_vind to use reshape(-1, nmo, nocc) so any batch size works. + try: + from functools import reduce as _reduce_nmr + + import pyscf.prop.nmr.rhf as _prop_nmr_rhf + from pyscf import lib as _pyscf_lib_nmr + + def _fixed_gen_vind(mf_arg, mo_coeff, mo_occ): + vresp = mf_arg.gen_response(singlet=True, hermi=2) + occidx = mo_occ > 0 + orbo = mo_coeff[:, occidx] + nocc = orbo.shape[1] + _nao, nmo = mo_coeff.shape + + def vind(mo1): + _mo1 = _np.asarray(mo1).reshape(-1, nmo, nocc) + dm1 = _np.asarray( + [ + _reduce_nmr(_np.dot, (mo_coeff, x * 2, orbo.T.conj())) + for x in _mo1 + ] + ) + dm1 = dm1 - dm1.transpose(0, 2, 1).conj() + v1mo = _pyscf_lib_nmr.einsum( + "xpq,pi,qj->xij", vresp(dm1), mo_coeff.conj(), orbo + ) + return v1mo.ravel() + + return vind + + _prop_nmr_rhf.gen_vind = _fixed_gen_vind + except Exception: + pass + + # pyscf-properties 0.1.0 get_vxc_giao computes + # blksize = min(int(X*BLKSIZE)*BLKSIZE, ngrids) + # which equals ngrids when ngrids < X*BLKSIZE, and ngrids may not be + # divisible by BLKSIZE. pyscf 2.x block_loop asserts blksize%BLKSIZE==0. + # Patch get_vxc_giao to round blksize down to the nearest BLKSIZE multiple. + try: + import numpy as _np_rks + import pyscf.prop.nmr.rks as _prop_nmr_rks + from pyscf.dft import numint as _numint_rks + + def _fixed_get_vxc_giao( + ni, mol, grids, xc_code, dms, max_memory=2000, verbose=None + ): + xctype = ni._xc_type(xc_code) + make_rho, nset, nao = ni._gen_rho_evaluator(mol, dms, hermi=1) + ngrids = len(grids.weights) + _BLKSIZE = _numint_rks.BLKSIZE + _raw_blk = int(max_memory / 12 * 1e6 / 8 / nao / _BLKSIZE) * _BLKSIZE + blksize = max(_BLKSIZE, (min(_raw_blk, ngrids) // _BLKSIZE) * _BLKSIZE) + shls_slice = (0, mol.nbas) + ao_loc = mol.ao_loc_nr() + + vmat = _np_rks.zeros((3, nao, nao)) + if xctype == "LDA": + buf = _np_rks.empty((4, blksize, nao)) + ao_deriv = 0 + for ao, mask, weight, coords in ni.block_loop( + mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf + ): + rho = make_rho(0, ao, mask, "LDA") + vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1] + vrho = vxc[0] + aow = _np_rks.einsum("pi,p->pi", ao, weight * vrho) + giao = mol.eval_gto( + "GTOval_ig", coords, comp=3, non0tab=mask, out=buf[1:] + ) + vmat[0] += _numint_rks._dot_ao_ao( + mol, aow, giao[0], mask, shls_slice, ao_loc + ) + vmat[1] += _numint_rks._dot_ao_ao( + mol, aow, giao[1], mask, shls_slice, ao_loc + ) + vmat[2] += _numint_rks._dot_ao_ao( + mol, aow, giao[2], mask, shls_slice, ao_loc + ) + rho = vxc = vrho = aow = None + elif xctype == "GGA": + buf = _np_rks.empty((10, blksize, nao)) + ao_deriv = 1 + for ao, mask, weight, coords in ni.block_loop( + mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf + ): + rho = make_rho(0, ao, mask, "GGA") + vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1] + vrho, vsigma = vxc[:2] + wv = _np_rks.empty_like(rho) + wv[0] = weight * vrho + wv[1:] = rho[1:] * (weight * vsigma * 2) + aow = _np_rks.einsum("npi,np->pi", ao[:4], wv) + giao = mol.eval_gto( + "GTOval_ig", coords, 3, non0tab=mask, out=buf[4:] + ) + vmat[0] += _numint_rks._dot_ao_ao( + mol, aow, giao[0], mask, shls_slice, ao_loc + ) + vmat[1] += _numint_rks._dot_ao_ao( + mol, aow, giao[1], mask, shls_slice, ao_loc + ) + vmat[2] += _numint_rks._dot_ao_ao( + mol, aow, giao[2], mask, shls_slice, ao_loc + ) + giao = mol.eval_gto( + "GTOval_ipig", coords, 9, non0tab=mask, out=buf[1:] + ) + _prop_nmr_rks._gga_sum_( + vmat, mol, ao, giao, wv, mask, shls_slice, ao_loc + ) + rho = vxc = vrho = vsigma = wv = aow = None + elif xctype == "MGGA": + raise NotImplementedError("meta-GGA") + + return vmat - vmat.transpose(0, 2, 1) + + _prop_nmr_rks.get_vxc_giao = _fixed_get_vxc_giao + except Exception: + pass try: if method_upper == "RHF":