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/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.
+
+
+
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.py b/quantui/app.py
index 9d14755..9cda920 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,
)
@@ -306,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,
)
@@ -1985,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)
@@ -2000,6 +2017,14 @@ 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.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))
@@ -3281,6 +3306,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}"
@@ -3485,6 +3521,15 @@ 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_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)
@@ -4108,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/app_builders.py b/quantui/app_builders.py
index 0499b05..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
@@ -1142,6 +1218,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="",
@@ -1529,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"),
@@ -1565,6 +1652,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 6ccfdc1..4db230b 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -119,6 +119,50 @@ 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
+ # 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.
_write_run_header(app)
@@ -140,6 +184,114 @@ 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 _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:
+ 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"]
@@ -597,23 +749,39 @@ 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_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"
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}. 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(
+ 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",
+ )
try:
app._activity_end(kind="ui")
except Exception:
@@ -637,8 +805,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/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/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/connectivity.py b/quantui/connectivity.py
new file mode 100644
index 0000000..7139421
--- /dev/null
+++ b/quantui/connectivity.py
@@ -0,0 +1,289 @@
+"""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, Tuple
+
+# 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
+
+
+# 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 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[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)`` —
+ the same, metal-aware criterion :func:`covalent_components` groups on.
+ """
+ n = len(atoms)
+ 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]
+ 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:
+ 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]] = []
+ 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/quantui/data/library/library.sqlite b/quantui/data/library/library.sqlite
index f5358b5..b269ae8 100644
Binary files a/quantui/data/library/library.sqlite and b/quantui/data/library/library.sqlite differ
diff --git a/quantui/data/manifests/inorganic.json b/quantui/data/manifests/inorganic.json
new file mode 100644
index 0000000..e242cde
--- /dev/null
+++ b/quantui/data/manifests/inorganic.json
@@ -0,0 +1,1412 @@
+[
+ {
+ "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
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexaaquaironii",
+ "name": "hexaaquairon(II)",
+ "formula": "H12FeO6",
+ "category": "inorganic-complex",
+ "charge": 2,
+ "multiplicity": 5,
+ "source": "quantui-idealized",
+ "synonyms": "hexaaquairon;iron(II) hexaaqua;Fe(H2O)6 2+",
+ "description": "[Fe(H2O)6]2+ \u2014 high-spin octahedral aqua complex (d6, 4 unpaired)",
+ "atoms": [
+ "Fe",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.12,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.707729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ 2.707729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ -2.12,
+ 0.0,
+ 0.0
+ ],
+ [
+ -2.707729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ -2.707729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ 0.0,
+ 2.12,
+ 0.0
+ ],
+ [
+ 0.759062,
+ 2.707729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ 2.707729,
+ 0.0
+ ],
+ [
+ 0.0,
+ -2.12,
+ 0.0
+ ],
+ [
+ 0.759062,
+ -2.707729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ -2.707729,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 2.12
+ ],
+ [
+ 0.759062,
+ 0.0,
+ 2.707729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ 2.707729
+ ],
+ [
+ 0.0,
+ 0.0,
+ -2.12
+ ],
+ [
+ 0.759062,
+ 0.0,
+ -2.707729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ -2.707729
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexaaquachromiumiii",
+ "name": "hexaaquachromium(III)",
+ "formula": "H12CrO6",
+ "category": "inorganic-complex",
+ "charge": 3,
+ "multiplicity": 4,
+ "source": "quantui-idealized",
+ "synonyms": "hexaaquachromium;chromium(III) hexaaqua;Cr(H2O)6 3+",
+ "description": "[Cr(H2O)6]3+ \u2014 octahedral aqua complex (d3, 3 unpaired)",
+ "atoms": [
+ "Cr",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.96,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.547729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ 2.547729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ -1.96,
+ 0.0,
+ 0.0
+ ],
+ [
+ -2.547729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ -2.547729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ 0.0,
+ 1.96,
+ 0.0
+ ],
+ [
+ 0.759062,
+ 2.547729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ 2.547729,
+ 0.0
+ ],
+ [
+ 0.0,
+ -1.96,
+ 0.0
+ ],
+ [
+ 0.759062,
+ -2.547729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ -2.547729,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 1.96
+ ],
+ [
+ 0.759062,
+ 0.0,
+ 2.547729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ 2.547729
+ ],
+ [
+ 0.0,
+ 0.0,
+ -1.96
+ ],
+ [
+ 0.759062,
+ 0.0,
+ -2.547729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ -2.547729
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexaaquanickelii",
+ "name": "hexaaquanickel(II)",
+ "formula": "H12NiO6",
+ "category": "inorganic-complex",
+ "charge": 2,
+ "multiplicity": 3,
+ "source": "quantui-idealized",
+ "synonyms": "hexaaquanickel;nickel(II) hexaaqua;Ni(H2O)6 2+",
+ "description": "[Ni(H2O)6]2+ \u2014 octahedral aqua complex (d8, 2 unpaired)",
+ "atoms": [
+ "Ni",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.05,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.637729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ 2.637729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ -2.05,
+ 0.0,
+ 0.0
+ ],
+ [
+ -2.637729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ -2.637729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ 0.0,
+ 2.05,
+ 0.0
+ ],
+ [
+ 0.759062,
+ 2.637729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ 2.637729,
+ 0.0
+ ],
+ [
+ 0.0,
+ -2.05,
+ 0.0
+ ],
+ [
+ 0.759062,
+ -2.637729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ -2.637729,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 2.05
+ ],
+ [
+ 0.759062,
+ 0.0,
+ 2.637729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ 2.637729
+ ],
+ [
+ 0.0,
+ 0.0,
+ -2.05
+ ],
+ [
+ 0.759062,
+ 0.0,
+ -2.637729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ -2.637729
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexaaquatitaniumiii",
+ "name": "hexaaquatitanium(III)",
+ "formula": "H12O6Ti",
+ "category": "inorganic-complex",
+ "charge": 3,
+ "multiplicity": 2,
+ "source": "quantui-idealized",
+ "synonyms": "hexaaquatitanium;titanium(III) hexaaqua;Ti(H2O)6 3+",
+ "description": "[Ti(H2O)6]3+ \u2014 d1 octahedral aqua complex (the classic single-band UV-Vis example)",
+ "atoms": [
+ "Ti",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H",
+ "O",
+ "H",
+ "H"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.03,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.617729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ 2.617729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ -2.03,
+ 0.0,
+ 0.0
+ ],
+ [
+ -2.617729,
+ 0.759062,
+ 0.0
+ ],
+ [
+ -2.617729,
+ -0.759062,
+ 0.0
+ ],
+ [
+ 0.0,
+ 2.03,
+ 0.0
+ ],
+ [
+ 0.759062,
+ 2.617729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ 2.617729,
+ 0.0
+ ],
+ [
+ 0.0,
+ -2.03,
+ 0.0
+ ],
+ [
+ 0.759062,
+ -2.617729,
+ 0.0
+ ],
+ [
+ -0.759062,
+ -2.617729,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 2.03
+ ],
+ [
+ 0.759062,
+ 0.0,
+ 2.617729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ 2.617729
+ ],
+ [
+ 0.0,
+ 0.0,
+ -2.03
+ ],
+ [
+ 0.759062,
+ 0.0,
+ -2.617729
+ ],
+ [
+ -0.759062,
+ 0.0,
+ -2.617729
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexacyanoferrateiii",
+ "name": "hexacyanoferrate(III)",
+ "formula": "C6FeN6",
+ "category": "inorganic-complex",
+ "charge": -3,
+ "multiplicity": 2,
+ "source": "quantui-idealized",
+ "synonyms": "ferricyanide;hexacyanoferrate(III);Fe(CN)6 3-",
+ "description": "[Fe(CN)6]3- \u2014 ferricyanide, low-spin octahedral (d5, 1 unpaired)",
+ "atoms": [
+ "Fe",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.93,
+ 0.0,
+ 0.0
+ ],
+ [
+ 3.09,
+ 0.0,
+ 0.0
+ ],
+ [
+ -1.93,
+ 0.0,
+ 0.0
+ ],
+ [
+ -3.09,
+ 0.0,
+ 0.0
+ ],
+ [
+ 0.0,
+ 1.93,
+ 0.0
+ ],
+ [
+ 0.0,
+ 3.09,
+ 0.0
+ ],
+ [
+ 0.0,
+ -1.93,
+ 0.0
+ ],
+ [
+ 0.0,
+ -3.09,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 1.93
+ ],
+ [
+ 0.0,
+ 0.0,
+ 3.09
+ ],
+ [
+ 0.0,
+ 0.0,
+ -1.93
+ ],
+ [
+ 0.0,
+ 0.0,
+ -3.09
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-hexacyanoferrateii",
+ "name": "hexacyanoferrate(II)",
+ "formula": "C6FeN6",
+ "category": "inorganic-complex",
+ "charge": -4,
+ "multiplicity": 1,
+ "source": "quantui-idealized",
+ "synonyms": "ferrocyanide;hexacyanoferrate(II);Fe(CN)6 4-",
+ "description": "[Fe(CN)6]4- \u2014 ferrocyanide, low-spin octahedral (d6, diamagnetic)",
+ "atoms": [
+ "Fe",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N",
+ "C",
+ "N"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.92,
+ 0.0,
+ 0.0
+ ],
+ [
+ 3.08,
+ 0.0,
+ 0.0
+ ],
+ [
+ -1.92,
+ 0.0,
+ 0.0
+ ],
+ [
+ -3.08,
+ 0.0,
+ 0.0
+ ],
+ [
+ 0.0,
+ 1.92,
+ 0.0
+ ],
+ [
+ 0.0,
+ 3.08,
+ 0.0
+ ],
+ [
+ 0.0,
+ -1.92,
+ 0.0
+ ],
+ [
+ 0.0,
+ -3.08,
+ 0.0
+ ],
+ [
+ 0.0,
+ 0.0,
+ 1.92
+ ],
+ [
+ 0.0,
+ 0.0,
+ 3.08
+ ],
+ [
+ 0.0,
+ 0.0,
+ -1.92
+ ],
+ [
+ 0.0,
+ 0.0,
+ -3.08
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-tetracarbonylnickel0",
+ "name": "tetracarbonylnickel(0)",
+ "formula": "C4NiO4",
+ "category": "inorganic-complex",
+ "charge": 0,
+ "multiplicity": 1,
+ "source": "quantui-idealized",
+ "synonyms": "tetracarbonylnickel;nickel tetracarbonyl;Ni(CO)4",
+ "description": "Ni(CO)4 \u2014 tetrahedral d10 carbonyl (18-electron, diamagnetic)",
+ "atoms": [
+ "Ni",
+ "C",
+ "O",
+ "C",
+ "O",
+ "C",
+ "O",
+ "C",
+ "O"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.050777,
+ 1.050777,
+ 1.050777
+ ],
+ [
+ 1.708957,
+ 1.708957,
+ 1.708957
+ ],
+ [
+ 1.050777,
+ -1.050777,
+ -1.050777
+ ],
+ [
+ 1.708957,
+ -1.708957,
+ -1.708957
+ ],
+ [
+ -1.050777,
+ 1.050777,
+ -1.050777
+ ],
+ [
+ -1.708957,
+ 1.708957,
+ -1.708957
+ ],
+ [
+ -1.050777,
+ -1.050777,
+ 1.050777
+ ],
+ [
+ -1.708957,
+ -1.708957,
+ 1.708957
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-tetraamminezincii",
+ "name": "tetraamminezinc(II)",
+ "formula": "H12N4Zn",
+ "category": "inorganic-complex",
+ "charge": 2,
+ "multiplicity": 1,
+ "source": "quantui-idealized",
+ "synonyms": "tetraamminezinc;zinc(II) tetraammine;Zn(NH3)4 2+",
+ "description": "[Zn(NH3)4]2+ \u2014 tetrahedral d10 ammine (diamagnetic)",
+ "atoms": [
+ "Zn",
+ "N",
+ "H",
+ "H",
+ "H",
+ "N",
+ "H",
+ "H",
+ "H",
+ "N",
+ "H",
+ "H",
+ "H",
+ "N",
+ "H",
+ "H",
+ "H"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.172021,
+ 1.172021,
+ 1.172021
+ ],
+ [
+ 2.153656,
+ 0.976071,
+ 0.976071
+ ],
+ [
+ 0.976071,
+ 2.153656,
+ 0.976071
+ ],
+ [
+ 0.976071,
+ 0.976071,
+ 2.153656
+ ],
+ [
+ 1.172021,
+ -1.172021,
+ -1.172021
+ ],
+ [
+ 2.153656,
+ -0.976071,
+ -0.976071
+ ],
+ [
+ 0.976071,
+ -2.153656,
+ -0.976071
+ ],
+ [
+ 0.976071,
+ -0.976071,
+ -2.153656
+ ],
+ [
+ -1.172021,
+ 1.172021,
+ -1.172021
+ ],
+ [
+ -0.583542,
+ 1.761127,
+ -1.761127
+ ],
+ [
+ -1.761127,
+ 0.583542,
+ -1.761127
+ ],
+ [
+ -1.761127,
+ 1.761127,
+ -0.583542
+ ],
+ [
+ -1.172021,
+ -1.172021,
+ 1.172021
+ ],
+ [
+ -0.583542,
+ -1.761127,
+ 1.761127
+ ],
+ [
+ -1.761127,
+ -0.583542,
+ 1.761127
+ ],
+ [
+ -1.761127,
+ -1.761127,
+ 0.583542
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-tetrachlorocobaltateii",
+ "name": "tetrachlorocobaltate(II)",
+ "formula": "Cl4Co",
+ "category": "inorganic-complex",
+ "charge": -2,
+ "multiplicity": 4,
+ "source": "quantui-idealized",
+ "synonyms": "tetrachlorocobaltate;CoCl4 2-",
+ "description": "[CoCl4]2- \u2014 tetrahedral high-spin cobalt(II) (d7, 3 unpaired), the classic blue ion",
+ "atoms": [
+ "Co",
+ "Cl",
+ "Cl",
+ "Cl",
+ "Cl"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 1.316359,
+ 1.316359,
+ 1.316359
+ ],
+ [
+ 1.316359,
+ -1.316359,
+ -1.316359
+ ],
+ [
+ -1.316359,
+ 1.316359,
+ -1.316359
+ ],
+ [
+ -1.316359,
+ -1.316359,
+ 1.316359
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-permanganate",
+ "name": "permanganate",
+ "formula": "MnO4",
+ "category": "inorganic-complex",
+ "charge": -1,
+ "multiplicity": 1,
+ "source": "quantui-idealized",
+ "synonyms": "permanganate;MnO4;tetraoxomanganate",
+ "description": "[MnO4]- \u2014 tetrahedral d0 manganese(VII) oxoanion (deep purple)",
+ "atoms": [
+ "Mn",
+ "O",
+ "O",
+ "O",
+ "O"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 0.941081,
+ 0.941081,
+ 0.941081
+ ],
+ [
+ 0.941081,
+ -0.941081,
+ -0.941081
+ ],
+ [
+ -0.941081,
+ 0.941081,
+ -0.941081
+ ],
+ [
+ -0.941081,
+ -0.941081,
+ 0.941081
+ ]
+ ]
+ },
+ {
+ "id": "inorganic-tetrachloroplatinateii",
+ "name": "tetrachloroplatinate(II)",
+ "formula": "Cl4Pt",
+ "category": "inorganic-complex",
+ "charge": -2,
+ "multiplicity": 1,
+ "source": "quantui-idealized",
+ "synonyms": "tetrachloroplatinate;PtCl4 2-",
+ "description": "[PtCl4]2- \u2014 square-planar platinum(II) (d8, diamagnetic), the cisplatin precursor",
+ "atoms": [
+ "Pt",
+ "Cl",
+ "Cl",
+ "Cl",
+ "Cl"
+ ],
+ "coordinates": [
+ [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ [
+ 2.31,
+ 0.0,
+ 0.0
+ ],
+ [
+ -2.31,
+ 0.0,
+ 0.0
+ ],
+ [
+ 0.0,
+ 2.31,
+ 0.0
+ ],
+ [
+ 0.0,
+ -2.31,
+ 0.0
+ ]
+ ]
+ }
+]
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/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/help_content.py b/quantui/help_content.py
index ce55c53..8ecc5ce 100644
--- a/quantui/help_content.py
+++ b/quantui/help_content.py
@@ -167,6 +167,19 @@
"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 (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), "
+ "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/quantui/inorganic_guards.py b/quantui/inorganic_guards.py
new file mode 100644
index 0000000..eef0132
--- /dev/null
+++ b/quantui/inorganic_guards.py
@@ -0,0 +1,146 @@
+"""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 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)
+ 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/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/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(){
diff --git a/quantui/preopt.py b/quantui/preopt.py
index fda5875..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,6 +99,184 @@ def _copy_molecule(molecule: Molecule) -> Molecule:
)
+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**. 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"
+ 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
+ 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"
+
+
+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
@@ -272,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
@@ -324,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/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/spin_presets.py b/quantui/spin_presets.py
new file mode 100644
index 0000000..23a33dc
--- /dev/null
+++ b/quantui/spin_presets.py
@@ -0,0 +1,276 @@
+"""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/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 (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`` for a metal /
+oxidation state / geometry 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")
+
+# 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:
+ """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
+ caveats: List[str] # explicit flags the UI must surface (never applied silently)
+
+ @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 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)]
+ 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}")
+
+
+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, 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}. 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 = (
+ " 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 _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, 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}")
+ 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),
+ caveats=_caveats_for(element, oxidation_state, d, geometry),
+ )
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/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py
index d6297d9..51d5dcb 100644
--- a/quantui/visualization_py3dmol.py
+++ b/quantui/visualization_py3dmol.py
@@ -181,25 +181,68 @@ 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",
+ "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)
@@ -262,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)
@@ -360,15 +417,40 @@ 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"
+ # **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),
+ width=width,
+ height=height,
+ bgcolor=bgcolor,
+ lighting=lighting,
+ )
else:
raise ValueError(f"Unknown backend: {backend}")
diff --git a/scripts/build_inorganic_examples.py b/scripts/build_inorganic_examples.py
new file mode 100644
index 0000000..f06a895
--- /dev/null
+++ b/scripts/build_inorganic_examples.py
@@ -0,0 +1,471 @@
+#!/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 _aqua_hydrogens(
+ o_pos: np.ndarray, metal_pos: np.ndarray, oh: float = 0.96, hoh_deg: float = 104.5
+) -> 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"]
+ 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",
+ ),
+ # ── 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).
+
+ 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)
+ # 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} Å")
+ # 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 is_metal(sym) and i not in bonded:
+ problems.append(f"metal {sym}{i} has no coordination bonds")
+ 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 _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(
+ "--rebuild",
+ 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
+
+ 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_basis_fix_button.py b/tests/test_basis_fix_button.py
new file mode 100644
index 0000000..3e3cdd1
--- /dev/null
+++ b/tests/test_basis_fix_button.py
@@ -0,0 +1,107 @@
+"""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.
+
+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
+
+import pytest
+
+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):
+ 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"
+
+ @pyscf_only
+ 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"
diff --git a/tests/test_bulk_library.py b/tests/test_bulk_library.py
index 6283ae9..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() >= 176 + 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) == 176 # presets + curated only
+ 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_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
diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py
new file mode 100644
index 0000000..30a6d17
--- /dev/null
+++ b/tests/test_connectivity.py
@@ -0,0 +1,149 @@
+"""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_bonds,
+ covalent_components,
+ describe_disconnection,
+ is_disconnected,
+ is_metal,
+ metal_coordination_bonds,
+)
+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
+
+
+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"))
+ 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
diff --git a/tests/test_inorganic_examples.py b/tests/test_inorganic_examples.py
new file mode 100644
index 0000000..b407e5a
--- /dev/null
+++ b/tests/test_inorganic_examples.py
@@ -0,0 +1,78 @@
+"""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
+
+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},
+ "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},
+}
+
+_MIN_INORGANIC_EXAMPLES = 14
+
+
+def _inorganic_entries():
+ return [e for e in ml.iter_entries() if e["category"] == "inorganic-complex"]
+
+
+def test_expected_examples_present_with_right_metadata():
+ for eid, exp in _EXPECTED.items():
+ e = ml.get(eid)
+ 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_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 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, e["id"]
diff --git a/tests/test_inorganic_guards.py b/tests/test_inorganic_guards.py
new file mode 100644
index 0000000..d8cf9ba
--- /dev/null
+++ b/tests/test_inorganic_guards.py
@@ -0,0 +1,182 @@
+"""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,
+ 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
+
+ _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_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
+
+
+@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):
+ # 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) == []
diff --git a/tests/test_library_governance.py b/tests/test_library_governance.py
index 92d5720..5bcd5c0 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 from the bundled
+# inorganic examples, MET.9). Guards against codec corruption.
_KNOWN = {
"H",
"He",
@@ -35,8 +36,16 @@
"Ar",
"K",
"Ca",
+ "Ti",
+ "Cr",
+ "Mn",
+ "Fe",
+ "Co",
+ "Ni",
+ "Zn",
"Br",
"I",
+ "Pt",
}
diff --git a/tests/test_metal_viewer_fallback.py b/tests/test_metal_viewer_fallback.py
new file mode 100644
index 0000000..13b9c91
--- /dev/null
+++ b/tests/test_metal_viewer_fallback.py
@@ -0,0 +1,99 @@
+"""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")
+
+
+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
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 20e16bf..944fbf9 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 ────────────────────────────────────
@@ -120,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
@@ -337,6 +352,62 @@ 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_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 preopt_mod._RDKIT_AVAILABLE:
+ pytest.skip("rdkit not installed")
+ 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
+
+ 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]])
+
+ 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
+ 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."""
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)
diff --git a/tests/test_spin_presets.py b/tests/test_spin_presets.py
new file mode 100644
index 0000000..ed12165
--- /dev/null
+++ b/tests/test_spin_presets.py
@@ -0,0 +1,166 @@
+"""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]
+
+ 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(
+ "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