From b689fe4799b5bf5c322639872f6b646b604237a9 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sat, 15 Aug 2026 21:50:36 +0000 Subject: [PATCH 01/20] Pre-run guard for metal complexes: basis coverage + charge/multiplicity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The moment a student loads a transition-metal complex, two failures surface as cryptic tracebacks deep in the calculation thread: the default 6-31G basis has no parameters for the metal (BasisNotFoundError on Pt), and an odd electron count with the default multiplicity 1 is inconsistent (Electron number N and spin S are not consistent). New quantui/inorganic_guards.py checks both before the run and returns a plain-language message; on_run_clicked runs the guard on the main thread and, if there is a problem, shows it in place of starting the run — pointing at def2-SVP/def2-TZVP for metals and explaining the charge/multiplicity parity rule — instead of clearing the panes and crashing in the background. PySCF's own basis loader is the source of truth, so the verdict matches what a run would hit. 13 new tests. Contributions: - Claude (Opus 4.8): code, tests, review - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app_runflow.py | 39 ++++++++++++ quantui/inorganic_guards.py | 109 +++++++++++++++++++++++++++++++++ tests/test_inorganic_guards.py | 94 ++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 quantui/inorganic_guards.py create mode 100644 tests/test_inorganic_guards.py diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 6ccfdc1..1a927b3 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -119,6 +119,45 @@ def _set_run_output(app: Any, outputs: tuple) -> None: def on_run_clicked(app: Any, btn: Any) -> None: """Reset result panes and start the background run thread.""" + # Pre-run guard (M-METAL MET.5): catch a basis with no parameters for an + # element (e.g. 6-31G on Pt) or a charge/multiplicity inconsistent with the + # electron count, and explain it here — on the main thread, before anything + # is cleared — instead of letting PySCF raise a cryptic error deep in the + # background calc thread. + mol = getattr(app, "_molecule", None) + if mol is not None: + try: + from quantui.inorganic_guards import preflight_messages + + problems = preflight_messages( + mol.atoms, + mol.get_electron_count(), + app.basis_dd.value, + int(app.mult_si.value), + ) + except Exception: # noqa: BLE001 — a guard failure must not block a run + problems = [] + if problems: + body = "\n\n".join(f" • {p}" for p in problems) + _set_run_output( + app, + ( + { + "output_type": "stream", + "name": "stdout", + "text": ( + "⚠ This calculation was not started — please fix " + "the following first:\n\n" + body + "\n" + ), + }, + ), + ) + try: + app.run_status.value = "Adjust the settings above, then Run again." + except Exception: # noqa: BLE001 — status label is best-effort + pass + return + # Write the header FIRST (atomic, main thread) — this also clears the # previous run's log via the single ``outputs`` assignment. _write_run_header(app) diff --git a/quantui/inorganic_guards.py b/quantui/inorganic_guards.py new file mode 100644 index 0000000..2783ce4 --- /dev/null +++ b/quantui/inorganic_guards.py @@ -0,0 +1,109 @@ +"""Pre-run guards for inorganic / metal calculations (M-METAL MET.5). + +Two mid-run PySCF failures are common the moment a student loads a +transition-metal complex, and both surface as cryptic tracebacks deep inside a +background thread: + +* a basis set with **no parameters for an element** — e.g. the default + ``6-31G`` on Pt raises ``BasisNotFoundError: Basis set not found for Pt``; and +* a **charge / multiplicity inconsistent with the electron count** — e.g. an odd + electron count with the default multiplicity 1 raises + ``Electron number N and spin S are not consistent``. + +These functions catch both **before the run starts** and return a plain-language +message the app shows in place of launching the doomed calculation. Pure logic — +no widgets and no calculation; the only dependency is PySCF's own basis loader, +used as the source of truth so the check matches exactly what a run would hit. +""" + +from __future__ import annotations + +from typing import Iterable, List, Optional + +# def2 basis sets QuantUI ships that carry effective core potentials for heavy +# elements (so they cover the whole periodic table, unlike the Pople / cc sets). +_ECP_BASIS_SUGGESTION = "def2-SVP or def2-TZVP" + + +def basis_unsupported_elements(basis: str, elements: Iterable[str]) -> List[str]: + """Return the unique elements ``basis`` has no parameters for (order-preserved). + + Uses ``pyscf.gto.basis.load`` — the same lookup the calculation performs — so + the verdict matches what a run would actually hit. Never raises: any loader + error is treated as "unsupported" for that element (the conservative choice, + since the run would then fail too). + """ + from pyscf import gto + + bad: List[str] = [] + seen = set() + for el in elements: + if el in seen: + continue + seen.add(el) + try: + gto.basis.load(basis, el) + except Exception: + bad.append(el) + return bad + + +def check_basis_coverage(elements: Iterable[str], basis: str) -> Optional[str]: + """Message if ``basis`` lacks any element, else ``None``.""" + bad = basis_unsupported_elements(basis, elements) + if not bad: + return None + els = ", ".join(bad) + return ( + f"The basis set '{basis}' has no parameters for {els}. " + f"Transition metals and other heavy elements need an ECP basis — switch " + f"to {_ECP_BASIS_SUGGESTION} (these cover the whole periodic table via " + f"effective core potentials) and run again." + ) + + +def check_charge_multiplicity(n_electrons: int, multiplicity: int) -> Optional[str]: + """Message if ``multiplicity`` is impossible for ``n_electrons``, else ``None``. + + The number of unpaired electrons is ``multiplicity - 1``; it cannot exceed + the electron count, and it must have the same parity as it (an odd electron + count is only compatible with an even multiplicity, and vice versa). + """ + if multiplicity < 1: + return f"Multiplicity must be at least 1 (got {multiplicity})." + n_unpaired = multiplicity - 1 + if n_unpaired > n_electrons: + return ( + f"Multiplicity {multiplicity} needs {n_unpaired} unpaired " + f"electrons, but the molecule has only {n_electrons}. Lower the " + f"multiplicity." + ) + if (n_electrons - n_unpaired) % 2 != 0: + needs = "an even" if n_electrons % 2 else "an odd" + suggestion = 2 if n_electrons % 2 else 1 + parity = "odd" if n_electrons % 2 else "even" + return ( + f"{n_electrons} electrons with multiplicity {multiplicity} is " + f"impossible: an {parity} electron count needs {needs} multiplicity " + f"(e.g. {suggestion}). Re-check the charge and multiplicity — a metal " + f"centre's oxidation state fixes its d-electron count and spin state." + ) + return None + + +def preflight_messages( + elements: Iterable[str], + n_electrons: int, + basis: str, + multiplicity: int, +) -> List[str]: + """Return the list of blocking pre-run problems (empty = OK to run).""" + elements = list(elements) + messages: List[str] = [] + basis_msg = check_basis_coverage(elements, basis) + if basis_msg: + messages.append(basis_msg) + spin_msg = check_charge_multiplicity(n_electrons, multiplicity) + if spin_msg: + messages.append(spin_msg) + return messages diff --git a/tests/test_inorganic_guards.py b/tests/test_inorganic_guards.py new file mode 100644 index 0000000..20262b3 --- /dev/null +++ b/tests/test_inorganic_guards.py @@ -0,0 +1,94 @@ +"""Pre-run guards for inorganic / metal calculations — M-METAL MET.5. + +Turn two cryptic mid-run PySCF crashes into a clear message before the run: +a basis with no parameters for an element, and a charge/multiplicity that is +impossible for the electron count. The charge/multiplicity logic is pure; the +basis check uses PySCF's loader and is gated. +""" + +from __future__ import annotations + +import pytest + +from quantui.inorganic_guards import ( + check_basis_coverage, + check_charge_multiplicity, + preflight_messages, +) + +_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)", +) + + +class TestChargeMultiplicity: + def test_even_electrons_singlet_ok(self): + assert check_charge_multiplicity(36, 1) is None + + def test_odd_electrons_doublet_ok(self): + assert check_charge_multiplicity(37, 2) is None + + def test_odd_electrons_singlet_is_flagged(self): + # The exact cisplatin-adjacent trap: odd count with the default mult 1. + msg = check_charge_multiplicity(37, 1) + assert msg is not None + assert "odd electron count needs an even multiplicity" in msg + + def test_even_electrons_doublet_is_flagged(self): + msg = check_charge_multiplicity(36, 2) + assert msg is not None + assert "even electron count needs an odd multiplicity" in msg + + def test_multiplicity_below_one(self): + assert check_charge_multiplicity(10, 0) is not None + + def test_more_unpaired_than_electrons(self): + msg = check_charge_multiplicity(1, 4) # 3 unpaired > 1 electron + assert msg is not None + assert "only 1" in msg + + +@pyscf_only +class TestBasisCoverage: + def test_pople_basis_lacks_a_metal(self): + msg = check_basis_coverage(["C", "H", "Pt"], "6-31G") + assert msg is not None + assert "Pt" in msg + assert "def2" in msg + + def test_def2_covers_metals(self): + assert check_basis_coverage(["C", "N", "Pt", "Zn"], "def2-SVP") is None + + def test_organic_basis_covers_organics(self): + assert check_basis_coverage(["C", "H", "O", "N"], "6-31G*") is None + + +@pyscf_only +class TestPreflight: + def test_clean_organic_run_has_no_problems(self): + # water, singlet, an organic basis — nothing to flag. + assert preflight_messages(["O", "H", "H"], 10, "6-31G", 1) == [] + + def test_metal_on_pople_basis_is_blocked(self): + # cisplatin (H6Cl2N2Pt), even electrons so only the basis fails. + elements = ["Pt", "Cl", "Cl", "N", "N"] + ["H"] * 6 + problems = preflight_messages(elements, 132, "6-31G", 1) + assert len(problems) == 1 + assert "Pt" in problems[0] + + def test_both_problems_reported_together(self): + # A metal on a Pople basis AND an impossible multiplicity. + problems = preflight_messages(["Pt", "H"], 79, "6-31G", 1) + assert len(problems) == 2 + + def test_def2_with_right_spin_is_clean(self): + assert preflight_messages(["Pt", "H"], 79, "def2-SVP", 2) == [] From b84e39e5a7d6252d635e8ebb4884dc52c442c82a Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sat, 15 Aug 2026 22:25:05 +0000 Subject: [PATCH 02/20] Bundle inorganic example geometries + metal guidance (M-METAL MET.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordination compounds can't ride the organic SMILES→embed path (it scatters the metal), so the online name search returns disconnected salt forms. Ship three known-good starting geometries with explicit, sanity-checked coordinates and correct charge/multiplicity instead: - cisplatin (square-planar Pt(II), H6Cl2N2Pt, neutral, singlet) - hexaamminecobalt(III) ([Co(NH3)6]3+, octahedral, +3, singlet) - ferrocene (Fe(C5H5)2 sandwich, neutral, singlet) scripts/build_inorganic_examples.py generates them parametrically from standard coordination geometry + literature bond lengths, checks each for clashes and a connected metal centre, and writes quantui/data/manifests/inorganic.json; the vendored library store is rebuilt from all manifests to include them. End-to-end smoke: bundled cisplatin loads, passes the def2-SVP guard, and RHF/def2-SVP converges (E=-3992.08 Ha). The basis-set help now steers metals to def2-SVP, the charge/multiplicity rule, and these examples / the XYZ Input tab over an online search. These are idealized starting geometries — a geometry-optimization validation pass is a local follow-up. Contributions: - Claude (Opus 4.8): geometry generator, library integration, help, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/data/library/library.sqlite | Bin 876544 -> 884736 bytes quantui/data/manifests/inorganic.json | 389 ++++++++++++++++++++++++++ quantui/help_content.py | 12 + scripts/build_inorganic_examples.py | 270 ++++++++++++++++++ tests/test_inorganic_examples.py | 82 ++++++ 5 files changed, 753 insertions(+) create mode 100644 quantui/data/manifests/inorganic.json create mode 100644 scripts/build_inorganic_examples.py create mode 100644 tests/test_inorganic_examples.py diff --git a/quantui/data/library/library.sqlite b/quantui/data/library/library.sqlite index f5358b5a2bae482c1f1b6f427d0a178f5b424cff..834b31bf3bedef8efbb162aef707299ea5e41ec4 100644 GIT binary patch delta 4217 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 delta 12212 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 XXOPz9Zz>GcA~5$JasD2zaq9mA@#R%b diff --git a/quantui/data/manifests/inorganic.json b/quantui/data/manifests/inorganic.json new file mode 100644 index 0000000..58457d6 --- /dev/null +++ b/quantui/data/manifests/inorganic.json @@ -0,0 +1,389 @@ +[ + { + "id": "inorganic-cisplatin", + "name": "cisplatin", + "formula": "H6Cl2N2Pt", + "category": "inorganic-complex", + "charge": 0, + "multiplicity": 1, + "source": "quantui-idealized", + "synonyms": "cisplatin;cis-platin;CDDP;PtCl2(NH3)2", + "description": "cis-diamminedichloroplatinum(II) \u2014 square-planar Pt(II) chemotherapy drug", + "atoms": [ + "Pt", + "Cl", + "Cl", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H" + ], + "coordinates": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 1.647559, + 1.647559, + 0.0 + ], + [ + -1.647559, + 1.647559, + 0.0 + ], + [ + -1.449569, + -1.449569, + 0.0 + ], + [ + -1.010448, + -2.370206, + 0.0 + ], + [ + -2.030266, + -1.350387, + 0.832679 + ], + [ + -2.030266, + -1.350387, + -0.832679 + ], + [ + 1.449569, + -1.449569, + 0.0 + ], + [ + 2.370206, + -1.010448, + 0.0 + ], + [ + 1.350387, + -2.030266, + 0.832679 + ], + [ + 1.350387, + -2.030266, + -0.832679 + ] + ] + }, + { + "id": "inorganic-hexaamminecobaltiii", + "name": "hexaamminecobalt(III)", + "formula": "H18CoN6", + "category": "inorganic-complex", + "charge": 3, + "multiplicity": 1, + "source": "quantui-idealized", + "synonyms": "hexaamminecobalt;cobalt hexammine;Co(NH3)6", + "description": "[Co(NH3)6]3+ \u2014 classic octahedral Werner complex (low-spin d6)", + "atoms": [ + "Co", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H", + "N", + "H", + "H", + "H" + ], + "coordinates": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 1.97, + 0.0, + 0.0 + ], + [ + 2.310483, + 0.961494, + 0.0 + ], + [ + 2.310483, + -0.480747, + 0.832679 + ], + [ + 2.310483, + -0.480747, + -0.832679 + ], + [ + -1.97, + 0.0, + 0.0 + ], + [ + -2.310483, + 0.961494, + 0.0 + ], + [ + -2.310483, + -0.480747, + -0.832679 + ], + [ + -2.310483, + -0.480747, + 0.832679 + ], + [ + 0.0, + 1.97, + 0.0 + ], + [ + 0.961494, + 2.310483, + 0.0 + ], + [ + -0.480747, + 2.310483, + -0.832679 + ], + [ + -0.480747, + 2.310483, + 0.832679 + ], + [ + 0.0, + -1.97, + 0.0 + ], + [ + 0.961494, + -2.310483, + 0.0 + ], + [ + -0.480747, + -2.310483, + 0.832679 + ], + [ + -0.480747, + -2.310483, + -0.832679 + ], + [ + 0.0, + 0.0, + 1.97 + ], + [ + 0.961494, + 0.0, + 2.310483 + ], + [ + -0.480747, + 0.832679, + 2.310483 + ], + [ + -0.480747, + -0.832679, + 2.310483 + ], + [ + 0.0, + 0.0, + -1.97 + ], + [ + 0.961494, + 0.0, + -2.310483 + ], + [ + -0.480747, + -0.832679, + -2.310483 + ], + [ + -0.480747, + 0.832679, + -2.310483 + ] + ] + }, + { + "id": "inorganic-ferrocene", + "name": "ferrocene", + "formula": "C10H10Fe", + "category": "inorganic-complex", + "charge": 0, + "multiplicity": 1, + "source": "quantui-idealized", + "synonyms": "ferrocene;bis(cyclopentadienyl)iron;Cp2Fe", + "description": "Fe(C5H5)2 \u2014 the archetypal metallocene sandwich compound", + "atoms": [ + "Fe", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H", + "C", + "H" + ], + "coordinates": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 1.21, + 0.0, + 1.66 + ], + [ + 2.29, + 0.0, + 1.66 + ], + [ + 0.373911, + 1.150778, + 1.66 + ], + [ + 0.707649, + 2.177919, + 1.66 + ], + [ + -0.978911, + 0.71122, + 1.66 + ], + [ + -1.852649, + 1.346028, + 1.66 + ], + [ + -0.978911, + -0.71122, + 1.66 + ], + [ + -1.852649, + -1.346028, + 1.66 + ], + [ + 0.373911, + -1.150778, + 1.66 + ], + [ + 0.707649, + -2.177919, + 1.66 + ], + [ + 1.21, + 0.0, + -1.66 + ], + [ + 2.29, + 0.0, + -1.66 + ], + [ + 0.373911, + 1.150778, + -1.66 + ], + [ + 0.707649, + 2.177919, + -1.66 + ], + [ + -0.978911, + 0.71122, + -1.66 + ], + [ + -1.852649, + 1.346028, + -1.66 + ], + [ + -0.978911, + -0.71122, + -1.66 + ], + [ + -1.852649, + -1.346028, + -1.66 + ], + [ + 0.373911, + -1.150778, + -1.66 + ], + [ + 0.707649, + -2.177919, + -1.66 + ] + ] + } +] diff --git a/quantui/help_content.py b/quantui/help_content.py index ce55c53..f20502b 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -167,6 +167,18 @@ "

Recommendation: Start with STO-3G for learning. " "Use 6-31G* for serious work. Only use cc-pVTZ if you need " "high-accuracy results and have time to wait.

" + "

Transition metals and heavy elements: the Pople " + "(6-31G…) and Dunning (cc-pV*) sets do " + "not cover most metals, so a calculation on, say, a platinum " + "or cobalt complex will stop with a message asking you to switch. " + "Use def2-SVP or def2-TZVP — these carry effective " + "core potentials that cover the whole periodic table. Remember to " + "set the charge and multiplicity from the metal's oxidation " + "state, and for a reliable starting geometry load one of the " + "bundled inorganic examples (cisplatin, hexaamminecobalt(III), " + "ferrocene) or paste your own coordinates in the XYZ Input " + "tab rather than relying on an online name search, which often " + "returns a disconnected salt form for coordination compounds.

