Source code for crystod.mo_diagram

"""Semi-quantitative molecular-orbital diagrams (crystod-mol --diagram).

Builds the qualitative MO diagram of a single-center molecule (one central
atom + surrounding ligands, e.g. NH3, CH4, SF6) from symmetry and overlap
alone -- no self-consistent quantum chemistry:

1. The molecular point group is detected and the ligand atomic orbitals are
   symmetry-adapted (SALCs) per irrep, reusing :mod:`crystod.molecular_salc`.
2. Every valence atomic orbital is a single-zeta Slater-type orbital (STO);
   the exponents and diagonal energies H_ii (valence-state ionization
   energies) are the standard extended-Hueckel parameters.
3. All two-center STO overlap integrals are evaluated exactly (numerical
   Gauss-Laguerre x Gauss-Legendre quadrature in prolate-spheroidal
   coordinates, machine precision) -- no neglect of ligand-ligand overlap.
4. Within each irrep block the generalized eigenvalue problem
   H C = S C E with the Wolfsberg-Helmholz off-diagonals
   H_ij = K S_ij (H_ii + H_jj)/2 (K = 1.75) is solved, so a large overlap
   integral directly produces a large bonding/antibonding splitting.
5. The result is written as an interactive HTML/SVG diagram (four columns:
   isolated ligand AOs | ligand-group SALCs | molecular orbitals | central
   atom AOs) with dashed correlation lines, electron filling and per-level
   composition.

References:
  M. Wolfsberg and L. Helmholz, J. Chem. Phys. 20, 837 (1952).
  R. Hoffmann, J. Chem. Phys. 39, 1397 (1963).
"""

from __future__ import annotations

import argparse
import json
import os
from dataclasses import dataclass, field

import numpy as np

from .decompose_irrep import get_character_table
from .molecular_salc import (
    _hm_symbol,
    _match_operations,
    _table_operations_cartesian,
    format_salc,
    get_permutation_matrices,
    get_symmetry,
    load_molecule,
    project_salcs,
)

WOLFSBERG_HELMHOLZ_K = 1.75

#: Standard extended-Hueckel valence parameters (Hoffmann and successors):
#: element -> list of ``(shell, n, l, zeta, H_ii)`` tuples, one per valence
#: shell, with the shell label (``"2s"``, ``"3d"``, ...), the principal and
#: azimuthal quantum numbers, the Slater exponent ``zeta`` in 1/bohr (a single
#: value, or a list of ``(zeta, coefficient)`` pairs for the double-zeta d and
#: f shells) and the diagonal energy ``H_ii`` (valence-state ionization energy)
#: in eV. The keys are the elements ``build_basis`` accepts.
EHT_PARAMETERS = {
    "H":  [("1s", 1, 0, 1.300, -13.6)],
    "Li": [("2s", 2, 0, 0.650, -5.4), ("2p", 2, 1, 0.650, -3.5)],
    "Be": [("2s", 2, 0, 0.975, -10.0), ("2p", 2, 1, 0.975, -6.0)],
    "B":  [("2s", 2, 0, 1.300, -15.2), ("2p", 2, 1, 1.300, -8.5)],
    "C":  [("2s", 2, 0, 1.625, -21.4), ("2p", 2, 1, 1.625, -11.4)],
    "N":  [("2s", 2, 0, 1.950, -26.0), ("2p", 2, 1, 1.950, -13.4)],
    "O":  [("2s", 2, 0, 2.275, -32.3), ("2p", 2, 1, 2.275, -14.8)],
    "F":  [("2s", 2, 0, 2.425, -40.0), ("2p", 2, 1, 2.425, -18.1)],
    "Na": [("3s", 3, 0, 0.733, -5.1), ("3p", 3, 1, 0.733, -3.0)],
    "Mg": [("3s", 3, 0, 1.100, -9.0), ("3p", 3, 1, 1.100, -4.5)],
    "Al": [("3s", 3, 0, 1.167, -12.3), ("3p", 3, 1, 1.167, -6.5)],
    "Si": [("3s", 3, 0, 1.383, -17.3), ("3p", 3, 1, 1.383, -9.2)],
    "P":  [("3s", 3, 0, 1.750, -18.6), ("3p", 3, 1, 1.300, -14.0)],
    "S":  [("3s", 3, 0, 2.122, -20.0), ("3p", 3, 1, 1.827, -13.3)],
    "Cl": [("3s", 3, 0, 2.183, -26.3), ("3p", 3, 1, 1.733, -14.2)],
    # 4th row and beyond: the standard extended-Hueckel compilation
    # (Hoffmann-group / YAeHMOP tables; d shells are the usual double-zeta
    # contractions, coefficients on normalized primitives)
    "K":  [("4s", 4, 0, 0.874, -4.34), ("4p", 4, 1, 0.874, -2.73)],
    "Ca": [("4s", 4, 0, 1.200, -7.0), ("4p", 4, 1, 1.200, -4.0)],
    "Sc": [("4s", 4, 0, 1.300, -8.87), ("4p", 4, 1, 1.300, -2.75),
           ("3d", 3, 2, [(4.35, 0.4228), (1.70, 0.7276)], -8.51)],
    "Ti": [("4s", 4, 0, 1.075, -8.97), ("4p", 4, 1, 0.675, -5.44),
           ("3d", 3, 2, [(4.55, 0.4206), (1.40, 0.7839)], -10.81)],
    "V":  [("4s", 4, 0, 1.300, -8.81), ("4p", 4, 1, 1.300, -5.52),
           ("3d", 3, 2, [(4.75, 0.4560), (1.70, 0.7520)], -11.0)],
    "Cr": [("4s", 4, 0, 1.700, -8.66), ("4p", 4, 1, 1.700, -5.24),
           ("3d", 3, 2, [(4.95, 0.5060), (1.80, 0.6750)], -11.2)],
    "Mn": [("4s", 4, 0, 0.970, -9.75), ("4p", 4, 1, 0.970, -5.89),
           ("3d", 3, 2, [(5.15, 0.5320), (1.70, 0.6490)], -11.67)],
    "Fe": [("4s", 4, 0, 1.900, -9.10), ("4p", 4, 1, 1.900, -5.32),
           ("3d", 3, 2, [(5.35, 0.5505), (2.00, 0.6260)], -12.6)],
    "Co": [("4s", 4, 0, 2.000, -9.21), ("4p", 4, 1, 2.000, -5.29),
           ("3d", 3, 2, [(5.55, 0.5680), (2.10, 0.6060)], -13.18)],
    "Ni": [("4s", 4, 0, 2.100, -9.17), ("4p", 4, 1, 2.100, -5.15),
           ("3d", 3, 2, [(5.75, 0.5817), (2.30, 0.5800)], -13.49)],
    "Cu": [("4s", 4, 0, 2.200, -11.4), ("4p", 4, 1, 2.200, -6.06),
           ("3d", 3, 2, [(5.95, 0.5933), (2.30, 0.5744)], -14.0)],
    "Zn": [("4s", 4, 0, 2.010, -12.41), ("4p", 4, 1, 1.700, -6.53)],
    "Ga": [("4s", 4, 0, 1.770, -14.58), ("4p", 4, 1, 1.550, -6.75)],
    "Ge": [("4s", 4, 0, 2.160, -16.0), ("4p", 4, 1, 1.850, -9.0)],
    "As": [("4s", 4, 0, 2.230, -16.22), ("4p", 4, 1, 1.890, -12.16)],
    "Se": [("4s", 4, 0, 2.440, -20.5), ("4p", 4, 1, 2.070, -14.4)],
    "Br": [("4s", 4, 0, 2.588, -22.07), ("4p", 4, 1, 2.131, -13.1)],
    "Rb": [("5s", 5, 0, 0.997, -4.18), ("5p", 5, 1, 0.997, -2.60)],
    "Sr": [("5s", 5, 0, 1.214, -6.62), ("5p", 5, 1, 1.214, -3.92)],
    "Zr": [("5s", 5, 0, 1.820, -8.0), ("5p", 5, 1, 1.780, -5.4),
           ("4d", 4, 2, [(3.835, 0.6210), (1.505, 0.5769)], -10.2)],
    "Nb": [("5s", 5, 0, 1.890, -10.1), ("5p", 5, 1, 1.850, -6.86),
           ("4d", 4, 2, [(4.080, 0.6401), (1.640, 0.5516)], -12.1)],
    "Mo": [("5s", 5, 0, 1.960, -8.34), ("5p", 5, 1, 1.900, -5.24),
           ("4d", 4, 2, [(4.540, 0.5899), (1.900, 0.5899)], -10.5)],
    # Cd: the YAeHMOP table keeps the filled 4d10 inactive (as for Zn);
    # the 4d enters the full-electron diagram basis as a core shell from
    # the archived PySCF levels instead
    "Cd": [("5s", 5, 0, 1.640, -11.80), ("5p", 5, 1, 1.600, -8.20)],
    "Sn": [("5s", 5, 0, 2.120, -16.16), ("5p", 5, 1, 1.820, -8.32)],
    "Sb": [("5s", 5, 0, 2.323, -18.8), ("5p", 5, 1, 1.999, -11.7)],
    "Te": [("5s", 5, 0, 2.510, -20.8), ("5p", 5, 1, 2.160, -14.8)],
    "I":  [("5s", 5, 0, 2.679, -18.0), ("5p", 5, 1, 2.322, -12.7)],
    "Cs": [("6s", 6, 0, 1.060, -3.88), ("6p", 6, 1, 1.060, -2.49)],
    "Ba": [("6s", 6, 0, 1.263, -5.49), ("6p", 6, 1, 1.263, -3.84)],
    # rare earths with published extended-Hueckel parameters (YAeHMOP
    # eht_parms.dat; the remaining lanthanides carry no fitted values
    # there and are left out)
    "La": [("6s", 6, 0, 2.140, -7.67), ("6p", 6, 1, 2.080, -5.01),
           ("5d", 5, 2, [(3.780, 0.7765), (1.381, 0.4586)], -8.21)],
    "Ce": [("6s", 6, 0, 1.799, -4.97), ("6p", 6, 1, 1.799, -4.97),
           ("5d", 5, 2, 2.747, -6.43),
           ("4f", 4, 3, 3.907, -11.28)],
    "Sm": [("6s", 6, 0, 1.400, -4.86), ("6p", 6, 1, 1.400, -4.86),
           ("5d", 5, 2, [(2.747, 0.7184), (1.267, 0.4447)], -6.06),
           ("4f", 4, 3, [(6.907, 0.7354), (2.639, 0.4597)], -11.28)],
    # Gd: the 6p exponent is absent from the YAeHMOP table; the 6s value
    # is used, as in every other lanthanide entry (6p zeta = 6s zeta)
    "Gd": [("6s", 6, 0, 1.369, -5.44), ("6p", 6, 1, 1.369, -5.44),
           ("5d", 5, 2, [(2.747, 0.7184), (1.267, 0.4447)], -6.06),
           ("4f", 4, 3, [(6.907, 0.7354), (2.639, 0.4597)], -11.28)],
    "Yb": [("6s", 6, 0, 1.540, -5.35), ("6p", 6, 1, 1.540, -5.35),
           ("5d", 5, 2, [(2.810, 0.7063), (1.216, 0.4834)], -5.21),
           ("4f", 4, 3, [(8.629, 0.7460), (3.198, 0.4564)], -13.86)],
    "Lu": [("6s", 6, 0, 1.666, -6.05), ("6p", 6, 1, 1.666, -6.05),
           ("5d", 5, 2, [(2.813, 0.7044), (1.210, 0.4880)], -5.12),
           ("4f", 4, 3, [(9.136, 0.7330), (3.666, 0.4459)], -22.40)],
    "Pb": [("6s", 6, 0, 2.350, -15.7), ("6p", 6, 1, 2.060, -8.0)],
    "Bi": [("6s", 6, 0, 2.560, -15.19), ("6p", 6, 1, 2.072, -7.79)],
}

VALENCE_ELECTRONS = {
    "H": 1, "Li": 1, "Be": 2, "B": 3, "C": 4, "N": 5, "O": 6, "F": 7,
    "Na": 1, "Mg": 2, "Al": 3, "Si": 4, "P": 5, "S": 6, "Cl": 7,
    "K": 1, "Ca": 2, "Sc": 3, "Ti": 4, "V": 5, "Cr": 6, "Mn": 7,
    "Fe": 8, "Co": 9, "Ni": 10, "Cu": 11, "Zn": 2, "Ga": 3, "Ge": 4,
    "As": 5, "Se": 6, "Br": 7, "Rb": 1, "Sr": 2, "Zr": 4, "Nb": 5,
    "Mo": 6, "Cd": 2, "Sn": 4, "Sb": 5, "Te": 6, "I": 7, "Cs": 1,
    "Ba": 2, "La": 3, "Ce": 4, "Sm": 8, "Gd": 10, "Yb": 16, "Lu": 17,
    "Pb": 4, "Bi": 5,
}

