Source code for crystod.mo_diagram_pyscf

"""Quantitative molecular-orbital diagrams via PySCF
(crystod-mol --diagram --pyscf).

The symmetry-only diagram of :mod:`crystod.mo_diagram` sketches the MO
diagram from SALCs + STO overlaps; this module makes it quantitative with
three self-consistent-field calculations sharing one AO space:

- the full molecule,
- the "left" fragment  (the other atoms replaced by ghost atoms),
- the "right" fragment (idem),

all at the same geometry and basis, so the fragment levels are the
pre-bonding energy states (counterpoise-consistent: every calculation uses
the full molecular basis) and the molecular MOs can be projected exactly
onto the fragment MOs for the correlation lines and composition panel.

By default the fragments are the ligand cage (left) and the central atom
(right), as in the symmetry-only mode; ``--ao-left``/``--ao-right`` select
any partition by chemical formula (CH3OH: ``--ao-left H4 --ao-right CO``;
O2: ``--ao-left O --ao-right O``).

Irrep labels use crystod's own point-group machinery (characters of the MO
under the exact character-table operations, evaluated on the AO
representation), so they agree with the symmetry-only diagram and with
crystod-group; linear molecules fall back to PySCF's Dooh/Coov labels
rendered as sigma/pi/delta.

The SCF engine is PySCF; if you use it in your research, please cite
  Q. Sun et al., J. Chem. Phys. 153, 024109 (2020);
  Q. Sun et al., WIREs Comput. Mol. Sci. 8, e1340 (2018);
  Q. Sun, J. Comput. Chem. 36, 1664 (2015).
(These are software papers: the fragment/ghost-basis construction and the
irrep bookkeeping above are CrystOD's, not theirs.)
"""

from __future__ import annotations

import os
import re
from dataclasses import dataclass, field

import numpy as np

from .decompose_irrep import get_character_table
from .mo_diagram import (
    CORE_SHELLS,
    _sketch_entries,
    canonical_sketch_partners,
    diagram_geometry,
    element_color,
    lowercase_irrep,
    render_diagram_page,
    svg_sub_digits,
)
from .molecular_salc import (
    _hm_symbol,
    _match_operations,
    _table_operations_cartesian,
    get_symmetry,
    load_molecule,
)

HARTREE_TO_EV = 27.211386245988

CORE_ELECTRON_COUNT = {
    element: sum(2 if shell.endswith("s") else 6 for shell in shells)
    for element, shells in CORE_SHELLS.items()
}

_GREEK = {"A": "σ", "E1": "π", "E2": "δ", "E3": "φ"}

# Fragment levels whose Mulliken population sits mostly on the GHOST basis
# functions (counterpoise/BSSE artifact levels, not states of the fragment)
# are dropped from the diagram and the compositions below this threshold.
GHOST_FRACTION_THRESHOLD = 0.35


def _import_pyscf():
    """Import PySCF, or raise ``ImportError`` naming the ``[quantum]`` extra.

    PySCF is an optional dependency (``pip install "CrystOD[quantum]"``);
    ``crystod-mol`` reports the same condition as a one-line ``ERROR:``
    before dispatching here, and the library API lets the ``ImportError``
    propagate.
    """
    from ._optional import require_pyscf

    require_pyscf("crystod-mol --diagram --pyscf (PyscfDiagram)")
    from pyscf import dft, gto, scf  # noqa: F401
    import pyscf

    return pyscf


def parse_fragment_spec(spec: str) -> dict[str, int]:
    """'H4' -> {'H': 4}; 'CO' -> {'C': 1, 'O': 1}; 'CH3' -> {'C': 1, 'H': 3}."""
    counts: dict[str, int] = {}
    position = 0
    for match in re.finditer(r"([A-Z][a-z]?)(\d*)", spec):
        if match.start() != position or not match.group(1):
            break
        counts[match.group(1)] = counts.get(match.group(1), 0) + int(match.group(2) or 1)
        position = match.end()
    if position != len(spec) or not counts:
        raise SystemExit(
            f"ERROR: could not parse the fragment formula '{spec}' "
            "(expected element symbols with optional counts, e.g. H4, CO, CH3)."
        )
    return counts


# ------------------------------------------------------- AO representation


def _euler_zyz(rotation: np.ndarray) -> tuple[float, float, float]:
    """z-y-z Euler angles of a proper rotation (active convention,
    R = Rz(alpha) Ry(beta) Rz(gamma))."""
    beta = float(np.arccos(np.clip(rotation[2, 2], -1.0, 1.0)))
    if rotation[2, 2] > 1.0 - 1e-10:
        return float(np.arctan2(rotation[1, 0], rotation[0, 0])), 0.0, 0.0
    if rotation[2, 2] < -1.0 + 1e-10:
        return float(np.arctan2(-rotation[1, 0], -rotation[0, 0])), np.pi, 0.0
    alpha = float(np.arctan2(rotation[1, 2], rotation[0, 2]))
    gamma = float(np.arctan2(rotation[2, 1], -rotation[2, 0]))
    return alpha, beta, gamma


def _real_sph_rotation(l: int, rotation: np.ndarray) -> np.ndarray:
    """Rotation matrix on PySCF's real spherical harmonics of momentum l.

    Improper operations pick up the parity (-1)^l. The convention (Euler
    extraction + pyscf Dmatrix orientation) is validated on every call
    against the l = 1 case, where the p_x/p_y/p_z representation must equal
    the rotation matrix itself.
    """
    from pyscf.symm import Dmatrix

    det = 1.0 if np.linalg.det(rotation) > 0 else -1.0
    proper = det * rotation
    alpha, beta, gamma = _euler_zyz(proper)
    D1 = Dmatrix.Dmatrix(1, alpha, beta, gamma, reorder_p=True)
    transpose = np.abs(D1.T - proper).max() < np.abs(D1 - proper).max()
    if min(np.abs(D1 - proper).max(), np.abs(D1.T - proper).max()) > 1e-8:
        raise SystemExit("ERROR: pyscf Dmatrix convention check failed.")
    D = Dmatrix.Dmatrix(l, alpha, beta, gamma, reorder_p=True)
    if transpose:
        D = D.T
    return D * (det ** l)


