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