Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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= <excitation energy, Ha> 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+)*")
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 30 additions & 4 deletions quantui/pubchem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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']}")
Expand Down
6 changes: 5 additions & 1 deletion quantui/tddft_calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
35 changes: 34 additions & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions tests/test_m_metal_regression.py
Original file line number Diff line number Diff line change
@@ -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
Loading