diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69d4c7a..c15a8fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: # Single line: PowerShell does not support backslash line continuation. run: pytest -m "not network" --ignore=tests/test_session_calc.py --ignore=tests/test_optimizer.py --ignore=tests/test_preopt.py --no-cov --no-header -q - # ── Windows: real PyFock Phase-1 calculation ─────────────────────────────── + # ── Windows: real PyFock calculation ─────────────────────────────────────── test-windows-pyfock: name: PyFock integration (windows-latest, Python 3.11) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc7307..84fb7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Added +- **PyFock geometry and analysis phase** — native-Windows PBE/def2 geometry + optimization now uses PyFock's ASE calculator with analytical density-fitted + gradients. Single-point and final optimized-geometry results retain orbital + arrays, Mulliken charges, dipoles, and engine-native cube generation. + - **PyFock Phase-1 backend (M-PYFOCK PYF.2–PYF.6)** — normalized engine dispatch, native-Windows PBE/def2 single points for neutral closed-shell molecules, capability-driven calculation menus, engine provenance in logs diff --git a/README.md b/README.md index 76cdde0..6782cb3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Real output from QuantUI, straight from the app: - **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, CCSD, CCSD(T), NMR shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running in your Python kernel. Optional PyFock 0.1.x adds native-Windows PBE - single points for neutral, closed-shell molecules with def2-SVP/def2-TZVP; + single points and geometry optimizations for neutral, closed-shell molecules + with def2-SVP/def2-TZVP, including orbital and population analysis; engine capabilities automatically gate the setup menus - **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, Acetonitrile) via a single checkbox @@ -127,7 +128,7 @@ Real output from QuantUI, straight from the app: | --- | --- | --- | | Linux / macOS | Full | PySCF installs natively | | WSL (Windows) | Full | Use an Ubuntu WSL environment | -| Windows (native) | Partial | PyFock runs guarded PBE single points; use WSL/Apptainer for the full PySCF feature set | +| Windows (native) | Partial | PyFock runs guarded PBE single points and geometry optimizations; use WSL/Apptainer for the full PySCF feature set | ### Windows users: native PyFock or the full container @@ -140,11 +141,12 @@ python -m pip install "quantui[pyfock,ase,app]" ``` In **System Settings → Quantum engine**, select PyFock (or leave Automatic -selected when PySCF is absent). Phase 1 intentionally supports only neutral, -closed-shell PBE single points with def2-SVP or def2-TZVP. Density fitting is -always enabled. Hybrids, charged/open-shell systems, geometry optimization, -solvent, checkpoint warm starts, GPU, Mulliken/dipole analysis, and orbital -export remain PySCF-only. +selected when PySCF is absent). The validated subset supports neutral, +closed-shell PBE single points and geometry optimizations with def2-SVP or +def2-TZVP. Density fitting is always enabled, analytical gradients drive +optimization, and orbital, Mulliken, dipole, and cube analysis are retained. +Hybrids, charged/open-shell systems, solvent, checkpoint warm starts, and GPU +remain PySCF-only. PySCF does not install on Windows natively. For the complete feature set, the [`apptainer/quantui.def`](https://github.com/The-Schultz-Lab/QuantUI/blob/main/apptainer/quantui.def) container bundles diff --git a/docs/installation.md b/docs/installation.md index deaf7a6..acf3d0b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,7 +27,7 @@ pip install "quantui[pyscf,ase,app]" ## Windows -For native Windows and the guarded PyFock single-point subset, use Python 3.11: +For native Windows and the guarded PyFock subset, use Python 3.11: ```powershell py -3.11 -m venv .venv @@ -35,8 +35,9 @@ py -3.11 -m venv .venv python -m pip install "quantui[pyfock,ase,app]" ``` -Automatic engine selection uses PyFock when PySCF is absent. Phase 1 supports -neutral, closed-shell PBE/def2-SVP or PBE/def2-TZVP single points. For all +Automatic engine selection uses PyFock when PySCF is absent. The validated +subset supports neutral, closed-shell PBE/def2-SVP or PBE/def2-TZVP single +points and geometry optimizations. For all other methods and workflows, use WSL or the Apptainer container described in [Platform Support](platforms.md). @@ -45,7 +46,7 @@ other methods and workflows, use WSL or the Apptainer container described in | Extra | What it adds | | --- | --- | | `pyscf` | Canonical PySCF backend (full calculation and analysis feature set) | -| `pyfock` | PyFock Phase-1 backend (native-Windows PBE single points) | +| `pyfock` | PyFock backend (native-Windows PBE single points and geometry optimization) | | `ase` | ASE bridge for trajectory export and structure I/O | | `app` | Voilà, JupyterLab, and notebook launcher dependencies | | `xtb` | GFN-FF metal pre-optimization via xtb | diff --git a/docs/platforms.md b/docs/platforms.md index 8a577af..f743f5e 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -7,13 +7,13 @@ PyFock subset or move to WSL/Apptainer for the full feature set. | --- | --- | --- | | **Linux / macOS** | :material-check-circle:{ .green } Full | PySCF installs natively via conda or pip | | **WSL (Windows)** | :material-check-circle:{ .green } Full | Ubuntu WSL environment — follows the Linux path exactly | -| **Windows native** | :material-alert-circle:{ .yellow } Partial | PyFock: neutral closed-shell PBE/def2 single points; WSL/Apptainer: full PySCF feature set | +| **Windows native** | :material-alert-circle:{ .yellow } Partial | PyFock: neutral closed-shell PBE/def2 single points and geometry optimizations; WSL/Apptainer: full PySCF feature set | !!! info "Windows users" Install `quantui[pyfock,ase,app]` under Python 3.11 for native PBE single - points. QuantUI's engine picker hides unsupported controls. For hybrids, - ions, radicals, optimizations, spectra, solvent, checkpointing, GPU, and - orbital analysis, use [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) + points and geometry optimizations. QuantUI's engine picker hides unsupported + controls. For hybrids, ions, radicals, spectra, solvent, checkpointing, and + GPU, use [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) with Ubuntu or the [Apptainer container](https://github.com/The-Schultz-Lab/QuantUI/blob/main/apptainer/README.md) which bundles the complete environment in a single file. diff --git a/pyproject.toml b/pyproject.toml index ec7df6a..bb5d169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,9 @@ pyscf = [ # extra supplies and caps that runtime dependency explicitly. pyfock = [ "pyfock>=0.1.7,<0.2", + # PyFock's ASE calculator is the geometry-optimization path used by + # QuantUI; include ASE here so the documented backend extra is complete. + "ase>=3.22.0,<4", "scipy>=1.10,<2", # 0.68.0rc1 can be selected by PyFock's permissive floor and brought a # prerelease llvmlite that crashed at import on Linux/Python 3.12. diff --git a/quantui/app.py b/quantui/app.py index a6be266..f513eeb 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1770,6 +1770,7 @@ def __init__(self) -> None: self._last_orb_mo_occ: Any = None self._last_orb_mol_atom: Any = None self._last_orb_mol_basis: Any = None + self._last_orb_engine_id: str = "pyscf" # Mulliken Populations panel state (table + Plotly bar chart). self._last_mulliken_symbols: Any = None self._last_mulliken_charges: Any = None @@ -4076,9 +4077,7 @@ def _apply_quantum_engine_capabilities(self) -> None: self.execution_backend_dd.value = "local" self.solvent_cb.value = False self.solvent_cb.disabled = True - self.solvent_cb.description = ( - "Implicit solvent — unavailable in PyFock Phase 1" - ) + self.solvent_cb.description = "Implicit solvent — unavailable in PyFock" self._freq_preopt_cb.value = False self._freq_preopt_cb.disabled = True self._freq_preopt_cb.layout.display = "none" @@ -5791,7 +5790,7 @@ def _on_scf_converged() -> None: def _run_required_final_single_point(target_mol, reason: str): """Run a required post-optimization single point on target geometry.""" - from quantui import run_in_session + from quantui.engines import EnginePreference, EngineRequest, run_calc _solvent = self.solvent_dd.value if self.solvent_cb.value else None self.run_status.value = ( @@ -5801,13 +5800,28 @@ def _run_required_final_single_point(target_mol, reason: str): f"\n-- Required single-point ({reason}) " "on optimized geometry --------------------------------\n" ) - sp_result = run_in_session( - molecule=target_mol, - method=self.method_dd.value, - basis=self.basis_dd.value, - progress_stream=log, # type: ignore[arg-type] - solvent=_solvent, + sp_result = run_calc( + EngineRequest( + request_id=_uuid.uuid4().hex, + calc_type="single_point", + method=self.method_dd.value, + basis=self.basis_dd.value, + charge=int(target_mol.charge), + multiplicity=int(target_mol.multiplicity), + molecule={ + "atoms": list(target_mol.atoms), + "coordinates": [list(c) for c in target_mol.coordinates], + }, + options={"verbose": 4, "scf_rescue": True}, + progress_stream=log, # type: ignore[arg-type] + solvent=_solvent, + ), + preferred=cast( + EnginePreference, + self._user_settings.compute.quantum_engine, + ), ) + sp_result = sp_result.to_session_result() if not bool(getattr(sp_result, "converged", False)): raise RuntimeError( "Required post-optimization single-point did not converge." @@ -5961,7 +5975,7 @@ def _run_required_final_single_point(target_mol, reason: str): ) self.run_status.value = "Optimizing geometry..." - from quantui import optimize_geometry + from quantui.engines import EnginePreference, EngineRequest, run_calc # History-based expected step count → "step k/~N" + a floor # for the live progress fraction. None on cold history. @@ -5973,19 +5987,35 @@ def _run_required_final_single_point(target_mol, reason: str): ) log.opt_fmax_target = float(self.fmax_fi.value) log.opt_fmax0 = None - result = optimize_geometry( - molecule=calc_mol, - method=self.method_dd.value, - basis=self.basis_dd.value, - fmax=self.fmax_fi.value, - steps=self.max_steps_si.value, - progress_stream=log, # type: ignore[arg-type] - expected_steps=( - int(round(_expected_steps)) if _expected_steps else None + result = run_calc( + EngineRequest( + request_id=_uuid.uuid4().hex, + calc_type="geometry_opt", + method=self.method_dd.value, + basis=self.basis_dd.value, + charge=int(calc_mol.charge), + multiplicity=int(calc_mol.multiplicity), + molecule={ + "atoms": list(calc_mol.atoms), + "coordinates": [list(c) for c in calc_mol.coordinates], + }, + options={ + "fmax": self.fmax_fi.value, + "steps": self.max_steps_si.value, + "expected_steps": ( + int(round(_expected_steps)) if _expected_steps else None + ), + "resume": _resume, + "scf_rescue": True, + }, + progress_stream=log, # type: ignore[arg-type] + checkpoint=_ckpt, ), - checkpoint=_ckpt, - resume=_resume, - ) + preferred=cast( + EnginePreference, + self._user_settings.compute.quantum_engine, + ), + ).to_session_result() _sp_result = _run_required_final_single_point( result.molecule, "after geometry optimisation", @@ -6017,6 +6047,19 @@ def _run_required_final_single_point(target_mol, reason: str): "pyscf_mol_basis", result.pyscf_mol_basis, ) + result.atom_symbols = getattr( + _sp_result, "atom_symbols", result.atom_symbols + ) + result.mulliken_charges = getattr( + _sp_result, "mulliken_charges", result.mulliken_charges + ) + result.dipole_moment_debye = getattr( + _sp_result, "dipole_moment_debye", result.dipole_moment_debye + ) + result.dipole_vector_debye = getattr( + _sp_result, "dipole_vector_debye", result.dipole_vector_debye + ) + result.engine_id = getattr(_sp_result, "engine_id", result.engine_id) result_html = self._format_opt_result(result) save_spectra, save_type = {}, "geometry_opt" elif ct == "Frequency": @@ -6961,6 +7004,18 @@ def _begin_run_checkpoint(self, log_stream: Optional[Any] = None) -> Optional[An the reason a calculation doesn't start. """ try: + from quantui.engines import EnginePreference, resolve_engine + + caps = resolve_engine( + cast( + EnginePreference, + self._user_settings.compute.quantum_engine, + ) + ).capabilities() + if not caps.supports_checkpoint_warm_start: + self._checkpoint_resumable = False + return None + from quantui.app_runflow import checkpoint_identity from quantui.checkpoint import Checkpoint diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py index 56ce282..f50a99e 100644 --- a/quantui/app_analysis.py +++ b/quantui/app_analysis.py @@ -234,6 +234,7 @@ def apply_analysis_context(app: Any, ctx: Any) -> None: app._last_orb_mo_coeff = None app._last_orb_mo_occ = None app._last_orb_method = None + app._last_orb_engine_id = "pyscf" # Mulliken state consumed by the Populations panel — reset so a context # without charges cannot leak the prior calc's chart into this one. app._last_mulliken_symbols = None diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index dfaaf7d..59fafbf 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -1297,6 +1297,7 @@ def show_orbital_diagram(app: Any, result: Any) -> bool: app._last_orb_mo_occ = mo_occ app._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None) app._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None) + app._last_orb_engine_id = str(getattr(result, "engine_id", "pyscf") or "pyscf") # AUDIT additional-concerns — snapshot the method that actually # produced this mo_coeff, from the result object itself, rather than # reading the live Method dropdown at cube-generation time (below). @@ -1580,6 +1581,7 @@ def _show_range_err() -> None: from quantui.orbital_visualization import ( generate_cube_from_arrays, + generate_pyfock_cube_from_arrays, infer_charge_and_spin, plot_cube_isosurface, render_orbital_isosurface_py3dmol, @@ -1641,7 +1643,14 @@ def _show_range_err() -> None: # the dropdown shows later — immutable result provenance instead # of a mutable, disconnectable UI control. _method_for_provenance = str(getattr(app, "_last_orb_method", "") or "") - generate_cube_from_arrays( + # The PySCF and PyFock generators have intentionally different + # optional keyword sets; both accept this shared call shape. + _cube_generator: Any = ( + generate_pyfock_cube_from_arrays + if getattr(app, "_last_orb_engine_id", "pyscf") == "pyfock" + else generate_cube_from_arrays + ) + _cube_generator( mol_atom, mol_basis, mo_coeff, diff --git a/quantui/engines/pyfock_engine.py b/quantui/engines/pyfock_engine.py index f32d292..c683dcc 100644 --- a/quantui/engines/pyfock_engine.py +++ b/quantui/engines/pyfock_engine.py @@ -1,11 +1,11 @@ -"""PyFock adapter for the guarded Phase-1 single-point DFT subset.""" +"""PyFock adapter for the guarded neutral, closed-shell DFT subset.""" from __future__ import annotations import importlib.util import sys from contextlib import redirect_stderr, redirect_stdout -from typing import Optional +from typing import Any, Optional from .base import ( EngineCapabilities, @@ -44,7 +44,7 @@ def _pyfock_version() -> str: class PyfockEngine: - """Pure-Python DFT engine for native Windows and teaching single points.""" + """Pure-Python DFT engine for native Windows and teaching workflows.""" @property def engine_id(self) -> str: @@ -54,17 +54,18 @@ def capabilities(self) -> EngineCapabilities: return EngineCapabilities( engine_id="pyfock", display_name="PyFock", - supported_calc_types=("single_point",), + supported_calc_types=("single_point", "geometry_opt"), supported_methods=_PYFOCK_METHODS, supported_basis_sets=_PYFOCK_BASES, supports_solvent=False, supports_checkpoint_warm_start=False, supports_gpu=False, supports_post_hf=False, - supports_orbital_export=False, + supports_orbital_export=True, platform_notes=( - "Phase-1 neutral, closed-shell PBE single points. Density fitting " - "is always on; hybrids, solvent, checkpoints, and GPU are gated off. " + "Neutral, closed-shell PBE single points and geometry optimizations. " + "Density fitting and analytical gradients are used; hybrids, " + "solvent, checkpoints, and GPU are gated off. " "Install with pip install quantui[pyfock]." ), recommended_auxbasis=_AUX_BASIS, @@ -72,8 +73,13 @@ def capabilities(self) -> EngineCapabilities: ) def run(self, request: EngineRequest) -> EngineResult: - """Run one neutral, closed-shell PBE single point through PyFock.""" + """Run one validated PyFock calculation.""" self._validate_request(request) + if request.calc_type == "geometry_opt": + return self._run_geometry_opt(request) + return self._run_single_point(request) + + def _run_single_point(self, request: EngineRequest) -> EngineResult: stream = request.progress_stream or sys.stdout atoms, coordinates = _molecule_arrays(request.molecule) @@ -94,7 +100,7 @@ def run(self, request: EngineRequest) -> EngineResult: "quantui[pyfock] extra and restart the kernel." ), ) from exc - print("\n-- PyFock Phase-1 single point -----------------------------") + print("\n-- PyFock single point -------------------------------------") print( f"Engine: PyFock {_pyfock_version() or 'unknown'} | " f"{request.method}/{request.basis} | density fitting: on" @@ -126,7 +132,7 @@ def run(self, request: EngineRequest) -> EngineResult: # functions). PyFock defaults to Cartesian 6d/10f, which shifts # even the water reference outside the milestone parity bound. dft.sao = True - energy, _density = dft.scf() + energy, density = dft.scf() except EngineUnavailableError: raise except Exception as exc: # noqa: BLE001 — normalize third-party failures @@ -153,10 +159,7 @@ def run(self, request: EngineRequest) -> EngineResult: getattr(dft, "mo_energies", None), getattr(dft, "mo_occupations", None), ) - warnings = [ - "PyFock Phase 1 uses density fitting with def2-universal-jfit.", - "Orbital export and Mulliken/dipole analysis are not available yet.", - ] + warnings = ["PyFock uses density fitting with def2-universal-jfit."] if not bool(getattr(dft, "converged", False)): warnings.append("PyFock reached its iteration limit without convergence.") @@ -173,21 +176,60 @@ def run(self, request: EngineRequest) -> EngineResult: homo_lumo_gap_ev=gap_ev, warnings=warnings, ) - result.native_result = result.to_session_result() + native = result.to_session_result() + _attach_analysis(native, mol, basis, dft, density, atoms, coordinates, warnings) + result.native_result = native return result + def _run_geometry_opt(self, request: EngineRequest) -> EngineResult: + from quantui.molecule import Molecule + from quantui.optimizer import optimize_geometry + + atoms, coordinates = _molecule_arrays(request.molecule) + molecule = Molecule( + atoms=list(atoms), + coordinates=[list(c) for c in coordinates], + charge=request.charge, + multiplicity=request.multiplicity, + ) + native = optimize_geometry( + molecule=molecule, + method=request.method, + basis=request.basis, + fmax=_positive_float(request.options.get("fmax"), default=0.05), + steps=_positive_int(request.options.get("steps"), default=200), + progress_stream=request.progress_stream, + expected_steps=request.options.get("expected_steps"), + engine_id=self.engine_id, + ncores=_positive_int(request.options.get("ncores"), default=1), + ) + return EngineResult( + request_id=request.request_id, + engine_id=self.engine_id, + status="success", + converged=native.converged, + energy_hartree=native.energy_hartree, + n_iterations=native.n_steps, + method=native.method, + basis=native.basis, + formula=native.formula, + native_result=native, + ) + def _validate_request(self, request: EngineRequest) -> None: caps = self.capabilities() if request.calc_type not in caps.supported_calc_types: raise UnsupportedCapabilityError( - f"PyFock does not support {request.calc_type!r} in Phase 1.", - user_message="PyFock Phase 1 supports Single Point calculations only.", + f"PyFock does not support {request.calc_type!r}.", + user_message=( + "PyFock supports Single Point and Geometry Opt calculations." + ), ) if request.method.upper() not in caps.supported_methods: raise UnsupportedCapabilityError( f"PyFock method {request.method!r} is not validated.", user_message=( - "PyFock Phase 1 currently supports PBE only. Hybrid " + "PyFock currently supports PBE only. Hybrid " "functionals require exact exchange that upstream PyFock " "does not yet provide." ), @@ -195,30 +237,28 @@ def _validate_request(self, request: EngineRequest) -> None: if request.basis not in (caps.supported_basis_sets or ()): raise UnsupportedCapabilityError( f"PyFock basis {request.basis!r} is not validated.", - user_message=( - "PyFock Phase 1 currently supports def2-SVP and def2-TZVP." - ), + user_message=("PyFock currently supports def2-SVP and def2-TZVP."), ) if request.charge != 0: raise UnsupportedCapabilityError( - "PyFock charged molecules are not enabled in Phase 1.", + "PyFock charged molecules are not enabled.", user_message=( - "PyFock Phase 1 is limited to neutral molecules while its " + "PyFock is limited to neutral molecules while its " "charge convention is validated. Select PySCF for ions." ), ) if request.multiplicity != 1: raise UnsupportedCapabilityError( - "PyFock open-shell calculations are not enabled in Phase 1.", + "PyFock open-shell calculations are not enabled.", user_message=( - "PyFock Phase 1 is limited to closed-shell singlets. " + "PyFock is limited to closed-shell singlets. " "Select PySCF for radicals or other spin states." ), ) if request.solvent: raise UnsupportedCapabilityError( - "PyFock solvent models are not enabled in Phase 1.", - user_message="Implicit solvent is not available with PyFock Phase 1.", + "PyFock solvent models are not enabled.", + user_message="Implicit solvent is not available with PyFock.", ) _molecule_arrays(request.molecule) @@ -229,6 +269,58 @@ def _load_pyfock_api(): return Mol, Basis, DFT +def _attach_analysis( + result: Any, + mol: Any, + basis: Any, + dft: Any, + density: Any, + atoms: list[str], + coordinates: list[list[float]], + warnings: list[str], +) -> None: + """Attach portable orbital, Mulliken, and dipole fields when available.""" + try: + import numpy as np + + result.mo_energy_hartree = np.asarray(dft.mo_energies, dtype=float) + result.mo_occ = np.asarray(dft.mo_occupations, dtype=float) + result.mo_coeff = np.asarray(dft.mo_coefficients, dtype=float) + result.pyscf_mol_atom = [ + (symbol, [float(x), float(y), float(z)]) + for symbol, (x, y, z) in zip(atoms, coordinates) + ] + result.pyscf_mol_basis = result.basis + except Exception as exc: # noqa: BLE001 - analysis is additive + warnings.append(f"PyFock orbital extraction was unavailable: {exc}") + + try: + import numpy as np + from pyfock import Integrals + + dmat = np.asarray(density, dtype=float) + overlap = np.asarray(Integrals.overlap_mat_symm(basis), dtype=float) + populations = np.diag(dmat @ overlap) + gross: Any = np.zeros(len(atoms), dtype=float) + for ao_index, atom_index in enumerate(basis.bfs_atoms): + gross[int(atom_index)] += populations[ao_index] + result.atom_symbols = list(atoms) + result.mulliken_charges = [ + float(charge - population) + for charge, population in zip(mol.Zcharges, gross) + ] + + dipole_matrix = Integrals.dipole_moment_mat_symm(basis) + dipole_debye = ( + np.asarray(mol.get_dipole_moment(dipole_matrix, dmat), dtype=float) + * 2.541746473 + ) + result.dipole_vector_debye = [float(v) for v in dipole_debye] + result.dipole_moment_debye = float(np.linalg.norm(dipole_debye)) + except Exception as exc: # noqa: BLE001 - analysis is additive + warnings.append(f"PyFock population analysis was unavailable: {exc}") + + def _molecule_arrays(molecule: dict): atoms = molecule.get("atoms") coordinates = molecule.get("coordinates") diff --git a/quantui/engines/pyscf_engine.py b/quantui/engines/pyscf_engine.py index 35ccb6b..afc9694 100644 --- a/quantui/engines/pyscf_engine.py +++ b/quantui/engines/pyscf_engine.py @@ -68,7 +68,9 @@ def capabilities(self) -> EngineCapabilities: ) def run(self, request: EngineRequest) -> EngineResult: - """Adapt the existing, feature-complete PySCF single-point path.""" + """Adapt the established PySCF paths to the shared engine contract.""" + if request.calc_type == "geometry_opt": + return self._run_geometry_opt(request) if request.calc_type != "single_point": raise UnsupportedCapabilityError( f"Shared engine dispatch does not yet own {request.calc_type!r}.", @@ -113,6 +115,42 @@ def run(self, request: EngineRequest) -> EngineResult: native_result=native, ) + def _run_geometry_opt(self, request: EngineRequest) -> EngineResult: + from quantui.molecule import Molecule + from quantui.optimizer import optimize_geometry + + molecule = Molecule( + atoms=list(request.molecule["atoms"]), + coordinates=[list(c) for c in request.molecule["coordinates"]], + charge=request.charge, + multiplicity=request.multiplicity, + ) + native = optimize_geometry( + molecule=molecule, + method=request.method, + basis=request.basis, + fmax=float(request.options.get("fmax", 0.05)), + steps=int(request.options.get("steps", 200)), + progress_stream=request.progress_stream, + expected_steps=request.options.get("expected_steps"), + checkpoint=request.checkpoint, + resume=bool(request.options.get("resume", False)), + scf_rescue=bool(request.options.get("scf_rescue", True)), + engine_id=self.engine_id, + ) + return EngineResult( + request_id=request.request_id, + engine_id=self.engine_id, + status="success", + converged=native.converged, + energy_hartree=native.energy_hartree, + n_iterations=native.n_steps, + method=native.method, + basis=native.basis, + formula=native.formula, + native_result=native, + ) + def build_pyscf_engine() -> Optional[QuantumEngine]: if not _AVAILABLE: diff --git a/quantui/help_content.py b/quantui/help_content.py index d80f7ac..6096e4a 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -44,8 +44,8 @@ "History to reload a previous run" "" "