def _atom_permutation(coordinates, tags, rotation, tolerance=1e-3):
    """perm[j] = i when the operation maps center j onto center i (same tag),
    or None when the operation does not preserve the tagged centers."""
    mapped = coordinates @ np.asarray(rotation).T
    permutation = []
    for j, position in enumerate(mapped):
        distances = np.linalg.norm(coordinates - position, axis=1)
        i = int(np.argmin(distances))
        if distances[i] > tolerance or tags[i] != tags[j] or i in permutation:
            return None
        permutation.append(i)
    return permutation


def _ao_representation(mol, rotation, permutation) -> np.ndarray:
    """Matrix of the symmetry operation on the (spherical) AO basis."""
    n_ao = mol.nao_nr()
    ao_loc = mol.ao_loc_nr()
    gamma = np.zeros((n_ao, n_ao))
    blocks: dict[int, np.ndarray] = {}
    for shell in range(mol.nbas):
        l = mol.bas_angular(shell)
        if l not in blocks:
            blocks[l] = _real_sph_rotation(l, rotation)
        source_atom = mol.bas_atom(shell)
        target_atom = permutation[source_atom]
        # the matching shell on the target atom: same position in the
        # per-atom shell list (all atoms of one tag share the basis layout)
        source_shells = [s for s in range(mol.nbas) if mol.bas_atom(s) == source_atom]
        target_shells = [s for s in range(mol.nbas) if mol.bas_atom(s) == target_atom]
        target_shell = target_shells[source_shells.index(shell)]
        p0, p1 = ao_loc[shell], ao_loc[shell + 1]
        q0 = ao_loc[target_shell]
        n_contracted = mol.bas_nctr(shell)
        width = 2 * l + 1
        for c in range(n_contracted):
            gamma[q0 + c * width:q0 + (c + 1) * width,
                  p0 + c * width:p0 + (c + 1) * width] = blocks[l]
    return gamma


# ------------------------------------------------------------------ levels


@dataclass
class PyscfLevel:
    column: str
    energy: float               # eV
    degeneracy: int
    label: str
    electrons: int
    irrep: str | None
    level_id: str
    orbital_indices: list[int]
    composition: list = field(default_factory=list)
    detail: str = ""
    real_fraction: float = 1.0  # Mulliken population share on the real atoms