# neutral-atom occupation of each explicitly treated shell (kept as a
# reference table; the crystal-orbital diagrams fill their full valence
# basis from VALENCE_ELECTRONS)
SHELL_OCCUPATIONS = {
    "K": {"4s": 1}, "Ca": {"4s": 2},
    "Sc": {"4s": 2, "3d": 1}, "Ti": {"4s": 2, "3d": 2},
    "V": {"4s": 2, "3d": 3}, "Cr": {"4s": 1, "3d": 5},
    "Mn": {"4s": 2, "3d": 5}, "Fe": {"4s": 2, "3d": 6},
    "Co": {"4s": 2, "3d": 7}, "Ni": {"4s": 2, "3d": 8},
    "Cu": {"4s": 1, "3d": 10}, "Zn": {"4s": 2},
    "Zr": {"5s": 2, "4d": 2}, "Nb": {"5s": 1, "4d": 4},
    "Mo": {"5s": 1, "4d": 5},
    "La": {"6s": 2, "5d": 1}, "Ce": {"6s": 2, "5d": 1, "4f": 1},
    "Sm": {"6s": 2, "4f": 6}, "Gd": {"6s": 2, "5d": 1, "4f": 7},
    "Yb": {"6s": 2, "4f": 14}, "Lu": {"6s": 2, "5d": 1, "4f": 14},
}


def shell_occupation(element: str, shell: str) -> int:
    """Neutral-atom electron count of one explicitly treated shell."""
    if element in SHELL_OCCUPATIONS and shell in SHELL_OCCUPATIONS[element]:
        return SHELL_OCCUPATIONS[element][shell]
    if element in SHELL_OCCUPATIONS:
        return 0
    # main-group elements: s fills first (max 2), the rest goes to p
    valence = VALENCE_ELECTRONS[element]
    if shell.endswith("s"):
        return min(valence, 2)
    if shell.endswith("p"):
        return max(valence - 2, 0)
    return 0

# Core shells (not treated explicitly, but counted in the MO numbering so
# that the labels match the photoelectron-spectroscopy convention,
# e.g. CH4: 1a1 = C 1s core, valence = 2a1 + 1t2).
_AR_CORE = ["1s", "2s", "2p", "3s", "3p"]
_KR_CORE = _AR_CORE + ["3d", "4s", "4p"]
_XE_CORE = _KR_CORE + ["4d", "5s", "5p"]
CORE_SHELLS = {
    "H": [], "Li": ["1s"], "Be": ["1s"], "B": ["1s"], "C": ["1s"],
    "N": ["1s"], "O": ["1s"], "F": ["1s"],
    "Na": ["1s", "2s", "2p"], "Mg": ["1s", "2s", "2p"],
    "Al": ["1s", "2s", "2p"], "Si": ["1s", "2s", "2p"],
    "P": ["1s", "2s", "2p"], "S": ["1s", "2s", "2p"], "Cl": ["1s", "2s", "2p"],
    "K": _AR_CORE, "Ca": _AR_CORE, "Sc": _AR_CORE, "Ti": _AR_CORE,
    "V": _AR_CORE, "Cr": _AR_CORE, "Mn": _AR_CORE, "Fe": _AR_CORE,
    "Co": _AR_CORE, "Ni": _AR_CORE, "Cu": _AR_CORE,
    "Zn": _AR_CORE + ["3d"], "Ga": _AR_CORE + ["3d"],
    "Ge": _AR_CORE + ["3d"], "As": _AR_CORE + ["3d"],
    "Se": _AR_CORE + ["3d"], "Br": _AR_CORE + ["3d"],
    "Rb": _KR_CORE, "Sr": _KR_CORE, "Zr": _KR_CORE, "Nb": _KR_CORE,
    "Mo": _KR_CORE,
    "Cd": _KR_CORE + ["4d"],
    "Sn": _KR_CORE + ["4d"], "Sb": _KR_CORE + ["4d"], "Te": _KR_CORE + ["4d"],
    "I": _KR_CORE + ["4d"],
    "Cs": _XE_CORE, "Ba": _XE_CORE,
    "La": _XE_CORE, "Ce": _XE_CORE, "Sm": _XE_CORE, "Gd": _XE_CORE,
    "Yb": _XE_CORE, "Lu": _XE_CORE,
    "Pb": _XE_CORE + ["4f", "5d"], "Bi": _XE_CORE + ["4f", "5d"],
}

P_LABELS = ["px", "py", "pz"]  # real-orbital order used by wigner_D_real(1)
D_LABELS = ["dxy", "dyz", "dz2", "dxz", "dx2-y2"]  # wigner_D_real(2) order
# wigner_D_real(3) order (complex_to_real_transform_orbital)
F_LABELS = ["fx(x2-3y2)", "fy(3x2-y2)", "fz(x2-y2)", "fxyz",
            "fxz2", "fyz2", "fz3"]

ANGSTROM_TO_BOHR = 1.0 / 0.529177210903


# ----------------------------------------------------- STO overlap integrals


def _slater_norm(n: int, zeta: float) -> float:
    """Radial normalization of N r^(n-1) e^(-zeta r)."""
    from math import factorial, sqrt

    return (2.0 * zeta) ** (n + 0.5) / sqrt(float(factorial(2 * n)))


_QUAD_CACHE: dict = {}


def _quadrature(n_lag: int = 40, n_leg: int = 48):
    key = (n_lag, n_leg)
    if key not in _QUAD_CACHE:
        t, wt = np.polynomial.laguerre.laggauss(n_lag)
        v, wv = np.polynomial.legendre.leggauss(n_leg)
        _QUAD_CACHE[key] = (t[:, None], wt[:, None], v[None, :], wv[None, :])
    return _QUAD_CACHE[key]


def _aligned_angular(l: int, m: int, cos, sin):
    """Theta-dependent part of the real spherical harmonic of the |m| channel
    (m = 0: sigma; 1: pi; 2: delta), without the cos/sin(m phi) factor."""
    if l == 0:
        return np.sqrt(1.0 / (4.0 * np.pi)) * np.ones_like(cos)
    if l == 1:
        if m == 0:
            return np.sqrt(3.0 / (4.0 * np.pi)) * cos
        return np.sqrt(3.0 / (4.0 * np.pi)) * sin
    if l == 2:
        if m == 0:
            return np.sqrt(5.0 / (16.0 * np.pi)) * (3.0 * cos ** 2 - 1.0)
        if m == 1:
            return np.sqrt(15.0 / (4.0 * np.pi)) * sin * cos
        return np.sqrt(15.0 / (16.0 * np.pi)) * sin ** 2
    if l == 3:
        if m == 0:
            return np.sqrt(7.0 / (16.0 * np.pi)) * (5.0 * cos ** 3 - 3.0 * cos)
        if m == 1:
            return np.sqrt(21.0 / (32.0 * np.pi)) * sin * (5.0 * cos ** 2 - 1.0)
        if m == 2:
            return np.sqrt(105.0 / (16.0 * np.pi)) * sin ** 2 * cos
        return np.sqrt(35.0 / (32.0 * np.pi)) * sin ** 3
    raise SystemExit(f"ERROR: aligned STO overlaps support l <= 3 (got l={l}).")


def sto_overlap_aligned(n1, l1, z1, n2, l2, z2, R, m) -> float:
    """Overlap of two real STOs with atom 1 at the origin and atom 2 at
    (0, 0, R), both orbitals quantized along the global z axis.

    ``m = 0``: sigma channel (s, p_z, d_z2); ``m = 1``: pi channel;
    ``m = 2``: delta channel. Distances in Bohr. Exact within quadrature
    (machine precision).
    """
    if m > min(l1, l2):
        return 0.0
    t, wt, v, wv = _quadrature()
    alpha = 0.5 * R * (z1 + z2)
    beta = 0.5 * R * (z1 - z2)
    mu = 1.0 + t / alpha  # Gauss-Laguerre: absorbs e^(-alpha(mu-1))
    nu = v
    r1 = 0.5 * R * (mu + nu)
    r2 = 0.5 * R * (mu - nu)
    integrand = (
        r1 ** (n1 - 1)
        * r2 ** (n2 - 1)
        * np.exp(-beta * nu)
        * (R ** 3 / 8.0)
        * (mu ** 2 - nu ** 2)
        / alpha
    )
    cos1 = (1.0 + mu * nu) / (mu + nu)
    cos2 = (mu * nu - 1.0) / (mu - nu)
    sin1 = np.sqrt(np.clip(1.0 - cos1 ** 2, 0.0, None))
    sin2 = np.sqrt(np.clip(1.0 - cos2 ** 2, 0.0, None))
    ang1 = _aligned_angular(l1, m, cos1, sin1)
    ang2 = _aligned_angular(l2, m, cos2, sin2)
    # phi integral: 2 pi for the m=0 channel, pi for cos/sin(m phi) channels
    phi = 2.0 * np.pi if m == 0 else np.pi
    value = float(np.sum(wt * wv * integrand * ang1 * ang2))
    return _slater_norm(n1, z1) * _slater_norm(n2, z2) * phi * np.exp(-alpha) * value


def sto_overlap_contracted(n1, l1, zeta1, n2, l2, zeta2, R, m) -> float:
    """Aligned overlap of two (possibly contracted) STOs.

    ``zeta`` is either a scalar (single-zeta) or a list of (zeta, coeff)
    primitives (the standard double-zeta d parametrization of the extended
    Hueckel tables; coefficients refer to normalized primitives)."""
    prim1 = zeta1 if isinstance(zeta1, (list, tuple)) else [(zeta1, 1.0)]
    prim2 = zeta2 if isinstance(zeta2, (list, tuple)) else [(zeta2, 1.0)]
    value = 0.0
    for z1, c1 in prim1:
        for z2, c2 in prim2:
            value += c1 * c2 * sto_overlap_aligned(n1, l1, z1, n2, l2, z2, R, m)
    return value


# ------------------------------------------------------------ AO basis and S