Platform note: PySCF's full feature set requires Linux, " - "macOS, or WSL. Native Windows can use the optional PyFock Phase-1 " - "subset for neutral closed-shell PBE single points.

" + "macOS, or WSL. Native Windows can use the optional PyFock subset " + "for neutral closed-shell PBE single points and geometry optimization.

" "

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

" "

While a calculation runs, see Live run progress in the " @@ -60,9 +60,11 @@ "uses PyFock, which is useful on native Windows.

" "

PySCF is QuantUI's canonical engine and provides the full " "calculation, solvent, checkpoint, GPU, and analysis feature set.

" - "

PyFock Phase 1 is deliberately narrower: neutral, " - "closed-shell PBE single points with def2-SVP or def2-TZVP. Density " - "fitting is always on. Unsupported methods and controls disappear " + "

PyFock is deliberately narrower: neutral, closed-shell " + "PBE single points and geometry optimizations with def2-SVP or " + "def2-TZVP. Density fitting is always on, analytical gradients drive " + "geometry optimization, and orbital/population analysis is retained. " + "Unsupported methods and controls disappear " "from the setup menus instead of failing deep inside a calculation.

" "

The selected engine is recorded in the live log, result card, " "and saved result.json.

" diff --git a/quantui/optimizer.py b/quantui/optimizer.py index 0e85a8c..09d790a 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -1,5 +1,5 @@ """ -QM geometry optimization using ASE-BFGS + PySCF gradients. +QM geometry optimization using ASE-BFGS + quantum-engine gradients. Performs a full quantum mechanical geometry optimization by coupling the ASE BFGS optimizer with a thin PySCF wrapper calculator. Atoms are @@ -12,8 +12,8 @@ Platform notes -------------- -Requires PySCF — **Linux / macOS / WSL only**. ASE >= 3.22 required. -This module imports PySCF lazily so it can be imported safely on Windows. +Requires ASE >= 3.22 and either PySCF or PyFock. Both engines are imported +lazily so this module remains safe to import on native Windows. Implementation note ------------------- @@ -333,6 +333,7 @@ class OptimizationResult: mulliken_charges: Optional[List[float]] = None dipole_moment_debye: Optional[float] = None dipole_vector_debye: Optional[List[float]] = None + engine_id: str = "pyscf" @property def energy_hartree(self) -> float: @@ -468,9 +469,11 @@ def optimize_geometry( checkpoint: Optional[Any] = None, resume: bool = False, scf_rescue: bool = True, + engine_id: str = "pyscf", + ncores: int = 1, ) -> OptimizationResult: """ - Optimize a molecular geometry at the QM level using ASE-BFGS + PySCF. + Optimize a molecular geometry at the QM level using ASE-BFGS. Runs a BFGS quasi-Newton geometry optimization. At each step the PySCF mean-field calculator provides the energy and analytical @@ -527,7 +530,7 @@ def optimize_geometry( (step number and maximum force) to *progress_stream*. """ # --- Dependency checks --- - if not ASE_AVAILABLE or _QuantUIPySCFCalc is None: + if not ASE_AVAILABLE: raise ImportError( "ASE is not installed — cannot run geometry optimization.\n" " pip install 'ase>=3.22.0'\n" @@ -551,14 +554,29 @@ def optimize_geometry( "on the optimized geometry." ) - try: - import pyscf as _pyscf # noqa: F401 — presence check - except ImportError as exc: - raise ImportError( - "PySCF is not installed — cannot run geometry optimization.\n" - " conda install -c conda-forge pyscf\n" - "Note: PySCF is Linux / macOS / WSL only." - ) from exc + if engine_id not in {"pyscf", "pyfock"}: + raise ValueError( + f"Unknown quantum engine for geometry optimization: {engine_id}" + ) + if engine_id == "pyscf": + if _QuantUIPySCFCalc is None: + raise ImportError("ASE calculator support is unavailable.") + try: + import pyscf as _pyscf # noqa: F401 — presence check + except ImportError as exc: + raise ImportError( + "PySCF is not installed — cannot run geometry optimization.\n" + " conda install -c conda-forge pyscf\n" + "Note: PySCF is Linux / macOS / WSL only." + ) from exc + else: + try: + from pyfock import PyFockCalculator as _PyFockCalculator + except (ImportError, OSError) as exc: + raise ImportError( + "PyFock is not installed — cannot run this geometry optimization.\n" + " pip install 'quantui[pyfock]'" + ) from exc try: from ase.optimize import BFGS # type: ignore[import] @@ -605,17 +623,44 @@ def optimize_geometry( start_molecule = _resume_from if _resume_from is not None else molecule atoms = molecule_to_atoms(start_molecule) - atoms.calc = _QuantUIPySCFCalc( - method=method, - basis=basis, - charge=molecule.charge, - spin=molecule.multiplicity - 1, - cancel_check=_cancel_check, - progress_stream=_stream, - status_label=status_label, - expected_steps=expected_steps, - scf_rescue=scf_rescue, - ) + if engine_id == "pyscf": + atoms.calc = _QuantUIPySCFCalc( + method=method, + basis=basis, + charge=molecule.charge, + spin=molecule.multiplicity - 1, + cancel_check=_cancel_check, + progress_stream=_stream, + status_label=status_label, + expected_steps=expected_steps, + scf_rescue=scf_rescue, + ) + else: + _pyfock_tmp = tempfile.TemporaryDirectory(prefix="quantui-pyfock-opt-") + atoms.calc = _PyFockCalculator( + basis=basis, + auxbasis="def2-universal-jfit", + charge=molecule.charge, + directory=_pyfock_tmp.name, + convergence_check="error", + force_mode="analytical", + xc=method, + isDF=True, + sao=True, + conv_crit=1.0e-7, + max_itr=50, + ncores=max(1, int(ncores)), + use_gpu=False, + ) + # Keep the directory alive for every BFGS force evaluation and expose + # the same metadata hooks consumed by the shared result builder. + atoms.calc._quantui_tmpdir = _pyfock_tmp + atoms.calc._density_fit_used = True + atoms.calc.dispersion_applied = None + _write_stream( + _stream, + "\nPyFock geometry optimization: analytical density-fitted gradients.\n", + ) # PySCF gradients (called by ASE-BFGS at every # step) emit fd-2 stderr from libcint / BLAS. Wrap the full BFGS run @@ -907,6 +952,7 @@ def _report_opt_fraction() -> None: dipole_moment_debye=_opt_dipole, dipole_vector_debye=_opt_dipole_vec, dispersion_applied=getattr(atoms.calc, "dispersion_applied", None), + engine_id=engine_id, ) diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 90526a4..60c5f90 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -8,11 +8,10 @@ colour-coded by occupation. Input is a NumPy array of MO energies (from ``results.npz`` or a live ``SessionResult``). -2. **Cube-file isosurface** (plotly + PySCF ``cubegen``) — Linux only. +2. **Cube-file isosurface** (plotly + engine-native cube generation). Generates a volumetric cube file for a selected MO, then renders an - isosurface in 3-D using ``plotly.graph_objects.Isosurface``. This - requires PySCF at *generation* time; the viewer works on any platform - once the cube data is saved. + isosurface in 3-D using ``plotly.graph_objects.Isosurface``. PySCF and + PyFock results each use their own basis evaluator. """ from __future__ import annotations @@ -910,6 +909,83 @@ def generate_cube_from_arrays( return output_path +def generate_pyfock_cube_from_arrays( + mol_atom: list, + mol_basis: str, + mo_coeff: np.ndarray, + orbital_index: int, + output_path: Path, + *, + nx: int = 60, + ny: int = 60, + nz: int = 60, + margin: float = 5.0, + charge: int = 0, + spin: int = 0, + method: str = "", + ncores: int = 1, +) -> Path: + """Generate a selected orbital cube with PyFock's native basis evaluator.""" + if spin != 0: + raise ValueError( + "PyFock orbital cubes currently require a closed-shell result." + ) + try: + from pyfock import Basis, Mol + from pyfock.Utils import write_orbital_cube + except (ImportError, OSError) as exc: + raise ImportError( + "PyFock is required to generate a cube for this result.\n" + " pip install 'quantui[pyfock]'" + ) from exc + + atoms = [ + [str(symbol), float(coords[0]), float(coords[1]), float(coords[2])] + for symbol, coords in mol_atom + ] + mol = Mol(atoms=atoms, charge=charge) + basis = Basis(mol, {"all": Basis.load(mol=mol, basis_name=mol_basis)}) + coeff = np.asarray(mo_coeff, dtype=float) + if coeff.ndim == 3: + coeff = coeff[0] + if orbital_index < 0 or orbital_index >= coeff.shape[1]: + raise IndexError(f"Orbital index {orbital_index} is out of range.") + + vector = coeff[:, orbital_index] + cart_to_sph = np.asarray(basis.cart2sph_basis(), dtype=float) + if vector.shape[0] == cart_to_sph.shape[0]: + vector = cart_to_sph.T @ vector + elif vector.shape[0] != cart_to_sph.shape[1]: + raise ValueError( + "PyFock MO coefficient count does not match the selected basis." + ) + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + write_orbital_cube( + mol, + basis, + vector, + str(output_path), + nx=nx, + ny=ny, + nz=nz, + ncores=max(1, int(ncores)), + ) + _write_cube_provenance( + output_path, + basis=mol_basis, + nx=nx, + ny=ny, + nz=nz, + charge=charge, + spin=spin, + method=method, + ) + logger.info("Wrote PyFock cube file: %s", output_path) + return output_path + + # ============================================================================ # Cube-file isosurface viewer (plotly — works anywhere) # ============================================================================ diff --git a/quantui/results_storage.py b/quantui/results_storage.py index 7876379..ba5f6bb 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -478,6 +478,7 @@ def save_orbitals(result_dir: Path, result: object) -> None: meta["mol_atom"] = [[sym, list(coords)] for sym, coords in mol_atom] if mol_basis is not None: meta["mol_basis"] = mol_basis + meta["engine_id"] = str(getattr(result, "engine_id", "pyscf") or "pyscf") if meta: (result_dir / "orbitals_meta.json").write_text(json.dumps(meta)) @@ -919,12 +920,14 @@ def load_orbitals(result_dir: Path): pyscf_mol_atom=None, pyscf_mol_basis=None, formula="", + engine_id="pyscf", ) meta_path = result_dir / "orbitals_meta.json" if meta_path.exists(): meta = json.loads(meta_path.read_text()) stub.pyscf_mol_atom = meta.get("mol_atom") stub.pyscf_mol_basis = meta.get("mol_basis") + stub.engine_id = str(meta.get("engine_id", "pyscf") or "pyscf") return stub diff --git a/tests/test_app.py b/tests/test_app.py index f49d0de..8e1d019 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -568,16 +568,19 @@ def test_geo_opt_dispatch(self, app_with_molecule): mock_sp.mo_coeff = [[0.0]] mock_sp.pyscf_mol_atom = [("O", [0.0, 0.0, 0.0])] mock_sp.pyscf_mol_basis = "STO-3G" + mock_opt_engine = MagicMock() + mock_opt_engine.to_session_result.return_value = mock_result + mock_sp_engine = MagicMock() + mock_sp_engine.to_session_result.return_value = mock_sp with patch( - "quantui.optimize_geometry", return_value=mock_result, create=True - ) as mock_opt: - with patch( - "quantui.run_in_session", return_value=mock_sp, create=True - ) as mock_sp_run: - with patch("quantui.save_result"): - app._do_run() - mock_opt.assert_called_once() - mock_sp_run.assert_called_once() + "quantui.engines.run_calc", + side_effect=[mock_opt_engine, mock_sp_engine], + ) as mock_run: + with patch("quantui.save_result"): + app._do_run() + assert mock_run.call_count == 2 + assert mock_run.call_args_list[0].args[0].calc_type == "geometry_opt" + assert mock_run.call_args_list[1].args[0].calc_type == "single_point" def test_pyscf_unavailable_shows_error(self, app_with_molecule): app = app_with_molecule @@ -608,7 +611,7 @@ def test_preopt_flag_mirrors_module_level(self): class TestQuantumEngineGating: - def test_pyfock_limits_setup_to_validated_phase1_subset(self): + def test_pyfock_limits_setup_to_validated_subset(self): from quantui.engines import PyfockEngine app = QuantUIApp() @@ -616,7 +619,7 @@ def test_pyfock_limits_setup_to_validated_phase1_subset(self): with patch("quantui.engines.resolve_engine", return_value=PyfockEngine()): app._apply_quantum_engine_capabilities() - assert tuple(app.calc_type_dd.options) == ("Single Point",) + assert tuple(app.calc_type_dd.options) == ("Single Point", "Geometry Opt") assert tuple(app.method_dd.options) == ("PBE",) assert tuple(app.basis_dd.options) == ("def2-SVP", "def2-TZVP") assert app.solvent_cb.disabled is True @@ -3114,6 +3117,43 @@ def test_isosurface_controls_hidden_when_no_mo_coeff(self): class TestIsosurfacePersistence: + def test_pyfock_result_uses_native_cube_generator(self, tmp_path): + app = QuantUIApp() + app._last_result_dir = tmp_path + app._last_orb_info = MagicMock() + app._last_orb_info.n_occupied = 1 + app._last_orb_info.mo_energies_ev = [-10.0, 2.0] + app._last_orb_info.formula = "H2" + app._last_orb_mo_coeff = [[1.0, 0.0], [0.0, 1.0]] + app._last_orb_mo_occ = [2.0, 0.0] + app._last_orb_mol_atom = [["H", [0.0, 0.0, 0.0]]] + app._last_orb_mol_basis = "def2-SVP" + app._last_orb_engine_id = "pyfock" + app._resolve_backend = lambda task: "plotlymol" + + def _fake_generate(_atom, _basis, _coeff, _idx, out_path, **_kwargs): + out_path.write_text("cube", encoding="utf-8") + return out_path + + with ( + patch( + "quantui.orbital_visualization.generate_pyfock_cube_from_arrays", + side_effect=_fake_generate, + ) as pyfock_gen, + patch( + "quantui.orbital_visualization.generate_cube_from_arrays" + ) as pyscf_gen, + patch( + "quantui.orbital_visualization.plot_cube_isosurface", + return_value=MagicMock(), + ), + patch("plotly.io.to_html", return_value="
iso
"), + ): + app._render_orbital_isosurface("HOMO") + + pyfock_gen.assert_called_once() + pyscf_gen.assert_not_called() + def test_render_orbital_isosurface_saves_cube_to_disk(self, tmp_path): app = QuantUIApp() app._last_result_dir = tmp_path diff --git a/tests/test_checkpoint_wiring.py b/tests/test_checkpoint_wiring.py index 64e2575..cbcb5f4 100644 --- a/tests/test_checkpoint_wiring.py +++ b/tests/test_checkpoint_wiring.py @@ -123,7 +123,7 @@ def test_frequency_run_receives_the_actual_checkpoint_and_resume(self): into ``run_freq_calc``. Drives the real dispatch (mocking only ``run_freq_calc`` itself) and inspects the actual call arguments. """ - from unittest.mock import patch + from unittest.mock import MagicMock, patch from quantui.app import QuantUIApp from quantui.freq_calc import FreqResult @@ -136,6 +136,11 @@ def test_frequency_run_receives_the_actual_checkpoint_and_resume(self): [[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], ) ) + # Engine capability gating may hide Frequency in environments where + # only the PyFock subset is installed; this test targets the app's + # dispatch wiring, so expose the option explicitly for the fixture. + if "Frequency" not in app.calc_type_dd.options: + app.calc_type_dd.options = tuple(app.calc_type_dd.options) + ("Frequency",) app.calc_type_dd.value = "Frequency" mock_result = FreqResult( energy_hartree=-76.0, @@ -150,15 +155,19 @@ def test_frequency_run_receives_the_actual_checkpoint_and_resume(self): raman_activities=[], zpve_hartree=0.02, ) - with patch( - "quantui.freq_calc.run_freq_calc", return_value=mock_result - ) as mock_run: + fake_checkpoint = MagicMock() + with ( + patch.object(app, "_begin_run_checkpoint", return_value=fake_checkpoint), + patch( + "quantui.freq_calc.run_freq_calc", return_value=mock_result + ) as mock_run, + ): with patch("quantui.save_result"): app._do_run() mock_run.assert_called_once() _, kwargs = mock_run.call_args - assert kwargs.get("checkpoint") is not None + assert kwargs.get("checkpoint") is fake_checkpoint assert "resume" in kwargs diff --git a/tests/test_engines_registry.py b/tests/test_engines_registry.py index 1e9ee7e..c96bedc 100644 --- a/tests/test_engines_registry.py +++ b/tests/test_engines_registry.py @@ -52,7 +52,7 @@ class TestPyfockCapabilities: def test_handshake_is_phase1_subset(self): caps = PyfockEngine().capabilities() assert caps.engine_id == "pyfock" - assert caps.supported_calc_types == ("single_point",) + assert caps.supported_calc_types == ("single_point", "geometry_opt") assert caps.supported_methods == ("PBE",) assert caps.supported_basis_sets == ("def2-SVP", "def2-TZVP") assert caps.recommended_auxbasis == "def2-universal-jfit" diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py index 6edcb7d..c2c7c32 100644 --- a/tests/test_orbital_visualization.py +++ b/tests/test_orbital_visualization.py @@ -7,6 +7,7 @@ """ import textwrap +import types import numpy as np import pytest @@ -594,6 +595,64 @@ def test_uhf_mo_coeff_uses_alpha_spin(self, tmp_path): assert result.exists() +class TestGeneratePyFockCubeFromArrays: + def test_transforms_spherical_coefficients_for_native_writer( + self, tmp_path, monkeypatch + ): + from quantui.orbital_visualization import generate_pyfock_cube_from_arrays + + captured = {} + + class FakeMol: + def __init__(self, *, atoms, charge): + captured["atoms"] = atoms + captured["charge"] = charge + + class FakeBasis: + @classmethod + def load(cls, *, mol, basis_name): + return basis_name + + def __init__(self, mol, assignment): + self.assignment = assignment + + def cart2sph_basis(self): + return np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + + def fake_writer(mol, basis, vector, path, **kwargs): + captured["vector"] = np.asarray(vector) + captured["kwargs"] = kwargs + path_obj = tmp_path / "orbital.cube" + assert str(path_obj) == path + path_obj.write_text("comment 1\ncomment 2\n") + + monkeypatch.setitem( + __import__("sys").modules, + "pyfock", + types.SimpleNamespace(Mol=FakeMol, Basis=FakeBasis), + ) + monkeypatch.setitem( + __import__("sys").modules, + "pyfock.Utils", + types.SimpleNamespace(write_orbital_cube=fake_writer), + ) + output = tmp_path / "orbital.cube" + result = generate_pyfock_cube_from_arrays( + [["H", [0.0, 0.0, 0.0]]], + "def2-SVP", + np.array([[2.0], [3.0]]), + 0, + output, + nx=8, + ny=9, + nz=10, + ) + + assert result == output + np.testing.assert_allclose(captured["vector"], [2.0, 3.0, 0.0]) + assert captured["kwargs"] == {"nx": 8, "ny": 9, "nz": 10, "ncores": 1} + + # --------------------------------------------------------------------------- # Cube provenance — M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4 # --------------------------------------------------------------------------- diff --git a/tests/test_pyfock_engine.py b/tests/test_pyfock_engine.py index ec97627..8394576 100644 --- a/tests/test_pyfock_engine.py +++ b/tests/test_pyfock_engine.py @@ -4,8 +4,11 @@ import io import os +import sys +from types import ModuleType from unittest.mock import patch +import numpy as np import pytest from quantui.engines import EngineRequest, PyfockEngine, UnsupportedCapabilityError @@ -36,6 +39,10 @@ class _FakeMol: def __init__(self, *, atoms, charge): self.atoms = atoms self.charge = charge + self.Zcharges = [8, 1, 1] + + def get_dipole_moment(self, dipole_matrix, density): + return np.array([0.0, 0.0, 0.5]) class _FakeBasis: @@ -49,6 +56,7 @@ def load(cls, *, mol, basis_name): def __init__(self, mol, assignment): self.mol = mol self.assignment = assignment + self.bfs_atoms = [0, 1, 2] class _FakeDFT: @@ -64,24 +72,48 @@ def __init__(self, mol, basis, auxbasis, **kwargs): self.niter = 7 self.mo_energies = [-0.8, -0.4, 0.1] self.mo_occupations = [2.0, 2.0, 0.0] + self.mo_coefficients = [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] self.max_itr = None self.sao = False def scf(self): print("fake PyFock SCF output") - return -76.123456, object() + return -76.123456, np.diag([2.0, 1.0, 1.0]) class TestPyfockAdapter: + def test_capabilities_include_phase2_analysis_and_geometry(self): + caps = PyfockEngine().capabilities() + assert caps.supported_calc_types == ("single_point", "geometry_opt") + assert caps.supports_orbital_export is True + def test_water_result_and_stdout_capture(self): stream = io.StringIO() request = _request( progress_stream=stream, options={"ncores": 4, "max_iterations": 30, "conv_crit": 1e-8}, ) - with patch( - "quantui.engines.pyfock_engine._load_pyfock_api", - return_value=(_FakeMol, _FakeBasis, _FakeDFT), + fake_pyfock = ModuleType("pyfock") + fake_pyfock.Integrals = type( + "FakeIntegrals", + (), + { + "overlap_mat_symm": staticmethod(lambda basis: np.eye(3)), + "dipole_moment_mat_symm": staticmethod( + lambda basis: np.zeros((3, 3, 3)) + ), + }, + ) + with ( + patch( + "quantui.engines.pyfock_engine._load_pyfock_api", + return_value=(_FakeMol, _FakeBasis, _FakeDFT), + ), + patch.dict(sys.modules, {"pyfock": fake_pyfock}), ): result = PyfockEngine().run(request) @@ -93,6 +125,12 @@ def test_water_result_and_stdout_capture(self): assert result.native_result.engine_id == "pyfock" assert result.native_result.density_fit is True assert result.native_result.scf_variant == "RKS" + assert result.native_result.mo_energy_hartree.tolist() == [-0.8, -0.4, 0.1] + assert result.native_result.mo_coeff.shape == (3, 3) + assert result.native_result.mulliken_charges == pytest.approx([6.0, 0.0, 0.0]) + assert result.native_result.dipole_moment_debye == pytest.approx( + 0.5 * 2.541746473 + ) assert _FakeDFT.last.sao is True assert "fake PyFock SCF output" in stream.getvalue() assert "density fitting: on" in stream.getvalue() @@ -100,7 +138,7 @@ def test_water_result_and_stdout_capture(self): @pytest.mark.parametrize( ("field", "value", "message"), [ - ("calc_type", "geometry_opt", "Single Point"), + ("calc_type", "frequency", "Single Point and Geometry Opt"), ("method", "B3LYP", "PBE only"), ("basis", "LANL2DZ", "def2-SVP"), ("charge", 1, "neutral molecules"), @@ -126,6 +164,27 @@ def scf(self): assert result.status == "error" assert result.error["code"] == "PYFOCK_CALCULATION_FAILED" + def test_geometry_optimization_dispatches_through_shared_optimizer(self): + native = type( + "Native", + (), + { + "converged": True, + "energy_hartree": -1.1, + "n_steps": 3, + "method": "PBE", + "basis": "def2-SVP", + "formula": "H2O", + }, + )() + with patch("quantui.optimizer.optimize_geometry", return_value=native) as opt: + result = PyfockEngine().run(_request(calc_type="geometry_opt")) + + assert result.native_result is native + assert result.engine_id == "pyfock" + assert result.n_iterations == 3 + assert opt.call_args.kwargs["engine_id"] == "pyfock" + @pytest.mark.pyfock @pytest.mark.skipif( diff --git a/tests/test_results_storage.py b/tests/test_results_storage.py index 8e80084..191474b 100644 --- a/tests/test_results_storage.py +++ b/tests/test_results_storage.py @@ -347,11 +347,13 @@ def test_writes_meta_json_when_mol_data_present(self, tmp_path): mo_coeff=None, pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], pyscf_mol_basis="sto-3g", + engine_id="pyfock", ) 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" + assert meta["engine_id"] == "pyfock" class TestLoadOrbitals: @@ -363,11 +365,13 @@ def test_roundtrip(self, tmp_path): mo_coeff=None, pyscf_mol_atom=[("O", [0.0, 0.0, 0.0])], pyscf_mol_basis="sto-3g", + engine_id="pyfock", ) 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" + assert loaded.engine_id == "pyfock" def test_raises_file_not_found_when_missing(self, tmp_path): with pytest.raises(FileNotFoundError):