" # UXP2.1: the two Pople notations are a recurring source of # confusion — a reader who only knows 6-31G(d) can conclude the # 6-31G* in the dropdown is a different set they can't select. diff --git a/scripts/build_inorganic_examples.py b/scripts/build_inorganic_examples.py new file mode 100644 index 0000000..9e7f64a --- /dev/null +++ b/scripts/build_inorganic_examples.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Generate bundled inorganic / coordination-complex examples — M-METAL MET.9. + +Metal complexes cannot ride the SMILES→embed path the organic library uses: it +scatters the metal (that is the M-METAL bug). So the bundled inorganic examples +carry **explicit, idealized coordinates** built here from standard coordination +geometry and literature bond lengths, with the correct charge and multiplicity. + +These are **starting geometries**, not reference structures — they are +connected, clash-free, and roughly metric so the DFT geometry optimization has a +sane place to begin. (A geometry-optimization validation pass is a local +follow-up, per the M-METAL cloud/local split.) + +Run to (re)write ``quantui/data/manifests/inorganic.json``; the library store is +then rebuilt from all manifests by ``molecule_library.build_from_manifests``. + + python scripts/build_inorganic_examples.py # write manifest + python scripts/build_inorganic_examples.py --rebuild # + rebuild the store +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import List, Tuple + +import numpy as np + +Atoms = List[str] +Coords = List[List[float]] + +_MANIFEST = ( + Path(__file__).resolve().parent.parent + / "quantui" + / "data" + / "manifests" + / "inorganic.json" +) + + +def _orthonormal_frame(axis: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Two unit vectors perpendicular to ``axis`` (and to each other).""" + axis = axis / np.linalg.norm(axis) + seed = ( + np.array([1.0, 0.0, 0.0]) if abs(axis[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) + ) + v = seed - axis * np.dot(seed, axis) + v /= np.linalg.norm(v) + w = np.cross(axis, v) + return v, w + + +def _ammine_hydrogens( + n_pos: np.ndarray, metal_pos: np.ndarray, nh: float = 1.02 +) -> Coords: + """Three H of an M–NH3, lone pair toward the metal, H splayed outward. + + Each N–H makes the tetrahedral 70.5° with the outward M→N axis (i.e. 109.5° + with the metal-facing lone pair), tripod-arranged at 120°. + """ + u = n_pos - metal_pos + u = u / np.linalg.norm(u) + v, w = _orthonormal_frame(u) + theta = math.radians(70.5) + out = [] + for k in range(3): + phi = math.radians(120.0 * k) + direction = math.cos(theta) * u + math.sin(theta) * ( + math.cos(phi) * v + math.sin(phi) * w + ) + out.append((n_pos + nh * direction).tolist()) + return out + + +def cisplatin() -> Tuple[Atoms, Coords, int, int]: + """cis-[PtCl2(NH3)2] — square planar Pt(II) d8, singlet, neutral.""" + atoms: Atoms = ["Pt"] + coords: Coords = [[0.0, 0.0, 0.0]] + metal = np.zeros(3) + d_ptcl, d_ptn = 2.33, 2.05 + # cis: the two Cl adjacent (90°), the two N adjacent (90°). + for ang in (45.0, 135.0): + a = math.radians(ang) + atoms.append("Cl") + coords.append([d_ptcl * math.cos(a), d_ptcl * math.sin(a), 0.0]) + for ang in (225.0, 315.0): + a = math.radians(ang) + n_pos = np.array([d_ptn * math.cos(a), d_ptn * math.sin(a), 0.0]) + atoms.append("N") + coords.append(n_pos.tolist()) + for h in _ammine_hydrogens(n_pos, metal): + atoms.append("H") + coords.append(h) + return atoms, coords, 0, 1 + + +def hexaamminecobalt() -> Tuple[Atoms, Coords, int, int]: + """[Co(NH3)6]3+ — octahedral Co(III) d6 low-spin, singlet, +3.""" + atoms: Atoms = ["Co"] + coords: Coords = [[0.0, 0.0, 0.0]] + metal = np.zeros(3) + d = 1.97 + axes = [ + (d, 0, 0), + (-d, 0, 0), + (0, d, 0), + (0, -d, 0), + (0, 0, d), + (0, 0, -d), + ] + for ax in axes: + n_pos = np.array(ax, dtype=float) + atoms.append("N") + coords.append(n_pos.tolist()) + for h in _ammine_hydrogens(n_pos, metal): + atoms.append("H") + coords.append(h) + return atoms, coords, 3, 1 + + +def ferrocene() -> Tuple[Atoms, Coords, int, int]: + """Fe(C5H5)2 — sandwich Fe(II) d6, singlet, neutral (eclipsed start).""" + atoms: Atoms = ["Fe"] + coords: Coords = [[0.0, 0.0, 0.0]] + r_c = 1.21 # ring carbon radius from the C5 axis + ch = 1.08 + z = 1.66 # Fe → ring-plane distance + for sign in (1.0, -1.0): + for k in range(5): + a = math.radians(72.0 * k) + cx, cy = r_c * math.cos(a), r_c * math.sin(a) + atoms.append("C") + coords.append([cx, cy, sign * z]) + # H radially outward in the ring plane. + hx, hy = (r_c + ch) * math.cos(a), (r_c + ch) * math.sin(a) + atoms.append("H") + coords.append([hx, hy, sign * z]) + return atoms, coords, 0, 1 + + +_BUILDERS = { + "cisplatin": ( + cisplatin, + "cis-diamminedichloroplatinum(II) — square-planar Pt(II) chemotherapy " "drug", + "cisplatin;cis-platin;CDDP;PtCl2(NH3)2", + ), + "hexaamminecobalt(III)": ( + hexaamminecobalt, + "[Co(NH3)6]3+ — classic octahedral Werner complex (low-spin d6)", + "hexaamminecobalt;cobalt hexammine;Co(NH3)6", + ), + "ferrocene": ( + ferrocene, + "Fe(C5H5)2 — the archetypal metallocene sandwich compound", + "ferrocene;bis(cyclopentadienyl)iron;Cp2Fe", + ), +} + +# A minimal covalent-radius table (Å) for the sanity connectivity check. +_COV = { + "H": 0.31, + "C": 0.76, + "N": 0.71, + "O": 0.66, + "Cl": 1.02, + "Fe": 1.32, + "Co": 1.26, + "Pt": 1.36, + "Zn": 1.22, +} + + +def _sanity(atoms: Atoms, coords: Coords) -> List[str]: + """Return a list of problems (empty = geometry looks sane).""" + problems: List[str] = [] + pts = np.array(coords) + n = len(atoms) + # No atomic clashes. + for i in range(n): + for j in range(i + 1, n): + dij = float(np.linalg.norm(pts[i] - pts[j])) + if dij < 0.7: + problems.append(f"clash: {atoms[i]}{i}-{atoms[j]}{j} = {dij:.2f} Å") + # Metal is bonded to the expected number of donors (within 1.3× radii sum). + metal_syms = {"Pt", "Co", "Fe", "Zn"} + for i, sym in enumerate(atoms): + if sym not in metal_syms: + continue + neigh = 0 + for j in range(n): + if j == i: + continue + cutoff = 1.3 * (_COV.get(sym, 1.3) + _COV.get(atoms[j], 0.7)) + if float(np.linalg.norm(pts[i] - pts[j])) <= cutoff: + neigh += 1 + if neigh == 0: + problems.append(f"metal {sym}{i} has no neighbours within bonding range") + return problems + + +def _formula(atoms: Atoms) -> str: + from collections import Counter + + c = Counter(atoms) + order = ["C", "H"] + sorted(k for k in c if k not in ("C", "H")) + seen = set() + out = "" + for el in order: + if el in c and el not in seen: + seen.add(el) + out += el + (str(c[el]) if c[el] > 1 else "") + return out + + +def build_manifest() -> list: + entries = [] + print("# building inorganic examples (idealized starting geometries)\n") + for name, (fn, desc, syn) in _BUILDERS.items(): + atoms, coords, charge, mult = fn() + problems = _sanity(atoms, coords) + status = "OK" if not problems else "PROBLEMS: " + "; ".join(problems) + print( + f" {name:24s} {_formula(atoms):12s} " + f"charge={charge:+d} mult={mult} {status}" + ) + if problems: + raise SystemExit(f"geometry sanity failed for {name}: {problems}") + entries.append( + { + "id": f"inorganic-{name.replace('(', '').replace(')', '').replace(' ', '-').lower()}", + "name": name, + "formula": _formula(atoms), + "category": "inorganic-complex", + "charge": charge, + "multiplicity": mult, + "source": "quantui-idealized", + "synonyms": syn, + "description": desc, + "atoms": atoms, + "coordinates": [[round(x, 6) for x in xyz] for xyz in coords], + } + ) + return entries + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--rebuild", + action="store_true", + help="Rebuild the library store from all manifests after writing.", + ) + args = ap.parse_args(argv) + + entries = build_manifest() + _MANIFEST.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") + print(f"\n# wrote {len(entries)} entries -> {_MANIFEST}") + + if args.rebuild: + from quantui import molecule_library as ml + + path = ml.build_from_manifests() + print(f"# rebuilt store -> {path} ({ml.count()} total entries)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_inorganic_examples.py b/tests/test_inorganic_examples.py new file mode 100644 index 0000000..34d31f1 --- /dev/null +++ b/tests/test_inorganic_examples.py @@ -0,0 +1,82 @@ +"""Bundled inorganic / coordination-complex examples — M-METAL MET.9. + +The metal examples ship as explicit-coordinate library entries (not SMILES, which +would scatter the metal). These tests guard that they stay in the vendored store, +load with the right charge/multiplicity, and keep a connected metal centre. +""" + +from __future__ import annotations + +import math + +from quantui import molecule_library as ml + +_EXPECTED = { + "inorganic-cisplatin": { + "formula": "H6Cl2N2Pt", + "charge": 0, + "mult": 1, + "metal": "Pt", + }, + "inorganic-hexaamminecobaltiii": { + "formula": "H18CoN6", + "charge": 3, + "mult": 1, + "metal": "Co", + }, + "inorganic-ferrocene": { + "formula": "C10H10Fe", + "charge": 0, + "mult": 1, + "metal": "Fe", + }, +} + +# Generous covalent-radius sums (Å) for the connectivity check. +_BOND_CUTOFF = {"Pt": 2.9, "Co": 2.7, "Fe": 2.7} + + +def test_all_examples_present(): + ids = {e["id"] for e in ml.iter_entries()} + for eid in _EXPECTED: + assert eid in ids, f"missing bundled inorganic example: {eid}" + + +def test_examples_have_correct_charge_multiplicity_and_formula(): + for eid, exp in _EXPECTED.items(): + e = ml.get(eid) + assert e is not None + assert e["formula"] == exp["formula"] + assert e["charge"] == exp["charge"] + assert e["multiplicity"] == exp["mult"] + assert e["category"] == "inorganic-complex" + + +def test_metal_centre_is_connected(): + """The whole point of M-METAL: the metal must not be a detached dot.""" + for eid, exp in _EXPECTED.items(): + e = ml.get(eid) + atoms = e["atoms"] + coords = e["coordinates"] + mi = atoms.index(exp["metal"]) + mx, my, mz = coords[mi] + cutoff = _BOND_CUTOFF[exp["metal"]] + neighbours = 0 + for j, (x, y, z) in enumerate(coords): + if j == mi: + continue + d = math.sqrt((x - mx) ** 2 + (y - my) ** 2 + (z - mz) ** 2) + if d <= cutoff: + neighbours += 1 + assert neighbours >= 2, f"{eid}: metal has only {neighbours} neighbours" + + +def test_electron_count_parity_matches_multiplicity(): + """A bundled example must not itself trip the charge/multiplicity guard.""" + from quantui.inorganic_guards import check_charge_multiplicity + from quantui.molecule import ATOMIC_NUMBERS + + for eid in _EXPECTED: + e = ml.get(eid) + n_elec = sum(ATOMIC_NUMBERS.get(a, 0) for a in e["atoms"]) - e["charge"] + assert check_charge_multiplicity(n_elec, e["multiplicity"]) is None From 0ca0798c5f65079fb4c79d3b88adfff2c3a253ae Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 00:37:59 +0000 Subject: [PATCH 03/20] Fix two governance tests for the MET.9 inorganic examples The three bundled coordination complexes (cisplatin, hexaamminecobalt(III), ferrocene) added Pt/Co/Fe to the shipped library and lifted the non-bulk (presets + curated) count from 176 to 179, which broke two pre-existing invariant tests that MET.9 hadn't yet updated: - test_library_governance.py: extend the element allowlist with the coordination-complex metals Fe/Co/Pt. - test_bulk_library.py: bump the pinned non-bulk count 176 -> 179 (both the exact preset-dict assertion and the total-count floor). Whole no-network suite green apart from 14 NMR tests that require pyscf-properties (uninstallable in this container); the two target tests and the MET.9 example / MET.5 guard tests all pass. ruff + black clean. Contributions: - Claude (Opus 4.8): diagnosis, governance test fixes, suite verification - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- tests/test_bulk_library.py | 4 ++-- tests/test_library_governance.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_bulk_library.py b/tests/test_bulk_library.py index 6283ae9..f2a911b 100644 --- a/tests/test_bulk_library.py +++ b/tests/test_bulk_library.py @@ -59,13 +59,13 @@ def test_provenance_file_exists(self): class TestBulkInStore: def test_total_count_includes_bulk(self): - assert ml.count() >= 176 + len(_bulk_entries()) + assert ml.count() >= 179 + len(_bulk_entries()) def test_bulk_excluded_from_preset_dict(self): # The browse dropdown must NOT balloon with thousands of bulk entries. d = config.MOLECULE_LIBRARY assert all(not k.startswith("qm9-") for k in d) - assert len(d) == 176 # presets + curated only + assert len(d) == 179 # presets + curated only (+3 MET.9 inorganics) def test_bulk_category_present(self): assert "bulk-qm9" in ml.categories() diff --git a/tests/test_library_governance.py b/tests/test_library_governance.py index 92d5720..696c1a4 100644 --- a/tests/test_library_governance.py +++ b/tests/test_library_governance.py @@ -13,7 +13,8 @@ from quantui import molecule_library as ml # Reasonable element-symbol whitelist for the bundled tiers (CHONF + curated -# heteroatoms + common ions). Guards against codec corruption. +# heteroatoms + common ions + the coordination-complex metals Fe/Co/Pt from the +# bundled inorganic examples, MET.9). Guards against codec corruption. _KNOWN = { "H", "He", @@ -35,8 +36,11 @@ "Ar", "K", "Ca", + "Fe", + "Co", "Br", "I", + "Pt", } From 6c1bb4d1af0eefe5e219549830893b67d73b051b Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:07:11 +0000 Subject: [PATCH 04/20] MET.4: pre-opt reports an honest failure on metal complexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A classical pre-optimization returns RMSD 0.0 in two very different cases: the bonded force field ran and the geometry was already fine (a real no-op), or the force field could not build a model at all. The second is the metal case — RDKit's DetermineBonds raises "Atom N has no valences defined" for a transition metal, preoptimize() catches it and returns the input unchanged, and the preview then told the student "your geometry is already reasonable." For a scattered metal complex that is false and misleading. - New preopt.preopt_support(molecule): mirrors the parse -> DetermineBonds -> MMFF/UFF perception steps without minimizing and returns a plain-language reason when no bonded FF can be built, else None. Never raises. - The preview's negligible-RMSD branch now probes it: an unsupported structure gets an honest message pointing to the DFT geometry optimization (and the bundled examples / XYZ paste as good starting points) instead of the "already reasonable" no-op wording, which is preserved for genuine organic no-ops. Tests: preopt_support None-for-organic / reason-for-metal, and the preview message branches (metal -> honest, organic -> unchanged). All pass; ruff+black clean. Voilà visual confirmation of the message remains a local check. Contributions: - Claude (Opus 4.8): MET.4 implementation, wiring, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app_runflow.py | 40 ++++++++++++++++------ quantui/preopt.py | 40 ++++++++++++++++++++++ tests/test_preopt_preview.py | 65 ++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 11 deletions(-) diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 1a927b3..983d54f 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -636,23 +636,41 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No app.preopt_preview_btn.disabled = False if rmsd <= _PREOPT_NEGLIGIBLE_RMSD_A: - # Nothing meaningful to show or decide. An animation of a geometry that - # does not visibly move, plus a Keep/Revert choice between two - # effectively identical structures, reads as "something happened, and - # you must now judge it" — when the honest answer is "your geometry was - # already fine". Report the number and stop. + # Nothing to show or decide — but a 0 Å result has two very different + # causes, and conflating them misleads (M-METAL MET.4). Either the + # bonded FF ran and the geometry was already fine (an honest no-op), or + # the FF could not build a model at all — a metal complex, where + # DetermineBonds raises and preoptimize() returns the input unchanged. + # Calling the latter "your geometry is already reasonable" tells a + # student their scattered metal structure is good. Probe which case this + # is and word it truthfully. + from quantui.preopt import preopt_support + + unsupported = preopt_support(relaxed) app._preopt_relaxed_mol = None app.preopt_preview_output.clear_output() app.preopt_preview_output.layout.display = "none" app._preopt_actions_box.layout.display = "none" app.preopt_accept_btn.disabled = True app.preopt_reset_btn.disabled = True - app.preopt_preview_status.value = _preopt_small( - "Pre-optimization (MMFF94/UFF) found no meaningful change — " - f"RMSD {rmsd:.3f} Å. Your geometry is already reasonable, so there " - "is nothing to keep or revert; the calculation will use it as-is.", - "#444", - ) + if unsupported is not None: + app.preopt_preview_status.value = _preopt_small( + "Classical pre-optimization isn't available for this structure " + f"({unsupported}). This is expected for metal complexes and other " + "systems RDKit can't perceive bonds for — skip the classical " + "pre-opt and let the DFT geometry optimization refine the " + "structure instead. Make sure the starting geometry is sensible " + "first (the bundled inorganic examples or an XYZ paste are good " + "starting points).", + "#b45309", + ) + else: + app.preopt_preview_status.value = _preopt_small( + "Pre-optimization (MMFF94/UFF) found no meaningful change — " + f"RMSD {rmsd:.3f} Å. Your geometry is already reasonable, so there " + "is nothing to keep or revert; the calculation will use it as-is.", + "#444", + ) try: app._activity_end(kind="ui") except Exception: diff --git a/quantui/preopt.py b/quantui/preopt.py index fda5875..9018aeb 100644 --- a/quantui/preopt.py +++ b/quantui/preopt.py @@ -66,6 +66,46 @@ def _copy_molecule(molecule: Molecule) -> Molecule: ) +def preopt_support(molecule: Molecule) -> Optional[str]: + """Why a bonded-FF pre-opt can't run on ``molecule``, or ``None`` if it can. + + Mirrors the perception steps in :func:`_rdkit_ff_relax` (parse → + ``DetermineBonds`` → MMFF/UFF parameter check) **without minimizing**, so a + caller can tell a genuine *"already optimal, nothing moved"* no-op apart from + *"the classical force field has no model for this molecule."* The latter is + the metal-complex case (M-METAL MET.4): a transition metal makes + ``DetermineBonds`` raise ``"Atom … has no valences defined"``, and + :func:`preoptimize` then returns the geometry unchanged at RMSD 0.0 — which + must **not** be reported as "your geometry is already reasonable." + + Returns ``None`` when a bonded force field can be built, otherwise a short + plain-language reason. Never raises. + """ + if not _RDKIT_AVAILABLE: + return "RDKit is not available" + from rdkit import Chem + from rdkit.Chem import AllChem, rdDetermineBonds + + xyz_block = ( + f"{len(molecule.atoms)}\n{molecule.get_formula()}\n" + f"{molecule.to_xyz_string()}\n" + ) + rdmol = Chem.MolFromXYZBlock(xyz_block) + if rdmol is None: + return "RDKit could not parse the geometry" + try: + rdDetermineBonds.DetermineBonds(rdmol, charge=int(molecule.charge)) + except Exception as exc: # noqa: BLE001 — perception failure is the signal + # Transition metals land here ("Atom N … has no valences defined"), as do + # geometries too distorted for distance-based bond perception. + return f"RDKit could not perceive bonds ({type(exc).__name__})" + if AllChem.MMFFHasAllMoleculeParams(rdmol) or AllChem.UFFHasAllMoleculeParams( + rdmol + ): + return None + return "no MMFF or UFF force-field parameters cover these elements" + + # Interactive-preview animation tuning (preoptimize_with_trajectory). The # trajectory is captured as fresh minimizations from the input at increasing # iteration budgets (see _rdkit_ff_relax). _PREVIEW_FRAMES is how many are shown diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py index 20e16bf..f37f0da 100644 --- a/tests/test_preopt_preview.py +++ b/tests/test_preopt_preview.py @@ -50,6 +50,20 @@ def _embed_smiles(smiles: str): ) +def _metal_complex() -> Molecule: + """A bundled coordination complex (cisplatin) — RDKit can't perceive its + metal bonds, so the classical FF has no model for it (M-METAL MET.4).""" + from quantui import molecule_library as ml + + e = next(x for x in ml.iter_entries() if x["id"] == "inorganic-cisplatin") + return Molecule( + atoms=e["atoms"], + coordinates=e["coordinates"], + charge=e["charge"], + multiplicity=e["multiplicity"], + ) + + # ── Backend: preoptimize_with_trajectory ──────────────────────────────────── @@ -337,6 +351,57 @@ def test_revert_discards_without_changing_molecule(self, app): assert app.preopt_preview_box.layout.display == "none" +class TestMetalPreoptHonesty: + """M-METAL MET.4: a 0 Å pre-opt on a metal complex is a *failure* to build a + force-field model, not a benign "your geometry is already reasonable".""" + + def test_preopt_support_none_for_organic(self): + from quantui.preopt import _RDKIT_AVAILABLE, preopt_support + + if not _RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + assert preopt_support(_water()) is None + + def test_preopt_support_reason_for_metal(self): + from quantui.preopt import _RDKIT_AVAILABLE, preopt_support + + if not _RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + reason = preopt_support(_metal_complex()) + assert reason is not None and isinstance(reason, str) and reason + + def test_metal_negligible_change_reports_honestly(self, app): + # The FF no-ops a metal complex at RMSD 0.0. The message must NOT claim + # the geometry is "already reasonable"; it must point to DFT geometry opt. + from quantui.app_runflow import _preopt_preview_done + from quantui.preopt import _RDKIT_AVAILABLE + + if not _RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + metal = _metal_complex() + _preopt_preview_done(app, metal, 0.0, [[list(c) for c in metal.coordinates]]) + + status = app.preopt_preview_status.value.lower() + assert "geometry optimization" in status + assert "isn't available" in status or "not available" in status + assert "already reasonable" not in status + # Nothing to keep/revert either way. + assert app._preopt_relaxed_mol is None + assert app._preopt_actions_box.layout.display == "none" + + def test_organic_negligible_change_still_says_already_reasonable(self, app): + # The honest-metal path must not regress the organic no-op wording. + from quantui.app_runflow import _preopt_preview_done + from quantui.preopt import _RDKIT_AVAILABLE + + if not _RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + _preopt_preview_done(app, _water(), 0.01, [[[0, 0, 0]]]) + status = app.preopt_preview_status.value.lower() + assert "already reasonable" in status + assert "no meaningful change" in status + + class TestStaleRunStatus: """Regression: 'Pre-optimized geometry accepted.' lingered next to Run after switching molecules / reverting a later preview.""" From e502dacf9e3c38d3212bd4df46472d8e90a41853 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:10:32 +0000 Subject: [PATCH 05/20] MET.2: warn when a fetched structure resolves to a disconnected salt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name search for a coordination complex often resolves to an ionic salt form (cisplatin → 2 NH₃ + 2 HCl + Pt²⁺) rather than the coordinated square-planar complex. The metal component is then laid out independently, and QuantUI would silently proceed to compute a wrong geometry. - New quantui/connectivity.py: a metal-aware, distance-based connectivity primitive (covalent-radii sum × 1.3 tolerance; Cordero 2008 radii). RDKit's DetermineBonds can't do this — it raises on transition metals — so this is a purely geometric component finder. Also the shared primitive for MET.1/MET.6. covalent_components / is_disconnected / describe_disconnection (a teaching-toned warning naming the fragments by formula). - The structure-search load path (_apply_pubchem_search_result) now runs the check and prepends the warning to the load message instead of loading a scattered geometry silently. The three bundled complexes stay one connected component, so a correctly coordinated structure is never flagged. Tests: 9 (component finding, fragment naming, bundled-complex-stays-connected, scattered-metal-salt flagged, and the load-path wire-in). ruff+black clean; existing search-path tests unaffected. Voilà visual confirmation is a local check. Contributions: - Claude (Opus 4.8): MET.2 connectivity primitive, load-path wiring, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app.py | 11 ++ quantui/connectivity.py | 202 +++++++++++++++++++++++++++++++++++++ tests/test_connectivity.py | 112 ++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 quantui/connectivity.py create mode 100644 tests/test_connectivity.py diff --git a/quantui/app.py b/quantui/app.py index 9d14755..7c5aab9 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -3281,6 +3281,17 @@ def _apply_pubchem_search_result( "⚠ No network detected — resolved offline from the bundled " f"library (not PubChem). {msg}" ) + # MET.2: a fetched name can resolve to a disconnected ionic salt + # (cisplatin → 2 NH₃ + 2 HCl + Pt²⁺) rather than the coordinated + # complex. Warn rather than let a wrong geometry silently feed a run. + try: + from .connectivity import describe_disconnection + + warning = describe_disconnection(mol.atoms, mol.coordinates) + except Exception: # noqa: BLE001 — a detection failure must not block load + warning = None + if warning: + msg = f"⚠ {warning} {msg}" self.pubchem_msg.value = msg else: self.pubchem_msg.value = f"Not found: {error}" diff --git a/quantui/connectivity.py b/quantui/connectivity.py new file mode 100644 index 0000000..893a55f --- /dev/null +++ b/quantui/connectivity.py @@ -0,0 +1,202 @@ +"""Distance-based (metal-aware) connectivity for coordination complexes. + +RDKit's ``DetermineBonds`` models only organic valence and *raises* on a +transition metal ("Atom N has no valences defined"), so the whole +structure → geometry → viewer stack loses the metal. This module provides a +purely geometric alternative used by the M-METAL work: two atoms are treated as +bonded when their separation is within a tolerance of the sum of their covalent +radii. That covers metal↔donor coordination bonds RDKit can't perceive, without +any valence model at all. + +Shared primitive for: + +* **MET.2** — detect a fetched structure that resolved to a *disconnected* salt + (cisplatin's name returns 2 NH₃ + 2 HCl + Pt²⁺, not the square-planar complex) + and warn instead of silently computing a wrong geometry. +* **MET.1 / MET.6** — coordination-aware connectivity for geometry handling and + for drawing coordination bonds in the viewer. + +Pure logic — no RDKit, no PySCF, no widgets. Never raises on ordinary input. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Sequence + +# Covalent radii in Å (Cordero et al., Dalton Trans. 2008, 2832). A broad subset +# covering the organic elements, common heteroatoms/ions, and the transition / +# heavy metals QuantUI's inorganic examples use. Anything missing falls back to +# ``_FALLBACK_RADIUS`` — generous enough not to sever a real bond by accident. +COVALENT_RADII: Dict[str, float] = { + "H": 0.31, + "He": 0.28, + "Li": 1.28, + "Be": 0.96, + "B": 0.84, + "C": 0.76, + "N": 0.71, + "O": 0.66, + "F": 0.57, + "Ne": 0.58, + "Na": 1.66, + "Mg": 1.41, + "Al": 1.21, + "Si": 1.11, + "P": 1.07, + "S": 1.05, + "Cl": 1.02, + "Ar": 1.06, + "K": 2.03, + "Ca": 1.76, + "Sc": 1.70, + "Ti": 1.60, + "V": 1.53, + "Cr": 1.39, + "Mn": 1.39, + "Fe": 1.52, + "Co": 1.50, + "Ni": 1.24, + "Cu": 1.32, + "Zn": 1.22, + "Ga": 1.22, + "Ge": 1.20, + "As": 1.19, + "Se": 1.20, + "Br": 1.20, + "Kr": 1.16, + "Ru": 1.46, + "Rh": 1.42, + "Pd": 1.39, + "Ag": 1.45, + "Cd": 1.44, + "I": 1.39, + "Pt": 1.36, + "Au": 1.36, + "Hg": 1.32, + "Pb": 1.46, +} + +_FALLBACK_RADIUS = 0.75 + +# A bond is inferred when the interatomic distance is within this factor of the +# summed covalent radii. 1.3 is the usual slack for covalent-radii bond +# perception; it keeps all three bundled coordination complexes (cisplatin, +# hexaamminecobalt(III), ferrocene) as a single connected component while still +# separating a genuine ionic salt into fragments. +DEFAULT_TOLERANCE = 1.3 + + +def _radius(symbol: str) -> float: + return COVALENT_RADII.get(symbol, _FALLBACK_RADIUS) + + +def covalent_components( + atoms: Sequence[str], + coords: Sequence[Sequence[float]], + tolerance: float = DEFAULT_TOLERANCE, +) -> List[List[int]]: + """Group atom indices into connected components by covalent-radii distance. + + Two atoms *i*, *j* are bonded when ``dist(i, j) <= tolerance * (r_i + r_j)``. + Returns a list of components (each a sorted list of atom indices), ordered + largest first then by first index — deterministic for a given input. + """ + n = len(atoms) + if n == 0: + return [] + adj: List[List[int]] = [[] for _ in range(n)] + for i in range(n): + ri = _radius(atoms[i]) + xi, yi, zi = coords[i][0], coords[i][1], coords[i][2] + for j in range(i + 1, n): + threshold = tolerance * (ri + _radius(atoms[j])) + dx = xi - coords[j][0] + dy = yi - coords[j][1] + dz = zi - coords[j][2] + if dx * dx + dy * dy + dz * dz <= threshold * threshold: + adj[i].append(j) + adj[j].append(i) + + seen = [False] * n + components: List[List[int]] = [] + for start in range(n): + if seen[start]: + continue + stack = [start] + comp: List[int] = [] + while stack: + u = stack.pop() + if seen[u]: + continue + seen[u] = True + comp.append(u) + stack.extend(adj[u]) + components.append(sorted(comp)) + components.sort(key=lambda c: (-len(c), c[0])) + return components + + +def _hill_formula(symbols: Sequence[str]) -> str: + """Formula in Hill order (C, H, then alphabetical); '2' subscripts inline.""" + counts: Dict[str, int] = {} + for s in symbols: + counts[s] = counts.get(s, 0) + 1 + + def fmt(sym: str) -> str: + c = counts[sym] + return sym if c == 1 else f"{sym}{c}" + + ordered: List[str] = [] + for special in ("C", "H"): + if special in counts: + ordered.append(fmt(special)) + for sym in sorted(k for k in counts if k not in ("C", "H")): + ordered.append(fmt(sym)) + return "".join(ordered) + + +def is_disconnected( + atoms: Sequence[str], + coords: Sequence[Sequence[float]], + tolerance: float = DEFAULT_TOLERANCE, +) -> bool: + """True when the geometry splits into more than one covalent component.""" + return len(covalent_components(atoms, coords, tolerance)) > 1 + + +def describe_disconnection( + atoms: Sequence[str], + coords: Sequence[Sequence[float]], + tolerance: float = DEFAULT_TOLERANCE, +) -> Optional[str]: + """A teaching-toned warning if the structure is disconnected, else ``None``. + + Names the fragments by formula (grouping identical ones with an ``N×`` + multiplier), so a student sees *why* the loaded structure is suspect — the + cisplatin-salt case (MET.2), where a name resolves to separate ions rather + than the coordinated complex. + """ + components = covalent_components(atoms, coords, tolerance) + if len(components) <= 1: + return None + + formula_counts: Dict[str, int] = {} + order: List[str] = [] + for comp in components: + f = _hill_formula([atoms[i] for i in comp]) + if f not in formula_counts: + order.append(f) + formula_counts[f] = formula_counts.get(f, 0) + 1 + parts = [ + (f"{formula_counts[f]}×{f}" if formula_counts[f] > 1 else f) for f in order + ] + fragments = " + ".join(parts) + + return ( + f"This structure is disconnected — it resolved to {len(components)} " + f"separate fragments ({fragments}), not one bonded molecule. For a metal " + "complex this usually means the name returned an ionic salt form rather " + "than the coordinated complex, so the geometry shown is not the real " + "molecule. Start from a known-good geometry instead — paste one in the " + "XYZ Input tab, or load a bundled inorganic example." + ) diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py new file mode 100644 index 0000000..8b01551 --- /dev/null +++ b/tests/test_connectivity.py @@ -0,0 +1,112 @@ +"""Distance-based connectivity + disconnected-salt warning (M-METAL MET.2). + +Pure logic (no RDKit / PySCF / network) plus the load-path wire-in that surfaces +the warning when a fetched name resolves to an ionic salt rather than the +coordinated complex. +""" + +from __future__ import annotations + +import pytest + +from quantui.connectivity import ( + covalent_components, + describe_disconnection, + is_disconnected, +) +from quantui.molecule import Molecule + + +def _water_coords(): + return ["O", "H", "H"], [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]] + + +def _two_far_waters(): + atoms = ["O", "H", "H", "O", "H", "H"] + coords = [ + [0.0, 0.0, 0.0], + [0.96, 0.0, 0.0], + [-0.24, 0.93, 0.0], + [10.0, 0.0, 0.0], + [10.96, 0.0, 0.0], + [9.76, 0.93, 0.0], + ] + return atoms, coords + + +def _cisplatin(): + from quantui import molecule_library as ml + + e = next(x for x in ml.iter_entries() if x["id"] == "inorganic-cisplatin") + return e["atoms"], e["coordinates"] + + +class TestCovalentComponents: + def test_single_molecule_is_one_component(self): + atoms, coords = _water_coords() + comps = covalent_components(atoms, coords) + assert len(comps) == 1 + assert sorted(comps[0]) == [0, 1, 2] + + def test_separated_fragments_split(self): + atoms, coords = _two_far_waters() + comps = covalent_components(atoms, coords) + assert len(comps) == 2 + # Largest-first, deterministic ordering; each water intact. + assert all(len(c) == 3 for c in comps) + + def test_empty_input(self): + assert covalent_components([], []) == [] + + def test_bundled_metal_complex_stays_connected(self): + # A correctly coordinated complex must NOT be flagged as a salt. + atoms, coords = _cisplatin() + assert len(covalent_components(atoms, coords)) == 1 + assert is_disconnected(atoms, coords) is False + + +class TestDescribeDisconnection: + def test_connected_returns_none(self): + atoms, coords = _water_coords() + assert describe_disconnection(atoms, coords) is None + + def test_disconnected_names_fragments(self): + atoms, coords = _two_far_waters() + msg = describe_disconnection(atoms, coords) + assert msg is not None + assert "disconnected" in msg.lower() + assert "2×H2O" in msg # identical fragments grouped with a multiplier + assert "XYZ Input" in msg # actionable next step + + def test_scattered_metal_salt_is_flagged(self): + # Simulate the cisplatin salt form: pull the Pt far from its ligands. + atoms, coords = _cisplatin() + coords = [list(c) for c in coords] + pt = atoms.index("Pt") + coords[pt] = [c + 8.0 for c in coords[pt]] + msg = describe_disconnection(atoms, coords) + assert msg is not None + assert "Pt" in msg + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + from quantui.app import QuantUIApp + + return QuantUIApp() + + +class TestLoadPathWarning: + def test_disconnected_result_warns(self, app): + atoms, coords = _two_far_waters() + mol = Molecule(atoms=atoms, coordinates=coords) + app._apply_pubchem_search_result("salt-like", mol=mol, source="pubchem") + assert "disconnected" in app.pubchem_msg.value.lower() + + def test_connected_result_no_warning(self, app): + atoms, coords = _water_coords() + mol = Molecule(atoms=atoms, coordinates=coords) + app._apply_pubchem_search_result("water", mol=mol, source="pubchem") + assert "disconnected" not in app.pubchem_msg.value.lower() + assert "Loaded" in app.pubchem_msg.value From f2b8fdf8191cf7559d7a29e304f3b8f827f4d123 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:14:42 +0000 Subject: [PATCH 06/20] MET.3: viewer falls back to py3Dmol instead of crashing on a metal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlotlyMol's draw_3D_rep runs RDKit valence perception, which raises "Atom N has no valences defined" on any transition metal. The main viewer then showed a red "Visualization failed" box instead of the structure, and the trajectory-frame renderer only caught ImportError so the ValueError crashed the frame. A viewer must never hard-error on a valid molecule. - visualize_molecule() router: the plotlymol branch now catches a render failure and falls back to py3Dmol (which renders straight from coordinates, no valence model) with a logged notice; it re-raises only if py3Dmol is also unavailable. This flows through render_molecule_html(), so the Results/Analysis viewers now show the metal instead of the failure box. - render_traj_frame(): broadened its except ImportError to except Exception so a metal's ValueError also falls through to the existing py3Dmol path. - Extracted the py3Dmol style tuple to a module constant, reused for validation and for the fallback's style guard. Organic molecules still render through PlotlyMol unchanged (regression-guarded). Tests: metal falls back to a py3Dmol view, render_molecule_html shows structure not the error box, organic still returns a plotly Figure, and it re-raises with no py3Dmol. ruff+black clean. The Voilà visual pass (metal actually looks right) remains a local check. Contributions: - Claude (Opus 4.8): MET.3 fallback in the router + trajectory renderer, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app_visualization.py | 4 +- quantui/visualization_py3dmol.py | 60 +++++++++++++++------- tests/test_metal_viewer_fallback.py | 77 +++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 tests/test_metal_viewer_fallback.py diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index a37d6ed..020e5c9 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -483,7 +483,9 @@ def render_traj_frame(app: Any, molecule: Any, output_widget: Any) -> None: with output_widget: display(fig) return - except ImportError: + except Exception: # noqa: BLE001 — MET.3: any PlotlyMol failure (missing + # backend, or the RDKit valence error a transition metal raises) must + # fall through to the py3Dmol renderer below, never crash the frame. pass # Fallback: py3Dmol diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index d6297d9..0a77d6c 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -190,16 +190,18 @@ def visualize_molecule_py3dmol( return view +_PY3DMOL_STYLES: tuple[Py3DmolStyle, ...] = ( + "ball+stick", + "stick", + "sphere", + "line", + "cartoon", +) + + def _validate_py3dmol_style(style: str) -> Py3DmolStyle: - valid_styles: tuple[Py3DmolStyle, ...] = ( - "ball+stick", - "stick", - "sphere", - "line", - "cartoon", - ) - if style not in valid_styles: - raise ValueError(f"style must be one of {list(valid_styles)}, got '{style}'") + if style not in _PY3DMOL_STYLES: + raise ValueError(f"style must be one of {list(_PY3DMOL_STYLES)}, got '{style}'") return cast(Py3DmolStyle, style) @@ -360,15 +362,37 @@ def visualize_molecule( "line": "stick", # plotlyMol has no line mode; use stick } mode = mode_map.get(style, "ball+stick") - return visualize_molecule_plotlymol( - molecule, - mode=mode, - width=width, - height=height, - bgcolor=bgcolor, - lighting=lighting, - **kwargs, - ) + try: + return visualize_molecule_plotlymol( + molecule, + mode=mode, + width=width, + height=height, + bgcolor=bgcolor, + lighting=lighting, + **kwargs, + ) + except Exception as exc: # noqa: BLE001 — a viewer must never hard-error + # MET.3: PlotlyMol runs RDKit valence perception, which raises on + # transition metals ("Atom N has no valences defined"). py3Dmol + # renders straight from coordinates with no valence model, so fall + # back to it rather than crash on a valid molecule. + if not PY3DMOL_AVAILABLE: + raise + logger.warning( + "PlotlyMol could not render %s (%s); falling back to py3Dmol.", + molecule.get_formula(), + exc, + ) + fallback_style = style if style in _PY3DMOL_STYLES else "ball+stick" + return visualize_molecule_py3dmol( + molecule, + style=_validate_py3dmol_style(fallback_style), + width=width, + height=height, + bgcolor=bgcolor, + lighting=lighting, + ) else: raise ValueError(f"Unknown backend: {backend}") diff --git a/tests/test_metal_viewer_fallback.py b/tests/test_metal_viewer_fallback.py new file mode 100644 index 0000000..f76a039 --- /dev/null +++ b/tests/test_metal_viewer_fallback.py @@ -0,0 +1,77 @@ +"""Viewer never hard-errors on a metal complex (M-METAL MET.3). + +PlotlyMol runs RDKit valence perception, which raises "Atom N has no valences +defined" on a transition metal. The backend router must fall back to py3Dmol +(which renders straight from coordinates) instead of crashing, and the HTML +renderer must show the structure rather than a red failure box. Organic +molecules must still render through PlotlyMol unchanged. +""" + +from __future__ import annotations + +import pytest + +from quantui.molecule import Molecule + + +def _mol(entry_id: str) -> Molecule: + from quantui import molecule_library as ml + + e = next(x for x in ml.iter_entries() if x["id"] == entry_id) + return Molecule( + atoms=e["atoms"], + coordinates=e["coordinates"], + charge=e["charge"], + multiplicity=e["multiplicity"], + ) + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]], + ) + + +@pytest.fixture(autouse=True) +def _need_both_backends(): + import quantui.visualization_py3dmol as viz + + if not (viz.PLOTLYMOL_AVAILABLE and viz.PY3DMOL_AVAILABLE): + pytest.skip("both plotlymol and py3dmol backends required") + + +class TestMetalViewerFallback: + def test_plotlymol_backend_falls_back_for_metal(self): + from quantui.visualization_py3dmol import visualize_molecule + + # Would raise ValueError without the fallback; must return a py3Dmol view + # (identified by its _make_html method), not a plotly Figure. + view = visualize_molecule(_mol("inorganic-cisplatin"), backend="plotlymol") + assert callable(getattr(view, "_make_html", None)) + + def test_render_html_shows_structure_not_error(self): + from quantui.visualization_py3dmol import render_molecule_html + + html = render_molecule_html( + _mol("inorganic-ferrocene"), backend="plotlymol", width=400, height=300 + ) + assert "Visualization failed" not in html + assert len(html) > 1000 # a real viewer payload, not a stub error box + + def test_organic_still_uses_plotlymol(self): + # Regression guard: the fallback must not divert organic molecules, which + # PlotlyMol renders fine (returns a plotly Figure, no _make_html). + from quantui.visualization_py3dmol import visualize_molecule + + fig = visualize_molecule(_water(), backend="plotlymol") + assert getattr(fig, "_make_html", None) is None + + def test_reraises_when_no_py3dmol_fallback(self, monkeypatch): + import quantui.visualization_py3dmol as viz + + monkeypatch.setattr(viz, "PY3DMOL_AVAILABLE", False) + # RDKit's valence perception raises ValueError on the metal; with no + # py3Dmol to fall back to, that must propagate rather than be swallowed. + with pytest.raises(ValueError): + viz.visualize_molecule(_mol("inorganic-cisplatin"), backend="plotlymol") From 5231de2aac70c1d47f15017a8bc707ec378226ab Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:37:51 +0000 Subject: [PATCH 07/20] MET.5: add LANL2DZ as a heavy-metal ECP basis option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the classroom request, expose LANL2DZ alongside the def2 sets for heavy metals. Verified PySCF's lanl2dz definition covers the ligand atoms (H/C/N/O/Cl) as well as the metals (Sc–Zn, Ru/Rh/Pd/Pt), so it runs as QuantUI's single molecule-wide basis rather than needing a mixed-basis setup. - config.SUPPORTED_BASIS_SETS gains "LANL2DZ". - descriptor_cards: new "ecp" family (classification, copy, card style) so the basis card renders. - calculator: a notes entry explaining the Los Alamos ECP. - help_content: the transition-metal guidance now mentions LANL2DZ for the heaviest centres (def2-SVP/TZVP remain the primary nudge). The pre-run guard (inorganic_guards) already uses PySCF's loader as the source of truth, so it correctly treats LANL2DZ as covering metals with no change. Tests: LANL2DZ covers metals + ligands; existing basis-list iteration tests (cards, notation) pass with the new entry. ruff+black clean. Contributions: - Claude (Opus 4.8): LANL2DZ wiring across basis list/cards/notes/help, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/calculator.py | 8 ++++++++ quantui/config.py | 1 + quantui/descriptor_cards.py | 8 ++++++++ quantui/help_content.py | 3 ++- tests/test_inorganic_guards.py | 7 +++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/quantui/calculator.py b/quantui/calculator.py index 89c5452..af9bf8b 100644 --- a/quantui/calculator.py +++ b/quantui/calculator.py @@ -174,6 +174,14 @@ def get_educational_notes(self) -> str: "def2-SVP is a good default for DFT calculations; def2-TZVP gives " "near-complete-basis accuracy for most properties." ) + elif self.basis.upper().startswith("LANL"): + notes.append( + "**LANL2DZ**: A double-zeta basis with a Los Alamos effective " + "core potential (ECP) on heavy elements — it replaces the core " + "electrons of a heavy metal with a potential, making transition " + "and heavy-metal calculations tractable. A common alternative to " + "the def2 sets for the heaviest centres." + ) if self.molecule.multiplicity > 1: notes.append( diff --git a/quantui/config.py b/quantui/config.py index 0f9b3bd..fd6b661 100644 --- a/quantui/config.py +++ b/quantui/config.py @@ -196,6 +196,7 @@ "cc-pVTZ", "def2-SVP", "def2-TZVP", + "LANL2DZ", ] diff --git a/quantui/descriptor_cards.py b/quantui/descriptor_cards.py index 21f04f3..3be91c4 100644 --- a/quantui/descriptor_cards.py +++ b/quantui/descriptor_cards.py @@ -90,6 +90,7 @@ "pople": ("#0d9488", "#f0fdfa", _ICON_BASIS_POPLE), "cc": ("#15803d", "#f0fdf4", _ICON_BASIS_CC), "def2": ("#c2410c", "#fff7ed", _ICON_BASIS_DEF2), + "ecp": ("#7c3aed", "#f5f3ff", _ICON_BASIS_DEF2), } # ── Basis family classification + one-line copy ────────────────────────────── @@ -113,6 +114,11 @@ "Optimised for DFT; def2-SVP a solid default, def2-TZVP near " "complete-basis accuracy.", ), + "ecp": ( + "ECP (LANL2DZ)", + "Effective core potential for heavy metals; pairs a small light-atom " + "basis with an ECP on the metal.", + ), } @@ -124,6 +130,8 @@ def basis_family(basis: str) -> str: return "cc" if "def2" in basis: return "def2" + if basis.upper().startswith("LANL"): + return "ecp" # 3-21G and the whole 6-31G family are Pople split-valence sets. if basis.startswith("6-31") or basis == "3-21G" or basis.startswith("6-311"): return "pople" diff --git a/quantui/help_content.py b/quantui/help_content.py index f20502b..8ecc5ce 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -172,7 +172,8 @@ "not cover most metals, so a calculation on, say, a platinum " "or cobalt complex will stop with a message asking you to switch. " "Use def2-SVP or def2-TZVP — these carry effective " - "core potentials that cover the whole periodic table. Remember to " + "core potentials that cover the whole periodic table (or " + "LANL2DZ, an ECP basis for the heaviest centres). Remember to " "set the charge and multiplicity from the metal's oxidation " "state, and for a reliable starting geometry load one of the " "bundled inorganic examples (cisplatin, hexaamminecobalt(III), " diff --git a/tests/test_inorganic_guards.py b/tests/test_inorganic_guards.py index 20262b3..20c6a41 100644 --- a/tests/test_inorganic_guards.py +++ b/tests/test_inorganic_guards.py @@ -68,6 +68,13 @@ def test_pople_basis_lacks_a_metal(self): def test_def2_covers_metals(self): assert check_basis_coverage(["C", "N", "Pt", "Zn"], "def2-SVP") is None + def test_lanl2dz_covers_metals_and_ligands(self): + # MET.5: LANL2DZ is offered for heavy metals and (unlike a mixed-basis + # setup) its PySCF definition also covers the ligand atoms, so a whole + # complex runs under it as QuantUI's single molecule-wide basis. + assert check_basis_coverage(["C", "H", "N", "Cl", "Pt"], "LANL2DZ") is None + assert check_basis_coverage(["Fe", "Ru", "Pd"], "LANL2DZ") is None + def test_organic_basis_covers_organics(self): assert check_basis_coverage(["C", "H", "O", "N"], "6-31G*") is None From 0859adc053b69dead3d947b1dc1d5cd0e404e302 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:40:29 +0000 Subject: [PATCH 08/20] =?UTF-8?q?MET.5:=20oxidation-state=20=E2=86=92=20sp?= =?UTF-8?q?in-state=20multiplicity=20suggestion=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chemistry core for the multiplicity presets. Given a metal centre and its oxidation state, computes the d-electron count (group − oxidation state) and suggests the physically reasonable spin multiplicities — deliberately a SUGGESTION, never an auto-set, because a metal's spin is not fixed by oxidation state alone. - Octahedral d4–d7 return BOTH high-spin and low-spin candidates (the ligand field decides), with an explanation naming strong- vs weak-field ligands; d0–d3 and d8–d10 are unambiguous. - Tetrahedral is treated as always high-spin (Δ_t too small for low-spin). - Square-planar handles the diamagnetic d8 case (Pt(II)/Pd(II)/Ni(II), e.g. cisplatin → singlet). - Charge is intentionally not inferred — it depends on the ligands, which the metal centre alone doesn't determine; the student sets that. - Scope: first-row TMs (Sc–Zn) and the common 4d/5d centres (Ru/Rh/Pd/Pt…), matching the class. Unsupported metals / out-of-range d-counts raise ValueError so a caller can fall back. 40 tests encode the textbook cases (they double as the chemistry-review record): Co(III) d6 LS-singlet/HS-quintet, Fe(III) d5 LS-doublet/HS-sextet, Cr(III) d3 quartet, Ni(II) d8 triplet, Cu(II) d9 doublet, Zn(II) d10 singlet, Pt(II) square-planar singlet, etc. ruff+black clean. This ships the engine only; the pick-and-apply UI is a separate step, pending your review of the chemistry numbers. Contributions: - Claude (Opus 4.8): d-count/spin-state engine + textbook-case tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/spin_presets.py | 200 +++++++++++++++++++++++++++++++++++++ tests/test_spin_presets.py | 141 ++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 quantui/spin_presets.py create mode 100644 tests/test_spin_presets.py diff --git a/quantui/spin_presets.py b/quantui/spin_presets.py new file mode 100644 index 0000000..06b4ea6 --- /dev/null +++ b/quantui/spin_presets.py @@ -0,0 +1,200 @@ +"""Oxidation-state → d-count → spin-state multiplicity suggestions (M-METAL MET.5). + +**Suggests, never sets.** A transition metal's spin multiplicity is *not* fixed +by its oxidation state alone: for an octahedral d⁴–d⁷ centre the ligand field +decides **high-spin vs low-spin** (strong-field ligands like CN⁻/CO/NH₃ pair the +electrons → low-spin; weak-field ligands like H₂O/halides → high-spin). This +module turns a metal + oxidation state into the d-electron count and returns +*both* physically reasonable spin states with a plain-language explanation, so +the student picks the one matching their complex rather than being handed a +single (possibly wrong) number. + +Scope (per the classroom's metals): first-row transition metals (Sc–Zn) and the +common 4d/5d centres (Ru, Rh, Pd, Pt, …). Geometries: octahedral (default, +high/low-spin), tetrahedral (effectively always high-spin), and square-planar +(the diamagnetic d⁸ case, e.g. Pt(II) in cisplatin). Charge is deliberately +*not* inferred — the overall complex charge depends on the ligand charges, which +the metal centre alone doesn't determine; the student supplies that. + +Pure logic — no PySCF, no widgets. Raises ``ValueError`` only for a metal / +oxidation state outside the supported set, so a caller can fall back cleanly. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +# Group number (new IUPAC 3–12) = neutral-atom valence (s + d) electron count, +# so the d-electron count of an ion is group − oxidation_state. Covers the +# first-row TMs and the common 4d/5d centres the course uses. +_GROUP: Dict[str, int] = { + # 3d + "Sc": 3, + "Ti": 4, + "V": 5, + "Cr": 6, + "Mn": 7, + "Fe": 8, + "Co": 9, + "Ni": 10, + "Cu": 11, + "Zn": 12, + # 4d + "Y": 3, + "Zr": 4, + "Nb": 5, + "Mo": 6, + "Tc": 7, + "Ru": 8, + "Rh": 9, + "Pd": 10, + "Ag": 11, + "Cd": 12, + # 5d + "Hf": 4, + "Ta": 5, + "W": 6, + "Re": 7, + "Os": 8, + "Ir": 9, + "Pt": 10, + "Au": 11, + "Hg": 12, +} + +GEOMETRIES = ("octahedral", "tetrahedral", "square_planar") + + +@dataclass(frozen=True) +class SpinState: + """One candidate spin state for a d^n centre.""" + + label: str # "high-spin", "low-spin", or "" when unambiguous + n_unpaired: int + multiplicity: int # n_unpaired + 1 + + +@dataclass(frozen=True) +class SpinSuggestion: + """The full suggestion for a metal centre — d-count + candidate spin states.""" + + element: str + oxidation_state: int + geometry: str + d_count: int + states: List[SpinState] + explanation: str + + @property + def is_ambiguous(self) -> bool: + """True when more than one spin state is offered (high- vs low-spin).""" + return len(self.states) > 1 + + +def supported_metals() -> List[str]: + """Metals this module can suggest for (sorted by atomic group then symbol).""" + return sorted(_GROUP, key=lambda el: (_GROUP[el], el)) + + +def d_electron_count(element: str, oxidation_state: int) -> int: + """d-electron count of ``element`` in the given oxidation state (group − ox).""" + if element not in _GROUP: + raise ValueError(f"{element!r} is not a supported transition metal") + d = _GROUP[element] - oxidation_state + if d < 0 or d > 10: + raise ValueError( + f"{element}({oxidation_state:+d}) gives d{d}, outside the d0–d10 range" + ) + return d + + +# Unpaired-electron counts by d-count. Octahedral splits high-spin vs low-spin +# for d4–d7; d0–d3 and d8–d10 are unambiguous. Tetrahedral is effectively always +# high-spin (Δ_t is small — no low-spin tetrahedral complexes in practice). +_OCTAHEDRAL_HS = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 4, 7: 3, 8: 2, 9: 1, 10: 0} +_OCTAHEDRAL_LS = {4: 2, 5: 1, 6: 0, 7: 1} +_TETRAHEDRAL = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 4, 7: 3, 8: 2, 9: 1, 10: 0} + + +def _states_for_geometry(d: int, geometry: str) -> List[SpinState]: + if geometry == "octahedral": + hs = _OCTAHEDRAL_HS[d] + if d in _OCTAHEDRAL_LS: + ls = _OCTAHEDRAL_LS[d] + return [ + SpinState("high-spin", hs, hs + 1), + SpinState("low-spin", ls, ls + 1), + ] + return [SpinState("", hs, hs + 1)] + if geometry == "tetrahedral": + u = _TETRAHEDRAL[d] + return [SpinState("", u, u + 1)] + if geometry == "square_planar": + # Square-planar is the classic strong-field d8 case (Ni(II)/Pd(II)/Pt(II)): + # diamagnetic, all electrons paired. Other d-counts in a square-planar + # field are uncommon in the teaching set; fall back to the octahedral + # unpaired count and flag the assumption in the explanation. + if d == 8: + return [SpinState("", 0, 1)] + u = _OCTAHEDRAL_HS[d] + return [SpinState("", u, u + 1)] + raise ValueError(f"geometry must be one of {GEOMETRIES}, got {geometry!r}") + + +def _explain( + element: str, ox: int, d: int, geometry: str, states: List[SpinState] +) -> str: + head = ( + f"{element}({ox:+d}) is a d{d} centre " + f"({element} is group {_GROUP[element]}; d-count = group − oxidation state)." + ) + if len(states) > 1: + hs, ls = states[0], states[1] + body = ( + f" In an {geometry} field this is ambiguous: strong-field ligands " + f"(e.g. CN⁻, CO, NH₃) give low-spin — {ls.n_unpaired} unpaired, " + f"multiplicity {ls.multiplicity}; weak-field ligands (e.g. H₂O, " + f"halides) give high-spin — {hs.n_unpaired} unpaired, multiplicity " + f"{hs.multiplicity}. Pick the one matching your ligands." + ) + elif geometry == "square_planar" and d == 8: + body = ( + " A square-planar d8 centre (e.g. Pt(II), Pd(II)) is diamagnetic — " + "all electrons paired, multiplicity 1." + ) + else: + s = states[0] + body = ( + f" This d-count has a single spin state in an {geometry} field: " + f"{s.n_unpaired} unpaired, multiplicity {s.multiplicity}." + ) + tail = ( + " This sets the multiplicity only — the overall charge depends on your " + "ligands, so set that from the complex." + ) + return head + body + tail + + +def suggest_spin_states( + element: str, oxidation_state: int, geometry: str = "octahedral" +) -> SpinSuggestion: + """Suggest candidate spin multiplicities for a metal centre. + + Returns a :class:`SpinSuggestion` with the d-count and one or two + :class:`SpinState` candidates (two when the octahedral field leaves + high-/low-spin ambiguous). Raises ``ValueError`` for an unsupported metal, + an out-of-range d-count, or an unknown geometry. + """ + if geometry not in GEOMETRIES: + raise ValueError(f"geometry must be one of {GEOMETRIES}, got {geometry!r}") + d = d_electron_count(element, oxidation_state) + states = _states_for_geometry(d, geometry) + return SpinSuggestion( + element=element, + oxidation_state=oxidation_state, + geometry=geometry, + d_count=d, + states=states, + explanation=_explain(element, oxidation_state, d, geometry, states), + ) diff --git a/tests/test_spin_presets.py b/tests/test_spin_presets.py new file mode 100644 index 0000000..35c5334 --- /dev/null +++ b/tests/test_spin_presets.py @@ -0,0 +1,141 @@ +"""Spin-state / multiplicity suggestion engine (M-METAL MET.5). + +The assertions below are the textbook cases the suggestions must reproduce — +this file doubles as the chemistry-review record for the d-count → spin-state +logic. Pure logic, no PySCF. +""" + +from __future__ import annotations + +import pytest + +from quantui.spin_presets import ( + d_electron_count, + suggest_spin_states, + supported_metals, +) + + +class TestDCount: + @pytest.mark.parametrize( + "element,ox,expected", + [ + ("Sc", 3, 0), + ("Ti", 3, 1), + ("V", 3, 2), + ("Cr", 3, 3), + ("Mn", 2, 5), + ("Fe", 3, 5), + ("Fe", 2, 6), + ("Co", 3, 6), + ("Co", 2, 7), + ("Ni", 2, 8), + ("Cu", 2, 9), + ("Zn", 2, 10), + ("Pt", 2, 8), + ("Pt", 4, 6), + ("Ru", 2, 6), + ("Pd", 2, 8), + ], + ) + def test_d_count(self, element, ox, expected): + assert d_electron_count(element, ox) == expected + + def test_unsupported_element(self): + with pytest.raises(ValueError): + d_electron_count("Xx", 2) + + def test_out_of_range(self): + with pytest.raises(ValueError): + d_electron_count("Sc", 5) # d-2, impossible + + +def _mults(element, ox, geometry="octahedral"): + s = suggest_spin_states(element, ox, geometry) + return s, sorted(st.multiplicity for st in s.states) + + +class TestOctahedralUnambiguous: + @pytest.mark.parametrize( + "element,ox,mult", + [ + ("Sc", 3, 1), # d0 + ("Ti", 3, 2), # d1 + ("V", 3, 3), # d2 + ("Cr", 3, 4), # d3 quartet + ("Ni", 2, 3), # d8 triplet + ("Cu", 2, 2), # d9 doublet + ("Zn", 2, 1), # d10 singlet + ], + ) + def test_single_state(self, element, ox, mult): + s, mults = _mults(element, ox) + assert not s.is_ambiguous + assert mults == [mult] + + +class TestOctahedralHighLowSpin: + @pytest.mark.parametrize( + "element,ox,hs_mult,ls_mult", + [ + ("Cr", 2, 5, 3), # d4: HS quintet / LS triplet + ("Mn", 2, 6, 2), # d5: HS sextet / LS doublet + ("Fe", 3, 6, 2), # d5 + ("Fe", 2, 5, 1), # d6: HS quintet / LS singlet + ("Co", 3, 5, 1), # d6 + ("Ru", 2, 5, 1), # d6 (4d — same d-count rules) + ("Co", 2, 4, 2), # d7: HS quartet / LS doublet + ], + ) + def test_two_states(self, element, ox, hs_mult, ls_mult): + s, mults = _mults(element, ox) + assert s.is_ambiguous + assert mults == sorted([hs_mult, ls_mult]) + # Labels present and the numbers pair with the right label. + by_label = {st.label: st.multiplicity for st in s.states} + assert by_label["high-spin"] == hs_mult + assert by_label["low-spin"] == ls_mult + assert "high-spin" in s.explanation and "low-spin" in s.explanation + + +class TestSquarePlanar: + def test_d8_is_diamagnetic_singlet(self): + # Cisplatin's Pt(II): square-planar d8 → all paired, multiplicity 1. + s, mults = _mults("Pt", 2, "square_planar") + assert not s.is_ambiguous + assert mults == [1] + assert "diamagnetic" in s.explanation + + def test_pd_ii_square_planar_singlet(self): + _, mults = _mults("Pd", 2, "square_planar") + assert mults == [1] + + +class TestTetrahedral: + @pytest.mark.parametrize( + "element,ox,mult", + [ + ("Fe", 2, 5), # d6 tetrahedral → high-spin quintet + ("Co", 2, 4), # d7 → quartet + ("Ni", 2, 3), # d8 → triplet + ], + ) + def test_always_high_spin_single_state(self, element, ox, mult): + s, mults = _mults(element, ox, "tetrahedral") + assert not s.is_ambiguous + assert mults == [mult] + + +class TestMisc: + def test_supported_metals_covers_scope(self): + metals = supported_metals() + for el in ("Sc", "Zn", "Fe", "Co", "Ru", "Rh", "Pd", "Pt"): + assert el in metals + + def test_bad_geometry(self): + with pytest.raises(ValueError): + suggest_spin_states("Fe", 3, "linear") + + def test_multiplicity_is_unpaired_plus_one(self): + for st in suggest_spin_states("Fe", 3).states: + assert st.multiplicity == st.n_unpaired + 1 From 58994583616040baaf6546558e7d301a9bb869b3 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 01:45:38 +0000 Subject: [PATCH 09/20] MET.5: one-click "Switch to def2-SVP" fix for a metal on a bad basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-run guard blocks a metal on a basis that can't handle it (e.g. 6-31G on Pt). Instead of making the student hunt the dropdown, offer a single button that sets the basis to def2-SVP — but only when that actually resolves the coverage, never for a charge/multiplicity problem def2-SVP can't fix. - New app.basis_fix_btn in the run-controls row, hidden by default. - The guard reveals it via _update_basis_fix_button() only when the current basis genuinely lacks an element AND def2-SVP covers them all; a spin-only problem leaves it hidden. - on_basis_fix() sets basis_dd to def2-SVP, hides the button, and updates the status to prompt a re-run. A clean run hides the button. Tests: hidden initially; metal+6-31G reveals it; click sets def2-SVP and hides it with a status note; a multiplicity-only problem does NOT reveal it; the helper hides it when def2-SVP wouldn't help. App/preopt suites unaffected (309 pass). ruff+black clean. The Voilà pass (button renders + clicks live) is local. Contributions: - Claude (Opus 4.8): basis-fix button widget, guard wiring, handler, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app.py | 7 +++ quantui/app_builders.py | 11 +++++ quantui/app_runflow.py | 54 +++++++++++++++++++++++ tests/test_basis_fix_button.py | 79 ++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 tests/test_basis_fix_button.py diff --git a/quantui/app.py b/quantui/app.py index 7c5aab9..448892a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -204,6 +204,9 @@ from quantui.app_runflow import ( on_accumulate as _run_on_accumulate, ) +from quantui.app_runflow import ( + on_basis_fix as _run_on_basis_fix, +) from quantui.app_runflow import ( on_basis_help as _run_on_basis_help, ) @@ -2000,6 +2003,7 @@ def _wire_callbacks(self) -> None: # Run self.run_btn.on_click(self._on_run_clicked) self.cancel_btn.on_click(self._safe_cb(self._on_cancel)) + self.basis_fix_btn.on_click(self._safe_cb(self._on_basis_fix)) self.preopt_preview_btn.on_click(self._safe_cb(self._on_preopt_preview)) self.preopt_accept_btn.on_click(self._safe_cb(self._on_preopt_accept)) self.preopt_reset_btn.on_click(self._safe_cb(self._on_preopt_reset)) @@ -3496,6 +3500,9 @@ def _on_cancel(self, btn=None) -> None: except Exception: pass + def _on_basis_fix(self, btn=None) -> None: + _run_on_basis_fix(self, btn) + def _on_preopt_preview(self, btn=None) -> None: _run_on_preopt_preview(self, btn) diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 0499b05..2593ca0 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -1142,6 +1142,16 @@ def build_shared_widgets( tooltip=("Stop the running calculation at the next SCF cycle / optimizer step"), ) + # MET.5: one-click fix shown only when a metal's basis blocks the run. + # Hidden until the pre-run guard reveals it; sets the basis to def2-SVP. + app.basis_fix_btn = widgets.Button( + description="Switch basis to def2-SVP", + button_style="warning", + icon="wrench", + layout=layout_fn(width="220px", height="36px", display="none"), + tooltip="Set the basis set to def2-SVP, which covers transition metals", + ) + app.log_clear_btn = widgets.Button( description="Clear", button_style="", @@ -1565,6 +1575,7 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None: [ app.run_btn, app.cancel_btn, + app.basis_fix_btn, # Status + elapsed/remaining chip stacked vertically so the # timer never crowds/truncates the (longer) status line. widgets.VBox( diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 983d54f..bb1ad9d 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -156,7 +156,12 @@ def on_run_clicked(app: Any, btn: Any) -> None: app.run_status.value = "Adjust the settings above, then Run again." except Exception: # noqa: BLE001 — status label is best-effort pass + # MET.5: if the blocker is purely the basis (def2-SVP would clear it), + # offer a one-click switch rather than making the student hunt the + # dropdown. Only reveal it when def2-SVP actually resolves coverage. + _update_basis_fix_button(app, mol) return + _hide_basis_fix_button(app) # Write the header FIRST (atomic, main thread) — this also clears the # previous run's log via the single ``outputs`` assignment. @@ -179,6 +184,55 @@ def on_run_clicked(app: Any, btn: Any) -> None: threading.Thread(target=app._do_run, daemon=True).start() +# The metal-capable basis the one-click MET.5 fix switches to. +_METAL_FIX_BASIS = "def2-SVP" + + +def _hide_basis_fix_button(app: Any) -> None: + try: + app.basis_fix_btn.layout.display = "none" + except Exception: # noqa: BLE001 — the button is a UI convenience only + pass + + +def _update_basis_fix_button(app: Any, mol: Any) -> None: + """Show the one-click fix only when def2-SVP would resolve basis coverage. + + A charge/multiplicity problem (which def2-SVP can't fix) must not trigger it, + so this checks the current basis genuinely lacks an element *and* def2-SVP + covers them all. + """ + try: + from quantui.inorganic_guards import check_basis_coverage + + current_bad = check_basis_coverage(mol.atoms, app.basis_dd.value) is not None + def2_ok = check_basis_coverage(mol.atoms, _METAL_FIX_BASIS) is None + except Exception: # noqa: BLE001 — never let the convenience button block a run + current_bad = def2_ok = False + if current_bad and def2_ok: + try: + app.basis_fix_btn.layout.display = "" + except Exception: # noqa: BLE001 + pass + else: + _hide_basis_fix_button(app) + + +def on_basis_fix(app: Any, btn: Any = None) -> None: + """One-click MET.5 fix: set the basis to def2-SVP and hide the button.""" + try: + app.basis_dd.value = _METAL_FIX_BASIS + except Exception: # noqa: BLE001 — a stale option list must not raise + return + _hide_basis_fix_button(app) + try: + app.run_status.value = ( + f"Basis set to {_METAL_FIX_BASIS} (covers metals) — press Run again." + ) + except Exception: # noqa: BLE001 — status label is best-effort + pass + + def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: """Update extra options panel based on selected calculation type.""" ct = change["new"] diff --git a/tests/test_basis_fix_button.py b/tests/test_basis_fix_button.py new file mode 100644 index 0000000..f1dedc3 --- /dev/null +++ b/tests/test_basis_fix_button.py @@ -0,0 +1,79 @@ +"""One-click "Switch to def2-SVP" basis fix (M-METAL MET.5). + +The pre-run guard blocks a metal on an incompatible basis; this offers a single +click to fix it — but only when def2-SVP actually resolves the coverage, never +for a charge/multiplicity problem it can't fix. +""" + +from __future__ import annotations + +import pytest + +from quantui.molecule import Molecule + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + from quantui.app import QuantUIApp + + return QuantUIApp() + + +def _cisplatin() -> Molecule: + from quantui import molecule_library as ml + + e = next(x for x in ml.iter_entries() if x["id"] == "inorganic-cisplatin") + return Molecule( + atoms=e["atoms"], + coordinates=e["coordinates"], + charge=e["charge"], + multiplicity=e["multiplicity"], + ) + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]], + ) + + +class TestBasisFixButton: + def test_hidden_initially(self, app): + assert app.basis_fix_btn.layout.display == "none" + + def test_metal_on_pople_reveals_fix(self, app): + app._molecule = _cisplatin() + app.basis_dd.value = "6-31G" # no Pt coverage + app.mult_si.value = 1 # consistent, so the basis is the only problem + app._on_run_clicked(None) + assert app.basis_fix_btn.layout.display == "" # shown + + def test_click_sets_def2_and_hides(self, app): + app._molecule = _cisplatin() + app.basis_dd.value = "6-31G" + app.mult_si.value = 1 + app._on_run_clicked(None) + + app._on_basis_fix(None) + assert app.basis_dd.value == "def2-SVP" + assert app.basis_fix_btn.layout.display == "none" + assert "def2-SVP" in app.run_status.value + + def test_multiplicity_only_problem_does_not_reveal_fix(self, app): + # Water on a valid basis but an impossible multiplicity: the guard blocks, + # but def2-SVP can't fix a spin problem, so the button stays hidden. + app._molecule = _water() + app.basis_dd.value = "6-31G" # covers C/H/O/N fine + app.mult_si.value = 2 # 10 electrons can't be a doublet + app._on_run_clicked(None) + assert app.basis_fix_btn.layout.display == "none" + + def test_helper_hides_when_def2_would_not_help(self, app): + from quantui.app_runflow import _update_basis_fix_button + + app._molecule = _water() + app.basis_dd.value = "6-31G" # already fine for water + _update_basis_fix_button(app, app._molecule) + assert app.basis_fix_btn.layout.display == "none" From b3b312ad074c88006bcdccc15883cd052822c895 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 02:18:31 +0000 Subject: [PATCH 10/20] Add GFN-FF (xtb) metal-capable pre-optimization backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RDKit's organic valence model can't pre-optimize a transition-metal complex (DetermineBonds raises "Atom … has no valences defined"), so until now a metal pre-opt could only no-op honestly (MET.4). GFN-FF — Grimme's general force field, which covers the whole periodic table — actually relaxes coordination complexes. Verified: GFN-FF perceives cisplatin as square-planar (Pt, 4 neighbors), [Co(NH3)6]3+ as octahedral (6), and ferrocene as a sandwich (10), and relaxes a distorted cisplatin back toward its minimum. Integration (quantui/preopt.py): - Optional backend, probed with importlib.find_spec so importing quantui stays light; the heavy xtb/ASE import is lazy. GFN-FF runs via the xtb ASE calculator + an ASE L-BFGS optimizer, capturing the trajectory in one run for the preview animation. - _relax_best() dispatch: RDKit MMFF/UFF for organics (fast, proven), GFN-FF for anything RDKit can't; non-destructive no-op only when NO backend applies. - preopt_support() now reports a metal as supported when xtb is present; preopt_engine_label() names the engine ("MMFF94/UFF" / "GFN-FF" / "") so the preview labels the result and the MET.4 honest-failure message (now pointing to xtb + the DFT opt) only fires when no backend exists. - Side effects contained: each run chdir's into a temp dir (libxtb drops gfnff_topo/adjacency scratch in cwd) and redirects fds 1/2; GFORTRAN_ UNBUFFERED_PRECONNECTED makes gfortran write unbuffered so the redirect actually catches its banner. Serialised by a lock (cwd + fds are global). Packaging: new [xtb] extra (Linux pip wheels only); added to the ubuntu CI job and the cloud SessionStart hook (both Linux) so the GFN-FF path is exercised, and to environment.yml as xtb-python (conda-forge, which has Windows/macOS builds). The Windows CI job deliberately omits it — its tests skip there. Tests (tests/test_preopt_gfnff.py, gated on xtb availability — they run on Linux): engine selection, distorted-metal relaxation, trajectory ends at the kept geometry, input never mutated, no scratch leaks into cwd, and the no-backend fallback. Existing "RDKit absent" no-op tests updated to disable both backends. Full no-network suite: 2527 tests, 14 failed (pre-existing env-only NMR needing pyscf-properties), 0 errors. ruff+black clean. How the relaxed metal looks in Voilà remains a local visual check. Contributions: - Claude (Opus 4.8): GFN-FF backend, dispatch/containment, packaging, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- .claude/hooks/session-start.sh | 4 +- .github/workflows/ci.yml | 4 +- local-setup/environment.yml | 3 + pyproject.toml | 11 ++ quantui/app_runflow.py | 21 ++-- quantui/preopt.py | 221 +++++++++++++++++++++++++++++---- tests/test_preopt.py | 6 +- tests/test_preopt_gfnff.py | 117 +++++++++++++++++ tests/test_preopt_preview.py | 32 +++-- 9 files changed, 366 insertions(+), 53 deletions(-) create mode 100644 tests/test_preopt_gfnff.py diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 33b3c0d..e726795 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -29,9 +29,9 @@ python -m pip install --upgrade --ignore-installed setuptools wheel || true # Editable install with the same extras CI uses. Editable + plain `install` # (not `ci`-style clean installs) so the resolved deps are cached in the # container image for later sessions. -pip install -e ".[pyscf,ase,dev]" +pip install -e ".[pyscf,ase,dev,xtb]" -echo "QuantUI cloud env ready: package + [pyscf,ase,dev] installed." >&2 +echo "QuantUI cloud env ready: package + [pyscf,ase,dev,xtb] installed." >&2 # Warn if the private planning repo isn't attached to this session. In a cloud # session an attached repo is cloned as a sibling of this one; the planning docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a62e48..b1ed318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,9 @@ jobs: - name: Install package and dev dependencies run: | python -m pip install --upgrade pip - pip install -e ".[pyscf,ase,dev]" + # xtb (GFN-FF metal pre-opt) is Linux-only on PyPI, so it's included + # here (ubuntu) but not in the Windows job below — its tests skip there. + pip install -e ".[pyscf,ase,dev,xtb]" - name: Run tests (skip network tests) run: | diff --git a/local-setup/environment.yml b/local-setup/environment.yml index 6705002..997e4ad 100644 --- a/local-setup/environment.yml +++ b/local-setup/environment.yml @@ -17,6 +17,9 @@ dependencies: - numpy>=1.24.0 - ase>=3.22.0 # Structure I/O, molecule library, geometry optimisation - rdkit>=2022.03.1 # PubChem SDF→XYZ conversion, SMILES input + - xtb-python>=20.2 # GFN-FF metal-capable pre-optimization (the Python API + + # ASE calculator; conda-forge has Windows/macOS builds, + # whereas the PyPI `xtb` wheel is Linux-only) # Visualization - py3dmol>=2.0.0 # Primary 3D molecular viewer diff --git a/pyproject.toml b/pyproject.toml index a05d755..9177dc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,17 @@ ase = [ "ase>=3.22.0,<4", ] +# GFN-FF metal-capable classical pre-optimization via xtb (Grimme's general +# force field). RDKit's organic valence model can't touch a transition metal; +# GFN-FF covers the whole periodic table. quantui.preopt uses it when present +# and falls back gracefully otherwise. xtb publishes **Linux pip wheels only** — +# on Windows/macOS install it from conda-forge (see local-setup/environment.yml). +# Depends on ase (the optimizer driving the relaxation). +xtb = [ + "xtb>=22.1", + "ase>=3.22.0,<4", +] + # Voilà app server — hides notebook code; students see only the widget UI. # Run with: voila notebooks/molecule_computations.ipynb app = [ diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index bb1ad9d..e21ea7b 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -698,9 +698,10 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No # Calling the latter "your geometry is already reasonable" tells a # student their scattered metal structure is good. Probe which case this # is and word it truthfully. - from quantui.preopt import preopt_support + from quantui.preopt import preopt_engine_label, preopt_support unsupported = preopt_support(relaxed) + engine = preopt_engine_label(relaxed) or "MMFF94/UFF" app._preopt_relaxed_mol = None app.preopt_preview_output.clear_output() app.preopt_preview_output.layout.display = "none" @@ -709,18 +710,15 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No app.preopt_reset_btn.disabled = True if unsupported is not None: app.preopt_preview_status.value = _preopt_small( - "Classical pre-optimization isn't available for this structure " - f"({unsupported}). This is expected for metal complexes and other " - "systems RDKit can't perceive bonds for — skip the classical " - "pre-opt and let the DFT geometry optimization refine the " - "structure instead. Make sure the starting geometry is sensible " - "first (the bundled inorganic examples or an XYZ paste are good " - "starting points).", + "Classical pre-optimization isn't available for this structure. " + f"{unsupported}. Make sure the starting geometry is sensible first " + "(a bundled inorganic example or an XYZ paste are good starting " + "points).", "#b45309", ) else: app.preopt_preview_status.value = _preopt_small( - "Pre-optimization (MMFF94/UFF) found no meaningful change — " + f"Pre-optimization ({engine}) found no meaningful change — " f"RMSD {rmsd:.3f} Å. Your geometry is already reasonable, so there " "is nothing to keep or revert; the calculation will use it as-is.", "#444", @@ -748,8 +746,11 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No with app.preopt_preview_output: display(HTML(_preopt_small(f"Preview render failed: {exc}", "#b91c1c"))) + from quantui.preopt import preopt_engine_label + + engine = preopt_engine_label(relaxed) or "MMFF94/UFF" app.preopt_preview_status.value = _preopt_small( - f"Relaxed (MMFF94/UFF): moved {rmsd:.3f} Å (RMSD) from your " + f"Relaxed ({engine}): moved {rmsd:.3f} Å (RMSD) from your " "input. Use ⇄ or the slider below to compare input vs relaxed, then " "Keep it or revert.", "#444", diff --git a/quantui/preopt.py b/quantui/preopt.py index 9018aeb..b05d927 100644 --- a/quantui/preopt.py +++ b/quantui/preopt.py @@ -40,8 +40,11 @@ from __future__ import annotations +import contextlib +import importlib.util import logging -from typing import List, Optional, Tuple +import threading +from typing import Iterator, List, Optional, Tuple from .molecule import Molecule @@ -55,6 +58,36 @@ except ImportError: pass +# Optional GFN-FF (xtb) backend for metal-capable pre-optimization. RDKit's +# organic valence model can't touch a transition metal; GFN-FF (Grimme's general +# force field, via xtb-python + ASE) covers the whole periodic table. Probe with +# find_spec so merely importing quantui doesn't pull in libxtb/ASE — the heavy +# import happens lazily inside _xtb_gfnff_relax. xtb ships Linux pip wheels only; +# on Windows/macOS it comes from conda (see environment.yml). +_XTB_AVAILABLE = ( + importlib.util.find_spec("xtb") is not None + and importlib.util.find_spec("ase") is not None +) + +if _XTB_AVAILABLE: + # libxtb prints from Fortran and gfortran buffers preconnected units (stdout) + # in its *own* runtime buffer — not libc's — so a buffer flush would land on + # the real terminal after our fd redirect is torn down. Ask gfortran to write + # unbuffered instead, so the redirect in _contained_xtb_run catches it live. + # Must be set before libxtb's runtime initialises (i.e. before it's imported). + import os as _os + + _os.environ.setdefault("GFORTRAN_UNBUFFERED_PRECONNECTED", "1") + +# Serialises GFN-FF runs: each one chdir's to a temp scratch dir and redirects +# the process stdout/stderr file descriptors (libxtb prints from Fortran, below +# Python's sys.stdout), both of which are process-global. +_XTB_LOCK = threading.Lock() + +# ASE force convergence threshold (eV/Å) for the GFN-FF pre-opt — loose, since +# this is a cleanup pass before the real DFT optimization, not a final geometry. +_XTB_FMAX = 0.05 + def _copy_molecule(molecule: Molecule) -> Molecule: """Return a fresh Molecule with the same data (never mutate the input).""" @@ -66,20 +99,14 @@ def _copy_molecule(molecule: Molecule) -> Molecule: ) -def preopt_support(molecule: Molecule) -> Optional[str]: - """Why a bonded-FF pre-opt can't run on ``molecule``, or ``None`` if it can. +def _rdkit_relax_reason(molecule: Molecule) -> Optional[str]: + """Why RDKit's bonded FF can't relax ``molecule``, or ``None`` if it can. Mirrors the perception steps in :func:`_rdkit_ff_relax` (parse → - ``DetermineBonds`` → MMFF/UFF parameter check) **without minimizing**, so a - caller can tell a genuine *"already optimal, nothing moved"* no-op apart from - *"the classical force field has no model for this molecule."* The latter is - the metal-complex case (M-METAL MET.4): a transition metal makes - ``DetermineBonds`` raise ``"Atom … has no valences defined"``, and - :func:`preoptimize` then returns the geometry unchanged at RMSD 0.0 — which - must **not** be reported as "your geometry is already reasonable." - - Returns ``None`` when a bonded force field can be built, otherwise a short - plain-language reason. Never raises. + ``DetermineBonds`` → MMFF/UFF parameter check) **without minimizing**. A + transition metal makes ``DetermineBonds`` raise ("Atom … has no valences + defined"); so does a geometry too distorted for distance-based perception. + Never raises. """ if not _RDKIT_AVAILABLE: return "RDKit is not available" @@ -96,8 +123,6 @@ def preopt_support(molecule: Molecule) -> Optional[str]: try: rdDetermineBonds.DetermineBonds(rdmol, charge=int(molecule.charge)) except Exception as exc: # noqa: BLE001 — perception failure is the signal - # Transition metals land here ("Atom N … has no valences defined"), as do - # geometries too distorted for distance-based bond perception. return f"RDKit could not perceive bonds ({type(exc).__name__})" if AllChem.MMFFHasAllMoleculeParams(rdmol) or AllChem.UFFHasAllMoleculeParams( rdmol @@ -106,6 +131,152 @@ def preopt_support(molecule: Molecule) -> Optional[str]: return "no MMFF or UFF force-field parameters cover these elements" +def preopt_engine_label(molecule: Molecule) -> str: + """Which pre-opt engine will run on ``molecule``: the RDKit FF, GFN-FF, or none. + + Deterministic and side-effect-free, so a caller (the preview) can label the + result without changing the relaxation's return signature. Returns + ``"MMFF94/UFF"`` (RDKit, organics), ``"GFN-FF"`` (xtb, metals / anything + RDKit can't), or ``""`` when no backend can handle it. + """ + if _RDKIT_AVAILABLE and _rdkit_relax_reason(molecule) is None: + return "MMFF94/UFF" + if _XTB_AVAILABLE: + return "GFN-FF" + return "" + + +def preopt_support(molecule: Molecule) -> Optional[str]: + """Why no pre-opt backend can handle ``molecule``, or ``None`` if one can. + + Lets a caller tell a genuine *"already optimal, nothing moved"* no-op apart + from *"nothing can pre-optimize this"* (M-METAL MET.4). RDKit's organic FF + handles organics; the optional GFN-FF (xtb) backend handles transition-metal + complexes RDKit's valence model rejects — so a metal is *supported* whenever + xtb is installed. When neither applies, the reason names the fix (install + xtb, or run the DFT geometry optimization). Never raises. + """ + rdkit_reason = _rdkit_relax_reason(molecule) + if rdkit_reason is None: + return None + if _XTB_AVAILABLE: + return None # GFN-FF (xtb) covers the whole periodic table + return ( + f"{rdkit_reason}, and the GFN-FF (xtb) metal backend is not installed. " + "Install xtb for metal-capable pre-optimization, or skip the classical " + "pre-opt and run the DFT geometry optimization" + ) + + +@contextlib.contextmanager +def _contained_xtb_run() -> Iterator[None]: + """Run a GFN-FF call with its side effects contained. + + libxtb drops ``gfnff_topo`` / ``gfnff_adjacency`` scratch files in the + current directory and prints an initialization banner from Fortran — below + Python's ``sys.stdout``, so only a file-descriptor redirect silences it. + This chdir's into a temp directory and points fds 1/2 at ``os.devnull`` for + the duration, restoring both afterward. Serialised by ``_XTB_LOCK`` because + cwd and the fds are process-global. + """ + import os + import sys + import tempfile + + with _XTB_LOCK: + prev_cwd = os.getcwd() + sys.stdout.flush() + sys.stderr.flush() + saved_out, saved_err = os.dup(1), os.dup(2) + devnull = os.open(os.devnull, os.O_WRONLY) + with tempfile.TemporaryDirectory(prefix="quantui_gfnff_") as scratch: + try: + os.chdir(scratch) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + yield + finally: + # libxtb prints from Fortran and block-buffers at the C level, so + # flush all C stdio streams *while* the fds still point at + # devnull — otherwise the banner flushes to the real terminal + # right after we restore them. + try: + import ctypes + + ctypes.CDLL(None).fflush(None) + except Exception: # noqa: BLE001 — best-effort silencing + pass + os.dup2(saved_out, 1) + os.dup2(saved_err, 2) + os.close(devnull) + os.close(saved_out) + os.close(saved_err) + os.chdir(prev_cwd) + + +def _xtb_gfnff_relax( + molecule: Molecule, steps: int, *, capture_frames: bool = False +) -> Tuple[List[List[float]], str, Optional[List[List[List[float]]]]]: + """Relax ``molecule`` with GFN-FF (xtb) via an ASE L-BFGS optimizer. + + The metal-capable counterpart to :func:`_rdkit_ff_relax`. Returns + ``(final_coords, "GFN-FF", frames)`` — ``frames`` is ``None`` unless + ``capture_frames`` is set, in which case ASE's per-step trajectory is + captured in a single optimization (no fresh restarts) and thinned to even + RMSD spacing for the preview animation. Raises on any failure so the caller + can fall back to the non-destructive no-op. + """ + import numpy as np + from ase import Atoms + from ase.optimize import LBFGS + from xtb.ase.calculator import XTB + + atoms = Atoms( + symbols=list(molecule.atoms), + positions=np.asarray(molecule.coordinates, dtype=float), + ) + waypoints: List[List[List[float]]] = [] + with _contained_xtb_run(): + atoms.calc = XTB(method="GFN-FF", charge=float(molecule.charge), verbosity=0) + if capture_frames: + waypoints.append(atoms.get_positions().tolist()) + opt = LBFGS(atoms, logfile=None) + if capture_frames: + opt.attach(lambda: waypoints.append(atoms.get_positions().tolist())) + opt.run(fmax=_XTB_FMAX, steps=int(steps)) + final = atoms.get_positions() + + coords = [[float(x), float(y), float(z)] for x, y, z in final] + if len(coords) != len(molecule.atoms): + raise ValueError("atom count changed during GFN-FF relaxation") + frames = None + if capture_frames: + # The last attached waypoint is the final geometry, so playback ends at + # exactly the geometry "Keep" would adopt. + frames = _select_even_rmsd_frames(waypoints, _PREVIEW_FRAMES) + return coords, "GFN-FF", frames + + +def _relax_best( + molecule: Molecule, steps: int, *, capture_frames: bool = False +) -> Tuple[List[List[float]], str, Optional[List[List[List[float]]]]]: + """Relax with the best available backend, metals included. + + RDKit MMFF/UFF for organics (fast, proven); GFN-FF (xtb) for anything RDKit + can't perceive (transition-metal complexes). When neither applies, the RDKit + path runs so its specific failure propagates and the caller no-ops exactly as + before. Raises only if no backend exists at all. + """ + if _RDKIT_AVAILABLE and _rdkit_relax_reason(molecule) is None: + return _rdkit_ff_relax(molecule, steps, capture_frames=capture_frames) + if _XTB_AVAILABLE: + return _xtb_gfnff_relax(molecule, steps, capture_frames=capture_frames) + if _RDKIT_AVAILABLE: + # No metal backend — let RDKit raise its real reason (caller → no-op). + return _rdkit_ff_relax(molecule, steps, capture_frames=capture_frames) + raise RuntimeError("no pre-optimization backend available") + + # Interactive-preview animation tuning (preoptimize_with_trajectory). The # trajectory is captured as fresh minimizations from the input at increasing # iteration budgets (see _rdkit_ff_relax). _PREVIEW_FRAMES is how many are shown @@ -312,16 +483,18 @@ def preoptimize( """ import numpy as np - if not _RDKIT_AVAILABLE: - logger.warning("RDKit unavailable — pre-opt skipped, geometry unchanged.") + if not (_RDKIT_AVAILABLE or _XTB_AVAILABLE): + logger.warning( + "No pre-opt backend (RDKit / xtb) available — geometry unchanged." + ) return _copy_molecule(molecule), 0.0 original = np.asarray(molecule.coordinates, dtype=float) try: - coords, ff_name, _frames = _rdkit_ff_relax(molecule, steps) + coords, ff_name, _frames = _relax_best(molecule, steps) except Exception as exc: # noqa: BLE001 — any FF failure → non-destructive no-op logger.warning( - "Bonded-FF pre-opt failed (%s); returning original geometry unchanged.", + "Pre-opt failed (%s); returning original geometry unchanged.", exc, ) return _copy_molecule(molecule), 0.0 @@ -364,18 +537,16 @@ def preoptimize_with_trajectory( original = np.asarray(molecule.coordinates, dtype=float) fallback_frames = [original.tolist()] - if not _RDKIT_AVAILABLE: + if not (_RDKIT_AVAILABLE or _XTB_AVAILABLE): logger.warning( - "RDKit unavailable — pre-opt preview skipped, geometry unchanged." + "No pre-opt backend (RDKit / xtb) available — geometry unchanged." ) return _copy_molecule(molecule), 0.0, fallback_frames try: - coords, ff_name, frames = _rdkit_ff_relax(molecule, steps, capture_frames=True) + coords, ff_name, frames = _relax_best(molecule, steps, capture_frames=True) except Exception as exc: # noqa: BLE001 — any FF failure → non-destructive no-op - logger.warning( - "Bonded-FF pre-opt preview failed (%s); geometry unchanged.", exc - ) + logger.warning("Pre-opt preview failed (%s); geometry unchanged.", exc) return _copy_molecule(molecule), 0.0, fallback_frames optimized = np.asarray(coords, dtype=float) diff --git a/tests/test_preopt.py b/tests/test_preopt.py index ec7274c..55e7468 100644 --- a/tests/test_preopt.py +++ b/tests/test_preopt.py @@ -73,11 +73,13 @@ def _oh_distances(mol: Molecule): class TestNonDestructive: - def test_rdkit_absent_returns_original_unchanged(self, monkeypatch): - """RDKit missing → return the original geometry, never raise.""" + def test_no_backend_returns_original_unchanged(self, monkeypatch): + """No pre-opt backend (RDKit and xtb both absent) → return the original + geometry, never raise.""" import quantui.preopt as preopt_mod monkeypatch.setattr(preopt_mod, "_RDKIT_AVAILABLE", False) + monkeypatch.setattr(preopt_mod, "_XTB_AVAILABLE", False) original = _water() mol, rmsd = preopt_mod.preoptimize(original) assert isinstance(mol, Molecule) diff --git a/tests/test_preopt_gfnff.py b/tests/test_preopt_gfnff.py new file mode 100644 index 0000000..f4b5ea0 --- /dev/null +++ b/tests/test_preopt_gfnff.py @@ -0,0 +1,117 @@ +"""GFN-FF (xtb) metal-capable pre-optimization backend (M-METAL). + +RDKit can't pre-optimize a transition-metal complex; GFN-FF (Grimme's general +force field, via xtb-python + ASE) can. These tests run only where xtb is +installed (Linux pip wheel / conda) and are skipped otherwise. +""" + +from __future__ import annotations + +import os + +import pytest + +import quantui.preopt as preopt_mod +from quantui.molecule import Molecule + +xtb_only = pytest.mark.skipif( + not preopt_mod._XTB_AVAILABLE, reason="xtb (GFN-FF backend) not installed" +) + + +def _metal(entry_id: str = "inorganic-cisplatin") -> Molecule: + from quantui import molecule_library as ml + + e = next(x for x in ml.iter_entries() if x["id"] == entry_id) + return Molecule( + atoms=e["atoms"], + coordinates=e["coordinates"], + charge=e["charge"], + multiplicity=e["multiplicity"], + ) + + +def _distorted_metal() -> Molecule: + m = _metal() + coords = [list(c) for c in m.coordinates] + ni = m.atoms.index("N") # pull one ammine in by 20% — real work for the FF + coords[ni] = [c * 0.8 for c in coords[ni]] + return Molecule( + atoms=list(m.atoms), + coordinates=coords, + charge=m.charge, + multiplicity=m.multiplicity, + ) + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.81, 0.67, 0.0], [-0.81, 0.67, 0.0]], + ) + + +class TestEngineSelection: + @xtb_only + def test_metal_routes_to_gfnff(self): + assert preopt_mod.preopt_engine_label(_metal()) == "GFN-FF" + + def test_organic_routes_to_rdkit(self): + if not preopt_mod._RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + assert preopt_mod.preopt_engine_label(_water()) == "MMFF94/UFF" + + @xtb_only + def test_metal_is_supported_with_xtb(self): + assert preopt_mod.preopt_support(_metal()) is None + + +class TestGfnffRelaxation: + @xtb_only + def test_relaxes_distorted_metal(self): + relaxed, rmsd = preopt_mod.preoptimize(_distorted_metal()) + assert rmsd > 0.02 # GFN-FF actually moved the compressed ammine back + assert relaxed.atoms == _distorted_metal().atoms # atom order preserved + assert relaxed.charge == 0 and relaxed.multiplicity == 1 + + @xtb_only + def test_trajectory_multiframe_ends_at_kept_geometry(self): + import numpy as np + + mol, rmsd, frames = preopt_mod.preoptimize_with_trajectory(_distorted_metal()) + assert rmsd > 0.02 + assert len(frames) >= 2 + # Last frame must equal the geometry "Keep" adopts. + assert np.allclose(frames[-1], mol.coordinates, atol=1e-6) + + @xtb_only + def test_input_is_never_mutated(self): + m = _distorted_metal() + before = [list(c) for c in m.coordinates] + preopt_mod.preoptimize(m) + assert [list(c) for c in m.coordinates] == before + + +class TestSideEffectContainment: + @xtb_only + def test_no_scratch_files_leak_into_cwd(self, tmp_path, monkeypatch): + # libxtb writes gfnff_topo / gfnff_adjacency to cwd; the run must contain + # them in its own temp dir and leave the working directory clean. + monkeypatch.chdir(tmp_path) + before = set(os.listdir(tmp_path)) + preopt_mod.preoptimize(_distorted_metal()) + leaked = set(os.listdir(tmp_path)) - before + assert leaked == set(), f"GFN-FF leaked files: {leaked}" + + +class TestFallbackWhenXtbAbsent: + def test_metal_noops_without_xtb(self, monkeypatch): + # With no GFN-FF backend, a metal falls back to the non-destructive no-op. + if not preopt_mod._RDKIT_AVAILABLE: + pytest.skip("rdkit not installed") + monkeypatch.setattr(preopt_mod, "_XTB_AVAILABLE", False) + m = _metal() + relaxed, rmsd = preopt_mod.preoptimize(m) + assert rmsd == 0.0 + assert relaxed.coordinates == m.coordinates + assert preopt_mod.preopt_engine_label(m) == "" diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py index f37f0da..944fbf9 100644 --- a/tests/test_preopt_preview.py +++ b/tests/test_preopt_preview.py @@ -134,10 +134,11 @@ def test_relaxation_is_gradual_no_dominating_jump(self): # (the regressed capture put ~80% in one frame). assert max(steps) <= 0.5 * total - def test_rdkit_absent_is_non_destructive_single_frame(self, monkeypatch): + def test_no_backend_is_non_destructive_single_frame(self, monkeypatch): import quantui.preopt as preopt_mod monkeypatch.setattr(preopt_mod, "_RDKIT_AVAILABLE", False) + monkeypatch.setattr(preopt_mod, "_XTB_AVAILABLE", False) original = _water() mol, rmsd, frames = preopt_mod.preoptimize_with_trajectory(original) assert rmsd == 0.0 @@ -362,22 +363,28 @@ def test_preopt_support_none_for_organic(self): pytest.skip("rdkit not installed") assert preopt_support(_water()) is None - def test_preopt_support_reason_for_metal(self): - from quantui.preopt import _RDKIT_AVAILABLE, preopt_support + def test_preopt_support_reason_for_metal_without_xtb(self, monkeypatch): + # With the GFN-FF (xtb) backend absent, a metal has no pre-opt backend, + # so preopt_support returns a reason that points to xtb / the DFT opt. + import quantui.preopt as preopt_mod - if not _RDKIT_AVAILABLE: + if not preopt_mod._RDKIT_AVAILABLE: pytest.skip("rdkit not installed") - reason = preopt_support(_metal_complex()) - assert reason is not None and isinstance(reason, str) and reason - - def test_metal_negligible_change_reports_honestly(self, app): - # The FF no-ops a metal complex at RMSD 0.0. The message must NOT claim - # the geometry is "already reasonable"; it must point to DFT geometry opt. + monkeypatch.setattr(preopt_mod, "_XTB_AVAILABLE", False) + reason = preopt_mod.preopt_support(_metal_complex()) + assert reason is not None and "xtb" in reason.lower() + + def test_metal_negligible_change_reports_honestly_without_xtb( + self, app, monkeypatch + ): + # With no GFN-FF backend, a metal FF no-op at RMSD 0.0 must NOT claim the + # geometry is "already reasonable"; it must point to xtb / the DFT opt. + import quantui.preopt as preopt_mod from quantui.app_runflow import _preopt_preview_done - from quantui.preopt import _RDKIT_AVAILABLE - if not _RDKIT_AVAILABLE: + if not preopt_mod._RDKIT_AVAILABLE: pytest.skip("rdkit not installed") + monkeypatch.setattr(preopt_mod, "_XTB_AVAILABLE", False) metal = _metal_complex() _preopt_preview_done(app, metal, 0.0, [[list(c) for c in metal.coordinates]]) @@ -385,7 +392,6 @@ def test_metal_negligible_change_reports_honestly(self, app): assert "geometry optimization" in status assert "isn't available" in status or "not available" in status assert "already reasonable" not in status - # Nothing to keep/revert either way. assert app._preopt_relaxed_mol is None assert app._preopt_actions_box.layout.display == "none" From a88b3e67b9d5fc9c8b9647fd60630f0f4446d9c5 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 02:58:25 +0000 Subject: [PATCH 11/20] MET.6: draw dashed coordination bonds so a metal isn't a lone dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3Dmol.js's own bond perception draws no bonds to a coordination metal, so the centre rendered as a detached sphere. Draw the metal↔donor bonds ourselves, dashed (GaussView convention), from the same distance-based, metal-aware connectivity the salt-warning already uses. - quantui/connectivity.py: COORDINATION_METALS + is_metal(); covalent_bonds() (all bonded index pairs) and metal_coordination_bonds() (only bonds touching a metal — exactly the ones 3Dmol.js misses). covalent_components() refactored to reuse covalent_bonds() (one distance pass). - visualize_molecule_py3dmol(): after addModel, _add_coordination_bonds() adds a thin gray dashed py3Dmol cylinder per metal↔donor bond, then zoomTo frames the now-bonded metal. Best-effort — never breaks the viewer, no-op for organics. Verified the perceived bonds are chemically right: cisplatin 4 (square-planar Pt–2Cl/2N), [Co(NH3)6]3+ 6 (octahedral), ferrocene 10 (sandwich); water 0. Tests: connectivity bond functions + is_metal; the py3Dmol HTML carries dashed cylinders for a metal and none for an organic. Full suite 2534 tests, only the pre-existing env-only NMR failures. ruff+black clean. How it *looks* in Voilà (bonds render dashed, metal framed) is the local visual check. Contributions: - Claude (Opus 4.8): connectivity bond API + dashed coordination-bond overlay, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/connectivity.py | 111 +++++++++++++++++++++++++--- quantui/visualization_py3dmol.py | 43 ++++++++++- tests/test_connectivity.py | 37 ++++++++++ tests/test_metal_viewer_fallback.py | 22 ++++++ 4 files changed, 200 insertions(+), 13 deletions(-) diff --git a/quantui/connectivity.py b/quantui/connectivity.py index 893a55f..7139421 100644 --- a/quantui/connectivity.py +++ b/quantui/connectivity.py @@ -21,7 +21,7 @@ from __future__ import annotations -from typing import Dict, List, Optional, Sequence +from typing import Dict, List, Optional, Sequence, Tuple # Covalent radii in Å (Cordero et al., Dalton Trans. 2008, 2832). A broad subset # covering the organic elements, common heteroatoms/ions, and the transition / @@ -86,25 +86,74 @@ DEFAULT_TOLERANCE = 1.3 +# Coordination-complex metal centres: the transition metals plus the common +# heavy main-group metals seen as centres. RDKit's organic bond perception can't +# bond these, and 3Dmol.js's own perception leaves them as lone dots — so the +# viewer draws their bonds explicitly (MET.6). +COORDINATION_METALS = frozenset( + { + # 3d / 4d / 5d transition metals + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + # common heavy main-group metal centres + "Al", + "Ga", + "In", + "Sn", + "Pb", + "Bi", + } +) + + def _radius(symbol: str) -> float: return COVALENT_RADII.get(symbol, _FALLBACK_RADIUS) -def covalent_components( +def is_metal(symbol: str) -> bool: + """True for a coordination-complex metal centre (see COORDINATION_METALS).""" + return symbol in COORDINATION_METALS + + +def covalent_bonds( atoms: Sequence[str], coords: Sequence[Sequence[float]], tolerance: float = DEFAULT_TOLERANCE, -) -> List[List[int]]: - """Group atom indices into connected components by covalent-radii distance. +) -> List[Tuple[int, int]]: + """All bonded atom-index pairs (i < j) by covalent-radii distance. - Two atoms *i*, *j* are bonded when ``dist(i, j) <= tolerance * (r_i + r_j)``. - Returns a list of components (each a sorted list of atom indices), ordered - largest first then by first index — deterministic for a given input. + Two atoms *i*, *j* are bonded when ``dist(i, j) <= tolerance * (r_i + r_j)`` — + the same, metal-aware criterion :func:`covalent_components` groups on. """ n = len(atoms) - if n == 0: - return [] - adj: List[List[int]] = [[] for _ in range(n)] + bonds: List[Tuple[int, int]] = [] for i in range(n): ri = _radius(atoms[i]) xi, yi, zi = coords[i][0], coords[i][1], coords[i][2] @@ -114,8 +163,46 @@ def covalent_components( dy = yi - coords[j][1] dz = zi - coords[j][2] if dx * dx + dy * dy + dz * dz <= threshold * threshold: - adj[i].append(j) - adj[j].append(i) + bonds.append((i, j)) + return bonds + + +def metal_coordination_bonds( + atoms: Sequence[str], + coords: Sequence[Sequence[float]], + tolerance: float = DEFAULT_TOLERANCE, +) -> List[Tuple[int, int]]: + """Bonds with a metal centre at one end — the coordination bonds to draw. + + These are exactly the bonds RDKit / 3Dmol.js miss on a coordination complex, + so the viewer draws them itself (MET.6). Purely organic bonds are excluded + (3Dmol.js already renders those). + """ + return [ + (i, j) + for (i, j) in covalent_bonds(atoms, coords, tolerance) + if is_metal(atoms[i]) or is_metal(atoms[j]) + ] + + +def covalent_components( + atoms: Sequence[str], + coords: Sequence[Sequence[float]], + tolerance: float = DEFAULT_TOLERANCE, +) -> List[List[int]]: + """Group atom indices into connected components by covalent-radii distance. + + Two atoms *i*, *j* are bonded when ``dist(i, j) <= tolerance * (r_i + r_j)``. + Returns a list of components (each a sorted list of atom indices), ordered + largest first then by first index — deterministic for a given input. + """ + n = len(atoms) + if n == 0: + return [] + adj: List[List[int]] = [[] for _ in range(n)] + for i, j in covalent_bonds(atoms, coords, tolerance): + adj[i].append(j) + adj[j].append(i) seen = [False] * n components: List[List[int]] = [] diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index 0a77d6c..2e48bfa 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -181,15 +181,56 @@ def visualize_molecule_py3dmol( else: view.setStyle({style: {}}) + # MET.6: 3Dmol.js's own bond perception leaves a coordination metal as a lone + # dot — it draws no bonds to the centre. Draw the metal↔donor bonds ourselves, + # dashed (GaussView convention), from the same distance-based connectivity the + # salt-warning uses. No-op for purely organic molecules. + _add_coordination_bonds(view, molecule) + # Set background view.setBackgroundColor(bgcolor) - # Zoom to fit + # Zoom to fit — includes the (now bonded) metal, so it is never off-screen. view.zoomTo() return view +# Dashed coordination-bond styling (py3Dmol addCylinder): a thin gray dashed +# cylinder from the metal centre to each donor atom. +_COORD_BOND_RADIUS = 0.06 +_COORD_BOND_COLOR = "#777777" + + +def _add_coordination_bonds(view, molecule) -> None: + """Draw dashed metal↔donor cylinders so a metal centre isn't a lone dot. + + Uses the distance-based, metal-aware connectivity finder. Best-effort: any + failure (or a molecule with no metal) simply leaves the view unchanged. + """ + try: + from quantui.connectivity import metal_coordination_bonds + + coords = molecule.coordinates + bonds = metal_coordination_bonds(molecule.atoms, coords) + for i, j in bonds: + xi, yi, zi = coords[i] + xj, yj, zj = coords[j] + view.addCylinder( + { + "start": {"x": float(xi), "y": float(yi), "z": float(zi)}, + "end": {"x": float(xj), "y": float(yj), "z": float(zj)}, + "radius": _COORD_BOND_RADIUS, + "color": _COORD_BOND_COLOR, + "dashed": True, + "fromCap": 1, + "toCap": 1, + } + ) + except Exception: # noqa: BLE001 — bond decoration must never break the viewer + logger.debug("coordination-bond overlay skipped", exc_info=True) + + _PY3DMOL_STYLES: tuple[Py3DmolStyle, ...] = ( "ball+stick", "stick", diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py index 8b01551..30a6d17 100644 --- a/tests/test_connectivity.py +++ b/tests/test_connectivity.py @@ -10,9 +10,12 @@ import pytest from quantui.connectivity import ( + covalent_bonds, covalent_components, describe_disconnection, is_disconnected, + is_metal, + metal_coordination_bonds, ) from quantui.molecule import Molecule @@ -89,6 +92,40 @@ def test_scattered_metal_salt_is_flagged(self): assert "Pt" in msg +class TestBonds: + def test_covalent_bonds_water(self): + atoms, coords = _water_coords() + bonds = covalent_bonds(atoms, coords) + # Two O–H bonds, no H–H. + assert sorted(bonds) == [(0, 1), (0, 2)] + + def test_is_metal(self): + assert is_metal("Pt") and is_metal("Fe") and is_metal("Co") + assert not is_metal("C") and not is_metal("N") and not is_metal("H") + + def test_metal_coordination_bonds_cisplatin(self): + atoms, coords = _cisplatin() + bonds = metal_coordination_bonds(atoms, coords) + # Square-planar Pt(II): 4 coordination bonds, all involving Pt. + assert len(bonds) == 4 + for i, j in bonds: + assert is_metal(atoms[i]) or is_metal(atoms[j]) + partners = sorted(atoms[j if is_metal(atoms[i]) else i] for i, j in bonds) + assert partners == ["Cl", "Cl", "N", "N"] + + def test_metal_coordination_bonds_counts(self): + from quantui import molecule_library as ml + + expected = {"inorganic-hexaamminecobaltiii": 6, "inorganic-ferrocene": 10} + for eid, n in expected.items(): + e = next(x for x in ml.iter_entries() if x["id"] == eid) + assert len(metal_coordination_bonds(e["atoms"], e["coordinates"])) == n + + def test_organic_has_no_coordination_bonds(self): + atoms, coords = _water_coords() + assert metal_coordination_bonds(atoms, coords) == [] + + @pytest.fixture def app(tmp_path, monkeypatch): monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) diff --git a/tests/test_metal_viewer_fallback.py b/tests/test_metal_viewer_fallback.py index f76a039..13b9c91 100644 --- a/tests/test_metal_viewer_fallback.py +++ b/tests/test_metal_viewer_fallback.py @@ -75,3 +75,25 @@ def test_reraises_when_no_py3dmol_fallback(self, monkeypatch): # py3Dmol to fall back to, that must propagate rather than be swallowed. with pytest.raises(ValueError): viz.visualize_molecule(_mol("inorganic-cisplatin"), backend="plotlymol") + + +class TestCoordinationBonds: + """M-METAL MET.6: the py3Dmol viewer draws dashed metal↔donor bonds so a + coordination metal is never a lone dot.""" + + def test_metal_html_has_dashed_cylinders(self): + pytest.importorskip("py3Dmol") + from quantui.visualization_py3dmol import render_molecule_html + + html = render_molecule_html( + _mol("inorganic-cisplatin"), backend="py3dmol", width=300, height=250 + ) + assert "addCylinder" in html # coordination bonds drawn + assert "dashed" in html + + def test_organic_html_has_no_cylinders(self): + pytest.importorskip("py3Dmol") + from quantui.visualization_py3dmol import render_molecule_html + + html = render_molecule_html(_water(), backend="py3dmol", width=300, height=250) + assert "addCylinder" not in html From 1600c25cfd5e04d27f85a041059da7d2e686eb6b Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 03:14:21 +0000 Subject: [PATCH 12/20] =?UTF-8?q?MET.5:=20spin=20engine=20=E2=80=94=20tran?= =?UTF-8?q?sparency=20pass=20(caveats,=20square-planar=20d8-only,=20NH3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per instructor review (err toward transparency + accuracy; flag every enforcement): - SpinSuggestion gains a `caveats` list — every assumption/enforcement is surfaced, never applied silently: tetrahedral "assumed high-spin"; an unusual oxidation state (vs a per-metal common-states table) "double-check it"; and an octahedral d8 note that the centre is often square-planar instead. - Square-planar is now restricted to d8 (the only textbook-clean case) — a non-d8 square-planar request raises a clear ValueError ("only standard for d8 … choose octahedral/tetrahedral or set multiplicity manually") instead of inventing an octahedral-fallback number. - NH3 reclassified as intermediate-field (not strong): strong-field exemplars are now CN-/CO/en, weak-field H2O/halides, with NH3 called out as "can go either way." Nothing is pre-selected for the student. Tests: non-d8 square-planar refusal; caveat coverage (tetrahedral, unusual/ common oxidation state, octahedral-d8 square-planar note). 45 tests pass; ruff+black clean. Contributions: - Claude (Opus 4.8): spin-engine transparency/accuracy revisions + tests - Jonathan Schultz: chemistry review, overall vision, planning, orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/spin_presets.py | 124 ++++++++++++++++++++++++++++++------- tests/test_spin_presets.py | 25 ++++++++ 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/quantui/spin_presets.py b/quantui/spin_presets.py index 06b4ea6..23a33dc 100644 --- a/quantui/spin_presets.py +++ b/quantui/spin_presets.py @@ -2,22 +2,31 @@ **Suggests, never sets.** A transition metal's spin multiplicity is *not* fixed by its oxidation state alone: for an octahedral d⁴–d⁷ centre the ligand field -decides **high-spin vs low-spin** (strong-field ligands like CN⁻/CO/NH₃ pair the -electrons → low-spin; weak-field ligands like H₂O/halides → high-spin). This -module turns a metal + oxidation state into the d-electron count and returns -*both* physically reasonable spin states with a plain-language explanation, so -the student picks the one matching their complex rather than being handed a -single (possibly wrong) number. +decides **high-spin vs low-spin** (strong-field ligands like CN⁻/CO/en pair the +electrons → low-spin; weak-field ligands like H₂O/halides → high-spin; NH₃ and +other intermediate-field ligands can go either way). This module turns a metal + +oxidation state into the d-electron count and returns *both* physically +reasonable spin states with a plain-language explanation, so the student picks +the one matching their complex rather than being handed a single (possibly +wrong) number. + +**Transparency over convenience.** Nothing is pre-selected for the student, and +every assumption or enforcement is surfaced as an explicit ``caveat`` string +(tetrahedral treated as high-spin; an unusual oxidation state; the square-planar +restriction) rather than applied silently. Scope (per the classroom's metals): first-row transition metals (Sc–Zn) and the common 4d/5d centres (Ru, Rh, Pd, Pt, …). Geometries: octahedral (default, -high/low-spin), tetrahedral (effectively always high-spin), and square-planar -(the diamagnetic d⁸ case, e.g. Pt(II) in cisplatin). Charge is deliberately -*not* inferred — the overall complex charge depends on the ligand charges, which -the metal centre alone doesn't determine; the student supplies that. +high/low-spin), tetrahedral (assumed high-spin), and square-planar — which is +**restricted to d⁸** (the only case with a standard, unambiguous spin state, +e.g. Pt(II) in cisplatin); a non-d⁸ square-planar request is refused with a +clear message rather than given a shaky number. Charge is deliberately *not* +inferred — the overall complex charge depends on the ligand charges, which the +metal centre alone doesn't determine; the student supplies that. -Pure logic — no PySCF, no widgets. Raises ``ValueError`` only for a metal / -oxidation state outside the supported set, so a caller can fall back cleanly. +Pure logic — no PySCF, no widgets. Raises ``ValueError`` for a metal / +oxidation state / geometry outside the supported set, so a caller can fall back +cleanly. """ from __future__ import annotations @@ -65,6 +74,41 @@ GEOMETRIES = ("octahedral", "tetrahedral", "square_planar") +# Well-established oxidation states for the in-scope metals (teaching level). A +# request outside this set isn't refused — it's computed and *flagged* ("unusual +# … double-check"), per the transparency-over-enforcement principle. +_COMMON_OXIDATION_STATES: Dict[str, frozenset] = { + "Sc": frozenset({3}), + "Ti": frozenset({3, 4}), + "V": frozenset({2, 3, 4, 5}), + "Cr": frozenset({2, 3, 6}), + "Mn": frozenset({2, 3, 4, 6, 7}), + "Fe": frozenset({2, 3}), + "Co": frozenset({2, 3}), + "Ni": frozenset({2, 3}), + "Cu": frozenset({1, 2}), + "Zn": frozenset({2}), + "Y": frozenset({3}), + "Zr": frozenset({4}), + "Nb": frozenset({5}), + "Mo": frozenset({4, 6}), + "Tc": frozenset({4, 7}), + "Ru": frozenset({2, 3, 4}), + "Rh": frozenset({3}), + "Pd": frozenset({2, 4}), + "Ag": frozenset({1}), + "Cd": frozenset({2}), + "Hf": frozenset({4}), + "Ta": frozenset({5}), + "W": frozenset({4, 6}), + "Re": frozenset({4, 7}), + "Os": frozenset({4, 6, 8}), + "Ir": frozenset({3, 4}), + "Pt": frozenset({2, 4}), + "Au": frozenset({1, 3}), + "Hg": frozenset({1, 2}), +} + @dataclass(frozen=True) class SpinState: @@ -85,6 +129,7 @@ class SpinSuggestion: d_count: int states: List[SpinState] explanation: str + caveats: List[str] # explicit flags the UI must surface (never applied silently) @property def is_ambiguous(self) -> bool: @@ -131,14 +176,16 @@ def _states_for_geometry(d: int, geometry: str) -> List[SpinState]: u = _TETRAHEDRAL[d] return [SpinState("", u, u + 1)] if geometry == "square_planar": - # Square-planar is the classic strong-field d8 case (Ni(II)/Pd(II)/Pt(II)): - # diamagnetic, all electrons paired. Other d-counts in a square-planar - # field are uncommon in the teaching set; fall back to the octahedral - # unpaired count and flag the assumption in the explanation. + # Square-planar is only standard for d8 (Ni(II)/Pd(II)/Pt(II)): + # diamagnetic, all electrons paired. Other d-counts have no textbook + # square-planar spin state, so refuse rather than invent one. if d == 8: return [SpinState("", 0, 1)] - u = _OCTAHEDRAL_HS[d] - return [SpinState("", u, u + 1)] + raise ValueError( + f"Square-planar spin states are only standard for d8; " + f"this centre is d{d}. Choose octahedral or tetrahedral, or set the " + f"multiplicity manually." + ) raise ValueError(f"geometry must be one of {GEOMETRIES}, got {geometry!r}") @@ -153,10 +200,12 @@ def _explain( hs, ls = states[0], states[1] body = ( f" In an {geometry} field this is ambiguous: strong-field ligands " - f"(e.g. CN⁻, CO, NH₃) give low-spin — {ls.n_unpaired} unpaired, " + f"(e.g. CN⁻, CO, en) give low-spin — {ls.n_unpaired} unpaired, " f"multiplicity {ls.multiplicity}; weak-field ligands (e.g. H₂O, " f"halides) give high-spin — {hs.n_unpaired} unpaired, multiplicity " - f"{hs.multiplicity}. Pick the one matching your ligands." + f"{hs.multiplicity}. Intermediate-field ligands like NH₃ can go " + f"either way depending on the metal — pick the state matching your " + f"complex." ) elif geometry == "square_planar" and d == 8: body = ( @@ -176,15 +225,41 @@ def _explain( return head + body + tail +def _caveats_for( + element: str, oxidation_state: int, d: int, geometry: str +) -> List[str]: + """Explicit flags to surface — assumptions/enforcements, never applied silently.""" + caveats: List[str] = [] + if geometry == "tetrahedral": + caveats.append( + "Tetrahedral fields are assumed high-spin (low-spin tetrahedral " + "complexes are essentially unknown)." + ) + if geometry == "octahedral" and d == 8: + caveats.append( + "A d8 centre is often square-planar instead (diamagnetic, " + "multiplicity 1) — confirm the geometry." + ) + common = _COMMON_OXIDATION_STATES.get(element) + if common is not None and oxidation_state not in common: + common_str = ", ".join(f"{o:+d}" for o in sorted(common)) + caveats.append( + f"{oxidation_state:+d} is an unusual oxidation state for {element} " + f"(common: {common_str}) — double-check it." + ) + return caveats + + def suggest_spin_states( element: str, oxidation_state: int, geometry: str = "octahedral" ) -> SpinSuggestion: """Suggest candidate spin multiplicities for a metal centre. - Returns a :class:`SpinSuggestion` with the d-count and one or two - :class:`SpinState` candidates (two when the octahedral field leaves - high-/low-spin ambiguous). Raises ``ValueError`` for an unsupported metal, - an out-of-range d-count, or an unknown geometry. + Returns a :class:`SpinSuggestion` with the d-count, one or two + :class:`SpinState` candidates (two when an octahedral field leaves + high-/low-spin ambiguous), and a list of ``caveats`` naming every assumption + or enforcement in play. Raises ``ValueError`` for an unsupported metal, an + out-of-range d-count, an unknown geometry, or a non-d⁸ square-planar request. """ if geometry not in GEOMETRIES: raise ValueError(f"geometry must be one of {GEOMETRIES}, got {geometry!r}") @@ -197,4 +272,5 @@ def suggest_spin_states( d_count=d, states=states, explanation=_explain(element, oxidation_state, d, geometry, states), + caveats=_caveats_for(element, oxidation_state, d, geometry), ) diff --git a/tests/test_spin_presets.py b/tests/test_spin_presets.py index 35c5334..ed12165 100644 --- a/tests/test_spin_presets.py +++ b/tests/test_spin_presets.py @@ -110,6 +110,31 @@ def test_pd_ii_square_planar_singlet(self): _, mults = _mults("Pd", 2, "square_planar") assert mults == [1] + def test_non_d8_square_planar_is_refused(self): + # Square-planar is only standard for d8; other d-counts must be refused + # (with a clear message), not given an invented number. + with pytest.raises(ValueError, match="d8"): + suggest_spin_states("Fe", 3, "square_planar") # d5 + + +class TestCaveats: + def test_tetrahedral_flags_high_spin_assumption(self): + s = suggest_spin_states("Fe", 2, "tetrahedral") + assert any("high-spin" in c.lower() for c in s.caveats) + + def test_unusual_oxidation_state_is_flagged(self): + # Fe(IV) is a d4 centre but an unusual oxidation state for Fe. + s = suggest_spin_states("Fe", 4, "octahedral") + assert any("unusual" in c.lower() for c in s.caveats) + + def test_common_oxidation_state_not_flagged_as_unusual(self): + s = suggest_spin_states("Fe", 3, "octahedral") + assert not any("unusual" in c.lower() for c in s.caveats) + + def test_octahedral_d8_notes_square_planar_alternative(self): + s = suggest_spin_states("Ni", 2, "octahedral") # d8 + assert any("square-planar" in c.lower() for c in s.caveats) + class TestTetrahedral: @pytest.mark.parametrize( From 60718a7c13da90aabb5ee3f68ce2803844436216 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 03:21:22 +0000 Subject: [PATCH 13/20] MET.5: pick-and-apply spin-state helper UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the spin-suggestion engine in the Calculate tab as a collapsible "Spin-state helper (metal complexes)" section: pick metal + oxidation state + geometry → Suggest shows the d-count, the candidate multiplicities with the plain-language explanation, and every caveat as a ⚠ flag. Each candidate gets an "Apply multiplicity N (high/low-spin)" button that sets the Multiplicity field — nothing is pre-selected or auto-applied, and charge is deliberately left alone (the apply note reminds the student to set it from their complex). - Refusals/bad input (e.g. non-d8 square-planar, an impossible d-count) render as a flag with the buttons hidden — never a crash. - Widgets in app_builders (spin_metal_dd / spin_ox_si / spin_geom_dd / spin_suggest_btn / spin_helper_output / two spin_apply_btns in an Accordion); handlers on_spin_suggest / on_spin_apply in app_runflow; wired in app.py. Tests (tests/test_spin_helper_ui.py): ambiguous → two buttons with correct labels, unambiguous → one, apply sets multiplicity but not charge, out-of-range apply index is a no-op, non-d8 square-planar refusal + caveat + impossible d-count all flagged with buttons hidden. Full suite 2548 tests, only the pre-existing env-only NMR failures. ruff+black clean. The live Voilà render of the helper is the local visual check. Contributions: - Claude (Opus 4.8): spin-helper widgets, suggest/apply handlers, tests - Jonathan Schultz: chemistry review, overall vision, planning, orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app.py | 19 +++++++++ quantui/app_builders.py | 77 +++++++++++++++++++++++++++++++++ quantui/app_runflow.py | 59 ++++++++++++++++++++++++++ tests/test_spin_helper_ui.py | 82 ++++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 tests/test_spin_helper_ui.py diff --git a/quantui/app.py b/quantui/app.py index 448892a..a707825 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -309,6 +309,12 @@ from quantui.app_runflow import ( on_solvent_cb_changed as _run_on_solvent_cb_changed, ) +from quantui.app_runflow import ( + on_spin_apply as _run_on_spin_apply, +) +from quantui.app_runflow import ( + on_spin_suggest as _run_on_spin_suggest, +) from quantui.app_runflow import ( populate_compare_list as _run_populate_compare_list, ) @@ -2004,6 +2010,13 @@ def _wire_callbacks(self) -> None: self.run_btn.on_click(self._on_run_clicked) self.cancel_btn.on_click(self._safe_cb(self._on_cancel)) self.basis_fix_btn.on_click(self._safe_cb(self._on_basis_fix)) + self.spin_suggest_btn.on_click(self._safe_cb(self._on_spin_suggest)) + self.spin_apply_btns[0].on_click( + self._safe_cb(lambda _b: self._on_spin_apply(0)) + ) + self.spin_apply_btns[1].on_click( + self._safe_cb(lambda _b: self._on_spin_apply(1)) + ) self.preopt_preview_btn.on_click(self._safe_cb(self._on_preopt_preview)) self.preopt_accept_btn.on_click(self._safe_cb(self._on_preopt_accept)) self.preopt_reset_btn.on_click(self._safe_cb(self._on_preopt_reset)) @@ -3503,6 +3516,12 @@ def _on_cancel(self, btn=None) -> None: def _on_basis_fix(self, btn=None) -> None: _run_on_basis_fix(self, btn) + def _on_spin_suggest(self, btn=None) -> None: + _run_on_spin_suggest(self, btn) + + def _on_spin_apply(self, index: int) -> None: + _run_on_spin_apply(self, index) + def _on_preopt_preview(self, btn=None) -> None: _run_on_preopt_preview(self, btn) diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 2593ca0..a073100 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -832,6 +832,82 @@ def build_shared_widgets( style={"description_width": "100px"}, layout=layout_fn(width="190px"), ) + + # MET.5 spin-state helper: suggest a multiplicity for a metal centre from its + # oxidation state (d-count) + geometry. Suggests, never sets — the student + # clicks an Apply button. Charge is not touched (depends on the ligands). + from quantui.spin_presets import supported_metals + + app.spin_metal_dd = widgets.Dropdown( + options=supported_metals(), + value="Fe", + description="Metal:", + style={"description_width": "90px"}, + layout=layout_fn(width="160px"), + ) + app.spin_ox_si = widgets.BoundedIntText( + value=3, + min=-4, + max=8, + description="Oxidation:", + style={"description_width": "90px"}, + layout=layout_fn(width="160px"), + ) + app.spin_geom_dd = widgets.Dropdown( + options=[ + ("Octahedral", "octahedral"), + ("Tetrahedral", "tetrahedral"), + ("Square-planar", "square_planar"), + ], + value="octahedral", + description="Geometry:", + style={"description_width": "90px"}, + layout=layout_fn(width="200px"), + ) + app.spin_suggest_btn = widgets.Button( + description="Suggest multiplicity", + icon="magic", + button_style="info", + layout=layout_fn(width="200px"), + ) + app.spin_helper_output = widgets.HTML(value="") + # Up to two candidate spin states (high/low-spin); hidden until suggested. + app.spin_apply_btns = tuple( + widgets.Button( + description="Apply", + icon="check", + button_style="success", + layout=layout_fn(width="260px", display="none"), + ) + for _ in range(2) + ) + app._spin_suggested_mults: list = [] + app.spin_helper_box = widgets.Accordion( + children=[ + widgets.VBox( + [ + widgets.HTML( + 'Suggests a ' + "spin multiplicity for a transition-metal centre from its " + "oxidation state and geometry. It never sets anything on " + "its own — review the note, then click Apply. Charge is " + "not changed (it depends on your ligands)." + ), + widgets.HBox( + [app.spin_metal_dd, app.spin_ox_si, app.spin_geom_dd], + layout=layout_fn(flex_wrap="wrap", gap="6px"), + ), + app.spin_suggest_btn, + app.spin_helper_output, + app.spin_apply_btns[0], + app.spin_apply_btns[1], + ], + layout=layout_fn(gap="6px"), + ) + ] + ) + app.spin_helper_box.set_title(0, "🧲 Spin-state helper (metal complexes)") + app.spin_helper_box.selected_index = None # collapsed by default # Classical (MMFF/UFF) pre-optimization is an explicit, transparent tool — # Preview → Keep/Revert — NOT a silent checkbox baked into the run. This # avoids the confusing dual path (accepting a previewed geometry while a @@ -1539,6 +1615,7 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None: layout=layout_fn(flex_wrap="wrap", align_items="flex-start"), ), app._open_shell_hint, + app.spin_helper_box, widgets.HBox( [app.calc_type_dd, app.calc_type_help_btn], layout=layout_fn(align_items="center", gap="4px"), diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index e21ea7b..4db230b 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -218,6 +218,65 @@ def _update_basis_fix_button(app: Any, mol: Any) -> None: _hide_basis_fix_button(app) +def _spin_small(text: str, color: str = "#444") -> str: + return f'{text}' + + +def on_spin_suggest(app: Any, btn: Any = None) -> None: + """Compute a spin-multiplicity suggestion and render it (MET.5). + + Suggests only — the Apply buttons (wired to on_spin_apply) do the setting. + A refusal (e.g. non-d8 square-planar) or bad input is shown as a flag, not a + crash. + """ + from quantui.spin_presets import suggest_spin_states + + for b in app.spin_apply_btns: + b.layout.display = "none" + app._spin_suggested_mults = [] + + try: + s = suggest_spin_states( + app.spin_metal_dd.value, + int(app.spin_ox_si.value), + app.spin_geom_dd.value, + ) + except ValueError as exc: + app.spin_helper_output.value = _spin_small(f"⚠ {exc}", "#b45309") + return + + lines = [_spin_small(s.explanation)] + for c in s.caveats: + lines.append(_spin_small(f"⚠ {c}", "#b45309")) + app.spin_helper_output.value = "
".join(lines) + + # Arm one Apply button per candidate spin state, labelled with the state. + app._spin_suggested_mults = [st.multiplicity for st in s.states] + for i, st in enumerate(s.states): + tag = f" ({st.label})" if st.label else "" + app.spin_apply_btns[i].description = ( + f"Apply multiplicity {st.multiplicity}{tag}" + ) + app.spin_apply_btns[i].layout.display = "" + + +def on_spin_apply(app: Any, index: int) -> None: + """Set the multiplicity field from a suggested spin state (MET.5).""" + mults = getattr(app, "_spin_suggested_mults", []) + if index >= len(mults): + return + mult = mults[index] + try: + app.mult_si.value = mult + except Exception: # noqa: BLE001 — out-of-range guard is best-effort + return + app.spin_helper_output.value = _spin_small( + f"Multiplicity set to {mult}. Remember to set the charge from your " + "complex — it isn't inferred.", + "#166534", + ) + + def on_basis_fix(app: Any, btn: Any = None) -> None: """One-click MET.5 fix: set the basis to def2-SVP and hide the button.""" try: diff --git a/tests/test_spin_helper_ui.py b/tests/test_spin_helper_ui.py new file mode 100644 index 0000000..f25c102 --- /dev/null +++ b/tests/test_spin_helper_ui.py @@ -0,0 +1,82 @@ +"""Pick-and-apply spin-state helper UI (M-METAL MET.5). + +Suggests a multiplicity for a metal centre; the student clicks Apply. Suggests +only — never sets charge, never auto-applies, and surfaces every caveat/refusal. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + from quantui.app import QuantUIApp + + return QuantUIApp() + + +def _suggest(app, metal, ox, geom): + app.spin_metal_dd.value = metal + app.spin_ox_si.value = ox + app.spin_geom_dd.value = geom + app._on_spin_suggest() + + +class TestSuggest: + def test_ambiguous_shows_two_apply_buttons(self, app): + _suggest(app, "Co", 3, "octahedral") # d6: HS 5 / LS 1 + assert [b.layout.display for b in app.spin_apply_btns] == ["", ""] + assert app._spin_suggested_mults == [5, 1] + labels = [b.description for b in app.spin_apply_btns] + assert "5" in labels[0] and "high-spin" in labels[0] + assert "1" in labels[1] and "low-spin" in labels[1] + + def test_unambiguous_shows_one_apply_button(self, app): + _suggest(app, "Zn", 2, "octahedral") # d10 → singlet only + assert app.spin_apply_btns[0].layout.display == "" + assert app.spin_apply_btns[1].layout.display == "none" + assert app._spin_suggested_mults == [1] + + def test_explanation_rendered(self, app): + _suggest(app, "Fe", 3, "octahedral") + assert "d5" in app.spin_helper_output.value + + +class TestApply: + def test_apply_sets_multiplicity_not_charge(self, app): + app.charge_si.value = 3 + _suggest(app, "Co", 3, "octahedral") + app._on_spin_apply(1) # low-spin, multiplicity 1 + assert app.mult_si.value == 1 + assert app.charge_si.value == 3 # charge never touched + assert "charge" in app.spin_helper_output.value.lower() + + def test_apply_high_spin(self, app): + _suggest(app, "Co", 3, "octahedral") + app._on_spin_apply(0) # high-spin, multiplicity 5 + assert app.mult_si.value == 5 + + def test_apply_out_of_range_index_is_safe(self, app): + _suggest(app, "Zn", 2, "octahedral") # only one state + before = app.mult_si.value + app._on_spin_apply(1) # no second state — must be a no-op + assert app.mult_si.value == before + + +class TestFlagsAndRefusals: + def test_non_d8_square_planar_is_flagged_no_buttons(self, app): + _suggest(app, "Fe", 3, "square_planar") # d5 — refused + assert "d8" in app.spin_helper_output.value + assert [b.layout.display for b in app.spin_apply_btns] == ["none", "none"] + assert app._spin_suggested_mults == [] + + def test_caveat_is_surfaced(self, app): + _suggest(app, "Fe", 2, "tetrahedral") # high-spin assumption caveat + assert "⚠" in app.spin_helper_output.value + + def test_out_of_range_dcount_is_flagged(self, app): + _suggest(app, "Sc", 5, "octahedral") # d-2, impossible + assert "⚠" in app.spin_helper_output.value + assert all(b.layout.display == "none" for b in app.spin_apply_btns) From d7ecf2915aa874e91346acf49761f651b759584e Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 05:07:05 +0000 Subject: [PATCH 14/20] Bundle 11 more inorganic examples (14 total), GFN-FF-validated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the shipped coordination-complex set from 3 to 14, covering a teaching spread of geometries, spin states, and charges: - Octahedral aqua (weak-field, high-spin): [Fe(H2O)6]2+ (d6, mult 5), [Cr(H2O)6]3+ (d3, 4), [Ni(H2O)6]2+ (d8, 3), [Ti(H2O)6]3+ (d1, 2 — the classic single-band UV-Vis ion). - Octahedral cyanide (strong-field, low-spin): [Fe(CN)6]3- ferricyanide (d5, 2), [Fe(CN)6]4- ferrocyanide (d6, 1). - Tetrahedral: Ni(CO)4 (d10, 1), [Zn(NH3)4]2+ (d10, 1), [CoCl4]2- (d7 HS, 4), [MnO4]- permanganate (d0, 1). - Square-planar: [PtCl4]2- (d8, 1), cisplatin's precursor. build_inorganic_examples.py gained parametric geometry generators (octahedral / tetrahedral / square-planar) and ligand placers (aqua / ammine / linear CN·CO / monatomic), so each complex is one homoleptic call. Sanity check now uses the shipped metal-aware connectivity finder (one component, every metal coordinated). New --validate-gfnff flag relaxes each with GFN-FF as a quality gate. Validated before shipping: every geometry GFN-FF-relaxes with small RMSD (0.01-0.18 Å) and stays a single connected component, and every multiplicity is consistent with quantui.spin_presets for its (metal, oxidation state, geometry). Store 2135 -> 2146 entries; non-bulk 179 -> 190. Governance updated: _KNOWN gains Ti/Cr/Mn/Ni/Zn; the pinned preset count -> 190. The inorganic-examples tests now validate connectivity + charge/mult parity across ALL inorganic entries, so future additions are covered automatically. Full suite 2548 tests, only the pre-existing env-only NMR failures; ≤10 MB store budget intact; ruff+black clean. Contributions: - Claude (Opus 4.8): geometry/ligand generators, 11 complexes, GFN-FF validation, tests - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/data/library/library.sqlite | Bin 884736 -> 888832 bytes quantui/data/manifests/inorganic.json | 1023 +++++++++++++++++++++++++ scripts/build_inorganic_examples.py | 255 +++++- tests/test_bulk_library.py | 4 +- tests/test_inorganic_examples.py | 98 ++- tests/test_library_governance.py | 9 +- 6 files changed, 1307 insertions(+), 82 deletions(-) diff --git a/quantui/data/library/library.sqlite b/quantui/data/library/library.sqlite index 834b31bf3bedef8efbb162aef707299ea5e41ec4..b269ae80085cf7c7419a6b14399f552e7af72a48 100644 GIT binary patch delta 6820 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 O3q(%Fi6#i z0vdJE#b{b|1gXhnqNv9<#u11%O>HHPwlp!0r8Z4%nj-i>dG%-~GyP}2e|P@f{decv z@82!0Nh~c*+&&bv9tmBaG1&4K`aEm&f9JpA|IB~VU+1s#ulE-=B*gcthUEB{K=`mi zLAY?u@?~*F`D55TZ+6xHN9g>9s-NX=r|6)s7({5+{=_FeOGECaLTB249EIb0&qh8Dsxq7hVy8nI5yM}H?p^dpfo9;|{_$!$1T zMDcF^1^+d#rja0lzXsprbNDkT#D8da+86N@xX(UfZ?{+2)5#?}&Q{j9*6-;%wAVUJ zp2m+`TdX|TZ>5t4){H{LVF%dHSq_^(^}O!Kb>L1|pSTy+xp%{Qc~@9p=?m*my2BYJ#` zy|&leug}Xn+#B0_KO8-CM%wh*)6%n3XU=*qJ8t%rw6wI88QGaBans33nq)qYvt#s6 zJBFE4i@m%osDpQ7^|(7xkWDa+L&PLAJ|Krc_PSo*8LR8Ovp4jH^`g$OS+Ge42>aOQ z18z!@B*3)C*;to!#OS7ui8`$_r0Y93=ofCir60NdiZ-_=ns*10M=6SU$WYEeIz+#E zr$WDVcbMi~ujp+al{dRK>fIi-tschR&(X;qUF*A3_1E1S_4_>`^NX7#pjqz-oza`C zFZZSxv4#b-?t4y0_m6LQw}0_9aLJHs;7fDo8fb~c>39r&5(lv#19S_0jat!X=#)A4 zS8yGI5>M%GfU?-@4*)$NH}*Qd`W_H2*-napS-XSOLzwD@c9LZZtkntet9M5bT!w3i z2YLwCun=M>!LT#zkbCqDTLRFtDBc8`Sieq=A6%Bu%-n=#t7SR$?z0y@ah#EEfc%S~ zX^dgR%?smfWp?c4g>Ky_J7Ag7dw4mdDXtd}U~_9PFNSoQiQ2=1Wgyu;1d@3M^Ah#I zBw%tH!Jy;WB;V_mqENJ5Z@*(#+9h_Oz0l6G)9lgqNIS|_;#H9+=9+Umcp|VL?3!rK zZ$r{VO8KN&CS{UayOXb`h&}5wbx0mE&3kySIcUpDlY``BGY!j&AnfMu;}Ds-d-({H z?5zYDX1*=c%RmixL5+YR?CT;0E;}PW1m;OhjPNF<0D zF;FmmkN=B*#V_zi-oR_Q;am8dJl`Dq93-s!hJC@#v(xNDujs1SCbpI>WiPYoESZgB z!&xM==zaPvy-HhX6Fo-jXf-XT#k7Ft&{=d6O{61efJ)Lw+DRL^M9z_uQ*<@U(~0{Rds5Y+Nf5kg>skNBwv$x zGFwiSD1qD9SR;l9Lcqn6m`^*;a^);y#D#QH&k(03L-K<8N{pIR_ ziRbbgb5_Wt2NL#R_HR~^Ca+RGJn5|aI2bU4oZ32 zUAt9nh9=`}H4wlP5fZncQ)rsYuE`=`S@~9lRcw9f72_W~G52Ha&itEfsHDKD_BquL Pe Coords: + """Two H of an M–OH2: the O lone pair faces the metal, H splayed outward.""" + u = o_pos - metal_pos + u = u / np.linalg.norm(u) + v, _w = _orthonormal_frame(u) + half = math.radians(hoh_deg / 2.0) + out = [] + for s in (1.0, -1.0): + direction = math.cos(half) * u + s * math.sin(half) * v + out.append((o_pos + oh * direction).tolist()) + return out + + +def _octahedral_dirs() -> List[np.ndarray]: + return [ + np.array(a, float) + for a in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) + ] + + +def _tetrahedral_dirs() -> List[np.ndarray]: + raw = ((1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)) + return [np.array(a, float) / math.sqrt(3.0) for a in raw] + + +def _square_planar_dirs() -> List[np.ndarray]: + return [np.array(a, float) for a in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0))] + + +def _aqua_ligand(d_mo: float): + """Place an aqua (H2O) donor along a unit direction from the metal.""" + + def place(metal: np.ndarray, u: np.ndarray) -> Tuple[Atoms, Coords]: + o = metal + u * d_mo + atoms: Atoms = ["O"] + coords: Coords = [o.tolist()] + for h in _aqua_hydrogens(o, metal): + atoms.append("H") + coords.append(h) + return atoms, coords + + return place + + +def _ammine_ligand(d_mn: float): + """Place an ammine (NH3) donor along a unit direction from the metal.""" + + def place(metal: np.ndarray, u: np.ndarray) -> Tuple[Atoms, Coords]: + n = metal + u * d_mn + atoms: Atoms = ["N"] + coords: Coords = [n.tolist()] + for h in _ammine_hydrogens(n, metal): + atoms.append("H") + coords.append(h) + return atoms, coords + + return place + + +def _linear_ligand(near: str, far: str, d_near: float, d_far: float): + """Place a linear diatomic donor (M–near≡far), e.g. cyanide C≡N or CO.""" + + def place(metal: np.ndarray, u: np.ndarray) -> Tuple[Atoms, Coords]: + p_near = metal + u * d_near + p_far = p_near + u * d_far + return [near, far], [p_near.tolist(), p_far.tolist()] + + return place + + +def _mono_ligand(elem: str, d: float): + """Place a single-atom donor (M–X), e.g. chloro or oxo.""" + + def place(metal: np.ndarray, u: np.ndarray) -> Tuple[Atoms, Coords]: + return [elem], [(metal + u * d).tolist()] + + return place + + +def _homoleptic( + metal_sym: str, dirs: List[np.ndarray], place, charge: int, mult: int +) -> Tuple[Atoms, Coords, int, int]: + """Assemble a homoleptic complex: one ligand type on every coordination site.""" + atoms: Atoms = [metal_sym] + coords: Coords = [[0.0, 0.0, 0.0]] + metal = np.zeros(3) + for u in dirs: + u = u / np.linalg.norm(u) + a, c = place(metal, u) + atoms.extend(a) + coords.extend(c) + return atoms, coords, charge, mult + + def cisplatin() -> Tuple[Atoms, Coords, int, int]: """cis-[PtCl2(NH3)2] — square planar Pt(II) d8, singlet, neutral.""" atoms: Atoms = ["Pt"] @@ -156,24 +252,92 @@ def ferrocene() -> Tuple[Atoms, Coords, int, int]: "Fe(C5H5)2 — the archetypal metallocene sandwich compound", "ferrocene;bis(cyclopentadienyl)iron;Cp2Fe", ), -} - -# A minimal covalent-radius table (Å) for the sanity connectivity check. -_COV = { - "H": 0.31, - "C": 0.76, - "N": 0.71, - "O": 0.66, - "Cl": 1.02, - "Fe": 1.32, - "Co": 1.26, - "Pt": 1.36, - "Zn": 1.22, + # ── Octahedral aqua complexes (weak-field H2O → high-spin) ────────────── + "hexaaquairon(II)": ( + lambda: _homoleptic("Fe", _octahedral_dirs(), _aqua_ligand(2.12), 2, 5), + "[Fe(H2O)6]2+ — high-spin octahedral aqua complex (d6, 4 unpaired)", + "hexaaquairon;iron(II) hexaaqua;Fe(H2O)6 2+", + ), + "hexaaquachromium(III)": ( + lambda: _homoleptic("Cr", _octahedral_dirs(), _aqua_ligand(1.96), 3, 4), + "[Cr(H2O)6]3+ — octahedral aqua complex (d3, 3 unpaired)", + "hexaaquachromium;chromium(III) hexaaqua;Cr(H2O)6 3+", + ), + "hexaaquanickel(II)": ( + lambda: _homoleptic("Ni", _octahedral_dirs(), _aqua_ligand(2.05), 2, 3), + "[Ni(H2O)6]2+ — octahedral aqua complex (d8, 2 unpaired)", + "hexaaquanickel;nickel(II) hexaaqua;Ni(H2O)6 2+", + ), + "hexaaquatitanium(III)": ( + lambda: _homoleptic("Ti", _octahedral_dirs(), _aqua_ligand(2.03), 3, 2), + "[Ti(H2O)6]3+ — d1 octahedral aqua complex (the classic single-band " + "UV-Vis example)", + "hexaaquatitanium;titanium(III) hexaaqua;Ti(H2O)6 3+", + ), + # ── Octahedral cyanides (strong-field CN- → low-spin) ─────────────────── + "hexacyanoferrate(III)": ( + lambda: _homoleptic( + "Fe", _octahedral_dirs(), _linear_ligand("C", "N", 1.93, 1.16), -3, 2 + ), + "[Fe(CN)6]3- — ferricyanide, low-spin octahedral (d5, 1 unpaired)", + "ferricyanide;hexacyanoferrate(III);Fe(CN)6 3-", + ), + "hexacyanoferrate(II)": ( + lambda: _homoleptic( + "Fe", _octahedral_dirs(), _linear_ligand("C", "N", 1.92, 1.16), -4, 1 + ), + "[Fe(CN)6]4- — ferrocyanide, low-spin octahedral (d6, diamagnetic)", + "ferrocyanide;hexacyanoferrate(II);Fe(CN)6 4-", + ), + # ── Tetrahedral complexes ─────────────────────────────────────────────── + "tetracarbonylnickel(0)": ( + lambda: _homoleptic( + "Ni", _tetrahedral_dirs(), _linear_ligand("C", "O", 1.82, 1.14), 0, 1 + ), + "Ni(CO)4 — tetrahedral d10 carbonyl (18-electron, diamagnetic)", + "tetracarbonylnickel;nickel tetracarbonyl;Ni(CO)4", + ), + "tetraamminezinc(II)": ( + lambda: _homoleptic("Zn", _tetrahedral_dirs(), _ammine_ligand(2.03), 2, 1), + "[Zn(NH3)4]2+ — tetrahedral d10 ammine (diamagnetic)", + "tetraamminezinc;zinc(II) tetraammine;Zn(NH3)4 2+", + ), + "tetrachlorocobaltate(II)": ( + lambda: _homoleptic("Co", _tetrahedral_dirs(), _mono_ligand("Cl", 2.28), -2, 4), + "[CoCl4]2- — tetrahedral high-spin cobalt(II) (d7, 3 unpaired), the " + "classic blue ion", + "tetrachlorocobaltate;CoCl4 2-", + ), + "permanganate": ( + lambda: _homoleptic("Mn", _tetrahedral_dirs(), _mono_ligand("O", 1.63), -1, 1), + "[MnO4]- — tetrahedral d0 manganese(VII) oxoanion (deep purple)", + "permanganate;MnO4;tetraoxomanganate", + ), + # ── Square-planar (d8) ────────────────────────────────────────────────── + "tetrachloroplatinate(II)": ( + lambda: _homoleptic( + "Pt", _square_planar_dirs(), _mono_ligand("Cl", 2.31), -2, 1 + ), + "[PtCl4]2- — square-planar platinum(II) (d8, diamagnetic), the cisplatin " + "precursor", + "tetrachloroplatinate;PtCl4 2-", + ), } def _sanity(atoms: Atoms, coords: Coords) -> List[str]: - """Return a list of problems (empty = geometry looks sane).""" + """Return a list of problems (empty = geometry looks sane). + + Uses the shipped, metal-aware connectivity finder so the checks match how the + app itself perceives bonds: no clashes, one connected component (not a + scattered salt), and every metal centre actually coordinated. + """ + from quantui.connectivity import ( + covalent_components, + is_metal, + metal_coordination_bonds, + ) + problems: List[str] = [] pts = np.array(coords) n = len(atoms) @@ -183,20 +347,15 @@ def _sanity(atoms: Atoms, coords: Coords) -> List[str]: dij = float(np.linalg.norm(pts[i] - pts[j])) if dij < 0.7: problems.append(f"clash: {atoms[i]}{i}-{atoms[j]}{j} = {dij:.2f} Å") - # Metal is bonded to the expected number of donors (within 1.3× radii sum). - metal_syms = {"Pt", "Co", "Fe", "Zn"} + # One connected component — never a scattered / disconnected structure. + comps = covalent_components(atoms, coords) + if len(comps) != 1: + problems.append(f"{len(comps)} disconnected fragments (expected 1)") + # Every metal centre is actually coordinated. + bonded = {i for bond in metal_coordination_bonds(atoms, coords) for i in bond} for i, sym in enumerate(atoms): - if sym not in metal_syms: - continue - neigh = 0 - for j in range(n): - if j == i: - continue - cutoff = 1.3 * (_COV.get(sym, 1.3) + _COV.get(atoms[j], 0.7)) - if float(np.linalg.norm(pts[i] - pts[j])) <= cutoff: - neigh += 1 - if neigh == 0: - problems.append(f"metal {sym}{i} has no neighbours within bonding range") + if is_metal(sym) and i not in bonded: + problems.append(f"metal {sym}{i} has no coordination bonds") return problems @@ -245,6 +404,40 @@ def build_manifest() -> list: return entries +def _validate_gfnff(entries: list) -> None: + """Relax each entry with GFN-FF and report — a real quality gate for the + idealized geometries (needs xtb; skipped if unavailable). + + Confirms each starting geometry is physically sane: the GFN-FF relaxation + stays a single connected component and doesn't move far (a large RMSD would + mean the idealized metrics are off). Spin-independent (GFN-FF is a force + field), so multiplicity doesn't enter here. + """ + try: + from quantui.connectivity import covalent_components + from quantui.molecule import Molecule + from quantui.preopt import _XTB_AVAILABLE, preoptimize + except Exception as exc: # noqa: BLE001 + print(f"\n# GFN-FF validation unavailable ({exc}); skipped") + return + if not _XTB_AVAILABLE: + print("\n# GFN-FF validation skipped (xtb not installed)") + return + + print("\n# GFN-FF validation (relax idealized geometry; expect small RMSD)\n") + for e in entries: + mol = Molecule( + atoms=e["atoms"], + coordinates=e["coordinates"], + charge=e["charge"], + multiplicity=e["multiplicity"], + ) + relaxed, rmsd = preoptimize(mol) + comps = len(covalent_components(relaxed.atoms, relaxed.coordinates)) + flag = "OK" if comps == 1 and rmsd < 0.6 else "REVIEW" + print(f" {e['name']:26s} RMSD={rmsd:5.3f} Å components={comps} {flag}") + + def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( @@ -252,12 +445,20 @@ def main(argv=None) -> int: action="store_true", help="Rebuild the library store from all manifests after writing.", ) + ap.add_argument( + "--validate-gfnff", + action="store_true", + help="Relax each geometry with GFN-FF (xtb) and report as a quality gate.", + ) args = ap.parse_args(argv) entries = build_manifest() _MANIFEST.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") print(f"\n# wrote {len(entries)} entries -> {_MANIFEST}") + if args.validate_gfnff: + _validate_gfnff(entries) + if args.rebuild: from quantui import molecule_library as ml diff --git a/tests/test_bulk_library.py b/tests/test_bulk_library.py index f2a911b..2a9dd36 100644 --- a/tests/test_bulk_library.py +++ b/tests/test_bulk_library.py @@ -59,13 +59,13 @@ def test_provenance_file_exists(self): class TestBulkInStore: def test_total_count_includes_bulk(self): - assert ml.count() >= 179 + len(_bulk_entries()) + assert ml.count() >= 190 + len(_bulk_entries()) def test_bulk_excluded_from_preset_dict(self): # The browse dropdown must NOT balloon with thousands of bulk entries. d = config.MOLECULE_LIBRARY assert all(not k.startswith("qm9-") for k in d) - assert len(d) == 179 # presets + curated only (+3 MET.9 inorganics) + assert len(d) == 190 # presets + curated only (+14 inorganic examples) def test_bulk_category_present(self): assert "bulk-qm9" in ml.categories() diff --git a/tests/test_inorganic_examples.py b/tests/test_inorganic_examples.py index 34d31f1..b407e5a 100644 --- a/tests/test_inorganic_examples.py +++ b/tests/test_inorganic_examples.py @@ -7,76 +7,72 @@ from __future__ import annotations -import math - from quantui import molecule_library as ml +# Explicit spot-checks (formula / charge / multiplicity) for a representative +# spread of geometries, spin states, and charges. The structural tests below +# cover *every* inorganic entry, so new ones don't need adding here. _EXPECTED = { - "inorganic-cisplatin": { - "formula": "H6Cl2N2Pt", - "charge": 0, - "mult": 1, - "metal": "Pt", - }, - "inorganic-hexaamminecobaltiii": { - "formula": "H18CoN6", - "charge": 3, - "mult": 1, - "metal": "Co", - }, - "inorganic-ferrocene": { - "formula": "C10H10Fe", - "charge": 0, - "mult": 1, - "metal": "Fe", - }, + "inorganic-cisplatin": {"formula": "H6Cl2N2Pt", "charge": 0, "mult": 1}, + "inorganic-hexaamminecobaltiii": {"formula": "H18CoN6", "charge": 3, "mult": 1}, + "inorganic-ferrocene": {"formula": "C10H10Fe", "charge": 0, "mult": 1}, + "inorganic-hexaaquaironii": {"formula": "H12FeO6", "charge": 2, "mult": 5}, + "inorganic-hexacyanoferrateiii": {"formula": "C6FeN6", "charge": -3, "mult": 2}, + "inorganic-tetracarbonylnickel0": {"formula": "C4NiO4", "charge": 0, "mult": 1}, + "inorganic-permanganate": {"formula": "MnO4", "charge": -1, "mult": 1}, + "inorganic-tetrachloroplatinateii": {"formula": "Cl4Pt", "charge": -2, "mult": 1}, } -# Generous covalent-radius sums (Å) for the connectivity check. -_BOND_CUTOFF = {"Pt": 2.9, "Co": 2.7, "Fe": 2.7} +_MIN_INORGANIC_EXAMPLES = 14 -def test_all_examples_present(): - ids = {e["id"] for e in ml.iter_entries()} - for eid in _EXPECTED: - assert eid in ids, f"missing bundled inorganic example: {eid}" +def _inorganic_entries(): + return [e for e in ml.iter_entries() if e["category"] == "inorganic-complex"] -def test_examples_have_correct_charge_multiplicity_and_formula(): +def test_expected_examples_present_with_right_metadata(): for eid, exp in _EXPECTED.items(): e = ml.get(eid) - assert e is not None + assert e is not None, f"missing bundled inorganic example: {eid}" assert e["formula"] == exp["formula"] assert e["charge"] == exp["charge"] assert e["multiplicity"] == exp["mult"] assert e["category"] == "inorganic-complex" -def test_metal_centre_is_connected(): - """The whole point of M-METAL: the metal must not be a detached dot.""" - for eid, exp in _EXPECTED.items(): - e = ml.get(eid) - atoms = e["atoms"] - coords = e["coordinates"] - mi = atoms.index(exp["metal"]) - mx, my, mz = coords[mi] - cutoff = _BOND_CUTOFF[exp["metal"]] - neighbours = 0 - for j, (x, y, z) in enumerate(coords): - if j == mi: - continue - d = math.sqrt((x - mx) ** 2 + (y - my) ** 2 + (z - mz) ** 2) - if d <= cutoff: - neighbours += 1 - assert neighbours >= 2, f"{eid}: metal has only {neighbours} neighbours" - - -def test_electron_count_parity_matches_multiplicity(): +def test_library_ships_a_full_inorganic_set(): + assert len(_inorganic_entries()) >= _MIN_INORGANIC_EXAMPLES + + +def test_every_metal_centre_is_connected(): + """The whole point of M-METAL: no metal is a detached dot. Checked across + ALL inorganic entries via the shipped, metal-aware connectivity finder.""" + from quantui.connectivity import ( + covalent_components, + is_metal, + metal_coordination_bonds, + ) + + for e in _inorganic_entries(): + atoms, coords = e["atoms"], e["coordinates"] + # One connected component — never a scattered salt. + assert len(covalent_components(atoms, coords)) == 1, e["id"] + bonded = {i for bond in metal_coordination_bonds(atoms, coords) for i in bond} + for i, sym in enumerate(atoms): + if is_metal(sym): + degree = sum( + 1 for a, b in metal_coordination_bonds(atoms, coords) if i in (a, b) + ) + assert ( + i in bonded and degree >= 2 + ), f"{e['id']}: {sym} under-coordinated" + + +def test_every_example_passes_the_charge_multiplicity_guard(): """A bundled example must not itself trip the charge/multiplicity guard.""" from quantui.inorganic_guards import check_charge_multiplicity from quantui.molecule import ATOMIC_NUMBERS - for eid in _EXPECTED: - e = ml.get(eid) + for e in _inorganic_entries(): n_elec = sum(ATOMIC_NUMBERS.get(a, 0) for a in e["atoms"]) - e["charge"] - assert check_charge_multiplicity(n_elec, e["multiplicity"]) is None + assert check_charge_multiplicity(n_elec, e["multiplicity"]) is None, e["id"] diff --git a/tests/test_library_governance.py b/tests/test_library_governance.py index 696c1a4..5bcd5c0 100644 --- a/tests/test_library_governance.py +++ b/tests/test_library_governance.py @@ -13,8 +13,8 @@ from quantui import molecule_library as ml # Reasonable element-symbol whitelist for the bundled tiers (CHONF + curated -# heteroatoms + common ions + the coordination-complex metals Fe/Co/Pt from the -# bundled inorganic examples, MET.9). Guards against codec corruption. +# heteroatoms + common ions + the coordination-complex metals from the bundled +# inorganic examples, MET.9). Guards against codec corruption. _KNOWN = { "H", "He", @@ -36,8 +36,13 @@ "Ar", "K", "Ca", + "Ti", + "Cr", + "Mn", "Fe", "Co", + "Ni", + "Zn", "Br", "I", "Pt", From b70c2e3ae117cb0b48ddb5d0aa56d332d6072f10 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 11:53:24 +0000 Subject: [PATCH 15/20] Sync the Charge/Multiplicity fields onto the active molecule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review surfaced that the pre-run guard reads the mult_si/charge_si widgets while the calculation reads mol.charge/mol.multiplicity (session_calc sets mol.charge and mol.spin = multiplicity - 1 from the active molecule). There was no widget→molecule sync, so editing the fields — or the new spin-state helper's "Apply multiplicity" — updated the widgets but NOT the molecule the run uses. Confirmed by test: setting mult_si.value left mol.multiplicity unchanged, so the guard could validate one multiplicity and the SCF run another, and the spin helper's Apply was silently a no-op for the calculation. Fix: charge_si/mult_si now observe onto self._molecule (_sync_charge_to_molecule / _sync_mult_to_molecule). _set_molecule assigns self._molecule before setting the fields, so the load-time field updates sync back as no-ops; a None molecule is a safe no-op. Molecule attributes are plainly mutable (validation lives in __init__), so a direct set never raises — the pre-run guard still catches an inconsistent charge/multiplicity. Also documented (not a bug) that the MET.3 PlotlyMol→py3Dmol fallback intentionally drops **kwargs, which carries PlotlyMol-only options py3Dmol can't accept — matching the primary py3dmol path. Tests (tests/test_charge_mult_sync.py): a multiplicity/charge edit reaches the molecule, the spin-helper Apply reaches the molecule the run uses, a None molecule is safe, and load keeps fields + molecule consistent. Full suite 2553 tests, only the pre-existing env-only NMR failures; ruff+black clean. Contributions: - Claude (Opus 4.8): widget→molecule sync fix + regression tests - Jonathan Schultz: code review, overall vision, planning, orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- quantui/app.py | 20 +++++++++ quantui/visualization_py3dmol.py | 3 ++ tests/test_charge_mult_sync.py | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/test_charge_mult_sync.py diff --git a/quantui/app.py b/quantui/app.py index a707825..9cda920 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1994,6 +1994,14 @@ def _wire_callbacks(self) -> None: self.basis_dd.observe(self._safe_cb(self._update_notes), names="value") # Multiplicity drives the open-shell hint (part of _update_notes). self.mult_si.observe(self._safe_cb(self._update_notes), names="value") + # Keep the active molecule's charge/multiplicity in step with the fields, + # so an edit here (or the spin-state helper's Apply) actually reaches the + # run — the calc reads mol.charge/mol.multiplicity, and the pre-run guard + # reads the widgets, so the two must not drift apart. + self.charge_si.observe( + self._safe_cb(self._sync_charge_to_molecule), names="value" + ) + self.mult_si.observe(self._safe_cb(self._sync_mult_to_molecule), names="value") self.method_dd.observe(self._safe_cb(self._update_estimate), names="value") self.basis_dd.observe(self._safe_cb(self._update_estimate), names="value") # Unfinished-calculations list (CHK.6) @@ -4145,6 +4153,18 @@ def _on_help_topic_changed(self, change=None) -> None: # ══ LOGIC METHODS ════════════════════════════════════════════════════════ + def _sync_charge_to_molecule(self, change=None) -> None: + """Push a Charge-field edit onto the active molecule (see the observer + wiring): the run reads ``mol.charge``, so the field must not drift.""" + if self._molecule is not None: + self._molecule.charge = int(self.charge_si.value) + + def _sync_mult_to_molecule(self, change=None) -> None: + """Push a Multiplicity-field edit (or a spin-helper Apply) onto the + active molecule: the run reads ``mol.multiplicity``.""" + if self._molecule is not None: + self._molecule.multiplicity = int(self.mult_si.value) + def _set_molecule(self, mol: Molecule, label: str = "") -> None: """Update shared state and refresh dependent widgets.""" self._molecule = mol diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index 2e48bfa..2e294e0 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -426,6 +426,9 @@ def visualize_molecule( exc, ) fallback_style = style if style in _PY3DMOL_STYLES else "ball+stick" + # **kwargs is intentionally not forwarded: it carries PlotlyMol-only + # options (e.g. resolution) that visualize_molecule_py3dmol doesn't + # accept — the same reason the backend=="py3dmol" path above omits it. return visualize_molecule_py3dmol( molecule, style=_validate_py3dmol_style(fallback_style), diff --git a/tests/test_charge_mult_sync.py b/tests/test_charge_mult_sync.py new file mode 100644 index 0000000..05c1e1c --- /dev/null +++ b/tests/test_charge_mult_sync.py @@ -0,0 +1,73 @@ +"""Charge/multiplicity fields sync onto the active molecule. + +The calculation reads ``mol.charge`` / ``mol.multiplicity`` (session_calc sets +``mol.charge`` and ``mol.spin = multiplicity - 1``); the pre-run guard reads the +widgets, and the spin-state helper's Apply writes ``mult_si``. Without a +widget→molecule sync, an edited multiplicity (or an applied spin state) never +reached the run and the guard could validate a different value than the calc +used. These tests guard the sync. +""" + +from __future__ import annotations + +import pytest + +from quantui.molecule import Molecule + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + from quantui.app import QuantUIApp + + return QuantUIApp() + + +def _load(app, charge=0, mult=1): + app._set_molecule( + Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]], + charge=charge, + multiplicity=mult, + ), + "m", + ) + + +class TestChargeMultSync: + def test_multiplicity_edit_reaches_molecule(self, app): + _load(app) + app.mult_si.value = 3 + assert app._molecule.multiplicity == 3 + + def test_charge_edit_reaches_molecule(self, app): + _load(app) + app.charge_si.value = -2 + assert app._molecule.charge == -2 + + def test_spin_apply_reaches_the_molecule_the_run_uses(self, app): + # The whole point: Apply must update mol.multiplicity, not just the field. + app._set_molecule( + Molecule( + atoms=["Co"], coordinates=[[0.0, 0.0, 0.0]], charge=3, multiplicity=1 + ), + "co", + ) + app.spin_metal_dd.value = "Co" + app.spin_ox_si.value = 3 + app.spin_geom_dd.value = "octahedral" + app._on_spin_suggest() + app._on_spin_apply(0) # high-spin, multiplicity 5 + assert app.mult_si.value == 5 + assert app._molecule.multiplicity == 5 + + def test_sync_is_safe_with_no_molecule(self, app): + app._molecule = None + app.mult_si.value = 2 # must not raise + app.charge_si.value = -1 + + def test_load_sets_fields_and_keeps_them_consistent(self, app): + _load(app, charge=1, mult=2) + assert app.charge_si.value == 1 and app.mult_si.value == 2 + assert app._molecule.charge == 1 and app._molecule.multiplicity == 2 From 049180dd84adda7f4c003d4c245017fc0b88cb9e Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 12:06:26 +0000 Subject: [PATCH 16/20] Document inorganic / coordination-complex support (README + landing page) README: new "Inorganic / coordination complexes" feature bullet (bundled 14-complex set, pre-run basis + charge/multiplicity guard, spin-state helper, optional GFN-FF pre-opt, salt warning, coordination-bond viewer); library-input line notes the 14 ready-to-run complexes; a new optional GFN-FF (xtb) install section (pip on Linux, conda-forge on Windows/macOS); LANL2DZ added to the basis list with metal guidance. docs/index.html (GitHub Pages): new "Inorganic & Coordination Complexes" feature card and an updated meta description. Contributions: - Claude (Opus 4.8): README + landing-page updates for the metal features - Jonathan Schultz: overall vision, planning, review, and orchestration Co-authored-by: Jonathan Schultz Co-authored-by: Claude Opus 4.8 --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++------ docs/index.html | 17 ++++++++++++++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 86d4cd1..bf90e2f 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ research and classroom use. ## What it does -- **Molecule input** — paste XYZ coordinates, browse an indexed three-tier - bundled library (20 presets + 156 curated molecules + ~1,900 QM9 structures, - searchable by name/formula), or run a structure search by name, SMILES, - InChI, PubChem CID, InChIKey, or CAS number (PubChem → NCI CACTUS → offline - bundled-library fallback; SMILES/InChI resolve locally with no network) +- **Molecule input** — paste XYZ coordinates, browse an indexed bundled library + (organic presets + curated molecules + ~1,900 QM9 structures + **14 + ready-to-run coordination complexes**, searchable by name/formula), or run a + structure search by name, SMILES, InChI, PubChem CID, InChIKey, or CAS number + (PubChem → NCI CACTUS → offline bundled-library fallback; SMILES/InChI resolve + locally with no network) - **Offline-first** — runs with no internet: the bundled molecule library and the 3D viewer's JavaScript (3Dmol.js) are vendored, so structure lookup and every 3D view work in an air-gapped classroom. (Network is used only for the @@ -48,6 +49,18 @@ research and classroom use. animation; vibrational frequency analysis with animated normal modes, user-tunable playback FPS, and a per-result-directory disk cache so mode switches on repeat visits and history replay are instant +- **Inorganic / coordination complexes** — first-class support for + transition-metal chemistry the organic pipeline can't handle: 14 bundled + metal complexes (octahedral / tetrahedral / square-planar; aqua, ammine, + cyanide, carbonyl, halide, oxo) with correct charge and spin; a pre-run guard + that catches a metal on an incompatible basis (nudges to def2-SVP / LANL2DZ) + and an impossible charge/multiplicity before the calculation starts; a + **spin-state helper** that suggests a multiplicity from a metal centre's + oxidation state and geometry (both high- and low-spin where the ligand field + decides — you pick); optional **GFN-FF (xtb) pre-optimization** that relaxes a + metal complex the classical organic force field can't; a warning when a name + search returns a disconnected salt instead of the coordinated complex; and a + viewer that draws the coordination bonds so the metal is never a lone dot - **Results persistence** — every calculation is saved automatically to a timestamped directory; a built-in browser lets you reload past results after a kernel restart; the full `pyscf.log` is shown inline @@ -164,6 +177,26 @@ and result cards will display the compute device. Whenever gpu4pyscf can't offload a particular call, QuantUI falls back to CPU automatically and the result card reflects which device ran. +### Optional: GFN-FF metal pre-optimization (xtb) + +The classical (MMFF/UFF) pre-optimizer relies on RDKit's organic valence +model, which can't handle a transition metal. Install +[xtb](https://github.com/grimme-lab/xtb) to enable **GFN-FF**, a general +force field that relaxes coordination complexes across the whole periodic +table. Fully optional — without it, metal pre-opt simply reports that it +isn't available and points you to the DFT geometry optimization. + +```bash +# Linux (PyPI wheels bundle the compiled library): +pip install quantui[xtb] + +# Windows / macOS (no PyPI wheel — use conda-forge): +conda install -c conda-forge xtb-python +``` + +QuantUI detects xtb automatically and routes metal pre-optimizations through +GFN-FF; organic molecules still use the faster RDKit force field. + --- ## Quick start @@ -365,7 +398,13 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](https://github.com/The-S ### Basis sets STO-3G (fast, good for learning) → 3-21G → 6-31G / 6-31G\* / 6-31G\*\* → -cc-pVDZ / cc-pVTZ → def2-SVP / def2-TZVP +cc-pVDZ / cc-pVTZ → def2-SVP / def2-TZVP → LANL2DZ + +For **transition metals and heavy elements**, use **def2-SVP** / **def2-TZVP** +or **LANL2DZ** — these carry the effective core potentials that cover metals, +whereas the Pople (`6-31G…`) and Dunning (`cc-pV…`) sets do not. QuantUI's +pre-run guard flags a metal on an incompatible basis and offers a one-click +switch to def2-SVP before the calculation starts. --- diff --git a/docs/index.html b/docs/index.html index 29ee797..2e2c2cb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ QuantUI — Free, open, and interactive quantum chemistry - + @@ -547,6 +547,21 @@

A complete PySCF workflow

+
+
🧲
+
Inorganic & Coordination Complexes
+

+ First-class transition-metal support: 14 bundled metal complexes + (octahedral / tetrahedral / square-planar) with correct charge and + spin, a pre-run guard that catches a metal on an incompatible basis + (nudges to def2-SVP / LANL2DZ) and an impossible multiplicity, a + spin-state helper that suggests high/low-spin + multiplicities from oxidation state + geometry, optional + GFN-FF (xtb) pre-optimization for metals, and a + viewer that draws the coordination bonds. +

+
+ From be8c17e7feedb5bb13173ff235fcf891e44890c1 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 18:04:12 +0000 Subject: [PATCH 17/20] Normalize RDKit's bond-order-perception exception in the PlotlyMol path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via PR #52's first real CI run: tests/test_metal_viewer_fallback.py:: test_reraises_when_no_py3dmol_fallback failed on ubuntu/Python 3.9 only, with RDKit's rdDetermineBonds.DetermineBondOrders raising a raw C++-level "IndexError: unordered_map::at" for cisplatin's platinum instead of the documented ValueError this codebase (and the test) expects — reproducible only under that specific Python-3.9 RDKit wheel; unreproducible locally on Python 3.11 with the same rdkit version string (2026.3.5). visualize_molecule_plotlymol() called the external plotlymol3d package's draw_3D_rep() with no exception handling, so whatever RDKit happened to raise propagated unchanged. The MET.3 fallback in visualize_molecule() already catches Exception broadly, so production behavior (falling back to py3Dmol) was never actually affected — only this one narrowly-typed regression test. Wrapped the call to normalize any RDKit failure into a single ValueError, matching the function's documented contract instead of depending on a third-party exception type that varies by platform/Python version. Full suite: same 14 pre-existing NMR failures, 0 new failures. ruff + black clean. (mypy is not yet enforced on this branch — that's M-TYPECHECK, next in the stack — so not run here.) Contributions: - Claude (Opus 4.8): diagnosis, fix, verification - Jonathan Schultz: direction and review Co-authored-by: Jonathan Schultz Co-authored-by: Claude --- quantui/visualization_py3dmol.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index 2e294e0..51d5dcb 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -305,12 +305,26 @@ def visualize_molecule_plotlymol( try: tmp.write(full_xyz) tmp.close() - fig = draw_3D_rep( - xyzfile=tmp.name, - charge=charge, - mode=mode, - resolution=resolution, - ) + try: + fig = draw_3D_rep( + xyzfile=tmp.name, + charge=charge, + mode=mode, + resolution=resolution, + ) + except Exception as exc: + # RDKit's bond-order perception (rdDetermineBonds), called inside + # plotlymol3d's draw_3D_rep, raises inconsistently across builds + # for the same "can't perceive this molecule's bonds" condition — + # a clean ValueError on most, a raw C++-level IndexError + # ("unordered_map::at") on at least one Python-3.9 RDKit wheel + # (a metal with no covalent-radius table entry). Normalized to one + # type here so callers — including the MET.3 fallback below, which + # must still catch it — don't depend on a third-party + # implementation detail that varies by platform/Python version. + raise ValueError( + f"Could not determine bonds for {molecule.get_formula()}: {exc}" + ) from exc if _plotlymol_format_lighting is not None: preset = LIGHTING_PRESETS.get(lighting, LIGHTING_PRESETS["soft"]) fig = _plotlymol_format_lighting(fig, **preset) From 54885ce132cab9c9d3722986fcc61d1d49d399c2 Mon Sep 17 00:00:00 2001 From: Jonathan Schultz Date: Sun, 16 Aug 2026 18:15:23 +0000 Subject: [PATCH 18/20] Gate the PySCF-backed basis_fix_button test on PySCF availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused the windows-latest/Python 3.11 CI failure on PR #52: test_metal_on_pople_reveals_fix asserted the basis-fix button becomes visible, which requires inorganic_guards.check_basis_coverage to actually detect a real coverage gap via pyscf.gto.basis.load. Windows CI installs no pyscf extra ("PySCF requires Linux/WSL" — see ci.yml's windows job), so basis_unsupported_elements's top-level `from pyscf import gto` raises ImportError, which on_run_clicked's broad except-and-continue around the preflight check swallows exactly like it's designed to (a guard failing must never block a run) — the button correctly never appears, and the test was asserting behavior that needs PySCF to exist. Not an app bug: this is the same "never break a run" fallback every guard in this codebase already uses, and the sibling test_inorganic_guards.py already gates its own PySCF-backed assertions the same way ("the basis check uses PySCF's loader and is gated") — this one test file just didn't carry the same guard yet. Added the identical pytest.mark.skipif(not _PYSCF_AVAILABLE, ...) pattern to the one test that needs it; the other four in the file don't (verified each holds regardless of PySCF availability — a click's own effects, and every "stays hidden" assertion, which routes through _update_basis_fix_button's own try/except around the same import). Verified: passes locally (PySCF present, so the guard doesn't skip — full coverage preserved on Linux); full suite still shows only the 14 pre-existing NMR failures; ruff + black clean. Contributions: - Claude (Opus 4.8): diagnosis, fix, verification - Jonathan Schultz: direction and review Co-authored-by: Jonathan Schultz Co-authored-by: Claude --- tests/test_basis_fix_button.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_basis_fix_button.py b/tests/test_basis_fix_button.py index f1dedc3..3e3cdd1 100644 --- a/tests/test_basis_fix_button.py +++ b/tests/test_basis_fix_button.py @@ -3,6 +3,20 @@ The pre-run guard blocks a metal on an incompatible basis; this offers a single click to fix it — but only when def2-SVP actually resolves the coverage, never for a charge/multiplicity problem it can't fix. + +Only ``test_metal_on_pople_reveals_fix`` needs the PySCF-availability guard +(see ``test_inorganic_guards.py``, which gates for the same reason): it is the +one test asserting the button becomes *visible*, which requires +``inorganic_guards.check_basis_coverage`` to actually detect a real basis gap +via PySCF's loader. Windows CI installs no ``pyscf`` extra ("PySCF requires +Linux/WSL"), so without the guard, ``on_run_clicked``'s broad +except-and-continue around the preflight check silently no-ops there and the +button never appears — not a bug in the app (that's the same "never break a +run" fallback every guard in this codebase uses), just a test asserting +PySCF-backed behavior on a platform where PySCF isn't installed. The other +tests here don't need the guard: a click's own effects +(``test_click_sets_def2_and_hides``) and every "stays hidden" assertion hold +regardless of whether PySCF is present. """ from __future__ import annotations @@ -11,6 +25,19 @@ from quantui.molecule import 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)", +) + @pytest.fixture def app(tmp_path, monkeypatch): @@ -43,6 +70,7 @@ class TestBasisFixButton: def test_hidden_initially(self, app): assert app.basis_fix_btn.layout.display == "none" + @pyscf_only def test_metal_on_pople_reveals_fix(self, app): app._molecule = _cisplatin() app.basis_dd.value = "6-31G" # no Pt coverage From c9949c7f1ad0d8762b81e28354e25e97a9d8ec36 Mon Sep 17 00:00:00 2001 From: Schultz Lab at NCCU Date: Thu, 20 Aug 2026 15:40:33 -0400 Subject: [PATCH 19/20] Attach ECP for ECP-carrying bases (LANL2DZ/def2) on all Mole builds Heavy-element runs (e.g. cisplatin on LANL2DZ or def2) were built with mol.basis set but mol.ecp never set, so PySCF applied no effective core potential: Pt ran all-electron (132 e- kept) against a valence-only basis. The SCF converged to a nonphysical energy (positive HOMO) and the gradients were garbage, so a geometry optimization looked like it was diverging (fmax ~3500 eV/A, energy sliding without converging). Add inorganic_guards.ecp_for_basis(basis, elements), which returns the {element: basis} ECP map for exactly the atoms that carry an ECP under the given basis (data-driven via gto.basis.load_ecp; empty for Pople/cc/ STO sets; never raises). Wire it into all five Mole-construction sites: session_calc, optimizer (the per-step force path), freq_calc, tddft_calc, and nmr_calc. After the fix cisplatin B3LYP/LANL2DZ gives E=-7137 eV and a sane step-0 fmax of 1.0 eV/A. Regression tests pin the electron counts (LANL2DZ puts an ECP on Pt and Cl -> 52 e-; def2 only on Pt -> 72 e-; no ECP -> 132). Co-Authored-By: Claude Opus 4.8 --- quantui/freq_calc.py | 4 ++ quantui/inorganic_guards.py | 37 ++++++++++++++++ quantui/nmr_calc.py | 3 ++ quantui/optimizer.py | 5 +++ quantui/session_calc.py | 6 +++ quantui/tddft_calc.py | 4 ++ tests/test_inorganic_guards.py | 81 ++++++++++++++++++++++++++++++++++ 7 files changed, 140 insertions(+) diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 02b9a33..e8475ad 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -228,9 +228,13 @@ def _status(msg: str) -> None: pass # ── Build Mole object ──────────────────────────────────────────────────── + from .inorganic_guards import ecp_for_basis + mol = gto.Mole() mol.atom = molecule.to_pyscf_format() mol.basis = basis + # Heavy-element ECP (LANL2DZ / def2); empty dict for all-electron bases. + mol.ecp = ecp_for_basis(basis, molecule.atoms) mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 mol.verbose = 4 diff --git a/quantui/inorganic_guards.py b/quantui/inorganic_guards.py index 2783ce4..eef0132 100644 --- a/quantui/inorganic_guards.py +++ b/quantui/inorganic_guards.py @@ -48,6 +48,43 @@ def basis_unsupported_elements(basis: str, elements: Iterable[str]) -> List[str] return bad +def ecp_for_basis(basis: str, elements: Iterable[str]) -> dict: + """Return the ``mol.ecp`` mapping ``basis`` needs over ``elements``. + + Basis sets like **LANL2DZ** and the **def2** family bundle an effective core + potential (ECP) for heavy elements, but PySCF only applies it when + ``mol.ecp`` is set *as well as* ``mol.basis``. Set only the basis and the + heavy atom is run all-electron against a valence-only basis: PySCF keeps the + full electron count, warns ``ECP not specified``, and produces garbage + energies and gradients — a geometry optimisation then walks off into + nonsense (fmax in the thousands, energy sliding without converging). + + This returns ``{element: basis}`` for exactly the elements that carry an ECP + under ``basis`` (via the same ``pyscf.gto.basis.load_ecp`` lookup a run + performs), so a caller can write:: + + mol.ecp = ecp_for_basis(basis, molecule.atoms) # {} for all-electron sets + + Pople / cc / STO sets have no ECP table, so this returns ``{}`` and the + caller leaves ``mol.ecp`` at its (empty) default. Never raises: a missing + ECP table is treated as "no ECP for that element". + """ + from pyscf import gto + + ecp: dict = {} + seen = set() + for el in elements: + if el in seen: + continue + seen.add(el) + try: + if gto.basis.load_ecp(basis, el): + ecp[el] = basis + except Exception: # noqa: BLE001 — no ECP table for this basis/element + pass + return ecp + + def check_basis_coverage(elements: Iterable[str], basis: str) -> Optional[str]: """Message if ``basis`` lacks any element, else ``None``.""" bad = basis_unsupported_elements(basis, elements) diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index 8977008..3ec26be 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -335,11 +335,14 @@ def _run_nmr_calc_body( import numpy as _np from . import config as _config + from .inorganic_guards import ecp_for_basis from .session_calc import maybe_apply_d3, resolve_xc mol = gto.Mole() mol.atom = molecule.to_pyscf_format() mol.basis = basis + # Heavy-element ECP (LANL2DZ / def2); empty dict for all-electron bases. + mol.ecp = ecp_for_basis(basis, molecule.atoms) mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 mol.verbose = 4 diff --git a/quantui/optimizer.py b/quantui/optimizer.py index d0afb7e..c12b9f0 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -164,9 +164,14 @@ def calculate( self.atoms.get_positions().tolist(), ) ] + from .inorganic_guards import ecp_for_basis + mol = gto.Mole() mol.atom = _atom_list_for_cube mol.basis = self.basis + # Attach the ECP so heavy-element gradients are physical — without it + # the optimisation walks off an all-electron/valence-basis surface. + mol.ecp = ecp_for_basis(self.basis, self.atoms.get_chemical_symbols()) mol.charge = self.charge mol.spin = self.spin mol.unit = "Angstrom" diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 04b96bf..db7c488 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -407,9 +407,15 @@ def _run_session_calc_body( ) # --- Build PySCF Mole object --- + from .inorganic_guards import ecp_for_basis + mol = gto.Mole() mol.atom = molecule.to_pyscf_format() mol.basis = basis + # LANL2DZ / def2 bundle an ECP for heavy elements that PySCF applies only + # when mol.ecp is set too; without it the metal runs all-electron on a + # valence basis (garbage energies/gradients). Empty for all-electron sets. + mol.ecp = ecp_for_basis(basis, molecule.atoms) mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 mol.verbose = verbose diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index c79f2bd..c1a518e 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -191,9 +191,13 @@ def _run_tddft_calc_body( dft, gto, scf = _dft, _gto, _scf # ── Build Mole object ──────────────────────────────────────────────────── + from .inorganic_guards import ecp_for_basis + mol = gto.Mole() mol.atom = molecule.to_pyscf_format() mol.basis = basis + # Heavy-element ECP (LANL2DZ / def2); empty dict for all-electron bases. + mol.ecp = ecp_for_basis(basis, molecule.atoms) mol.charge = molecule.charge mol.spin = molecule.multiplicity - 1 mol.verbose = 4 diff --git a/tests/test_inorganic_guards.py b/tests/test_inorganic_guards.py index 20c6a41..d8cf9ba 100644 --- a/tests/test_inorganic_guards.py +++ b/tests/test_inorganic_guards.py @@ -13,9 +13,19 @@ from quantui.inorganic_guards import ( check_basis_coverage, check_charge_multiplicity, + ecp_for_basis, preflight_messages, ) +# cisplatin (PtCl2(NH3)2) as element list + a geometry, reused across ECP tests. +_CISPLATIN_ELEMENTS = ["Pt", "Cl", "Cl", "N", "N"] + ["H"] * 6 +_CISPLATIN_ATOM = ( + "Pt 0 0 0; Cl 1.648 1.648 0; Cl -1.648 1.648 0; " + "N -1.45 -1.45 0; N 1.45 -1.45 0; " + "H -1.01 -2.37 0; H -2.03 -1.35 0.833; H -2.03 -1.35 -0.833; " + "H 2.37 -1.01 0; H 1.35 -2.03 0.833; H 1.35 -2.03 -0.833" +) + _PYSCF_AVAILABLE = False try: import pyscf as _pyscf # noqa: F401 @@ -79,6 +89,77 @@ def test_organic_basis_covers_organics(self): assert check_basis_coverage(["C", "H", "O", "N"], "6-31G*") is None +@pyscf_only +class TestEcpForBasis: + """MET.5/MET.8: ECP-carrying bases must actually attach their ECP. + + Regression guard for the cisplatin geometry-opt divergence — LANL2DZ/def2 + were run with ``mol.ecp`` unset, so Pt kept all 78 electrons and the SCF and + gradients were garbage. + """ + + def test_lanl2dz_selects_all_ecp_atoms(self): + ecp = ecp_for_basis("LANL2DZ", _CISPLATIN_ELEMENTS) + # LANL2DZ carries an ECP for every atom heavier than Ne — so Pt *and* + # Cl — while the first-row N/H ligand atoms stay all-electron. + assert ecp == {"Pt": "LANL2DZ", "Cl": "LANL2DZ"} + + def test_def2_selects_only_the_heavy_metal(self): + # def2's ECP boundary is higher (Z >= 37): Cl is all-electron in def2, + # so only Pt is selected — a real, correct difference from LANL2DZ. + assert ecp_for_basis("def2-SVP", _CISPLATIN_ELEMENTS) == {"Pt": "def2-SVP"} + assert ecp_for_basis("def2-TZVP", ["Pt"]) == {"Pt": "def2-TZVP"} + + def test_all_electron_basis_returns_empty(self): + # Pople / cc / STO have no ECP table — even over a metal, and never raise. + assert ecp_for_basis("6-31G", ["C", "H", "O", "N"]) == {} + assert ecp_for_basis("6-31G", ["Pt", "Cl"]) == {} + assert ecp_for_basis("cc-pVDZ", ["C", "H"]) == {} + + def test_light_only_molecule_under_ecp_basis_is_empty(self): + # def2 over an organic: the bundled ECP applies to no atom present. + assert ecp_for_basis("def2-SVP", ["C", "H", "O"]) == {} + + def test_duplicate_elements_collapse(self): + assert ecp_for_basis("LANL2DZ", ["Pt", "Pt", "H", "H"]) == {"Pt": "LANL2DZ"} + + def test_ecp_drops_core_electrons_on_cisplatin(self): + # The bug, stated as a number: all-electron cisplatin is 132 electrons. + # LANL2DZ puts an ECP on Pt (removes 60) and both Cl (removes 10 each), + # so the correct count is 132 - 60 - 20 = 52. + from pyscf import gto + + mol = gto.Mole() + mol.atom = _CISPLATIN_ATOM + mol.basis = "LANL2DZ" + mol.ecp = ecp_for_basis("LANL2DZ", _CISPLATIN_ELEMENTS) + mol.build() + assert mol.nelectron == 52 + + def test_def2_ecp_drops_only_pt_core(self): + # def2 keeps Cl all-electron, so only Pt's 60 core electrons go: + # 132 - 60 = 72. Confirms the helper tracks each basis's own boundary. + from pyscf import gto + + mol = gto.Mole() + mol.atom = _CISPLATIN_ATOM + mol.basis = "def2-SVP" + mol.ecp = ecp_for_basis("def2-SVP", _CISPLATIN_ELEMENTS) + mol.build() + assert mol.nelectron == 72 + + def test_no_ecp_keeps_all_electrons(self): + # Same molecule, ECP omitted — the pre-fix behaviour, pinned so the + # 60-electron difference is unmistakable. + from pyscf import gto + + mol = gto.Mole() + mol.atom = _CISPLATIN_ATOM + mol.basis = "LANL2DZ" + mol.build() + assert mol.nelectron == 132 + + @pyscf_only class TestPreflight: def test_clean_organic_run_has_no_problems(self): From 32f2d1b681a9bf87fca6ff53c7d6fffbeb5b4064 Mon Sep 17 00:00:00 2001 From: Schultz Lab at NCCU Date: Thu, 20 Aug 2026 16:06:12 -0400 Subject: [PATCH 20/20] Smooth orbital isosurfaces (3Dmol.js smoothness: 5) The py3Dmol orbital viewer called addVolumetricData without a smoothness option, so 3Dmol.js used its default (1) and rendered the raw marching-cubes mesh: the lobes showed visible triangle facets regardless of cubegen grid density (the roughness is the mesh, not the sampling). Pass smoothness: 5 on both the +/- isosurfaces so a few Laplacian passes smooth the mesh, giving GaussView-like surfaces with no recompute. Co-Authored-By: Claude Opus 4.8 --- quantui/orbital_visualization.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 9cd97e0..70f5454 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -1018,10 +1018,14 @@ def orbital_colors(scheme: str) -> tuple[str, str]: try{ vw.removeShape(shapes[i]); }catch(e){} } shapes=[]; + // smoothness = Laplacian smoothing passes 3Dmol.js runs on the raw + // marching-cubes mesh. Default (1) leaves visible triangle facets on the + // lobes; the roughness is the mesh, not the grid, so more cubegen points + // don't fix it but a few smoothing passes do (GaussView-like surfaces). shapes.push(vw.addVolumetricData(DATA,"cube", - {isoval: state.iso, color: state.pos, opacity: state.op})); + {isoval: state.iso, color: state.pos, opacity: state.op, smoothness: 5})); shapes.push(vw.addVolumetricData(DATA,"cube", - {isoval: -state.iso, color: state.neg, opacity: state.op})); + {isoval: -state.iso, color: state.neg, opacity: state.op, smoothness: 5})); } function build(){