diff --git a/quantui/app.py b/quantui/app.py index e7240a2..8e53d40 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -727,6 +727,16 @@ def _layout(**kwargs: Any) -> widgets.Layout: ) _RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)") _RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)") +# TD-DFT root convergence (M-PROGRESS D2). PySCF's Davidson solver prints +# "root %d converged |r|= ... e= max|de|= ..." at +# verbose=5 (DEBUG) — see tddft_calc.py's td.verbose. This is the only +# per-root progress signal the solve emits; without it the heartbeat's +# generic "still working" line is all a user sees during a multi-minute +# excited-state solve. +_RE_TD_ROOT = re.compile( + r"root\s+(\d+)\s+converged\s+\|r\|=\s*[\d.eE+\-]+\s+e=\s*([\d.eE+\-]+)" +) +_HARTREE_TO_EV = 27.211386245988 # Step/point/state counters inside a status message. Removed before the # message is used as a per-stage timing key — see _LogCapture._stage_key. _RE_STAGE_NUMBERS = re.compile(r"\d+(?:[./]\d+)*") @@ -934,6 +944,18 @@ def write(self, text: str) -> None: except Exception: self._status.value = f"SCF cycle {n}" continue + m = _RE_TD_ROOT.search(line) + if m and self._status is not None: + root, e_ha = m.group(1), m.group(2) + try: + root_n = int(root) + 1 # PySCF's root index is 0-based + ev = float(e_ha) * _HARTREE_TO_EV + self._status.value = ( + f"TD-DFT root {root_n} converged · {ev:.3f} eV" + ) + except Exception: + self._status.value = f"TD-DFT root {root} converged" + continue m = _RE_CONV.search(line) if m: if self._status is not None: diff --git a/quantui/pubchem.py b/quantui/pubchem.py index 4db54b1..31bbc9a 100644 --- a/quantui/pubchem.py +++ b/quantui/pubchem.py @@ -339,7 +339,13 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]: Returns: Tuple of (xyz_string, metadata_dict) xyz_string format: "n_atoms\\ncomment\\natom x y z\\n..." - metadata includes: formula, molecular_weight, charge + metadata includes: formula, molecular_weight, charge, coords_embedded + (whether the coordinates were re-embedded rather than taken from the + source SDF), metal_detected (a coordination-complex metal centre is + present, per ``connectivity.is_metal`` — when true and the source SDF + had any conformer, the source coordinates are kept rather than + re-embedded, since RDKit's valence perception can't see a metal-donor + bond and would otherwise scatter the ligands) Raises: ValueError: If SDF parsing fails @@ -366,6 +372,24 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]: # "3D" structure. conf = mol.GetConformer() if mol.GetNumConformers() else None coords_embedded = conf is None or not conf.Is3D() + + # M-METAL MET.1: RDKit's valence-based bond perception draws no bond + # between a metal centre and its donor atoms, so a coordination complex + # looks like several disconnected fragments to GetMolFrags/EmbedMolecule + # — exactly the "salt" shape _separate_fragments exists to fix. Applying + # it here would push a real complex's ligands away from the metal instead + # of a counterion away from an ion. Prefer whatever coordinates the + # source SDF already has (even a flat 2D layout keeps the metal-donor + # proximity that the GFN-FF pre-optimization can relax into 3D); only + # fall back to a from-scratch embed when the source truly has none, and + # skip the fragment-separation step in that case so the embed is treated + # as one system. + from .connectivity import is_metal + + has_metal = any(is_metal(atom.GetSymbol()) for atom in mol.GetAtoms()) + if has_metal and conf is not None: + coords_embedded = False + if coords_embedded: if AllChem.EmbedMolecule(mol, randomSeed=42) != 0: AllChem.EmbedMolecule(mol, randomSeed=42, useRandomCoords=True) @@ -376,9 +400,10 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]: AllChem.UFFOptimizeMolecule(mol) except Exception: pass - # Salts/counterions embed jammed together — separate them so bond - # perception doesn't see a bonded counterion. - _separate_fragments(mol) + if not has_metal: + # Salts/counterions embed jammed together — separate them so + # bond perception doesn't see a bonded counterion. + _separate_fragments(mol) # Extract coordinates and build XYZ string conf = mol.GetConformer() @@ -403,6 +428,7 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]: "num_atoms": mol.GetNumAtoms(), "num_heavy_atoms": mol.GetNumHeavyAtoms(), "coords_embedded": coords_embedded, + "metal_detected": has_metal, } logger.debug(f"Converted SDF to XYZ: {metadata['formula']}") diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index cd621b7..13363a3 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -288,7 +288,11 @@ def _run_tddft_calc_body( ) td = mf.TDHF() if using_hf else mf.TDDFT() td.nstates = nstates - td.verbose = 3 + # verbose=5 (DEBUG) is what surfaces PySCF's per-root "root %d + # converged" lines during the Davidson solve — the only progress + # signal available while it runs (M-PROGRESS D2). _LogCapture.write + # in app.py greps for them to update the live status label. + td.verbose = 5 td.stdout = stream td.kernel() diff --git a/tests/conftest.py b/tests/conftest.py index b87548d..4bbad53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,6 +149,39 @@ def sample_sdf_water(): """ +@pytest.fixture +def sample_sdf_metal_complex_2d(): + """Synthetic flat-2D SDF for a Pt(NH3)2Cl2-like coordination complex. + + Mirrors what PubChem/CACTUS actually hand back for a real complex (see + M-METAL MET.2): no bond entries link the metal to its donor atoms, so + RDKit's ``GetMolFrags`` sees Pt, each NH3, and each Cl as separate + fragments even though this is one real coordinated molecule. All-zero Z + coordinates keep RDKit's 2D/3D autodetection landing on 2D, which is what + triggers a re-embed for a non-metal input (MET.1's failure mode). + """ + return """ + Test 2D + + 9 4 0 0 0 0 999 V2000 + 0.0000 0.0000 0.0000 Pt 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0000 0.0000 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3000 0.5000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3000 -0.5000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -2.0000 0.0000 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0 + -2.3000 0.5000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -2.3000 -0.5000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0000 2.0000 0.0000 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0000 -2.0000 0.0000 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2 3 1 0 0 0 0 + 2 4 1 0 0 0 0 + 5 6 1 0 0 0 0 + 5 7 1 0 0 0 0 +M END +$$$$ +""" + + @pytest.fixture def temp_test_dir(tmp_path): """Create a temporary directory for test files.""" diff --git a/tests/test_app.py b/tests/test_app.py index 3fbf828..14527a9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -17,7 +17,14 @@ import ipywidgets as widgets import pytest -from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _AnalysisContext, _LogCapture +from quantui.app import ( + _RE_CONV, + _RE_CYCLE, + _RE_TD_ROOT, + QuantUIApp, + _AnalysisContext, + _LogCapture, +) from quantui.molecule import Molecule # --------------------------------------------------------------------------- @@ -392,6 +399,32 @@ def test_empty_write_is_noop(self): cap.write("") assert cap.getvalue() == "" + def test_td_root_regex_parses_line(self): + # Real line PySCF's Davidson solver prints at td.verbose=5 (DEBUG). + line = "root 0 converged |r|= 3.36e-16 e= 0.48304531721958305 max|de|= 0.483" + m = _RE_TD_ROOT.search(line) + assert m is not None + assert m.group(1) == "0" + assert m.group(2) == "0.48304531721958305" + + def test_status_label_updated_on_td_root_converged(self): + # M-PROGRESS D2: the only per-root progress signal during a + # multi-minute TD-DFT excited-state solve. + cap, status = self._make_capture() + cap.write("root 1 converged |r|= 1.2e-9 e= 0.2 max|de|= -0.0003\n") + assert "TD-DFT root 2" in status.value # PySCF's root index is 0-based + assert "eV" in status.value + + def test_td_root_status_survives_a_malformed_energy(self, monkeypatch): + # float(e) is user-facing formatting, not the regex match itself — + # a malformed capture must still surface something, not crash the run. + cap, status = self._make_capture() + monkeypatch.setattr("quantui.app.float", lambda *_a, **_k: 1 / 0, raising=False) + cap.write("root 3 converged |r|= 1e-9 e= 0.4 max|de|= 0.0001\n") + # Fallback uses the raw captured (0-based) index, same as the + # existing SCF-cycle fallback a few lines above. + assert "TD-DFT root 3 converged" in status.value + def test_close_is_noop_and_does_not_raise(self): """Regression (found via the L6 audit fix's Python 3.9 CI matrix): ase.utils.IOContext.openfile() — used by BFGS(..., logfile=...) in diff --git a/tests/test_m_metal_regression.py b/tests/test_m_metal_regression.py new file mode 100644 index 0000000..65a1da3 --- /dev/null +++ b/tests/test_m_metal_regression.py @@ -0,0 +1,159 @@ +"""Consolidated inorganic / coordination-complex regression set (M-METAL MET.7). + +Phase 1 shipped connectivity perception (MET.2/MET.6), the PlotlyMol fallback +(MET.3), pre-opt honesty (MET.4), the basis/spin guards (MET.5), and 14 bundled +example complexes (MET.9) — each with its own focused unit-test file +(``test_connectivity.py``, ``test_inorganic_guards.py``, +``test_metal_viewer_fallback.py``, ``test_preopt_gfnff.py``, +``test_inorganic_examples.py``). This file does not re-test those mechanisms +in isolation; it is the regression net that loops every one of them over +*every* bundled entry at once, plus the one thing nothing else covers yet: a +real PySCF single point on a metal complex. +""" + +from __future__ import annotations + +import pytest + +from quantui import molecule_library as ml +from quantui.molecule import ATOMIC_NUMBERS, Molecule + +_PYSCF_AVAILABLE = False +try: + import pyscf as _pyscf # noqa: F401 + + _PYSCF_AVAILABLE = True +except ImportError: + pass + +pyscf_only = pytest.mark.skipif( + not _PYSCF_AVAILABLE, reason="PySCF not installed (Linux/macOS/WSL only)" +) + + +def _inorganic_entries(): + return [e for e in ml.iter_entries() if e["category"] == "inorganic-complex"] + + +_ENTRIES = _inorganic_entries() +_ENTRY_IDS = [e["id"] for e in _ENTRIES] + + +def _molecule(entry) -> Molecule: + return Molecule( + atoms=entry["atoms"], + coordinates=entry["coordinates"], + charge=entry["charge"], + multiplicity=entry["multiplicity"], + ) + + +class TestBundledSetIsNonEmpty: + def test_at_least_fourteen_entries(self): + # Guards the parametrization below from silently collecting zero + # cases if the manifest ever regresses. + assert len(_ENTRIES) >= 14 + + +@pytest.mark.parametrize("entry", _ENTRIES, ids=_ENTRY_IDS) +class TestEveryBundledComplexClearsPhase1: + def test_connectivity_is_a_single_component(self, entry): + from quantui.connectivity import covalent_components + + components = covalent_components(entry["atoms"], entry["coordinates"]) + assert len(components) == 1, entry["id"] + + def test_metal_centre_is_not_a_lone_dot(self, entry): + from quantui.connectivity import is_metal, metal_coordination_bonds + + bonds = metal_coordination_bonds(entry["atoms"], entry["coordinates"]) + bonded = {i for pair in bonds for i in pair} + for i, sym in enumerate(entry["atoms"]): + if is_metal(sym): + assert i in bonded, f"{entry['id']}: {sym} has no coordination bond" + + def test_charge_multiplicity_guard_passes(self, entry): + from quantui.inorganic_guards import check_charge_multiplicity + + n_elec = sum(ATOMIC_NUMBERS.get(a, 0) for a in entry["atoms"]) - entry["charge"] + assert check_charge_multiplicity(n_elec, entry["multiplicity"]) is None, entry[ + "id" + ] + + @pyscf_only + def test_basis_guard_clears_with_def2_svp(self, entry): + from quantui.inorganic_guards import check_basis_coverage + + assert check_basis_coverage(entry["atoms"], "def2-SVP") is None, entry["id"] + + def test_plotlymol_backend_never_hard_crashes(self, entry): + import quantui.visualization_py3dmol as viz + + if not (viz.PLOTLYMOL_AVAILABLE and viz.PY3DMOL_AVAILABLE): + pytest.skip("both plotlymol and py3dmol backends required") + # MET.3: PlotlyMol's RDKit valence perception raises on a metal; the + # router must fall back to py3Dmol rather than propagate. Whatever + # comes back must be a usable view object, not an exception. + view = viz.visualize_molecule(_molecule(entry), backend="plotlymol") + assert view is not None, entry["id"] + + def test_preopt_never_reports_a_false_no_op(self, entry): + # MET.4: preopt_support must not claim "supported" when nothing can + # actually relax the molecule (that's what turns a real failure into + # a misleading 0.0 A "no meaningful change" upstream). + from quantui.preopt import preopt_support + + reason = preopt_support(_molecule(entry)) + if reason is not None: + assert isinstance(reason, str) and reason, entry["id"] + + def test_scattering_the_metal_still_trips_the_disconnection_warning(self, entry): + # MET.2, generalized across the whole bundled set: pulling the metal + # away from its donors (the shape a resolved "salt" takes) must still + # be caught for every entry, not just the cisplatin case the original + # unit test covers. + from quantui.connectivity import describe_disconnection, is_metal + + atoms = entry["atoms"] + metal_idx = next((i for i, s in enumerate(atoms) if is_metal(s)), None) + if metal_idx is None: + pytest.skip(f"{entry['id']} has no metal centre to scatter") + coords = [list(c) for c in entry["coordinates"]] + coords[metal_idx] = [c + 8.0 for c in coords[metal_idx]] + msg = describe_disconnection(atoms, coords) + assert msg is not None, entry["id"] + assert atoms[metal_idx] in msg + + +@pyscf_only +class TestSmallECPSinglePoint: + """One real SCF run through the whole compute pipeline as a cloud + regression guard. This is NOT the MET.8 exit gate — that needs the + instructor's local Voila + full-set DFT geometry-optimization pass. It + only catches a basis/ECP/guard regression between now and that pass, on + the one complex (cisplatin) already validated locally per MET.8/MET.5. + """ + + def test_cisplatin_rhf_def2svp_converges(self): + from pyscf import gto, scf + + from quantui.inorganic_guards import ecp_for_basis + + entry = ml.get("inorganic-cisplatin") + assert entry is not None + molecule = _molecule(entry) + + mol = gto.Mole() + mol.atom = molecule.to_pyscf_format() + mol.basis = "def2-SVP" + mol.ecp = ecp_for_basis("def2-SVP", molecule.atoms) + mol.charge = molecule.charge + mol.spin = molecule.multiplicity - 1 + mol.verbose = 0 + mol.build() + + mf = scf.RHF(mol) + mf.max_cycle = 100 + mf.kernel() + + assert mf.converged diff --git a/tests/test_pubchem.py b/tests/test_pubchem.py index 537ad76..aa4ecc8 100644 --- a/tests/test_pubchem.py +++ b/tests/test_pubchem.py @@ -169,6 +169,82 @@ def test_sdf_to_xyz_metadata_fields(self, sample_sdf_water): for field in required_fields: assert field in metadata, f"Missing metadata field: {field}" + @rdkit_only + def test_sdf_to_xyz_flags_metal_detected(self, sample_sdf_water): + """A non-metal molecule reports metal_detected=False.""" + _, metadata = sdf_to_xyz(sample_sdf_water) + assert metadata["metal_detected"] is False + + @rdkit_only + def test_metal_complex_keeps_source_coordinates_instead_of_reembedding( + self, sample_sdf_metal_complex_2d + ): + """M-METAL MET.1 regression. + + RDKit sees no bond between Pt and its donor atoms, so re-embedding + (as a non-metal disconnected input would trigger) scatters the + "fragments" via ``_separate_fragments``. For a metal complex with a + source conformer, the original — even if flat 2D — coordinates must + be kept so the real Pt-N/Pt-Cl proximity survives for a downstream + GFN-FF pre-optimization to relax into 3D. + """ + xyz_string, metadata = sdf_to_xyz(sample_sdf_metal_complex_2d) + + assert metadata["metal_detected"] is True + assert metadata["coords_embedded"] is False + + lines = xyz_string.strip().split("\n")[2:] + coords = {} + for line in lines: + parts = line.split() + symbol = parts[0] + xyz = tuple(float(v) for v in parts[1:4]) + coords.setdefault(symbol, []).append(xyz) + + pt = coords["Pt"][0] + # Source geometry placed the two N donors at exactly 2.0 Å from Pt; + # _separate_fragments would have pushed them apart by 3+ Å instead. + for n in coords["N"]: + dist = sum((a - b) ** 2 for a, b in zip(pt, n)) ** 0.5 + assert dist == pytest.approx(2.0, abs=1e-4) + + @rdkit_only + def test_non_metal_disconnected_input_still_gets_fragments_separated(self): + """Regression: the metal carve-out must not touch the salt-separation + path a non-metal disconnected molecule still needs (MET.2's "salt" + case this milestone already handles).""" + overlapping_waters = """ + Test 2D + + 6 4 0 0 0 0 999 V2000 + 0.0000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7570 0.5870 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7570 0.5870 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1000 0.1000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8570 0.6870 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.6570 0.6870 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 3 1 0 0 0 0 + 4 5 1 0 0 0 0 + 4 6 1 0 0 0 0 +M END +$$$$ +""" + xyz_string, metadata = sdf_to_xyz(overlapping_waters) + + assert metadata["metal_detected"] is False + assert metadata["coords_embedded"] is True + + lines = xyz_string.strip().split("\n")[2:] + oxygens = [ + tuple(float(v) for v in line.split()[1:4]) + for line in lines + if line.split()[0] == "O" + ] + assert len(oxygens) == 2 + dist = sum((a - b) ** 2 for a, b in zip(oxygens[0], oxygens[1])) ** 0.5 + assert dist >= 3.0 - 1e-6 + class TestFetchMolecule: """Test high-level molecule fetching."""