[docs] class PyscfDiagram: """Quantitative MO diagram from three PySCF SCF calculations in one AO space. The engine behind ``crystod-mol --diagram --pyscf``. Constructing the object runs the analysis: the point group is detected and the molecule aligned to the standard frame (a linear molecule is put along z); the atoms are split into a left and a right fragment, by default the ligand cage and the central atom, or by the two formulas; three SCF calculations are run at the same geometry in the same basis, for the molecule and for each fragment with the removed atoms kept as ghost atoms (counterpoise-consistent), the fragments with fractional occupation of degenerate frontier shells so that they keep the point-group symmetry; the molecular MOs are projected exactly onto the fragment MOs for the correlation lines and compositions; the levels are labeled by the characters of the MOs under the character-table operations (the same labels as ``MODiagram`` and ``crystod-group``; sigma/pi/delta from PySCF's Dooh/Coov symmetry for linear molecules) and given a COOP bonding character. Fragment levels that live mostly on the ghost basis (BSSE artifacts) are dropped from the diagram. PySCF is an optional dependency (``pip install "CrystOD[quantum]"``); constructing the object without it raises ``ImportError``. Args: xyz_path (str): Path of the molecule file in XYZ format. tolerance (float): Distance tolerance in Angstrom for the symmetry detection (``--tolerance``). center_element (str): Element of the central atom (``--center``) for the default ligand-cage and central-atom split; by default the atom closest to the molecular center. left_spec (str): Formula of the left fragment (``--ao-left``), e.g. ``"H4"``; given together with ``right_spec`` it replaces the default split. right_spec (str): Formula of the right fragment (``--ao-right``), e.g. ``"CO"``. basis (str): PySCF basis set (``--basis``), e.g. ``"def2-svp"`` or ``"sto-3g"``. theory (str): ``"scf"`` for Hartree-Fock (RHF/ROHF) or ``"dft"`` for Kohn-Sham DFT (RKS/ROKS) (``--theory``). xc (str): Exchange-correlation functional for ``theory="dft"`` (``--xc``). charge (int): Total charge of the molecule (``--charge``). spin (int): Molecular spin 2S (``--spin``); by default 0 or 1 by electron parity (``spin=2`` for triplet O2). Attributes: xyz_path: The molecule file as given. formula: Conventional formula (central atom first, or the Hill formula for an explicit split). schoenflies: Schoenflies symbol of the point group. hm: Its Hermann-Mauguin symbol, or ``None`` for a non-crystallographic group. linear: Whether the molecule is linear. character_table: Character table of the point group, or ``None``. operations: Rotation matrices of the group in the standard frame (empty when ``hm`` is ``None``). operation_classes: Class label of every entry of ``operations``. symbols: Element symbol of every atom. coordinates: ``(n_atoms, 3)`` Cartesian coordinates in Angstrom. center: Site index of the central atom, or ``None`` for an explicit split. left: Site indices of the left fragment. right: Site indices of the right fragment. left_name: Formula of the left fragment (``"H3"``). right_name: Formula of the right fragment (``"N"``). basis: The PySCF basis set; ``theory``, ``xc``, ``charge`` and ``spin`` hold the other settings as used. n_electrons: Number of electrons of the molecule (all electrons). calculations: One dict per column (``"left"``, ``"mo"``, ``"right"``) with the PySCF ``mol`` and mean-field object ``mf``, ``spin``, ``charge``, ``converged``, the total ``energy`` in Hartree, ``mo_energy`` in eV, ``mo_occ``, ``mo_coeff`` and the ``real_sites`` (the non-ghost atoms). levels: ``PyscfLevel`` lists per column, energy ascending. Each level has ``energy`` (eV), ``degeneracy``, ``irrep``, ``label``, ``electrons``, ``orbital_indices`` (columns of ``mo_coeff``), ``composition`` (pairs of a level id and its weight; for the molecular column the projection onto the fragment levels) and ``real_fraction`` (Mulliken population on the real atoms); the molecular levels also carry ``bond_character`` and ``overlap_population``, the fragment levels ``dominant_spec``. homo: Highest occupied molecular level (``None`` if none). lumo: Lowest unoccupied molecular level (``None`` if none). Raises: ImportError: PySCF is not installed. SystemExit: The file is missing, the fragment formulas cannot be parsed or do not partition the molecule, no unique central atom can be identified, or ``spin`` is inconsistent with the electron count. Example: Ammonia in a minimal basis (three Hartree-Fock calculations, a few seconds):: from crystod import mol from crystod.examples import example_path diagram = mol.PyscfDiagram(example_path("XYZ_NH3.xyz"), basis="sto-3g") diagram.print_report() print(diagram.left_name, diagram.right_name) # H3 N print(diagram.homo.label, diagram.lumo.label) # 3a1 4a1 diagram.write_html("MolOD_NH3_pyscf.html") """ def __init__(self, xyz_path, tolerance=0.3, center_element=None, left_spec=None, right_spec=None, basis="def2-svp", theory="scf", xc="b3lyp", charge=0, spin=None): _import_pyscf() self.xyz_path = xyz_path self.basis = basis self.theory = theory self.xc = xc self.charge = charge molecule = load_molecule(xyz_path) self.symbols = [site.specie.symbol for site in molecule] self.coordinates = np.array([site.coords for site in molecule]) # ---- symmetry: align to the standard point-group frame when possible schoenflies, operations = get_symmetry(molecule, tolerance) self.schoenflies = schoenflies self.hm = _hm_symbol(schoenflies) self.linear = "*" in schoenflies self.character_table = None self.operations = [] self.operation_classes = [] if self.hm is not None: self.character_table = get_character_table(self.hm) table_ops, table_classes = _table_operations_cartesian(self.character_table) alignment, matched = _match_operations(operations, table_ops, table_classes) self.operations = [table_ops[i] for i in matched] self.operation_classes = [table_classes[i] for i in matched] self.coordinates = self.coordinates @ alignment.T self._symmetrize_coordinates(tolerance) elif self.linear: # molecular axis along z, so pyscf's symmetry frame matches ours centered = self.coordinates - self.coordinates.mean(axis=0) _, _, Vt = np.linalg.svd(centered) axis = Vt[0] / np.linalg.norm(Vt[0]) z = np.array([0.0, 0.0, 1.0]) v = np.cross(axis, z) if np.linalg.norm(v) < 1e-12: rotation = np.eye(3) if axis[2] > 0 else np.diag([1.0, -1.0, -1.0]) else: v = v / np.linalg.norm(v) angle = np.arccos(np.clip(axis @ z, -1.0, 1.0)) K = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) rotation = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K self.coordinates = centered @ rotation.T # ---- fragments self._identify_fragments(center_element, left_spec, right_spec) self.formula = self._conventional_formula() # ---- the three SCF calculations self.n_electrons = ( sum(_atomic_number(s) for s in self.symbols) - self.charge ) if spin is None: spin = self.n_electrons % 2 if (self.n_electrons - spin) % 2: raise SystemExit( f"ERROR: spin {spin} (2S) is inconsistent with " f"{self.n_electrons} electrons." ) self.spin = spin self.calculations = { "mo": self._run_scf(sorted(self.left + self.right), self.charge, self.spin), "left": self._run_scf(self.left, 0, self._fragment_spin(self.left), fractional=True), "right": self._run_scf(self.right, 0, self._fragment_spin(self.right), fractional=True), } self._build_levels() self._link_levels() # COOP bonding character of every molecular MO (needs the occupations # and tooltips, which _build_levels/_link_levels fill) self._assign_bond_characters() # ---------------------------------------------------------------- setup def _symmetrize_coordinates(self, tolerance): from .molecular_salc import get_permutation_matrices permutations = get_permutation_matrices( self.operations, self.coordinates, tolerance ) symmetrized = np.zeros_like(self.coordinates) for rotation, permutation in zip(self.operations, permutations): symmetrized += (permutation.T @ self.coordinates) @ rotation self.coordinates = symmetrized / len(self.operations) def _identify_fragments(self, center_element, left_spec, right_spec): if (left_spec is None) != (right_spec is None): raise SystemExit("ERROR: give both --ao-left and --ao-right (or neither).") if left_spec is not None: left_counts = parse_fragment_spec(left_spec) right_counts = parse_fragment_spec(right_spec) totals: dict[str, int] = {} for symbol in self.symbols: totals[symbol] = totals.get(symbol, 0) + 1 combined = { element: left_counts.get(element, 0) + right_counts.get(element, 0) for element in set(left_counts) | set(right_counts) } if combined != totals: molecule_formula = " ".join(f"{k}{v}" for k, v in sorted(totals.items())) raise SystemExit( f"ERROR: --ao-left {left_spec} + --ao-right {right_spec} does " f"not partition the molecule ({molecule_formula})." ) remaining = dict(left_counts) self.left, self.right = [], [] for i, symbol in enumerate(self.symbols): if remaining.get(symbol, 0) > 0: self.left.append(i) remaining[symbol] -= 1 else: self.right.append(i) self.left_name = _format_formula(left_counts) self.right_name = _format_formula(right_counts) self.center = None return # default: ligand cage (left) | central atom (right) distances = np.linalg.norm( self.coordinates - self.coordinates.mean(axis=0), axis=1 ) if center_element is not None: candidates = [i for i, s in enumerate(self.symbols) if s == center_element] if len(candidates) != 1: raise SystemExit( f"ERROR: --center {center_element} must select exactly one " f"atom (found {len(candidates)})." ) self.center = candidates[0] else: self.center = int(np.argmin(distances)) others = np.delete(distances, self.center) if len(others) and distances[self.center] > 0.5 * others.min(): raise SystemExit( "ERROR: could not identify a unique central atom; use " "--center EL, or partition explicitly with " "--ao-left/--ao-right." ) self.left = [i for i in range(len(self.symbols)) if i != self.center] self.right = [self.center] left_counts: dict[str, int] = {} for i in self.left: left_counts[self.symbols[i]] = left_counts.get(self.symbols[i], 0) + 1 self.left_name = _format_formula(left_counts) self.right_name = self.symbols[self.center] def _conventional_formula(self): counts: dict[str, int] = {} for symbol in self.symbols: counts[symbol] = counts.get(symbol, 0) + 1 if self.center is not None: ligand = {k: v for k, v in counts.items()} ligand[self.symbols[self.center]] -= 1 if ligand[self.symbols[self.center]] == 0: del ligand[self.symbols[self.center]] formula = self.symbols[self.center] + _format_formula(ligand) return {"OH2": "H2O", "SH2": "H2S", "SeH2": "H2Se"}.get(formula, formula) return _format_formula(counts, hill=True) def _fragment_spin(self, sites): # fragments are spin-averaged pre-bonding references: lowest spin # consistent with the electron count, plus fractional occupation of # the degenerate frontier shell (see _run_scf) electrons = sum(_atomic_number(self.symbols[i]) for i in sites) return electrons % 2 def _run_scf(self, real_sites, charge, spin, fractional=False): """One SCF in the full molecular basis (removed atoms as ghosts). Fragments use fractional occupations of degenerate frontier levels (``scf.addons.frac_occ``): a partially filled degenerate shell (C 2p, the t2 shell of an H4 cage, ...) would otherwise break the point-group symmetry, whereas the pre-bonding reference states should keep it. """ from pyscf import dft, gto, scf real = set(real_sites) atom = [] for i, (symbol, position) in enumerate(zip(self.symbols, self.coordinates)): name = symbol if i in real else f"ghost-{symbol}" atom.append(f"{name} {position[0]:.10f} {position[1]:.10f} {position[2]:.10f}") try: mol = gto.M( atom="; ".join(atom), basis=self.basis, charge=charge, spin=spin, unit="Angstrom", verbose=0, symmetry=self.linear, ) except Exception: mol = gto.M( atom="; ".join(atom), basis=self.basis, charge=charge, spin=spin, unit="Angstrom", verbose=0, ) def make_mf(): if self.theory == "dft": base = dft.RKS(mol) if spin == 0 else dft.ROKS(mol) base.xc = self.xc else: base = scf.RHF(mol) if spin == 0 else scf.ROHF(mol) base.verbose = 0 base.max_cycle = 200 return base mf = make_mf() if fractional: mf = scf.addons.frac_occ(mf) mf.kernel() if fractional and not mf.converged: # the default degeneracy detection of frac_occ can miss part of # the frontier shell (symmetry-broken occupations never become # stationary); retry with a wide degeneracy window mf = scf.addons.frac_occ(make_mf(), tol=0.1) mf.kernel() return { "mol": mol, "mf": mf, "spin": spin, "charge": charge, "converged": bool(mf.converged), "energy": float(mf.e_tot), "mo_energy": np.asarray(mf.mo_energy, dtype=float) * HARTREE_TO_EV, "mo_occ": np.asarray(mf.mo_occ, dtype=float), "mo_coeff": np.asarray(mf.mo_coeff, dtype=float), "real_sites": sorted(real), } # ------------------------------------------------------------ labeling def _valid_operations(self, calc): """Table operations that map real atoms onto real atoms (and ghosts onto ghosts) of the same element, with the atom permutations.""" real = set(calc["real_sites"]) tags = [ (symbol, i in real) for i, symbol in enumerate(self.symbols) ] valid = [] for rotation, class_name in zip(self.operations, self.operation_classes): permutation = _atom_permutation(self.coordinates, tags, rotation) if permutation is not None: valid.append((rotation, class_name, permutation)) return valid def _irrep_labels(self, calc, groups): """Irrep label per degenerate group (list parallel to groups), or None when no labeling is possible.""" if self.character_table is not None: valid = self._valid_operations(calc) if len(valid) == len(self.operations): return self._labels_from_characters(calc, groups, valid) return None if self.linear: return self._labels_from_pyscf_symmetry(calc, groups) return None def _labels_from_characters(self, calc, groups, valid): mol = calc["mol"] S = mol.intor("int1e_ovlp") C = calc["mo_coeff"] rotation_list = list(self.character_table["rotation_list"]) table = self.character_table["character_table"] # one representative per class is enough (characters are class functions) representatives = {} for rotation, class_name, permutation in valid: representatives.setdefault(class_name, (rotation, permutation)) gammas = { class_name: _ao_representation(mol, rotation, permutation) for class_name, (rotation, permutation) in representatives.items() } labels = [] for group in groups: vectors = C[:, group] characters = [] for class_name in rotation_list: gamma = gammas[class_name] characters.append( float(np.trace(vectors.T @ S @ gamma @ vectors)) ) best, best_error = None, 1e9 for irrep, chars in table.items(): row = [float(np.real(c)) for c in np.atleast_1d(chars)] error = max(abs(a - b) for a, b in zip(characters, row)) if error < best_error: best, best_error = irrep, error labels.append(best if best_error < 0.05 else None) return labels def _labels_from_pyscf_symmetry(self, calc, groups): """sigma/pi/delta labels for linear molecules via PySCF's Dooh/Coov. The geometry was pre-aligned to the z axis, so the symmetric mol of the calculation itself carries the symmetry-adapted basis and no reorientation of the MO coefficients is needed.""" from pyscf import symm mol = calc["mol"] if getattr(mol, "symm_orb", None) is None: return None try: names = symm.label_orb_symm( mol, mol.irrep_name, mol.symm_orb, calc["mo_coeff"], check=False ) except Exception: return None labels = [] for group in groups: raw = str(names[group[0]]) match = re.match(r"([AE])(\d?)([gu]?)", raw) if not match: labels.append(None) continue letter = match.group(1) + (match.group(2) if match.group(1) == "E" else "") greek = _GREEK.get("A" if letter.startswith("A") else letter) labels.append((greek or raw) + match.group(3)) return labels # ------------------------------------------------------------- levels def _build_levels(self): self.levels: dict[str, list[PyscfLevel]] = {} for column in ("left", "mo", "right"): calc = self.calculations[column] energies = calc["mo_energy"] occupations = calc["mo_occ"] groups = _group_degenerate(energies, tol=2e-3) labels = self._irrep_labels(calc, groups) if labels is not None: # an accidental near-degeneracy can merge different irreps # into one unlabeled group: re-split those finely and relabel refined = [] for group, label in zip(groups, labels): if label is None and len(group) > 1: subgroups = _group_degenerate(energies[group], tol=1e-6) refined.extend([[group[i] for i in sub] for sub in subgroups]) else: refined.append(group) if len(refined) != len(groups): groups = sorted(refined, key=lambda g: float(energies[g[0]])) labels = self._irrep_labels(calc, groups) counters: dict[str, int] = {} mol = calc["mol"] S_ao = mol.intor("int1e_ovlp") C_ao = calc["mo_coeff"] real = set(calc["real_sites"]) ao_atom = np.zeros(mol.nao_nr(), dtype=int) ao_loc = mol.ao_loc_nr() for b in range(mol.nbas): ao_atom[ao_loc[b]:ao_loc[b + 1]] = mol.bas_atom(b) real_mask = np.array([atom in real for atom in ao_atom]) column_levels = [] for g_index, group in enumerate(groups): energy = float(np.mean(energies[group])) electrons = int(round(float(np.sum(occupations[group])))) irrep = labels[g_index] if labels else None if irrep is not None: counters[irrep] = counters.get(irrep, 0) + 1 label = f"{counters[irrep]}{lowercase_irrep(irrep)}" else: label = f"{g_index + 1}" fraction = 1.0 if column != "mo": populations = [ float(np.sum((C_ao[:, k] * (S_ao @ C_ao[:, k]))[real_mask])) for k in group ] fraction = min(1.0, max(0.0, float(np.mean(populations)))) level = PyscfLevel( column=column, energy=energy, degeneracy=len(group), label=label, electrons=electrons, irrep=irrep, level_id=f"{column}_{g_index}", orbital_indices=list(group), real_fraction=fraction, ) column_levels.append(level) self.levels[column] = column_levels occupied = [l for l in self.levels["mo"] if l.electrons > 0] empty = [l for l in self.levels["mo"] if l.electrons == 0] self.homo = occupied[-1] if occupied else None self.lumo = empty[0] if empty else None def _link_levels(self): """Project the molecule MOs onto the fragment MOs (same AO space).""" S = self.calculations["mo"]["mol"].intor("int1e_ovlp") C_mo = self.calculations["mo"]["mo_coeff"] projections = {} for column in ("left", "right"): projections[column] = self.calculations[column]["mo_coeff"].T @ S @ C_mo for level in self.levels["mo"]: weights = [] for column in ("left", "right"): P = projections[column] for fragment_level in self.levels[column]: if fragment_level.real_fraction < GHOST_FRACTION_THRESHOLD: continue # counterpoise/BSSE artifact level weight = float(np.sum( P[np.ix_(fragment_level.orbital_indices, level.orbital_indices)] ** 2 )) * fragment_level.real_fraction if weight > 1e-6: weights.append((fragment_level, weight)) total = sum(w for _, w in weights) or 1.0 level.composition = [ (fragment_level.level_id, weight / total) for fragment_level, weight in weights ] # tooltips names = { level.level_id: f"{self._display_name(level.column)} {level.label}" for column_levels in self.levels.values() for level in column_levels } for column_levels in self.levels.values(): for level in column_levels: parts = ", ".join( f"{100 * w:.0f}% {names[i]}" for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.02 ) level.detail = ( f"{level.label}: E = {level.energy:.2f} eV, " f"{level.electrons} e-" + (f" | {parts}" if parts else "") ) def _assign_bond_characters(self): """COOP bonding character of every molecular MO between the left and right fragment AO blocks, via the shared crystal-engine classifier (crystal_orbital_diagram.assign_bond_characters). The classifier reads fragment labels as "element shell irrep" for its semicore detection (e.g. the N 1s core of NH3) and expects (n_ao x degeneracy) vector matrices, so lightweight proxy levels adapt the PySCF data: each fragment level is tagged with its dominant (element, contracted-shell) block by Loewdin population -- the tag is also kept as level.dominant_spec for the VESTA line colors of the fragment columns. The physics is not duplicated. """ from types import SimpleNamespace from .crystal_orbital_diagram import assign_bond_characters mol = self.calculations["mo"]["mol"] S = mol.intor("int1e_ovlp") values, vectors = np.linalg.eigh(S) sqrt_overlap = ( vectors * np.sqrt(np.clip(values, 0.0, None)) ) @ vectors.T ao_atom = np.zeros(mol.nao_nr(), dtype=int) ao_loc = mol.ao_loc_nr() for b in range(mol.nbas): ao_atom[ao_loc[b]:ao_loc[b + 1]] = mol.bas_atom(b) rows = { "left": np.where(np.isin(ao_atom, self.left))[0], "right": np.where(np.isin(ao_atom, self.right))[0], } # per-(element, contracted-shell) AO blocks: the AO labels carry the # shell names, e.g. "0 N 1s" / "0 N 2px" / "1 H 1s" -> "1s", "2p" spec_lists: dict[tuple[str, str], list[int]] = {} for index, label in enumerate(mol.ao_labels()): parts = label.split() name = parts[2] stripped = name.lstrip("0123456789") shell = name[: len(name) - len(stripped)] + stripped[0] spec_lists.setdefault((parts[1], shell), []).append(index) spec_ranges = { key: np.array(indices, dtype=int) for key, indices in spec_lists.items() } proxy_levels: dict[str, list] = {} for column in ("left", "right"): row_set = set(rows[column].tolist()) side_ranges = {} for key, indices in spec_ranges.items(): side = np.array([i for i in indices if i in row_set], dtype=int) if side.size: side_ranges[key] = side C = self.calculations[column]["mo_coeff"] proxies = [] for level in self.levels[column]: if level.real_fraction < GHOST_FRACTION_THRESHOLD: continue # counterpoise/BSSE artifact level space = C[:, level.orbital_indices] gross = (np.abs(sqrt_overlap @ space) ** 2).sum(axis=1) dominant = max( side_ranges, key=lambda key: float(gross[side_ranges[key]].sum()), ) level.dominant_spec = dominant proxies.append(SimpleNamespace( label=f"{dominant[0]} {dominant[1]} {level.label}", energy=level.energy, electrons=level.electrons, )) proxy_levels[column] = proxies C_mo = self.calculations["mo"]["mo_coeff"] proxy_levels["mo"] = [ SimpleNamespace( vectors=C_mo[:, level.orbital_indices], # (n_ao, degeneracy) degeneracy=level.degeneracy, electrons=level.electrons, energy=level.energy, label=level.label, detail="", ) for level in self.levels["mo"] ] assign_bond_characters( proxy_levels, S, rows["left"], rows["right"], spec_ranges, sqrt_overlap=sqrt_overlap, ) for level, proxy in zip(self.levels["mo"], proxy_levels["mo"]): level.bond_character = proxy.bond_character level.overlap_population = proxy.overlap_population level.detail += proxy.detail def _column_name(self, column): return {"left": self.left_name, "mo": self.formula, "right": self.right_name}[column] def _display_name(self, column): """Column name for composition lists; disambiguates identical fragment formulas (O2 from O + O -> 'O(L)' / 'O(R)').""" name = self._column_name(column) if column != "mo" and self.left_name == self.right_name: return f"{name}({'L' if column == 'left' else 'R'})" return name def _sketch_partners(self, column, level): """Per-atom s/p amplitudes of every partner orbital of a level. Every contracted s/p function is weighted by its actual radial amplitude at a representative bonding-region radius (r0 = 2 bohr) before the per-atom sum, so the drawn lobe signs are the signs of the real wave function there. (A bare coefficient sum is wrong: tight and diffuse contracted functions enter one MO with opposite signs, e.g. the bonding 1e of NH3 would get an inverted N-2p lobe and become indistinguishable from the antibonding 2e.) The lobe SIZE is then rescaled to the Loewdin population of the (atom, l) channel, mirroring the crystal-engine sketch (crystal_orbital_pyscf.sketch_partners): raw r0 amplitudes misstate the sizes, and on core levels (e.g. the C 1s combinations of C6H6, whose contracted 1s has died off at r0) they would draw the valence orthogonalization tails instead of the 1s spheres. d and higher shells are omitted. Only the REAL atoms of the calculation are drawn: the fragment orbitals also carry small tails on the ghost basis functions (counterpoise polarization), which would obscure the pre-bonding SALC picture. Degenerate partners are canonicalized to match the SALC viewer.""" from pyscf.gto.mole import gto_norm calc = self.calculations[column] mol = calc["mol"] C = calc["mo_coeff"] real = set(calc["real_sites"]) ao_loc = mol.ao_loc_nr() r0 = 2.0 # bohr angular = {0: 0.28209479, 1: 0.48860251} # Y00, Y1m lobe constants cache = getattr(self, "_sketch_sqrt_overlap", None) if cache is None: cache = self._sketch_sqrt_overlap = {} sqrt_overlap = cache.get(column) if sqrt_overlap is None: S = mol.intor("int1e_ovlp") values_S, vectors_S = np.linalg.eigh(S) sqrt_overlap = cache[column] = ( vectors_S * np.sqrt(np.clip(values_S, 0.0, None)) ) @ vectors_S.T partners = [] channel_pop: dict[tuple[int, int], float] = {} # multiplet-summed channel_amp2: dict[tuple[int, int], float] = {} # Loewdin / |amp|^2 for k in level.orbital_indices: vector = C[:, k] gross = np.abs(sqrt_overlap @ vector) ** 2 per_atom: dict[int, list[float]] = {} for shell in range(mol.nbas): l = mol.bas_angular(shell) if l > 1: continue atom = mol.bas_atom(shell) if atom not in real: continue values = per_atom.setdefault(atom, [0.0, 0.0, 0.0, 0.0]) p0 = ao_loc[shell] width = 2 * l + 1 exponents = np.asarray(mol.bas_exp(shell)) # bas_ctr_coeff is over unit-normalized primitives; the # gto_norm factor makes this the actual AO value at r0 contractions = np.asarray(mol.bas_ctr_coeff(shell)) \ * gto_norm(l, exponents)[:, None] radial = angular[l] * r0**l * ( contractions * np.exp(-exponents[:, None] * r0**2) ).sum(axis=0) n_ctr = mol.bas_nctr(shell) channel_pop[(atom, l)] = channel_pop.get((atom, l), 0.0) + \ float(gross[p0:p0 + n_ctr * width].sum()) for c in range(n_ctr): block = vector[p0 + c * width:p0 + (c + 1) * width] if l == 0: values[0] += float(block[0]) * float(radial[c]) else: # pyscf p order: (px, py, pz) for m in range(3): values[1 + m] += float(block[m]) * float(radial[c]) partners.append(per_atom) for atom, values in per_atom.items(): channel_amp2[(atom, 0)] = channel_amp2.get((atom, 0), 0.0) + \ values[0] ** 2 channel_amp2[(atom, 1)] = channel_amp2.get((atom, 1), 0.0) + \ values[1] ** 2 + values[2] ** 2 + values[3] ** 2 # one shared factor per (atom, l) channel across the multiplet: keeps # symmetry-equivalent atoms exactly equal and the sigma/pi contrast # between partners, while lobe areas track the electron weight factor = { key: np.sqrt(max(channel_pop.get(key, 0.0), 0.0) / amp2) for key, amp2 in channel_amp2.items() if amp2 > 1e-24 } partners = [ { atom: [values[0] * factor.get((atom, 0), 0.0)] + [values[m] * factor.get((atom, 1), 0.0) for m in (1, 2, 3)] for atom, values in per_atom.items() } for per_atom in partners ] partners = canonical_sketch_partners(partners) # non-degenerate levels skip the RREF canonicalization above; fix # their arbitrary global phase so the dominant lobe is positive # (first lobe within tolerance of the max, so symmetry-equal lobes # whose ordering is decided by numerical noise stay reproducible) if len(partners) == 1 and partners[0]: flat = np.array([v for row in partners[0].values() for v in row]) anchor = int(np.argmax(np.abs(flat) >= (1 - 1e-4) * np.abs(flat).max())) if flat[anchor] < 0: partners[0] = { atom: [-v for v in row] for atom, row in partners[0].items() } return [_sketch_entries(per_atom) for per_atom in partners] def _core_count(self, column): calc = self.calculations[column] return sum( CORE_ELECTRON_COUNT.get(self.symbols[i], 0) for i in calc["real_sites"] ) // 2 # ------------------------------------------------------------- report
[docs] def print_report(self): """Print the text report of ``crystod-mol --diagram --pyscf`` to stdout. Sections: molecule and point group, the two fragments with their spins, the three SCF calculations (method, basis, total energies, convergence) and the counterpoise-consistent interaction energy, the molecular orbitals up to 12 eV above the LUMO (energy, occupation, composition in fragment levels), the electron filling with HOMO, LUMO and gap, and the PySCF references. """ print("\n* Molecule *") print(f"{self.xyz_path} ({self.formula}, {len(self.symbols)} atoms)") print("\n* Point group *") if self.hm is not None: print(f"{self.schoenflies} (Hermann-Mauguin: {self.hm})") else: print(f"{self.schoenflies}") print("\n* Fragments (pre-bonding states; removed atoms kept as ghost basis) *") left_atoms = ", ".join(self.symbols[i] for i in self.left) right_atoms = ", ".join(self.symbols[i] for i in self.right) print(f"left : {self.left_name} ({left_atoms}), " f"spin 2S = {self.calculations['left']['spin']}") print(f"right: {self.right_name} ({right_atoms}), " f"spin 2S = {self.calculations['right']['spin']}") print("(fragments are spin/spherically averaged: fractional " "occupation of degenerate frontier shells)") method = ("Hartree-Fock method" if self.theory == "scf" else f"DFT method ({self.xc.upper()} functional)") print(f"\n* PySCF calculations ({method} / {self.basis} basis) *") for column in ("left", "right", "mo"): calc = self.calculations[column] name = self._column_name(column) flavor = ("R" if calc["spin"] == 0 else "RO") + ( "KS" if self.theory == "dft" else "HF" ) convergence = "converged" if calc["converged"] else "NOT CONVERGED" print(f"{name:>8} ({flavor}): E_tot = {calc['energy']:.6f} Ha ({convergence})") interaction = ( self.calculations["mo"]["energy"] - self.calculations["left"]["energy"] - self.calculations["right"]["energy"] ) print(f"interaction energy E(mol) - E(left) - E(right) = " f"{interaction:.6f} Ha = {interaction * HARTREE_TO_EV:.3f} eV") print("(counterpoise-consistent: all three calculations use the full " "molecular basis)") print("\n* Molecular orbitals *") print(f"{'MO':>6} {'E (eV)':>10} {'occ':>4} composition") core = self._core_count("mo") shown = [ level for level in self.levels["mo"] if min(level.orbital_indices) >= core ] names = { level.level_id: f"{self._display_name(level.column)} {level.label}" for column in ("left", "right") for level in self.levels[column] } for level in reversed(shown): if level.energy > (self.lumo.energy + 12 if self.lumo else 1e9): continue parts = ", ".join( f"{100 * w:.0f}% {names[i]}" for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.02 ) degeneracy = f" x{level.degeneracy}" if level.degeneracy > 1 else "" print(f"{level.label + degeneracy:>6} {level.energy:10.2f} " f"{level.electrons:>4} {parts}") if core: print(f"(+ {core} core level(s) below; visible in the HTML by " "panning the energy window)") ghost_hidden = sum( 1 for column in ("left", "right") for level in self.levels[column] if level.real_fraction < GHOST_FRACTION_THRESHOLD ) if ghost_hidden: print(f"({ghost_hidden} fragment level(s) dominated by the ghost " "basis (BSSE artifacts) are omitted from the diagram and " "the compositions)") print(f"\n* Electron filling ({self.n_electrons} electrons, " f"molecule spin 2S = {self.spin}) *") configuration = " ".join( f"({level.label})^{level.electrons}" for level in self.levels["mo"] if level.electrons > 0 ) print(configuration) if self.homo and self.lumo: print(f"HOMO = {self.homo.label} ({self.homo.energy:.2f} eV), " f"LUMO = {self.lumo.label} ({self.lumo.energy:.2f} eV), " f"gap = {self.lumo.energy - self.homo.energy:.2f} eV") print("\nMethod: fragment-resolved SCF MO diagram") print("\nIf you use PySCF in your research, please cite:") print("Q. Sun et al., J. Chem. Phys. 153, 024109 (2020);") print("Q. Sun et al., WIREs Comput. Mol. Sci. 8, e1340 (2018);") print("Q. Sun, J. Comput. Chem. 36, 1664 (2015).")
# --------------------------------------------------------------- HTML
[docs] def write_html(self, output_path): """Write the interactive three-column HTML diagram. Columns: left-fragment MOs, molecule MOs, right-fragment MOs, with correlation lines weighted by the projections, electron arrows, HOMO/LUMO marks, an adjustable energy window (core levels reachable by panning), per-level details and the orbital sketch viewer. Args: output_path (str): Path of the HTML file to write (``crystod-mol`` uses ``MolOD_{molecule}_pyscf.html`` by default). """ columns = {"left": 200, "mo": 480, "right": 760} half = {"left": 34, "mo": 34, "right": 34} order = ["left", "mo", "right"] side = {"left": -1, "mo": 1, "right": 1} headers = { column: f"{svg_sub_digits(self._display_name(column))} MOs" for column in order } names = { other.level_id: f"{self._display_name(other.column)} {other.label}" for levels in self.levels.values() for other in levels } ghost_hidden = sum( 1 for column_levels in self.levels.values() for level in column_levels if level.real_fraction < GHOST_FRACTION_THRESHOLD ) levels_json = [] bond_letter = {"bonding": "b", "nonbonding": "n", "antibonding": "a"} for column_levels in self.levels.values(): for level in column_levels: if level.real_fraction < GHOST_FRACTION_THRESHOLD: continue character = getattr(level, "bond_character", None) # fragment levels carry the VESTA color of their dominant # element (set in _assign_bond_characters) spec = getattr(level, "dominant_spec", None) elc = (element_color(spec[0]) if level.column != "mo" and spec else None) levels_json.append({ **({"elc": elc} if elc else {}), "id": level.level_id, "col": level.column, "e": round(level.energy, 4), "deg": level.degeneracy, **({"bond": bond_letter[character]} if character else {}), "label": level.label, "el": level.electrons, "occ": level.electrons > 0, "links": [ [i, round(w, 4)] for i, w in level.composition if w >= 0.02 ], "comp": [ [names[i], round(100 * w, 1)] for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.005 ], "detail": level.detail, "orb": self._sketch_partners(level.column, level) or None, }) # default window: valence occupied .. a little above the LUMO floors = [] for column in ("left", "mo", "right"): core = self._core_count(column) energies = sorted( level.energy for level in self.levels[column] if level.real_fraction >= GHOST_FRACTION_THRESHOLD ) if core < len(energies): floors.append(energies[core] if core else energies[0]) e_min = min(floors) - 3.0 if floors else -30.0 top_candidates = [level.energy for level in self.levels["mo"]] e_max = (self.lumo.energy + 8.0) if self.lumo else max(top_candidates) + 2.0 e_max = min(e_max, max(top_candidates) + 2.0) # all-electron core levels of heavy atoms reach thousands of eV below; # when any level lies below -40 eV, default to the chemically relevant # window [-40, 15] (never hiding the LUMO) -- the "Show all energy # levels" button in the page restores the full range all_energies = [ level.energy for column in ("left", "mo", "right") for level in self.levels[column] ] if all_energies and min(all_energies) < -40.0: e_min = -40.0 e_max = max(e_max, 15.0) method = ("Hartree-Fock method" if self.theory == "scf" else f"DFT method ({self.xc.upper()})") formula_html = re.sub(r"(\d+)", r"<sub>\1</sub>", self.formula) gap = "" if self.homo and self.lumo: gap = (f"HOMO&ndash;LUMO gap " f"{self.lumo.energy - self.homo.energy:.2f} eV") chips = [ formula_html, f"{self.schoenflies}" + (f" ({self.hm})" if self.hm else ""), f"{self.left_name} + {self.right_name}", f"{method} / {self.basis} basis (PySCF)", gap, ] render_diagram_page( output_path, title=f"MO diagram: {self.formula} (PySCF)", heading_html=f"Molecular-orbital diagram: {formula_html} " f"<span style=\"color:#90a4ae;font-size:14px\">" f"{self.left_name} + {self.right_name}, PySCF</span>", chips=chips, columns=columns, half=half, order=order, side=side, headers=headers, levels_json=levels_json, homo_id=self.homo.level_id if self.homo else None, lumo_id=self.lumo.level_id if self.lumo else None, e_min=e_min, e_max=e_max, geometry=diagram_geometry(self.symbols, self.coordinates), foot_html=( "Quantitative MO diagram from three SCF calculations in one " "AO space (fragments with ghost basis on the removed atoms, " "counterpoise-consistent; molecule MOs projected exactly onto " "the fragment MOs, weighted by each fragment level's " "real-atom Mulliken population)." + (f" {ghost_hidden} fragment level(s) dominated by the ghost " "basis (BSSE artifacts, real-atom population &lt; 35%) are " "not drawn." if ghost_hidden else "") + (" MO line colors: " "<span style=\"color:#1565c0\">bonding</span> / " "<span style=\"color:#333\">nonbonding</span> / " "<span style=\"color:#d32f2f\">antibonding</span>, from " "the left&ndash;right overlap population 2 Re " "c<sub>L</sub>&#8224;S c<sub>R</sub> of each state " "(COOP-style; semicore orthogonality tails excluded; " "value in the level's tooltip)." if any(getattr(level, "bond_character", None) for level in self.levels["mo"]) else "") + " SCF engine: PySCF &mdash; if you use it in your " "research, please cite Q. Sun et al., J. Chem. Phys. 153, " "024109 (2020); Q. Sun et al., WIREs Comput. Mol. Sci. 8, " "e1340 (2018); Q. Sun, J. Comput. Chem. 36, 1664 (2015). " "Generated by CrystOD (crystod-mol --diagram --pyscf)." ), )
def _group_degenerate(energies, tol=2e-3): groups: list[list[int]] = [] for i in np.argsort(energies): if groups and abs(energies[groups[-1][0]] - energies[i]) < tol: groups[-1].append(int(i)) else: groups.append([int(i)]) return groups def _format_formula(counts: dict[str, int], hill: bool = False) -> str: keys = sorted(counts) if hill and "C" in counts: keys = ["C"] + (["H"] if "H" in counts else []) + sorted( k for k in counts if k not in ("C", "H") ) return "".join(f"{k}{counts[k]}" if counts[k] > 1 else k for k in keys) def _atomic_number(symbol: str) -> int: from pyscf.data.elements import charge return int(charge(symbol)) def run_pyscf_diagram(args) -> None: diagram = PyscfDiagram( args.xyz, args.tolerance, args.center, left_spec=args.ao_left, right_spec=args.ao_right, basis=args.basis, theory=args.theory, xc=args.xc, charge=args.charge, spin=args.spin, ) diagram.print_report() stem = os.path.splitext(os.path.basename(args.xyz))[0] output_path = args.output or f"MolOD_{stem}_pyscf.html" diagram.write_html(output_path) print(f"\nMO diagram written to {output_path}")