[docs] @dataclass class AtomicOrbital: """One real valence atomic orbital of the extended-Hueckel basis. ``build_basis`` creates one instance per (site, shell, m) from ``EHT_PARAMETERS``; the list of instances indexes the rows and columns of the matrices returned by ``overlap_matrix`` and ``hamiltonian_matrix``. Attributes: atom: Site index of the orbital's atom in the molecule. element: Element symbol of that atom. shell: Shell label, e.g. ``"1s"``, ``"2p"``, ``"3d"``. n: Principal quantum number of the Slater-type orbital. l: Azimuthal quantum number (0 = s, 1 = p, 2 = d, 3 = f). m: Index of the real orbital component within the shell: 0 for s, the position in ``px, py, pz`` for p, in ``dxy, dyz, dz2, dxz, dx2-y2`` for d and in ``fx(x2-3y2), fy(3x2-y2), fz(x2-y2), fxyz, fxz2, fyz2, fz3`` for f (the ``wigner_D_real`` component order). zeta: Slater exponent in 1/bohr (a single value, or a list of ``(zeta, coefficient)`` pairs for a double-zeta d or f shell). h_ii: Diagonal Hamiltonian element (valence-state ionization energy) in eV. """ atom: int # site index in the molecule element: str shell: str # e.g. "2s", "2p" n: int l: int m: int # index into P_LABELS for l = 1, 0 for l = 0 zeta: float h_ii: float # VSIP / diagonal Hamiltonian (eV) @property def orbital_label(self) -> str: """Label of the real orbital, e.g. ``"2s"``, ``"2px"``, ``"3dz2"``.""" if self.l == 0: return self.shell if self.l == 1: return self.shell[:-1] + P_LABELS[self.m] if self.l == 2: return self.shell[:-1] + D_LABELS[self.m] return self.shell[:-1] + F_LABELS[self.m]
[docs] def build_basis(symbols: list[str], site_indices: list[int]) -> list[AtomicOrbital]: """Assemble the extended-Hueckel valence AO basis of a set of atoms. For every selected site, every valence shell of its element listed in ``EHT_PARAMETERS`` contributes its ``2 l + 1`` real orbitals, in the order of the table and of the ``wigner_D_real`` components. This is the AO space of ``MODiagram`` and ``EhtFragmentDiagram`` (all atoms of the molecule). Args: symbols: Element symbol of every atom of the molecule. site_indices: Indices into ``symbols`` of the atoms to include (a fragment, or ``range(len(symbols))`` for the whole molecule). Returns: List of ``AtomicOrbital`` objects; its order is the AO index used by ``overlap_matrix`` and ``hamiltonian_matrix``. Raises: SystemExit: An element has no extended-Hueckel parameters; the message lists the supported elements (``ValueError`` when called through ``crystod.mol``). Example: >>> from crystod import mol >>> [ao.orbital_label for ao in mol.build_basis(["N", "H"], [0, 1])] ['2s', '2px', '2py', '2pz', '1s'] """ orbitals = [] for i in site_indices: element = symbols[i] if element not in EHT_PARAMETERS: raise SystemExit( f"ERROR: no extended-Hueckel parameters for element {element} " f"(supported: {', '.join(EHT_PARAMETERS)})." ) for shell, n, l, zeta, h_ii in EHT_PARAMETERS[element]: for m in range(2 * l + 1): orbitals.append(AtomicOrbital(i, element, shell, n, l, m, zeta, h_ii)) return orbitals
# bond-frame (channel |m|, azimuthal type) of each real-orbital component in # the wigner_D_real component order; type 0 = cos(m phi)-like (sigma counts # as cos), type 1 = sin(m phi)-like. Only equal (channel, type) pairs couple. _BOND_CHANNELS = { 0: [(0, 0)], # (px, py, pz) 1: [(1, 0), (1, 1), (0, 0)], # (dxy, dyz, dz2, dxz, dx2-y2) 2: [(2, 1), (1, 1), (0, 0), (1, 0), (2, 0)], # (fx(x2-3y2), fy(3x2-y2), fz(x2-y2), fxyz, fxz2, fyz2, fz3) 3: [(3, 0), (3, 1), (2, 0), (2, 1), (1, 0), (1, 1), (0, 0)], } def _bond_frame_rotation(direction: np.ndarray) -> np.ndarray: """Cartesian rotation g (columns = bond frame axes) with g e_z = n.""" n = direction ref = np.array([0.0, 0.0, 1.0]) if abs(n[2]) < 0.9 else np.array([1.0, 0.0, 0.0]) e1 = np.cross(ref, n) e1 = e1 / np.linalg.norm(e1) e2 = np.cross(n, e1) return np.column_stack([e1, e2, n]) def make_aligned_cache(): """Cached aligned-channel overlap evaluator for AtomicOrbital pairs.""" pair_cache: dict = {} def aligned(a: AtomicOrbital, b: AtomicOrbital, R: float, m: int) -> float: key = (a.element, a.shell, b.element, b.shell, round(R, 10), m) if key not in pair_cache: pair_cache[key] = sto_overlap_contracted( a.n, a.l, a.zeta, b.n, b.l, b.zeta, R, m ) return pair_cache[key] return aligned def pair_overlap(a: AtomicOrbital, b: AtomicOrbital, vector: np.ndarray, aligned) -> float: """Overlap of two real STOs separated by ``vector`` (Bohr), via Slater-Koster assembly of the aligned sigma/pi/delta integrals. s and p orbitals use the explicit closed forms; any pair involving a d orbital is rotated into the bond frame with wigner_D_real.""" R = float(np.linalg.norm(vector)) direction = vector / R if a.l <= 1 and b.l <= 1: if a.l == 0 and b.l == 0: return aligned(a, b, R, 0) if a.l == 0 and b.l == 1: return direction[b.m] * aligned(a, b, R, 0) if a.l == 1 and b.l == 0: return direction[a.m] * aligned(a, b, R, 0) sigma = aligned(a, b, R, 0) pi = aligned(a, b, R, 1) delta = 1.0 if a.m == b.m else 0.0 return ( direction[a.m] * direction[b.m] * sigma + (delta - direction[a.m] * direction[b.m]) * pi ) from .operations import wigner_D_real g = _bond_frame_rotation(direction) Da = wigner_D_real(a.l, g) Db = wigner_D_real(b.l, g) channels_a = _BOND_CHANNELS[a.l] channels_b = _BOND_CHANNELS[b.l] value = 0.0 for mu in range(2 * a.l + 1): for nu in range(2 * b.l + 1): if channels_a[mu] != channels_b[nu]: continue channel = channels_a[mu][0] value += Da[a.m, mu] * Db[b.m, nu] * aligned(a, b, R, channel) return value
[docs] def overlap_matrix(orbitals: list[AtomicOrbital], coordinates: np.ndarray) -> np.ndarray: """AO overlap matrix S of an extended-Hueckel basis. Every two-center overlap between Slater-type orbitals (single-zeta, or the contracted double-zeta d and f shells) is evaluated exactly by numerical quadrature in prolate-spheroidal coordinates for the aligned sigma/pi/delta/phi channels and assembled for the actual bond direction by Slater-Koster rotation (``wigner_D_real``), so ligand-ligand overlaps are never neglected. Orbitals on the same atom are orthonormal. Args: orbitals: AO basis from ``build_basis``. coordinates: ``(n_atoms, 3)`` Cartesian coordinates in Angstrom, indexed by ``AtomicOrbital.atom``. Returns: Symmetric ``(n_ao, n_ao)`` array with unit diagonal. Example: >>> import numpy as np >>> from crystod import mol >>> orbitals = mol.build_basis(["H", "H"], [0, 1]) >>> S = mol.overlap_matrix(orbitals, np.array([[0, 0, 0], [0, 0, 0.74]])) >>> print(f"{S[0, 1]:.4f}") 0.6364 """ n_ao = len(orbitals) S = np.eye(n_ao) aligned = make_aligned_cache() for i in range(n_ao): for j in range(i + 1, n_ao): a, b = orbitals[i], orbitals[j] if a.atom == b.atom: S[i, j] = S[j, i] = 0.0 if (a.shell, a.m) != (b.shell, b.m) else 1.0 continue vector = (coordinates[b.atom] - coordinates[a.atom]) * ANGSTROM_TO_BOHR S[i, j] = S[j, i] = pair_overlap(a, b, vector, aligned) return S
[docs] def hamiltonian_matrix(orbitals: list[AtomicOrbital], S: np.ndarray) -> np.ndarray: """Extended-Hueckel Hamiltonian from the overlap matrix. The Wolfsberg-Helmholz prescription: the diagonal ``H_ii`` is the valence-state ionization energy of the orbital and the off-diagonal ``H_ij = K S_ij (H_ii + H_jj) / 2`` with ``K = 1.75`` (``WOLFSBERG_HELMHOLZ_K``), so a large overlap integral produces a large bonding/antibonding splitting. ``H`` and ``S`` define the generalized eigenproblem ``H C = S C E`` solved by the MO diagrams. Args: orbitals: AO basis from ``build_basis`` (supplies ``H_ii``). S: Overlap matrix of the same basis from ``overlap_matrix``. Returns: Symmetric ``(n_ao, n_ao)`` array in eV. Example: >>> import numpy as np >>> from crystod import mol >>> orbitals = mol.build_basis(["H", "H"], [0, 1]) >>> S = mol.overlap_matrix(orbitals, np.array([[0, 0, 0], [0, 0, 0.74]])) >>> H = mol.hamiltonian_matrix(orbitals, S) >>> print(f"{H[0, 0]:.1f} {H[0, 1]:.3f}") -13.6 -15.146 """ h = np.array([orbital.h_ii for orbital in orbitals]) H = 0.5 * WOLFSBERG_HELMHOLZ_K * (h[:, None] + h[None, :]) * S np.fill_diagonal(H, h) return H
# ------------------------------------------------------------ symmetry setup @dataclass class FragmentShell: """One (fragment, shell) block: SALCs of one orbital shell on one set of symmetry-equivalent sites.""" name: str # e.g. "3H (1s)" or "N 2p" element: str shell: str l: int h_ii: float sites: list[int] is_center: bool salcs: dict = field(default_factory=dict) # irrep -> list of AO-space vectors site_labels: list[str] = field(default_factory=list) def _ao_space_vector(vector, shell: FragmentShell, ao_index, n_ao: int) -> np.ndarray: """Map a (site x orbital) SALC vector onto the full AO basis.""" result = np.zeros(n_ao) width = 2 * shell.l + 1 for k, site in enumerate(shell.sites): for m in range(width): result[ao_index[(site, shell.shell, m)]] = vector[k * width + m] return result def _generalized_eigh(H: np.ndarray, S: np.ndarray): """Solve H C = S C E for a (small) positive-definite S.""" values, vectors = np.linalg.eigh(S) if values.min() < 1e-10: raise SystemExit("ERROR: linearly dependent symmetry-adapted basis.") X = vectors @ np.diag(values ** -0.5) @ vectors.T energies, transformed = np.linalg.eigh(X @ H @ X) return energies, X @ transformed def _group_degenerate(energies, tol: float = 1e-4): """Indices of degenerate groups (list of lists), energies ascending.""" 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 # ---------------------------------------------------------------- MO levels @dataclass class Level: """One horizontal level of the diagram (possibly degenerate).""" column: str # "ligand-ao" | "salc" | "mo" | "center-ao" energy: float # eV degeneracy: int # number of orbitals drawn side by side irrep: str | None # Mulliken symbol (table capitalization) or None label: str # display label, e.g. "2a1", "a1", "2p" electrons: int = 0 composition: list = field(default_factory=list) # (level_id, weight) level_id: str = "" detail: str = "" # tooltip text vectors: list = field(default_factory=list) # AO-space partner vectors def lowercase_irrep(name: str) -> str: return name[0].lower() + name[1:] if name else name
[docs] class MODiagram: """Symmetry-adapted extended-Hueckel MO diagram of a single-center molecule. The engine behind ``crystod-mol --diagram`` (without ``--pyscf`` and without ``--ao-left/--ao-right``). Constructing the object runs the whole analysis: the point group is detected and the molecule rotated into the standard point-group frame; the central atom and the ligand sites are identified; the ligand orbitals of every shell are symmetry-adapted per irrep (``project_salcs``); the extended-Hueckel matrices of the full valence basis are built (``build_basis``, ``overlap_matrix``, ``hamiltonian_matrix``); and the generalized eigenproblem is solved irrep by irrep, first for the ligand cage alone (the SALC levels) and then for the ligand SALCs together with the central-atom orbitals (the molecular orbitals). The electrons are filled in, the MOs are numbered in the photoelectron convention (core shells counted, e.g. ``2a1`` and ``1t2`` for CH4) and given a COOP bonding character. ``print_report`` writes the text report and ``write_html`` the interactive four-column diagram (ligand AOs, ligand SALCs, MOs, central-atom AOs). Args: xyz_path: Path of the molecule file in XYZ format. tolerance: Distance tolerance in Angstrom for the symmetry detection (``--tolerance``). center_element: Element of the central atom (``--center``); by default the atom closest to the molecular center, which must be unambiguous. Attributes: xyz_path: The molecule file as given. formula: Conventional formula, central atom first (``"NH3"``, ``"SF6"``; the group-16 hydrides as ``"H2O"``). schoenflies: Schoenflies symbol of the point group (``"C3v"``). hm: Hermann-Mauguin symbol of the point group (``"3m"``). character_table: Character table of the point group, in the format of ``crystod.group.get_character_table``. operations: Rotation matrices of the group in the standard frame. operation_classes: Class label of every entry of ``operations``. symbols: Element symbol of every atom. coordinates: ``(n_atoms, 3)`` Cartesian coordinates in Angstrom, in the standard point-group frame and symmetrized over the group. center: Site index of the central atom. ligand_sites: Site indices of the ligand atoms, per element. orbitals: The valence AO basis as a list of ``AtomicOrbital``. ao_index: Map from ``(atom, shell, m)`` to the index in ``orbitals``. S: AO overlap matrix. H: Extended-Hueckel Hamiltonian in eV. n_electrons: Number of valence electrons. fragment_shells: ``FragmentShell`` records, one per shell of the central atom and of each ligand element, holding the SALCs of that shell per irrep as AO-space vectors. mo_levels: Molecular-orbital ``Level`` objects, energy ascending. Each has ``energy`` (eV), ``degeneracy``, ``irrep``, ``label`` (``"3a1"``), ``electrons``, ``composition`` (pairs of a level id and its weight, over the SALC and central-AO levels), ``vectors`` (AO-space coefficients), ``bond_character`` (``"bonding"``, ``"nonbonding"`` or ``"antibonding"``) and ``overlap_population``. salc_levels: Ligand SALC ``Level`` objects (the second column). center_levels: Central-atom AO levels (the fourth column). ligand_ao_levels: Isolated ligand AO levels (the first column). levels: All levels keyed by ``("ligand-ao", element, shell)``, ``("center-ao", shell)`` and ``("mo", level_id)``. irrep_blocks: Per-irrep report data (SALC levels, central shells, SALC to central-AO overlap integrals, MO groups). core_counts: Number of core levels per irrep, counted in the MO numbering. core_summary: Text lines describing those core shells (``"N 1s -> a1"``). homo: Highest occupied ``Level`` (``None`` if none is occupied). lumo: Lowest unoccupied ``Level`` (``None`` if all are occupied). Raises: SystemExit: The file is missing, the point group is not one of the 32 crystallographic groups (linear molecules), no unique central atom can be identified, or an element has no extended-Hueckel parameters. Example: >>> from crystod import mol >>> from crystod.examples import example_path >>> diagram = mol.MODiagram(example_path("XYZ_NH3.xyz")) >>> diagram.formula, diagram.schoenflies, diagram.n_electrons ('NH3', 'C3v', 8) >>> [(level.label, level.electrons) for level in diagram.mo_levels] [('2a1', 2), ('1e', 4), ('3a1', 2), ('2e', 0), ('4a1', 0)] >>> diagram.homo.label, diagram.lumo.label ('3a1', '2e') >>> diagram.write_html("MolOD_NH3.html") """ def __init__(self, xyz_path: str, tolerance: float = 0.3, center_element: str | None = None): self.xyz_path = xyz_path self.tolerance = tolerance self.molecule = load_molecule(xyz_path) self.formula = self.molecule.composition.reduced_formula schoenflies, operations = get_symmetry(self.molecule, tolerance) self.schoenflies = schoenflies self.hm = _hm_symbol(schoenflies) if self.hm is None: raise SystemExit( "ERROR: the MO diagram supports the 32 crystallographic point " f"groups; this molecule's group is {schoenflies}." ) 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) # standard point-group frame (as --align): exact table operations self.operations = [table_ops[i] for i in matched] self.operation_classes = [table_classes[i] for i in matched] self.symbols = [site.specie.symbol for site in self.molecule] self.coordinates = ( np.array([site.coords for site in self.molecule]) @ alignment.T ) self._symmetrize_coordinates() self._identify_fragments(center_element) # conventional formula: central atom first (CH4, NH3, SF6, ...); # the group-16 hydrides are conventionally written hydrogen-first self.formula = self.symbols[self.center] + "".join( f"{element}{len(sites)}" if len(sites) > 1 else element for element, sites in sorted(self.ligand_sites.items()) ) self.formula = {"OH2": "H2O", "SH2": "H2S", "SeH2": "H2Se"}.get( self.formula, self.formula ) self._build_matrices() self._project_fragment_salcs() self._count_core_shells() self._solve() # COOP bonding character of every MO (needs the occupations, which # _solve fills) self._assign_bond_characters() def _symmetrize_coordinates(self) -> None: """Average the (numerically noisy) input geometry over the group orbit so that symmetry-required degeneracies are exact.""" permutations = get_permutation_matrices( self.operations, self.coordinates, self.tolerance ) symmetrized = np.zeros_like(self.coordinates) for rotation, permutation in zip(self.operations, permutations): # P[i, j] = 1 when g maps site j onto site i, so g^-1 x_i -> x_j symmetrized += (permutation.T @ self.coordinates) @ rotation self.coordinates = symmetrized / len(self.operations) # -------------------------------------------------- fragment recognition def _identify_fragments(self, center_element: str | None) -> None: distances = np.linalg.norm(self.coordinates, 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; specify " "it with --center EL (single-center molecules only)." ) self.ligand_sites: dict[str, list[int]] = {} for i, symbol in enumerate(self.symbols): if i != self.center: self.ligand_sites.setdefault(symbol, []).append(i) if not self.ligand_sites: raise SystemExit("ERROR: the molecule has no ligand atoms.") # ------------------------------------------------------- matrices, SALCs def _build_matrices(self) -> None: self.orbitals = build_basis(self.symbols, list(range(len(self.symbols)))) self.ao_index = { (ao.atom, ao.shell, ao.m): i for i, ao in enumerate(self.orbitals) } self.S = overlap_matrix(self.orbitals, self.coordinates) self.H = hamiltonian_matrix(self.orbitals, self.S) self.n_electrons = sum(VALENCE_ELECTRONS[s] for s in self.symbols) def _project_fragment_salcs(self) -> None: n_ao = len(self.orbitals) self.fragment_shells: list[FragmentShell] = [] center_symbol = self.symbols[self.center] def make(element, sites, is_center): coordinates = self.coordinates[sites] permutations = get_permutation_matrices( self.operations, coordinates, self.tolerance ) count = len(sites) for shell, _n, l, _zeta, h_ii in EHT_PARAMETERS[element]: if is_center: name = f"{element} {shell}" else: name = f"{count}{element} {shell}" if count > 1 else f"{element} {shell}" fragment = FragmentShell( name, element, shell, l, h_ii, list(sites), is_center ) fragment.site_labels = [ f"{element}{k + 1}" for k in range(count) ] if not is_center else [element] raw = project_salcs( self.operations, self.operation_classes, permutations, l, self.character_table, ) fragment.salcs = { irrep: [ _ao_space_vector(v, fragment, self.ao_index, n_ao) for v in vectors ] for irrep, vectors in raw.items() } self.fragment_shells.append(fragment) for element, sites in sorted(self.ligand_sites.items()): make(element, sites, False) make(center_symbol, [self.center], True) def _count_core_shells(self) -> None: """Number of core levels per irrep (for the MO numbering only). A core shell of angular momentum l decomposes into the same irreps as the corresponding valence shell of the same fragment, so the valence SALC structure is reused for the counting. """ rotation_list = list(self.character_table["rotation_list"]) e_index = rotation_list.index("E") dimension = { irrep: int(round(float(np.real(np.atleast_1d(chars)[e_index])))) for irrep, chars in self.character_table["character_table"].items() } self.core_counts: dict[str, int] = {} self.core_summary: list[str] = [] by_key = {(f.element, f.is_center, f.l): f for f in self.fragment_shells} counted = set() for fragment in self.fragment_shells: if (fragment.element, fragment.is_center) in counted: continue counted.add((fragment.element, fragment.is_center)) for core_shell in CORE_SHELLS[fragment.element]: l = 0 if core_shell.endswith("s") else 1 proxy = by_key.get((fragment.element, fragment.is_center, l)) if proxy is None: continue irreps = [] for irrep, vectors in proxy.salcs.items(): count = len(vectors) // dimension[irrep] self.core_counts[irrep] = self.core_counts.get(irrep, 0) + count irreps.extend([lowercase_irrep(irrep)] * count) origin = ( f"{fragment.element} {core_shell}" if fragment.is_center else f"{len(fragment.sites)}{fragment.element} {core_shell}" ) self.core_summary.append(f"{origin} -> {' + '.join(sorted(irreps))}") # ------------------------------------------------------------ the solve def _solve(self) -> None: """Per-irrep generalized eigenproblems; build all diagram levels.""" self.levels: dict[str, Level] = {} self.mo_levels: list[Level] = [] self.salc_levels: list[Level] = [] self.center_levels: list[Level] = [] self.ligand_ao_levels: list[Level] = [] self.irrep_blocks: list[dict] = [] # per-irrep report data # leftmost / rightmost columns: isolated atomic levels for fragment in self.fragment_shells: if fragment.is_center: continue key = ("ligand-ao", fragment.element, fragment.shell) if key not in self.levels: level = Level( "ligand-ao", fragment.h_ii, 2 * fragment.l + 1, None, fragment.name, level_id=f"lig_{fragment.element}_{fragment.shell}", detail=f"isolated {fragment.element} {fragment.shell} " f"(H_ii = {fragment.h_ii:.1f} eV)", ) self.levels[key] = level self.ligand_ao_levels.append(level) for fragment in self.fragment_shells: if not fragment.is_center: continue irreps = sorted(fragment.salcs) annotation = " + ".join(lowercase_irrep(i) for i in irreps) level = Level( "center-ao", fragment.h_ii, 2 * fragment.l + 1, None, f"{fragment.shell} ({annotation})", level_id=f"cen_{fragment.shell}", detail=f"{fragment.element} {fragment.shell} " f"(H_ii = {fragment.h_ii:.1f} eV), irreps: {annotation}", ) for m in range(2 * fragment.l + 1): unit = np.zeros(len(self.orbitals)) unit[self.ao_index[(self.center, fragment.shell, m)]] = 1.0 level.vectors.append(unit) self.levels[("center-ao", fragment.shell)] = level self.center_levels.append(level) irrep_names = list(self.character_table["character_table"]) mo_records = [] # (energy, irrep, degeneracy, composition, columns) for irrep in irrep_names: ligand_columns, ligand_owner = [], [] center_columns, center_owner = [], [] for fragment in self.fragment_shells: for vector in fragment.salcs.get(irrep, []): if fragment.is_center: center_columns.append(vector) center_owner.append(fragment) else: ligand_columns.append(vector) ligand_owner.append(fragment) if not ligand_columns and not center_columns: continue block = {"irrep": irrep, "salc_levels": [], "center_shells": [], "overlaps": [], "mo_groups": []} # ligand-group SALC levels: ligand-only eigenproblem basis_columns, basis_level_ids = [], [] if ligand_columns: Bl = np.array(ligand_columns).T Sl = Bl.T @ self.S @ Bl Hl = Bl.T @ self.H @ Bl energies_l, C_l = _generalized_eigh(Hl, Sl) Ll = Bl @ C_l # S-orthonormal ligand levels in AO space for group in _group_degenerate(energies_l): energy = float(energies_l[group[0]]) # shell composition of this ligand level (Mulliken) weights: dict[str, float] = {} for column in group: c = C_l[:, column] mulliken = c * (Sl @ c) for w, fragment in zip(mulliken, ligand_owner): weights[fragment.name] = weights.get(fragment.name, 0.0) + float(w) total = sum(weights.values()) shares = {k: v / total for k, v in weights.items()} dominant = max(shares, key=shares.get) level_id = f"salc_{irrep}_{len([l for l in self.salc_levels if l.irrep == irrep])}" detail = ", ".join( f"{100 * share:.0f}% {name}" for name, share in sorted(shares.items(), key=lambda kv: -kv[1]) if share > 0.005 ) level = Level( "salc", energy, len(group), irrep, lowercase_irrep(irrep), level_id=level_id, detail=f"ligand SALC {lowercase_irrep(irrep)}: {detail}", ) # correlation to the isolated-AO column for name, share in shares.items(): fragment = next(f for f in self.fragment_shells if f.name == name) level.composition.append( (f"lig_{fragment.element}_{fragment.shell}", share) ) level.vectors = [Ll[:, c] for c in group] self.salc_levels.append(level) block["salc_levels"].append( (level, level.vectors) ) for column in group: basis_columns.append(Ll[:, column]) basis_level_ids.append(level.level_id) for vector, fragment in zip(center_columns, center_owner): basis_columns.append(vector) basis_level_ids.append(f"cen_{fragment.shell}") center_shells_here = [] for fragment in center_owner: if fragment.shell not in center_shells_here: center_shells_here.append(fragment.shell) block["center_shells"] = center_shells_here # inter-fragment overlap report (SALC level | central shell) for level, level_columns in block["salc_levels"]: for shell in center_shells_here: columns = [ v for v, f in zip(center_columns, center_owner) if f.shell == shell ] M = np.array( [[u @ self.S @ v for v in columns] for u in level_columns] ) value = float(np.linalg.svd(M, compute_uv=False)[0]) if M.size else 0.0 block["overlaps"].append((level, shell, value)) # full block: ligand levels + central AOs B = np.array(basis_columns).T Sb = B.T @ self.S @ B Hb = B.T @ self.H @ B energies, C = _generalized_eigh(Hb, Sb) for group in _group_degenerate(energies): energy = float(energies[group[0]]) weights: dict[str, float] = {} for column in group: c = C[:, column] mulliken = c * (Sb @ c) for w, level_id in zip(mulliken, basis_level_ids): weights[level_id] = weights.get(level_id, 0.0) + float(w) total = sum(weights.values()) composition = [ (level_id, w / total) for level_id, w in weights.items() ] vectors = [B @ C[:, k] for k in group] mo_records.append((energy, irrep, len(group), composition, vectors)) block["mo_groups"].append((energy, len(group), composition)) self.irrep_blocks.append(block) # number the ligand SALC levels per irrep when there is more than one from collections import Counter salc_irrep_counts = Counter(level.irrep for level in self.salc_levels) salc_counter: dict[str, int] = {} for level in sorted(self.salc_levels, key=lambda l: l.energy): if salc_irrep_counts[level.irrep] > 1: salc_counter[level.irrep] = salc_counter.get(level.irrep, 0) + 1 level.label = f"{salc_counter[level.irrep]}{lowercase_irrep(level.irrep)}" # electron filling and MO labels (numbering counts the core shells) mo_records.sort(key=lambda record: record[0]) remaining = self.n_electrons irrep_counter: dict[str, int] = dict(self.core_counts) for energy, irrep, degeneracy, composition, vectors in mo_records: irrep_counter[irrep] = irrep_counter.get(irrep, 0) + 1 label = f"{irrep_counter[irrep]}{lowercase_irrep(irrep)}" electrons = int(min(remaining, 2 * degeneracy)) remaining -= electrons level = Level( "mo", energy, degeneracy, irrep, label, electrons=electrons, composition=composition, level_id=f"mo_{len(self.mo_levels)}", vectors=vectors, ) parts = ", ".join( f"{100 * w:.0f}% {self._level_name(i)}" for i, w in sorted(composition, key=lambda kv: -kv[1]) if w > 0.005 ) level.detail = ( f"{label}: E = {energy:.2f} eV, {electrons} e- | {parts}" ) self.mo_levels.append(level) self.levels[("mo", level.level_id)] = level occupied = [l for l in self.mo_levels if l.electrons > 0] empty = [l for l in self.mo_levels if l.electrons == 0] self.homo = occupied[-1] if occupied else None self.lumo = empty[0] if empty else None def _assign_bond_characters(self) -> None: """COOP bonding character of every MO between the ligand cage (left) and the central atom (right), 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 and expects (n_ao x degeneracy) vector matrices, so lightweight proxy levels adapt the molecular data (fragment columns: one isolated shell per (element, shell) at H_ii with the neutral-atom occupation); the physics is not duplicated. """ from types import SimpleNamespace from .crystal_orbital_diagram import assign_bond_characters rows = { side: np.array( [i for i, ao in enumerate(self.orbitals) if (ao.atom == self.center) == (side == "right")], dtype=int, ) for side in ("left", "right") } spec_lists: dict[tuple[str, str], list[int]] = {} for i, ao in enumerate(self.orbitals): spec_lists.setdefault((ao.element, ao.shell), []).append(i) spec_ranges = { key: np.array(indices, dtype=int) for key, indices in spec_lists.items() } fragments: dict[str, list] = {"left": [], "right": []} seen: set[tuple[str, str, str]] = set() for shell in self.fragment_shells: side = "right" if shell.is_center else "left" if (side, shell.element, shell.shell) in seen: continue seen.add((side, shell.element, shell.shell)) fragments[side].append(SimpleNamespace( label=f"{shell.element} {shell.shell} -", energy=shell.h_ii, electrons=(shell_occupation(shell.element, shell.shell) * len(shell.sites)), )) proxies = [ SimpleNamespace( vectors=np.array(level.vectors).T, # (n_ao, degeneracy) degeneracy=level.degeneracy, electrons=level.electrons, energy=level.energy, label=level.label, detail="", ) for level in self.mo_levels ] assign_bond_characters( {"left": fragments["left"], "mo": proxies, "right": fragments["right"]}, self.S, rows["left"], rows["right"], spec_ranges, ) for level, proxy in zip(self.mo_levels, proxies): level.bond_character = proxy.bond_character level.overlap_population = proxy.overlap_population level.detail += proxy.detail def _level_name(self, level_id: str) -> str: for level in (self.salc_levels + self.center_levels + self.ligand_ao_levels): if level.level_id == level_id: if level.column == "salc": return f"SALC {level.label}" if level.column == "center-ao": return f"{self.symbols[self.center]} {level.label.split()[0]}" return level.label return level_id # ------------------------------------------------------------- reporting
[docs] def print_report(self) -> None: """Print the text report of ``crystod-mol --diagram`` to stdout. Sections: molecule and point group, fragments, the extended-Hueckel AO parameters, the ligand SALCs per irrep, the ligand SALC to central AO overlap integrals, the molecular orbitals (energy, occupation, composition), the electron filling with HOMO, LUMO and gap, and the method references. """ table = self.character_table print("\n* Molecule *") print(f"{self.xyz_path} ({self.formula}, {len(self.symbols)} atoms)") print("\n* Point group *") print(f"{self.schoenflies} (Hermann-Mauguin: {self.hm})") center_symbol = self.symbols[self.center] ligands = ", ".join( f"{len(sites)} {element}" for element, sites in sorted(self.ligand_sites.items()) ) print("\n* Fragments *") print(f"central atom: {center_symbol}; ligands: {ligands}") print( "\n* Valence Atomic Orbital (AO) parameters " "(single-zeta STO, extended Hueckel) *" ) seen = set() for element in [center_symbol] + sorted(self.ligand_sites): if element in seen: continue seen.add(element) for shell, n, l, zeta, h_ii in EHT_PARAMETERS[element]: print(f"{element} {shell}: zeta = {zeta:.3f} / bohr, H_ii = {h_ii:.1f} eV") print("\n* Ligand SALCs (standard point-group axes) *") for fragment in self.fragment_shells: if fragment.is_center: continue width = 2 * fragment.l + 1 orbital_names = [fragment.shell] if fragment.l == 0 else [ fragment.shell[:-1] + p for p in P_LABELS ] term_labels = [ f"{orbital_names[m]}({label})" for label in fragment.site_labels for m in range(width) ] print(f"-- {fragment.name} --") for irrep in table["character_table"]: if irrep not in fragment.salcs: continue site_major = [] for vector in fragment.salcs[irrep]: compact = np.zeros(len(term_labels)) for k, site in enumerate(fragment.sites): for m in range(width): compact[k * width + m] = vector[ self.ao_index[(site, fragment.shell, m)] ] site_major.append(compact) formatted = ", ".join(format_salc(v, term_labels) for v in site_major) print(f"{irrep}: [{formatted}]") print("\n* Ligand SALC | central AO overlap integrals *") any_overlap = False for block in self.irrep_blocks: for level, shell, value in block["overlaps"]: any_overlap = True print( f"{block['irrep']:>4}: < {level.label} (E = {level.energy:7.2f} eV)" f" | {center_symbol} {shell} > S = {value:.4f}" ) if not any_overlap: print("(none: no irrep is shared by the ligand SALCs and the central AOs)") print("\n* Molecular orbitals (Wolfsberg-Helmholz, K = 1.75) *") print(f"{'MO':>6} {'E (eV)':>9} {'occ':>4} composition") for level in reversed(self.mo_levels): parts = ", ".join( f"{100 * w:.0f}% {self._level_name(i)}" for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.005 ) degeneracy = f" x{level.degeneracy}" if level.degeneracy > 1 else "" print(f"{level.label + degeneracy:>6} {level.energy:9.2f} {level.electrons:>4} {parts}") print(f"\n* Electron filling ({self.n_electrons} valence electrons) *") configuration = " ".join( f"({level.label})^{level.electrons}" for level in self.mo_levels if level.electrons > 0 ) print(configuration) if self.core_summary: print("(MO numbering counts the core shells, not shown: " + "; ".join(self.core_summary) + ")") 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: symmetry-adapted extended Hueckel over single-zeta STOs") print("(exact two-center overlaps; energies are semi-quantitative).") print("M. Wolfsberg and L. Helmholz, J. Chem. Phys. 20, 837 (1952);") print("R. Hoffmann, J. Chem. Phys. 39, 1397 (1963).")
# ---------------------------------------------------------- HTML diagram
[docs] def write_html(self, output_path: str) -> None: """Write the interactive HTML/SVG diagram. Four columns (isolated ligand AOs, ligand SALCs, MOs, central-atom AOs) with dashed correlation lines, electron arrows, HOMO/LUMO marks, an adjustable energy window, per-level details on hover and the orbital sketch viewer; the page is self-contained. Args: output_path: Path of the HTML file to write (``crystod-mol`` uses ``MolOD_{molecule}.html`` by default). """ write_diagram_html(self, output_path)
# ------------------------------------------------------- orbital sketches COVALENT_RADII = { "H": 0.31, "Li": 1.28, "Be": 0.96, "B": 0.84, "C": 0.76, "N": 0.71, "O": 0.66, "F": 0.57, "Na": 1.66, "Mg": 1.41, "Al": 1.21, "Si": 1.11, "P": 1.07, "S": 1.05, "Cl": 1.02, } _ELEMENT_COLORS: dict | None = None def element_color(symbol: str) -> str: """VESTA color of an element, for the fragment-level line colors.""" global _ELEMENT_COLORS if _ELEMENT_COLORS is None: colors_path = os.path.join( os.path.dirname(__file__), "vesta_element_rgb.json") try: with open(colors_path) as handle: _ELEMENT_COLORS = json.load(handle) except OSError: _ELEMENT_COLORS = {} rgb = _ELEMENT_COLORS.get(symbol) return "#{:02x}{:02x}{:02x}".format(*rgb) if rgb else "#607d8b" def diagram_geometry(symbols: list[str], coordinates: np.ndarray) -> dict: """Atoms (with VESTA colors) and bonds for the in-panel orbital sketch.""" colors_path = os.path.join(os.path.dirname(__file__), "vesta_element_rgb.json") try: with open(colors_path) as handle: rgb = json.load(handle) except OSError: rgb = {} center = np.asarray(coordinates, dtype=float) center = center - center.mean(axis=0) atoms = [] for symbol, position in zip(symbols, center): color = rgb.get(symbol) color_hex = ( "#{:02x}{:02x}{:02x}".format(*color) if color else "#9e9e9e" ) atoms.append([symbol, round(float(position[0]), 4), round(float(position[1]), 4), round(float(position[2]), 4), color_hex]) bonds = [] for i in range(len(symbols)): for j in range(i + 1, len(symbols)): cutoff = 1.25 * (COVALENT_RADII.get(symbols[i], 0.8) + COVALENT_RADII.get(symbols[j], 0.8)) if np.linalg.norm(center[i] - center[j]) < cutoff: bonds.append([i, j]) radius = float(np.max(np.linalg.norm(center, axis=1))) if len(center) else 1.0 return {"atoms": atoms, "bonds": bonds, "radius": max(radius, 0.8)} def canonical_sketch_partners(per_atom_list: list[dict]) -> list[dict]: """Canonicalize the compressed partner amplitudes of a degenerate level. The partners of an exactly degenerate level are defined only up to an orthogonal mixture; the SCF (or eigh) returns an arbitrary one. Applying the same RREF + Gram-Schmidt canonicalization as the SALC viewer makes the displayed partners match the visualized SALCs (crystod-mol --visualize).""" if len(per_atom_list) < 2: return per_atom_list from .molecular_salc import _rref_orthogonal atoms = sorted(set().union(*[set(d) for d in per_atom_list])) rows = np.array([ np.concatenate([ np.asarray(d.get(a, [0.0, 0.0, 0.0, 0.0]), dtype=float) for a in atoms ]) for d in per_atom_list ]) # drop negligible feature columns (e.g. tiny polarization tails) so the # RREF pivots land on the same dominant components as the SALC viewer peak = np.max(np.abs(rows)) or 1.0 rows[:, np.max(np.abs(rows), axis=0) < 0.05 * peak] = 0.0 canonical = _rref_orthogonal(list(rows)) if len(canonical) != len(rows): return per_atom_list result = [] for row in canonical: per_atom = {} for k, a in enumerate(atoms): values = row[4 * k:4 * k + 4] if np.max(np.abs(values)) > 1e-8: per_atom[a] = [float(v) for v in values] result.append(per_atom) return result def _sketch_entries(per_atom: dict) -> list: """Compress {atom: [s, px, py, pz]} to sketch entries, normalized so the largest component is 1; atoms with negligible amplitude are dropped.""" if not per_atom: return [] peak = max( max(abs(value) for value in values) for values in per_atom.values() ) if peak < 1e-8: return [] entries = [] for atom, values in sorted(per_atom.items()): scaled = [round(float(value) / peak, 3) for value in values] if max(abs(value) for value in scaled) >= 0.04: entries.append([atom] + scaled) return entries # --------------------------------------------------------------- SVG diagram _DIAGRAM_SCRIPT = r""" const CFG = __CONFIG__; let LEVELS = __LEVELS__; let GEOM = __GEOM__; const VARIANTS = __VARIANTS__; const byId = {}; LEVELS.forEach(l => byId[l.id] = l); let eMin = CFG.eMin, eMax = CFG.eMax; const NS = 'http://www.w3.org/2000/svg'; const gGrid = document.getElementById('gGrid'); const gCon = document.getElementById('gCon'); const gLvl = document.getElementById('gLvl'); const svg = document.getElementById('diagram'); function el(name, attrs, text) { const node = document.createElementNS(NS, name); for (const k in attrs) node.setAttribute(k, attrs[k]); if (text !== undefined) node.textContent = text; return node; } // Mulliken label with subscripts, e.g. 2a1g -> 2a_(1g); also 1σg -> 1σ_(g) function mulliken(node, text) { const re = /[σπδφ]/.test(text) ? /(\d*)([σπδφ])([gu]?[+-]?)/g : /\b(\d*)([abet])([123]?[gu]?'{0,2})\b/g; let last = 0, m; while ((m = re.exec(text)) !== null) { if (m.index > last) node.appendChild(document.createTextNode(text.slice(last, m.index))); node.appendChild(document.createTextNode(m[1] + m[2])); if (m[3]) { const sub = el('tspan', {'baseline-shift': 'sub', 'font-size': '9'}); sub.textContent = m[3]; node.appendChild(sub); } last = m.index + m[0].length; } if (last < text.length) node.appendChild(document.createTextNode(text.slice(last))); } function yOf(E) { return CFG.top + (eMax - E) / (eMax - eMin) * CFG.H; } function segments(level) { const x = CFG.columns[level.col], h = CFG.half[level.col], d = level.deg; const seg = Math.min(2 * h, (2 * h + 8) / d - 5); const total = d * seg + (d - 1) * 5; const out = []; for (let k = 0; k < d; k++) { const x1 = x - total / 2 + k * (seg + 5); out.push([x1, x1 + seg]); } return out; } // push overlapping labels apart, keeping clusters centered function nudge(values, gap) { const order = values.map((v, i) => i).sort((a, b) => values[a] - values[b]); const out = values.slice(); for (let k = 1; k < order.length; k++) { const p = order[k - 1], c = order[k]; if (out[c] < out[p] + gap) out[c] = out[p] + gap; } let cluster = order.length ? [order[0]] : []; const clusters = []; for (let k = 1; k < order.length; k++) { const p = order[k - 1], c = order[k]; if (out[c] - out[p] < gap + 1e-6) cluster.push(c); else { clusters.push(cluster); cluster = [c]; } } if (cluster.length) clusters.push(cluster); clusters.forEach(members => { const off = members.reduce((s, i) => s + out[i] - values[i], 0) / members.length; members.forEach(i => out[i] -= off); }); return out; } function tickStep(span) { for (const s of [0.5, 1, 2, 5, 10, 20, 50, 100]) if (span / s <= 12) return s; return 200; } let sketchLevel = null, sketchPartner = 0; let rotYaw = -0.6, rotPitch = 0.35; function show(id) { const d = byId[id]; if (!d) return; const head = document.createElement('h2'); mullikenHtml(head, d.label); let sub = 'E = ' + d.e.toFixed(2) + ' eV'; if (d.deg > 1) sub += ' (×' + d.deg + ')'; if (d.el !== null) sub += ' • ' + d.el + ' e−'; let html = '<div class="sub">' + sub + '</div>'; for (const [name, pct] of d.comp) { html += '<div class="cname">' + name + ' — ' + pct + '%</div>'; html += '<div class="bar" style="width:' + Math.max(3, 2.1 * pct) + 'px"></div>'; } if (d.orb && GEOM) { if (sketchLevel !== id) sketchPartner = 0; html += '<div id="onav"></div>'; html += '<svg id="oview" viewBox="0 0 222 190" width="222" height="190"></svg>'; html += '<div class="ohint">orbital sketch' + (GEOM && GEOM.desc ? ' (' + GEOM.desc + ')' : '') + ' — drag to rotate</div>'; } const body = document.getElementById('pbody'); body.innerHTML = html; body.insertBefore(head, body.firstChild); if (d.orb && GEOM) { sketchLevel = id; setupSketch(d); } } function setupSketch(d) { const nav = document.getElementById('onav'); nav.textContent = ''; // rebuildable: clear before adding partner buttons if (d.orb.length > 1) { for (let k = 0; k < d.orb.length; k++) { const b = document.createElement('button'); b.textContent = k + 1; b.className = 'obtn' + (k === sketchPartner ? ' sel' : ''); b.addEventListener('click', () => { sketchPartner = k; setupSketch(d); }); nav.appendChild(b); } const note = document.createElement('span'); note.className = 'ohint'; note.textContent = ' degenerate partner'; nav.appendChild(note); } const view = document.getElementById('oview'); if (!view.dataset.bound) { // attach the drag handlers only once view.dataset.bound = '1'; let dragging = null; view.addEventListener('pointerdown', e => { dragging = [e.clientX, e.clientY]; view.setPointerCapture(e.pointerId); e.stopPropagation(); }); view.addEventListener('pointermove', e => { if (!dragging) return; rotYaw += (e.clientX - dragging[0]) * 0.012; rotPitch += (e.clientY - dragging[1]) * 0.012; dragging = [e.clientX, e.clientY]; drawSketch(byId[sketchLevel]); }); view.addEventListener('pointerup', () => dragging = null); } drawSketch(d); } // symmetric 3x3 eigen-decomposition (Jacobi) for the d-lobe axes function jacobi3(A) { let a = A.map(r => r.slice()); let V = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; for (let sweep = 0; sweep < 30; sweep++) { let p = 0, q = 1, m = Math.abs(a[0][1]); if (Math.abs(a[0][2]) > m) { p = 0; q = 2; m = Math.abs(a[0][2]); } if (Math.abs(a[1][2]) > m) { p = 1; q = 2; m = Math.abs(a[1][2]); } if (m < 1e-9) break; const th = 0.5 * Math.atan2(2 * a[p][q], a[q][q] - a[p][p]); const c = Math.cos(th), s = Math.sin(th); const an = a.map(r => r.slice()), Vn = V.map(r => r.slice()); for (let k = 0; k < 3; k++) { an[k][p] = c * a[k][p] - s * a[k][q]; an[k][q] = s * a[k][p] + c * a[k][q]; } const a2 = an.map(r => r.slice()); for (let k = 0; k < 3; k++) { a2[p][k] = c * an[p][k] - s * an[q][k]; a2[q][k] = s * an[p][k] + c * an[q][k]; } for (let k = 0; k < 3; k++) { Vn[k][p] = c * V[k][p] - s * V[k][q]; Vn[k][q] = s * V[k][p] + c * V[k][q]; } a = a2; V = Vn; } return [0, 1, 2].map(i => [a[i][i], [V[0][i], V[1][i], V[2][i]]]); } function drawSketch(d) { const view = document.getElementById('oview'); if (!view) return; const cy2 = Math.cos(rotYaw), sy2 = Math.sin(rotYaw); const cp = Math.cos(rotPitch), sp = Math.sin(rotPitch); function rot(p) { const x1 = cy2 * p[0] + sy2 * p[2]; const z1 = -sy2 * p[0] + cy2 * p[2]; return [x1, cp * p[1] - sp * z1, sp * p[1] + cp * z1]; } const scale = 78 / (GEOM.radius + 0.7); const cx = 111, cyc = 92; const pts = GEOM.atoms.map(a => rot([a[1], a[2], a[3]])); const prims = []; if (GEOM.cell) { // periodic boundary: dashed supercell frame (crystal diagrams only; // GEOM.cell = [origin, a1, a2, a3] in the centered sketch coordinates) const [o, va, vb, vc] = GEOM.cell; const corner = (fa, fb, fc) => rot([ o[0] + fa * va[0] + fb * vb[0] + fc * vc[0], o[1] + fa * va[1] + fb * vb[1] + fc * vc[1], o[2] + fa * va[2] + fb * vb[2] + fc * vc[2]]); const C = []; for (let fa = 0; fa <= 1; fa++) for (let fb = 0; fb <= 1; fb++) for (let fc = 0; fc <= 1; fc++) C.push(corner(fa, fb, fc)); [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7], [4,5],[4,6],[5,7],[6,7]].forEach(([i, j]) => { prims.push([0.5 * (C[i][2] + C[j][2]) - 60, '<line x1="' + (cx + scale * C[i][0]).toFixed(1) + '" y1="' + (cyc - scale * C[i][1]).toFixed(1) + '" x2="' + (cx + scale * C[j][0]).toFixed(1) + '" y2="' + (cyc - scale * C[j][1]).toFixed(1) + '" stroke="#90a4ae" stroke-width="1" stroke-dasharray="4 3"/>']); }); } GEOM.bonds.forEach(([i, j]) => { prims.push([0.5 * (pts[i][2] + pts[j][2]) - 50, '<line x1="' + (cx + scale * pts[i][0]).toFixed(1) + '" y1="' + (cyc - scale * pts[i][1]).toFixed(1) + '" x2="' + (cx + scale * pts[j][0]).toFixed(1) + '" y2="' + (cyc - scale * pts[j][1]).toFixed(1) + '" stroke="#b0bec5" stroke-width="2.4"/>']); }); GEOM.atoms.forEach((a, i) => { const r = a[0] === 'H' ? 3.2 : 5; prims.push([pts[i][2], '<circle cx="' + (cx + scale * pts[i][0]).toFixed(1) + '" cy="' + (cyc - scale * pts[i][1]).toFixed(1) + '" r="' + r + '" fill="' + a[4] + '" stroke="#546e7a" stroke-width="0.8"/>']); }); const POS = '#e8c400', NEG = '#19b8d8'; (d.orb[sketchPartner] || []).forEach(entry => { const i = entry[0], s = entry[1], p = [entry[2], entry[3], entry[4]]; const ax = cx + scale * pts[i][0], ay = cyc - scale * pts[i][1]; if (Math.abs(s) >= 0.04) { prims.push([pts[i][2] + 0.02, '<circle cx="' + ax.toFixed(1) + '" cy="' + ay.toFixed(1) + '" r="' + (3 + 13 * Math.abs(s)).toFixed(1) + '" fill="' + (s > 0 ? POS : NEG) + '" fill-opacity="0.55" stroke="' + (s > 0 ? POS : NEG) + '" stroke-opacity="0.9"/>']); } const pn = Math.hypot(p[0], p[1], p[2]); if (pn >= 0.04) { const v = rot(p); const L = 5 + 15 * pn; const sn = Math.hypot(v[0], v[1]) || 1e-6; const ux = v[0] / sn, uy = -v[1] / sn; const off = L * 0.62 * Math.min(1, sn / pn); const rl = (2.5 + L * 0.42).toFixed(1); prims.push([pts[i][2] + 0.02 + 0.3 * v[2] / (pn || 1), '<circle cx="' + (ax + off * ux).toFixed(1) + '" cy="' + (ay + off * uy).toFixed(1) + '" r="' + rl + '" fill="' + POS + '" fill-opacity="0.55" stroke="' + POS + '" stroke-opacity="0.9"/>']); prims.push([pts[i][2] + 0.02 - 0.3 * v[2] / (pn || 1), '<circle cx="' + (ax - off * ux).toFixed(1) + '" cy="' + (ay - off * uy).toFixed(1) + '" r="' + rl + '" fill="' + NEG + '" fill-opacity="0.55" stroke="' + NEG + '" stroke-opacity="0.9"/>']); } if (entry.length >= 10) { // d part: lobes along the eigenaxes of the symmetric traceless matrix const dv = [entry[5], entry[6], entry[7], entry[8], entry[9]]; const dn = Math.hypot(dv[0], dv[1], dv[2], dv[3], dv[4]); if (dn >= 0.04) { const s3 = 1 / Math.sqrt(3); const M = [ [-s3 * dv[2] + dv[4], dv[0], dv[3]], [dv[0], -s3 * dv[2] - dv[4], dv[1]], [dv[3], dv[1], 2 * s3 * dv[2]], ]; jacobi3(M).forEach(pair => { const lam = pair[0], axis = pair[1]; if (Math.abs(lam) < 0.28 * dn) return; const v = rot(axis); const L = 4 + 13 * Math.abs(lam); const sn2 = Math.hypot(v[0], v[1]) || 1e-6; const ux = v[0] / sn2, uy = -v[1] / sn2; const off = L * 0.6 * Math.min(1, sn2); const rl = (2 + L * 0.4).toFixed(1); const color = lam > 0 ? POS : NEG; [1, -1].forEach(sgn => { prims.push([pts[i][2] + 0.02 + sgn * 0.3 * v[2], '<circle cx="' + (ax + sgn * off * ux).toFixed(1) + '" cy="' + (ay + sgn * off * uy).toFixed(1) + '" r="' + rl + '" fill="' + color + '" fill-opacity="0.5" stroke="' + color + '" stroke-opacity="0.85"/>']); }); }); } } }); prims.sort((a, b) => a[0] - b[0]); view.innerHTML = prims.map(p => p[1]).join(''); } // HTML (panel) version of the subscript renderer function mullikenHtml(node, text) { const re = /[σπδφ]/.test(text) ? /(\d*)([σπδφ])([gu]?[+-]?)/g : /\b(\d*)([abet])([123]?[gu]?'{0,2})\b/g; let last = 0, m; while ((m = re.exec(text)) !== null) { node.appendChild(document.createTextNode(text.slice(last, m.index) + m[1] + m[2])); if (m[3]) { const sub = document.createElement('sub'); sub.textContent = m[3]; node.appendChild(sub); } last = m.index + m[0].length; } node.appendChild(document.createTextNode(text.slice(last))); } function highlight(id, on) { gCon.querySelectorAll('.con').forEach(c => { const linked = c.classList.contains('l_' + id); c.classList.toggle('hi', on && linked); c.classList.toggle('dim', on && !linked); }); } function render() { gGrid.textContent = ''; gCon.textContent = ''; gLvl.textContent = ''; document.getElementById('emin').value = eMin.toFixed(1); document.getElementById('emax').value = eMax.toFixed(1); // gridlines and ticks const step = tickStep(eMax - eMin); for (let v = Math.ceil(eMin / step) * step; v <= eMax + 1e-9; v += step) { const y = yOf(v); gGrid.appendChild(el('line', {x1: CFG.left - 13, y1: y, x2: CFG.left - 3, y2: y, 'class': 'axis'})); gGrid.appendChild(el('text', {x: CFG.left - 17, y: y + 4, 'class': 'tick', 'text-anchor': 'end'}, String(Math.round(v * 100) / 100))); gGrid.appendChild(el('line', {x1: CFG.left + 2, y1: y, x2: CFG.width - 20, y2: y, 'class': 'grid'})); } const visible = LEVELS.filter(l => { const y = yOf(l.e); return y >= CFG.top - 6 && y <= CFG.top + CFG.H + 6; }); const visIds = new Set(visible.map(l => l.id)); // connectors (below the levels) visible.forEach(level => { const x = CFG.columns[level.col], y = yOf(level.e); (level.links || []).forEach(([sid, w]) => { const src = byId[sid]; if (!src || !visIds.has(sid)) return; const sx = CFG.columns[src.col], sy = yOf(src.e); let x1, x2; if (sx < x) { x1 = sx + CFG.half[src.col]; x2 = x - CFG.half[level.col]; } else { x1 = sx - CFG.half[src.col]; x2 = x + CFG.half[level.col]; } const line = el('line', {x1: x1, y1: sy, x2: x2, y2: y, 'class': 'con l_' + level.id + ' l_' + sid}); line.style.opacity = (0.2 + 0.55 * Math.min(1, w)).toFixed(2); gCon.appendChild(line); }); }); // levels with nudged labels per column CFG.order.forEach(col => { const levels = visible.filter(l => l.col === col); const rawY = levels.map(l => yOf(l.e)); const labY = nudge(rawY, 13); const side = CFG.side[col]; levels.forEach((level, i) => { const x = CFG.columns[col], y = rawY[i], h = CFG.half[col]; const g = el('g', {'class': 'lvl', tabindex: 0}); g.dataset.id = level.id; let css = level.el !== null ? (level.occ ? 'occ' : 'virt') : 'frag'; // bonding-character coloring (crystal-orbital diagrams with --pyscf): // blue = bonding, black = nonbonding, red = antibonding if (level.bond) css += ' bond-' + level.bond; segments(level).forEach(([x1, x2], k) => { const seg = el('line', {x1: x1, y1: y, x2: x2, y2: y, 'class': 'seg ' + css}); // fragment/sublattice levels: VESTA element color of the dominant shell if (level.elc) seg.style.stroke = level.elc; g.appendChild(seg); if (level.el) { const d = level.deg; const ups = Math.min(level.el, d) > k ? 1 : 0; const downs = (level.el - Math.min(level.el, d)) > k ? 1 : 0; const arrows = '↑'.repeat(ups) + '↓'.repeat(downs); if (arrows) g.appendChild(el('text', {x: (x1 + x2) / 2, y: y - 3, 'class': 'el', 'text-anchor': 'middle'}, arrows)); } }); const lx = x + side * (h + 9), ly = labY[i]; if (Math.abs(ly - y) > 6) g.appendChild(el('line', {x1: x + side * h, y1: y, x2: lx - side * 2, y2: ly, 'class': 'lead'})); const text = el('text', {x: lx, y: ly + 4, 'class': 'lab', 'text-anchor': side > 0 ? 'start' : 'end'}); mulliken(text, level.label); g.appendChild(text); const title = el('title', {}); title.textContent = level.detail; g.appendChild(title); if (level.id === CFG.homo || level.id === CFG.lumo) g.appendChild(el('text', {x: lx + 40, y: ly + 4, 'class': 'hl'}, level.id === CFG.homo ? 'HOMO' : 'LUMO')); g.addEventListener('mouseenter', () => { highlight(level.id, true); show(level.id); }); g.addEventListener('mouseleave', () => highlight(level.id, false)); g.addEventListener('click', () => show(level.id)); gLvl.appendChild(g); }); }); } function setRange(lo, hi) { if (!(isFinite(lo) && isFinite(hi)) || hi - lo < 0.5) return; eMin = lo; eMax = hi; render(); } document.getElementById('emin').addEventListener('change', e => setRange(parseFloat(e.target.value), eMax)); document.getElementById('emax').addEventListener('change', e => setRange(eMin, parseFloat(e.target.value))); document.getElementById('ereset').addEventListener('click', () => setRange(CFG.eMin, CFG.eMax)); document.getElementById('eshowall').addEventListener('click', () => { const Es = LEVELS.map(l => l.e); const lo = Math.min(...Es), hi = Math.max(...Es); const pad = 0.03 * (hi - lo) || 1.0; setRange(lo - pad, hi + pad); }); // k-point variants (crystal-orbital diagrams): swap the level set in place function applyVariant(index) { const variant = VARIANTS[index]; LEVELS = variant.levels; if (variant.geom) GEOM = variant.geom; CFG.homo = variant.homo; CFG.lumo = variant.lumo; CFG.eMin = variant.eMin; CFG.eMax = variant.eMax; Object.keys(byId).forEach(key => delete byId[key]); LEVELS.forEach(l => byId[l.id] = l); document.querySelectorAll('.kbtn').forEach((btn, i) => btn.classList.toggle('sel', i === index)); eMin = CFG.eMin; eMax = CFG.eMax; render(); const panel = document.getElementById('panel'); if (panel) show(CFG.homo && byId[CFG.homo] ? CFG.homo : LEVELS[0].id); } if (VARIANTS) { document.querySelectorAll('.kbtn').forEach((btn, i) => btn.addEventListener('click', () => applyVariant(i))); // ?k=R (or #R) chooses the k point the page opens on, so that a copy embedded // in another page can start at any point of the star without regenerating it const asked = (new URLSearchParams(location.search).get('k') || decodeURIComponent(location.hash.slice(1)) || '').trim(); if (asked) { const norm = s => String(s).toLowerCase().replace(/[\s()]/g, ''); const want = norm(asked); const index = VARIANTS.findIndex(v => norm(v.key) === want || norm(String(v.key).split(' ')[0]) === want); if (index >= 0) applyVariant(index); } } // Ctrl/Cmd + wheel (or trackpad pinch): zoom the energy axis around the cursor svg.addEventListener('wheel', e => { if (!(e.ctrlKey || e.metaKey)) return; // plain scroll keeps scrolling the page e.preventDefault(); const box = svg.getBoundingClientRect(); const my = (e.clientY - box.top) * (CFG.height / box.height); const Ec = eMax - (my - CFG.top) / CFG.H * (eMax - eMin); const f = e.deltaY > 0 ? 1.15 : 1 / 1.15; const lo = Ec + (eMin - Ec) * f, hi = Ec + (eMax - Ec) * f; if (hi - lo >= 0.5 && hi - lo <= 2000) setRange(lo, hi); }, {passive: false}); // drag: pan the energy window let dragY = null, dragMin = 0, dragMax = 0; svg.addEventListener('pointerdown', e => { dragY = e.clientY; dragMin = eMin; dragMax = eMax; svg.setPointerCapture(e.pointerId); }); svg.addEventListener('pointermove', e => { if (dragY === null) return; const box = svg.getBoundingClientRect(); const dE = (e.clientY - dragY) * (CFG.height / box.height) / CFG.H * (dragMax - dragMin); eMin = dragMin + dE; eMax = dragMax + dE; render(); }); svg.addEventListener('pointerup', () => dragY = null); svg.addEventListener('pointercancel', () => dragY = null); render(); """ def svg_sub_digits(text: str) -> str: """Digits after a letter as SVG subscripts (H4 -> H_4) for column headers.""" import re return re.sub( r"(?<=[A-Za-z])(\d+)", r'<tspan baseline-shift="sub" font-size="10">\1</tspan>', text, ) def render_diagram_page( output_path: str, *, title: str, heading_html: str, chips: list[str], columns: dict[str, float], half: dict[str, float], order: list[str], side: dict[str, int], headers: dict[str, str], levels_json: list[dict], homo_id: str | None, lumo_id: str | None, e_min: float, e_max: float, foot_html: str, geometry: dict | None = None, variants: list[dict] | None = None, ) -> None: """Write the standalone interactive MO-diagram page. Fully generic in the column layout: ``order`` lists the column keys left to right, ``columns``/``half`` give their x positions and level half widths, ``side`` the label side (+1 right, -1 left), ``headers`` the column titles (SVG-ready). The level records carry everything else; the embedded script renders the plot client-side so the energy window can be changed interactively (input boxes, Ctrl/Cmd + scroll zoom, drag pan). """ width, height = 960, 780 top, left = 84, 74 plot_height = height - top - 36 config = { "width": width, "height": height, "top": top, "left": left, "H": plot_height, "columns": columns, "half": half, "order": order, "side": side, "eMin": round(e_min, 2), "eMax": round(e_max, 2), "homo": homo_id, "lumo": lumo_id, } header_svg = "".join( f'<text x="{columns[column]}" y="{top - 40}" class="header" ' f'text-anchor="middle">{text}</text>' for column, text in headers.items() ) chip_html = "".join(f'<span class="chip">{chip}</span>' for chip in chips if chip) script = ( _DIAGRAM_SCRIPT .replace("__CONFIG__", json.dumps(config)) .replace("__LEVELS__", json.dumps(levels_json, ensure_ascii=False)) .replace("__GEOM__", json.dumps(geometry)) .replace("__VARIANTS__", json.dumps(variants, ensure_ascii=False)) ) kbar_html = "" if variants: buttons = "".join( f'<button class="kbtn obtn{" sel" if i == 0 else ""}">{v["key"]}</button>' for i, v in enumerate(variants) ) kbar_html = f'<div id="kbar">k point: {buttons}</div>' from .cli.common import CRYSTOD_CITATION_HTML as citation_html html = f"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>{title}</title> <style> body {{ font-family: 'Helvetica Neue', Arial, sans-serif; margin: 0; background: #fafafa; color: #222; }} #page {{ max-width: 1280px; margin: 0 auto; padding: 14px 18px; }} h1 {{ font-size: 19px; margin: 4px 0 6px; font-weight: 600; }} .chip {{ display: inline-block; background: #eceff1; border-radius: 4px; padding: 2px 9px; margin: 0 6px 6px 0; font-size: 12.5px; color: #37474f; }} /* the diagram carries a viewBox, so it is allowed to shrink into the available width instead of pushing the level panel underneath it -- an embedded copy (iframe) is usually narrower than the natural 960 + 250 px */ #flex {{ display: flex; gap: 14px; align-items: flex-start; }} #flex > #diagram {{ flex: 1 1 auto; min-width: 0; max-width: 100%; height: auto; }} #controls {{ font-size: 12.5px; color: #37474f; margin: 2px 0 8px; }} #controls input {{ width: 62px; font-size: 12.5px; padding: 1px 4px; border: 1px solid #b0bec5; border-radius: 3px; }} #controls button {{ font-size: 12px; padding: 2px 10px; margin-left: 6px; border: 1px solid #b0bec5; border-radius: 3px; background: #eceff1; cursor: pointer; }} #controls .hint {{ color: #90a4ae; margin-left: 10px; }} #kbar {{ font-size: 12.5px; color: #37474f; margin: 2px 0 4px; }} #kbar .kbtn {{ font-size: 12px; padding: 2px 10px; margin: 0 4px 4px 0; border: 1px solid #b0bec5; border-radius: 3px; background: #eceff1; cursor: pointer; }} #kbar .kbtn.sel {{ background: #1565c0; color: #fff; border-color: #1565c0; }} svg {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; touch-action: none; cursor: ns-resize; }} #panel {{ width: 250px; flex: 0 0 250px; background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 12px 14px; font-size: 13px; min-height: 120px; }} /* below this width the diagram would be squeezed past readability: stack instead */ @media (max-width: 720px) {{ #flex {{ flex-wrap: wrap; }} #panel {{ flex: 1 1 100%; width: auto; }} }} #panel h2 {{ font-size: 14px; margin: 0 0 4px; }} #panel .sub {{ color: #666; font-size: 12px; margin-bottom: 8px; }} #oview {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 5px; margin-top: 8px; cursor: grab; touch-action: none; display: block; }} .ohint {{ color: #90a4ae; font-size: 11px; margin-top: 2px; }} .obtn {{ font-size: 11px; padding: 1px 8px; margin: 6px 4px 0 0; border: 1px solid #b0bec5; border-radius: 3px; background: #eceff1; cursor: pointer; }} .obtn.sel {{ background: #1565c0; color: #fff; border-color: #1565c0; }} .bar {{ height: 8px; background: #90caf9; border-radius: 3px; margin: 1px 0 5px; }} .cname {{ font-size: 12.2px; }} .axis {{ stroke: #555; stroke-width: 1; }} .grid {{ stroke: #000; stroke-opacity: 0.045; }} .tick {{ font-size: 11px; fill: #555; }} .axistitle {{ font-size: 12.5px; fill: #333; }} .header {{ font-size: 13.5px; font-weight: 600; fill: #263238; }} .seg {{ stroke-width: 2.4; }} .seg.frag {{ stroke: #607d8b; }} .seg.occ {{ stroke: #1565c0; }} .seg.virt {{ stroke: #b0bec5; }} .seg.bond-b {{ stroke: #1565c0; }} .seg.bond-n {{ stroke: #333333; }} .seg.bond-a {{ stroke: #d32f2f; }} .con {{ stroke: #888; stroke-width: 1; stroke-dasharray: 5 4; }} .con.hi {{ stroke: #e65100; stroke-width: 1.6; opacity: 0.95 !important; }} .con.dim {{ opacity: 0.06 !important; }} .lab {{ font-size: 12.5px; fill: #222; }} .lead {{ stroke: #bbb; stroke-width: 0.7; }} .el {{ font-size: 10.5px; fill: #1565c0; }} .hl {{ font-size: 11px; fill: #e65100; font-weight: 600; }} .lvl {{ cursor: pointer; outline: none; }} .lvl:hover .seg {{ stroke-width: 4; }} #foot {{ color: #777; font-size: 11.5px; margin-top: 8px; }} </style> </head> <body> <div id="page"> <h1>{heading_html}</h1> <div>{chip_html}</div> {kbar_html} <div id="controls"> Energy window (eV): <input id="emin" type="number" step="1"> &ndash; <input id="emax" type="number" step="1"> <button id="ereset">reset</button> <button id="eshowall">Show all energy levels</button> <span class="hint">Ctrl/&#8984; + scroll (or pinch) to zoom, drag to pan</span> </div> <div id="flex"> <svg id="diagram" viewBox="0 0 {width} {height}" width="{width}" height="{height}"> <defs><clipPath id="plotclip"> <rect x="{left - 14}" y="{top - 10}" width="{width - left - 6}" height="{plot_height + 20}"/> </clipPath></defs> {header_svg} <line x1="{left - 8}" y1="{top - 14}" x2="{left - 8}" y2="{top + plot_height + 6}" class="axis"/> <text x="{left - 44}" y="{top + plot_height / 2:.1f}" class="axistitle" transform="rotate(-90 {left - 44} {top + plot_height / 2:.1f})" text-anchor="middle">E (eV)</text> <g id="gGrid"></g> <g id="gCon" clip-path="url(#plotclip)"></g> <g id="gLvl" clip-path="url(#plotclip)"></g> </svg> <div id="panel"><h2>Level details</h2> <div class="sub">hover or click a level</div><div id="pbody"></div></div> </div> <div id="foot">{foot_html}<br>{citation_html}</div> </div> <script> {script} </script> </body> </html> """ with open(output_path, "w") as handle: handle.write(html) def write_diagram_html(diagram: MODiagram, output_path: str) -> None: import re columns = {"ligand-ao": 165, "salc": 360, "mo": 600, "center-ao": 830} half = {"ligand-ao": 30, "salc": 30, "mo": 34, "center-ao": 30} order = ["ligand-ao", "salc", "mo", "center-ao"] side = {"ligand-ao": -1, "salc": -1, "mo": 1, "center-ao": 1} all_levels = ( diagram.ligand_ao_levels + diagram.salc_levels + diagram.mo_levels + diagram.center_levels ) energies = [level.energy for level in all_levels] e_min, e_max = min(energies), max(energies) padding = 0.06 * (e_max - e_min) or 1.0 def per_atom_components(vector): components: dict[int, list[float]] = {} for index, orbital in enumerate(diagram.orbitals): if abs(vector[index]) < 1e-10: continue values = components.setdefault(orbital.atom, [0.0, 0.0, 0.0, 0.0]) if orbital.l == 0: values[0] += float(vector[index]) else: values[1 + orbital.m] += float(vector[index]) return components bond_letter = {"bonding": "b", "nonbonding": "n", "antibonding": "a"} def level_element(level): """Element of the level's dominant shell (fragment columns only), for the VESTA line color.""" if level.column == "center-ao": return diagram.symbols[diagram.center] if level.column == "ligand-ao": return level.level_id.split("_")[1] # "lig_{El}_{shell}" if level.column == "salc" and level.composition: source_id = max(level.composition, key=lambda kv: kv[1])[0] return source_id.split("_")[1] return None levels_json = [] for level in all_levels: partners = canonical_sketch_partners( [per_atom_components(vector) for vector in level.vectors] ) orb = [_sketch_entries(per_atom) for per_atom in partners] or None character = getattr(level, "bond_character", None) element = level_element(level) levels_json.append({ **({"elc": element_color(element)} if element 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 if level.column == "mo" else None, "occ": bool(level.column == "mo" and level.electrons > 0), "links": [ [source_id, round(weight, 4)] for source_id, weight in level.composition if weight >= 0.02 ], "comp": [ [diagram._level_name(source_id), round(100 * weight, 1)] for source_id, weight in sorted(level.composition, key=lambda kv: -kv[1]) if weight > 0.005 ], "detail": level.detail, "orb": orb, }) center_symbol = diagram.symbols[diagram.center] ligand_parts = [ (f"{len(sites)}{element}" if len(sites) > 1 else element) for element, sites in sorted(diagram.ligand_sites.items()) ] salc_text = "".join( f"{element}{len(sites)}" if len(sites) > 1 else element for element, sites in sorted(diagram.ligand_sites.items()) ) headers = { "ligand-ao": f"{' + '.join(ligand_parts)} AOs", "salc": f"{svg_sub_digits(salc_text)} SALCs", "mo": f"{svg_sub_digits(diagram.formula)} MOs", "center-ao": f"{center_symbol} AOs", } formula_html = re.sub(r"(\d+)", r"<sub>\1</sub>", diagram.formula) gap = "" if diagram.homo and diagram.lumo: gap = ( f"HOMO&ndash;LUMO gap {diagram.lumo.energy - diagram.homo.energy:.2f} eV" ) chips = [ formula_html, f"{diagram.schoenflies} ({diagram.hm})", f"{diagram.n_electrons} valence electrons", gap, "extended H&uuml;ckel / STO overlaps", ] bond_foot = "" if any(getattr(level, "bond_character", None) for level in diagram.mo_levels): bond_foot = ( " MO line colors: " "<span style=\"color:#1565c0\">bonding</span> / " "<span style=\"color:#333\">nonbonding</span> / " "<span style=\"color:#d32f2f\">antibonding</span>, from the " "ligand&ndash;central-atom 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)." ) render_diagram_page( output_path, title=f"MO diagram: {diagram.formula} ({diagram.schoenflies})", heading_html=f"Molecular-orbital diagram: {formula_html}", chips=chips, columns=columns, half=half, order=order, side=side, headers=headers, levels_json=levels_json, homo_id=diagram.homo.level_id if diagram.homo else None, lumo_id=diagram.lumo.level_id if diagram.lumo else None, e_min=e_min - padding, e_max=e_max + padding, foot_html=( "Semi-quantitative diagram from symmetry + overlap only " "(symmetry-adapted extended H&uuml;ckel, Wolfsberg&ndash;Helmholz " "K = 1.75, single-&zeta; STOs, exact two-center overlap integrals)." + bond_foot + " Generated by CrystOD (crystod-mol --diagram)." ), geometry=diagram_geometry(diagram.symbols, diagram.coordinates), ) # ----------------------------------------------------------------------- CLI def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Semi-quantitative molecular-orbital diagram from symmetry " "and overlap (symmetry-adapted extended Hueckel)." ) parser.add_argument("--xyz", required=True, metavar="FILE", help="Molecule file in XYZ format.") parser.add_argument("--center", default=None, metavar="EL", help="Element of the central atom (default: the atom " "closest to the molecular center).") parser.add_argument("--tolerance", type=float, default=0.3, help="Distance tolerance (Angstrom) for the symmetry " "detection (default: 0.3).") parser.add_argument("--output", default=None, metavar="FILE", help="Output HTML path " "(default: MolOD_{molecule}.html, or " "MolOD_{molecule}_pyscf.html with --pyscf).") parser.add_argument("--pyscf", action="store_true", help="Quantitative diagram from three PySCF SCF " "calculations (molecule + two fragments in the " "full molecular basis).") parser.add_argument("--basis", default="def2-svp", type=str.lower, help="PySCF basis set (default: def2-svp).") parser.add_argument("--theory", default="scf", choices=["scf", "dft"], help="PySCF level of theory (default: scf = Hartree-Fock).") parser.add_argument("--xc", default="b3lyp", type=str.lower, help="Exchange-correlation functional for --theory dft " "(default: b3lyp).") parser.add_argument("--charge", type=int, default=0, help="Total charge of the molecule (--pyscf; default 0).") parser.add_argument("--spin", type=int, default=None, help="Molecular spin 2S (--pyscf; default: 0 or 1 by " "electron parity, e.g. use --spin 2 for triplet O2).") parser.add_argument("--ao-left", default=None, metavar="FORMULA", help="Left-fragment formula, e.g. H4 or O; without " "--pyscf this selects the two-fragment " "extended-Hueckel diagram (with --pyscf, " "default: the ligand atoms).") parser.add_argument("--ao-right", default=None, metavar="FORMULA", help="Right-fragment formula, e.g. CO or O " "(with --pyscf, default: the central atom).") return parser def main(argv: list[str] | None = None) -> None: from .cli.common import print_crystod_citation parser = build_parser() args = parser.parse_args(argv) if args.pyscf: from .mo_diagram_pyscf import run_pyscf_diagram run_pyscf_diagram(args) print_crystod_citation() return if args.ao_left or args.ao_right: from .mo_diagram_fragment import run_fragment_diagram run_fragment_diagram(args) print_crystod_citation() return diagram = MODiagram(args.xyz, args.tolerance, args.center) diagram.print_report() stem = os.path.splitext(os.path.basename(args.xyz))[0] output_path = args.output or f"MolOD_{stem}.html" diagram.write_html(output_path) print(f"\nMO diagram written to {output_path}") print_crystod_citation() print() if __name__ == "__main__": main()