Source code for crystod.multiplet_energy

"""Coulomb multiplet energies in Racah/Slater parameters (crystod-group --multiplet).

For an electron configuration over irrep shells of one atomic l shell, the
diagonal energies <psi(term)|H_ee|psi(term)> of the electron-electron
(Coulomb) interaction are exact linear combinations of the Slater-Condon
parameters F^k (k = 0, 2, ..., 2l).  For d shells they are expressed in the
Racah parameters A, B, C (F^0 = A + 7C/5, F^2 = 49B + 7C, F^4 = 63C/5), e.g.
(t2g)^3 of m-3m:

    ^4A2g: 3A - 15B      ^2Eg: 3A - 6B + 3C
    ^2T1g: 3A - 6B + 3C  ^2T2g: 3A + 5C

(Tanabe-Sugano strong-field energies).  The pipeline is exact and general:

1. two-electron integrals <m1 m2|1/r12|m3 m4> in the complex Y_lm basis from
   Gaunt coefficients (sympy, exact), transformed to the real-orbital basis;
2. the shell orbitals are identified as the irrep subspaces of the
   (2l+1)-dimensional real-orbital representation (projection with
   wigner_D_real over the point-group elements);
3. the Coulomb Hamiltonian is built over the Slater determinants of the
   configuration (Slater-Condon rules) and each term is isolated with a total-
   spin projector (polynomial in S^2) times the point-group character
   projector (the group action on determinants);
4. a term appearing once has an exact linear energy; a term appearing m times
   mixes within the configuration (configuration interaction): for m = 2 the
   two eigenvalues are printed in closed form (mean +- sqrt of a quadratic
   form), for larger m numerically at C/B = 4.5.

Every run is closed by the trace identity sum (2S+1) dim(Gamma) E = tr(H_ee)
over the configuration.  The one-electron (crystal-field) part is an additive
constant within a configuration and is omitted.
"""

from __future__ import annotations

from fractions import Fraction
from itertools import combinations
from math import comb

import numpy as np

from .ligand_field import ORBITAL_AZIMUTHAL_NUMBER
from .molecular_salc import _table_operations_cartesian
from .operations import wigner_D_real

# Condon-Shortley reduced denominators F_k = F^k / DENOM[l][k/2] make the
# term-energy coefficients small integers; for l = 2 the Racah combination
# A = F_0 - 49 F_4, B = F_2 - 5 F_4, C = 35 F_4 is used instead.
_REDUCED_DENOMS = {
    0: [Fraction(1)],
    1: [Fraction(1), Fraction(25)],
    2: [Fraction(1), Fraction(49), Fraction(441)],
    3: [Fraction(1), Fraction(225), Fraction(1089), Fraction(184041, 25)],
}
_PARAM_NAMES = {
    0: ["F0"],
    1: ["F0", "F2"],
    2: ["A", "B", "C"],
    3: ["F0", "F2", "F4", "F6"],
}
_SNAP_TOL = 1e-6
_TYPICAL_C_OVER_B = Fraction(9, 2)


# ---------------------------------------------------------------------------
# exact two-electron integrals of one l shell
# ---------------------------------------------------------------------------


def _complex_two_electron(l: int):
    """<m1 m2|1/r12|m3 m4> = sum_k c^k(m1,m3) c^k(m4,m2) F^k as exact sympy
    coefficient vectors over k = 0, 2, ..., 2l."""
    import sympy
    from sympy.physics.wigner import gaunt

    k_values = list(range(0, 2 * l + 1, 2))

    def c_coefficient(k, m, mp):
        # c^k(l m; l m') = sqrt(4 pi / (2k+1)) int Y*_{lm} Y_{k,m-m'} Y_{lm'}
        return (
            sympy.sqrt(4 * sympy.pi / (2 * k + 1))
            * sympy.Integer(-1) ** m
            * gaunt(l, k, l, -m, m - mp, mp)
        )

    integrals = {}
    m_range = range(-l, l + 1)
    for m1 in m_range:
        for m2 in m_range:
            for m3 in m_range:
                m4 = m1 + m2 - m3
                if m4 < -l or m4 > l:
                    continue
                coeffs = [
                    sympy.simplify(c_coefficient(k, m1, m3) * c_coefficient(k, m4, m2))
                    for k in k_values
                ]
                if any(value != 0 for value in coeffs):
                    integrals[(m1, m2, m3, m4)] = coeffs
    return integrals, k_values


def _sympy_real_transform(l: int):
    """Exact sympy version of complex_to_real_transform_orbital."""
    import sympy

    size = 2 * l + 1
    inv_sqrt2 = 1 / sympy.sqrt(2)
    rows = {}
    for m in range(-l, l + 1):
        row = [sympy.Integer(0)] * size
        if m < 0:
            row[l + m] = sympy.I * inv_sqrt2
            row[l - m] = -((-1) ** abs(m)) * sympy.I * inv_sqrt2
        elif m == 0:
            row[l] = sympy.Integer(1)
        else:
            row[l - m] = inv_sqrt2
            row[l + m] = ((-1) ** m) * inv_sqrt2
        rows[m] = row

    if l == 1:
        order = [1, -1, 0]  # px, py, pz
    elif l == 2:
        order = [-2, -1, 0, 1, 2]  # dxy, dyz, dz2, dxz, dx2-y2
    elif l == 3:
        order = [3, -3, 2, -2, 1, -1, 0]
    else:
        order = list(range(-l, l + 1))
    return [rows[m] for m in order]


def real_two_electron_integrals(l: int) -> dict[tuple[int, int, int, int], tuple]:
    """<ab|1/r12|cd> in the real-orbital basis as coefficient tuples over the
    printed parameters (Racah A, B, C for l = 2, reduced F_k otherwise).
    Coefficients are high-precision floats: the tensor entries of f shells
    are not all rational (sqrt-valued off-diagonal integrals); the final term
    energies are snapped to exact fractions downstream."""
    import sympy

    complex_integrals, k_values = _complex_two_electron(l)
    transform = _sympy_real_transform(l)
    size = 2 * l + 1
    n_params = len(k_values)

    # nonzero complex components of each real orbital
    components = [
        [(m - l, value) for m, value in enumerate(row) if value != 0]
        for row in transform
    ]

    raw: dict[tuple[int, int, int, int], list] = {}
    for a in range(size):
        for b in range(size):
            for c in range(size):
                for d in range(size):
                    total = [sympy.Integer(0)] * n_params
                    for m1, t1 in components[a]:
                        for m2, t2 in components[b]:
                            for m3, t3 in components[c]:
                                for m4, t4 in components[d]:
                                    entry = complex_integrals.get((m1, m2, m3, m4))
                                    if entry is None:
                                        continue
                                    weight = (
                                        sympy.conjugate(t1)
                                        * sympy.conjugate(t2)
                                        * t3
                                        * t4
                                    )
                                    for p in range(n_params):
                                        total[p] += weight * entry[p]
                    values = []
                    for v in total:
                        # the integral is real, but its F^k coefficients need
                        # not be rational (f shells have sqrt-valued entries)
                        # -> evaluate at high precision and keep as float
                        real_part, imag_part = sympy.expand(v).evalf(40).as_real_imag()
                        if abs(float(imag_part)) > 1e-20:
                            raise SystemExit(
                                "ERROR: two-electron integral came out complex; "
                                "please report this case."
                            )
                        values.append(float(real_part))
                    if any(abs(v) > 1e-14 for v in values):
                        raw[(a, b, c, d)] = values

    # F^k -> printed parameters (exact linear substitution)
    if l == 2:
        # F^0 = A + 7C/5, F^2 = 49B + 7C, F^4 = 63C/5
        conversion = [
            [Fraction(1), Fraction(0), Fraction(0)],
            [Fraction(0), Fraction(49), Fraction(0)],
            [Fraction(0), Fraction(0), Fraction(0)],
        ]
        conversion[0][2] = Fraction(7, 5)
        conversion[1][2] = Fraction(7)
        conversion[2][2] = Fraction(63, 5)
    else:
        denoms = _REDUCED_DENOMS[l]
        conversion = [
            [denoms[i] if i == j else Fraction(0) for j in range(n_params)]
            for i in range(n_params)
        ]

    integrals = {}
    for key, values in raw.items():
        converted = tuple(
            sum(values[k] * float(conversion[k][p]) for k in range(n_params))
            for p in range(n_params)
        )
        integrals[key] = converted
    return integrals


# ---------------------------------------------------------------------------
# shell subspaces of the l shell
# ---------------------------------------------------------------------------


def _shell_bases(character_table: dict, l: int, shell_irreps: list[str]):
    """Orthonormal basis (columns) of each shell's irrep subspace within the
    (2l+1)-dimensional real-orbital space, plus the Cartesian group action."""
    operations, class_labels = _table_operations_cartesian(character_table)
    class_names = list(character_table["rotation_list"])
    class_index = {name: i for i, name in enumerate(class_names)}
    d_matrices = [wigner_D_real(l, op) for op in operations]
    order = len(operations)

    bases = []
    for irrep in shell_irreps:
        characters = np.asarray(
            character_table["character_table"][irrep], dtype=float
        )
        dim = int(round(characters[class_index["E"]]))
        projector = np.zeros((2 * l + 1, 2 * l + 1))
        for label, dmat in zip(class_labels, d_matrices):
            projector += characters[class_index[label]] * dmat
        projector *= dim / order
        eigenvalues, eigenvectors = np.linalg.eigh((projector + projector.T) / 2)
        kept = eigenvectors[:, eigenvalues > 0.5]
        if kept.shape[1] != dim:
            raise SystemExit(
                f"ERROR: the {irrep} subspace of the l={l} shell has "
                f"multiplicity {kept.shape[1] // max(dim, 1)} in this point "
                "group; the shell orbitals are not defined by symmetry alone, "
                "so multiplet energies are unavailable here."
            )
        bases.append(kept)
    return bases, operations, class_labels, d_matrices, class_index


# ---------------------------------------------------------------------------
# Slater determinants and the Coulomb Hamiltonian
# ---------------------------------------------------------------------------


class _DeterminantSpace:
    """Slater determinants of a fixed shell-occupation configuration.

    Spin-orbital index = 2*orbital + spin (spin 0 = up, 1 = down) over the
    concatenated shell orbitals."""

    def __init__(self, shell_dims: list[int], occupations: list[int]):
        self.n_orbitals = sum(shell_dims)
        offsets = np.cumsum([0] + shell_dims)
        shell_choices = []
        for dim, n in zip(shell_dims, occupations):
            offset = offsets[len(shell_choices)]
            spin_orbitals = [
                2 * (offset + orb) + spin for orb in range(dim) for spin in (0, 1)
            ]
            shell_choices.append(
                [tuple(sorted(chosen)) for chosen in combinations(spin_orbitals, n)]
            )
        dets = [()]
        for choices in shell_choices:
            dets = [prev + choice for prev in dets for choice in choices]
        self.dets = [tuple(sorted(det)) for det in dets]
        self.index = {det: i for i, det in enumerate(self.dets)}

    def sz2(self, det) -> int:
        """2 Sz of a determinant."""
        return sum(1 if so % 2 == 0 else -1 for so in det)

    def sector(self, sz2: int) -> list[int]:
        return [i for i, det in enumerate(self.dets) if self.sz2(det) == sz2]


def _apply_one_body(det: tuple, create: int, annihilate: int):
    """a+_create a_annihilate |det> -> (sign, new det) or None."""
    if annihilate not in det:
        return None
    position = det.index(annihilate)
    sign = (-1) ** position
    removed = det[:position] + det[position + 1 :]
    if create in removed:
        return None
    insert_at = sum(1 for so in removed if so < create)
    sign *= (-1) ** insert_at
    return sign, removed[:insert_at] + (create,) + removed[insert_at:]


def _coulomb_hamiltonians(space, sector, integrals_by_param):
    """One numpy matrix per parameter on the determinant sector
    (Slater-Condon rules; spin-conserving <ij|kl> delta factors)."""
    n_params = len(integrals_by_param)
    size = len(sector)
    matrices = [np.zeros((size, size)) for _ in range(n_params)]
    dets = [space.dets[i] for i in sector]
    position = {space.dets[i]: row for row, i in enumerate(sector)}

    def v(p, i, j, k, l_):
        """<ij|kl> over spin-orbitals for parameter p (0 unless spins match)."""
        if i % 2 != k % 2 or j % 2 != l_ % 2:
            return 0.0
        return integrals_by_param[p].get((i // 2, j // 2, k // 2, l_ // 2), 0.0)

    for row, det in enumerate(dets):
        occupied = list(det)
        # diagonal
        for p in range(n_params):
            total = 0.0
            for a, b in combinations(occupied, 2):
                total += v(p, a, b, a, b) - v(p, a, b, b, a)
            matrices[p][row, row] = total
        # single and double excitations within the same shell-occupation space
        for i_pos, i in enumerate(occupied):
            for a in range(2 * space.n_orbitals):
                if a in det:
                    continue
                one = _apply_one_body(det, a, i)
                if one is None:
                    continue
                sign_a, det_a = one
                col = position.get(det_a)
                if col is not None and col > row:
                    for p in range(n_params):
                        total = 0.0
                        for j in occupied:
                            if j == i:
                                continue
                            total += v(p, a, j, i, j) - v(p, a, j, j, i)
                        value = sign_a * total
                        matrices[p][row, col] += value
                        matrices[p][col, row] += value
                for j_pos in range(i_pos + 1, len(occupied)):
                    j = occupied[j_pos]
                    for b in range(a + 1, 2 * space.n_orbitals):
                        if b in det:
                            continue
                        two = _apply_one_body(det_a, b, j)
                        if two is None:
                            continue
                        sign_b, det2 = two
                        col = position.get(det2)
                        if col is None or col <= row:
                            continue
                        for p in range(n_params):
                            value = sign_a * sign_b * (
                                v(p, a, b, i, j) - v(p, a, b, j, i)
                            )
                            matrices[p][row, col] += value
                            matrices[p][col, row] += value
    return matrices


def _s2_matrix(space, sector):
    """S^2 on the determinant sector (units hbar = 1)."""
    size = len(sector)
    matrix = np.zeros((size, size))
    position = {space.dets[i]: row for row, i in enumerate(sector)}
    for row, det_index in enumerate(sector):
        det = space.dets[det_index]
        sz = space.sz2(det) / 2.0
        matrix[row, row] += sz * sz + sz
        # S- S+ : S+ = sum_p a+_{p up} a_{p down}
        for orb in range(space.n_orbitals):
            up, down = 2 * orb, 2 * orb + 1
            plus = _apply_one_body(det, up, down)
            if plus is None:
                continue
            sign_p, det_p = plus
            for orb2 in range(space.n_orbitals):
                up2, down2 = 2 * orb2, 2 * orb2 + 1
                minus = _apply_one_body(det_p, down2, up2)
                if minus is None:
                    continue
                sign_m, det_m = minus
                col = position.get(det_m)
                if col is not None:
                    matrix[row, col] += sign_p * sign_m
    return matrix


def _group_action(space, sector, orbital_matrices):
    """Determinant-space matrices of the group elements on a sector.

    orbital_matrices: per group element, the M x M orthogonal matrix on the
    concatenated shell orbitals (block diagonal over shells)."""
    dets = [space.dets[i] for i in sector]
    size = len(dets)
    matrices = []
    for u in orbital_matrices:
        result = np.zeros((size, size))
        for col, det in enumerate(dets):
            occ = list(det)
            n = len(occ)
            # column vectors of transformed spin-orbitals in the full basis
            for row, target in enumerate(dets):
                sub = np.zeros((n, n))
                for jj, so_t in enumerate(target):
                    for kk, so_c in enumerate(occ):
                        if so_t % 2 != so_c % 2:
                            continue
                        sub[jj, kk] = u[so_t // 2, so_c // 2]
                value = np.linalg.det(sub) if n else 1.0
                if abs(value) > 1e-12:
                    result[row, col] = value
        matrices.append(result)
    return matrices


# ---------------------------------------------------------------------------
# term energies
# ---------------------------------------------------------------------------


class SnapError(ValueError):
    """A floating-point coefficient has no exact small-denominator fraction."""


def _snap_exact(value: float) -> Fraction:
    fraction = Fraction(value).limit_denominator(720)
    if abs(float(fraction) - value) > _SNAP_TOL:
        raise SnapError(value)
    return fraction


def _snap(value: float) -> Fraction:
    try:
        return _snap_exact(value)
    except SnapError:
        raise SystemExit(
            "ERROR: multiplet-energy coefficient failed to snap to an exact "
            f"fraction ({value}); please report this case."
        )


def _split_square(value: int) -> tuple[int, int]:
    """value = square**2 * radicand with radicand squarefree (value > 0)."""
    square, rest, factor = 1, value, 2
    while factor * factor <= rest:
        while rest % (factor * factor) == 0:
            rest //= factor * factor
            square *= factor
        factor += 1
    return square, rest


def format_linear(coeffs, params) -> str:
    parts = []
    for coeff, param in zip(coeffs, params):
        if coeff == 0:
            continue
        magnitude = abs(coeff)
        if magnitude.denominator == 1:
            body = param if magnitude == 1 else f"{magnitude.numerator}{param}"
        else:
            body = f"({magnitude}){param}"
        if not parts:
            parts.append(body if coeff > 0 else f"-{body}")
        else:
            parts.append(f"+ {body}" if coeff > 0 else f"- {body}")
    return " ".join(parts) if parts else "0"


def format_offdiag(offdiag, params) -> str:
    """Coupled-parent off-diagonal (coeffs, radicand) as a display string:
    '(10B)' for a rational form, '(5sqrt(3)B)' for a single sqrt term,
    '(sqrt(3)(5B + 2C))' in general."""
    coeffs, radicand = offdiag
    linear = format_linear(coeffs, params)
    if radicand == 1:
        return f"({linear})"
    nonzero = [(coeff, param) for coeff, param in zip(coeffs, params)
               if coeff != 0]
    if len(nonzero) == 1:
        coeff, param = nonzero[0]
        magnitude = abs(coeff)
        sign = "-" if coeff < 0 else ""
        if magnitude == 1:
            prefix = ""
        elif magnitude.denominator == 1:
            prefix = str(magnitude.numerator)
        else:
            prefix = f"({magnitude})"
        return f"({sign}{prefix}sqrt({radicand}){param})"
    return f"(sqrt({radicand})({linear}))"


def _format_quadratic(matrix, params) -> str:
    parts = []
    n = len(params)
    for i in range(n):
        for j in range(i, n):
            coeff = matrix[i][j] if i == j else matrix[i][j] + matrix[j][i]
            if coeff == 0:
                continue
            name = f"{params[i]}^2" if i == j else f"{params[i]}{params[j]}"
            magnitude = abs(coeff)
            if magnitude.denominator == 1:
                body = name if magnitude == 1 else f"{magnitude.numerator}{name}"
            else:
                body = f"({magnitude}){name}"
            if not parts:
                parts.append(body if coeff > 0 else f"-{body}")
            else:
                parts.append(f"+ {body}" if coeff > 0 else f"- {body}")
    return " ".join(parts) if parts else "0"


def _single_square_root(matrix, params) -> str | None:
    """(1/2)sqrt(Q) as k sqrt(m) PARAM when Q = c PARAM^2 (one variable only)."""
    n = len(params)
    nonzero = [
        (i, j)
        for i in range(n)
        for j in range(n)
        if matrix[i][j] != 0
    ]
    if len(nonzero) != 1 or nonzero[0][0] != nonzero[0][1]:
        return None
    index = nonzero[0][0]
    coeff = matrix[index][index]
    if coeff <= 0:
        return None

    sq_num, rad_num = _split_square(coeff.numerator)
    sq_den, rad_den = _split_square(coeff.denominator)
    rational = Fraction(sq_num, 2 * sq_den * rad_den)
    radicand = rad_num * rad_den
    if radicand == 1:
        prefix = "" if rational == 1 else (
            str(rational.numerator)
            if rational.denominator == 1
            else f"({rational})"
        )
        return f"{prefix}{params[index]}"
    prefix = "" if rational == 1 else (
        str(rational.numerator) if rational.denominator == 1 else f"({rational})"
    )
    return f"{prefix}sqrt({radicand}){params[index]}"


class TermEnergy:
    """Coulomb energy of one term of a configuration.

    Returned by ``compute_term_energies``: a term occurring once has an
    exact linear energy; a term occurring ``multiplicity >= 2`` times is a
    CI block, given in closed form for two occurrences and numerically
    otherwise.

    Args:
        spin: Total spin ``S`` of the term (a ``Fraction``).
        irrep: Spatial irrep label of the term.
        dim: Dimension of the spatial irrep.
        multiplicity: Number of times the term occurs in the configuration.
        params: Parameter names (``["A", "B", "C"]`` for d shells).

    Attributes:
        linear: Exact coefficients (one ``Fraction`` per parameter) of a
            unique term; ``None`` for a CI block.
        ci_mean: Coefficients of the mean ``(E1 + E2) / 2`` of a two-fold
            CI block, with ``ci_quadratic`` the matrix of the quadratic form
            ``(E1 - E2)^2``; both ``None`` otherwise.
        numeric: Eigenvalues of a CI block at the reference parameter
            point; ``None`` for a unique term.
        reference_note: Description of the reference parameter point.
    """

    def __init__(self, spin, irrep, dim, multiplicity, params):
        self.spin = spin
        self.irrep = irrep
        self.dim = dim
        self.multiplicity = multiplicity
        self.params = params
        self.linear = None  # tuple of Fractions (m == 1)
        self.ci_mean = None  # tuple of Fractions: (E1+E2)/2 (m == 2)
        self.ci_quadratic = None  # matrix of Fractions: (E1-E2)^2 (m == 2)
        self.numeric = None  # list of floats at the reference point
        self.reference_note = "the reference parameter point"

    def reference_values(self, reference) -> list[float]:
        """Numeric energies at the reference parameter values.

        Args:
            reference: Parameter values in the order of ``params``.

        Returns:
            One value for a unique term, ``multiplicity`` values for a CI
            block.
        """
        if self.linear is not None:
            return [float(sum(c * r for c, r in zip(self.linear, reference)))]
        return list(self.numeric)

    def describe(self) -> list[str]:
        """The energy as printed by ``crystod-group --multiplet --orbital``.

        Returns:
            One line, e.g. ``"3A - 15B"`` for a unique term or
            ``"3A - 6B + 3C +- ...   (x2, configuration mixing)"`` for a
            two-fold CI block.
        """
        if self.linear is not None:
            return [format_linear(self.linear, self.params)]
        if self.ci_mean is not None:
            mean = format_linear(self.ci_mean, self.params)
            simple = _single_square_root(self.ci_quadratic, self.params)
            if simple is not None:
                return [f"{mean} +- {simple}   (x2, configuration mixing)"]
            disc = _format_quadratic(self.ci_quadratic, self.params)
            return [f"{mean} +- (1/2)sqrt({disc})   (x2, configuration mixing)"]
        values = ", ".join(f"{value:+.3f}" for value in sorted(self.numeric))
        return [
            f"[{values}]  (x{self.multiplicity}, configuration mixing; numeric)"
        ]


[docs] def compute_term_energies( character_table: dict, l: int, shells: list[tuple[str, int]], terms: list[tuple[Fraction, str, int]], ): """Coulomb multiplet energies of every term of a configuration. The ``Multiplet Energies`` block of ``crystod-group --multiplet CONFIG --pg PG --orbital ORB``: exact diagonal energies of the electron-electron interaction as linear combinations of the Racah parameters ``A, B, C`` (d shells) or of the reduced Slater-Condon parameters (``F0, F2`` for p, ``F0, F2, F4, F6`` for f), from two-electron integrals over the real orbitals of the shell, Slater determinants, and spin and point-group projectors. A term occurring more than once mixes (configuration interaction): for two occurrences the eigenvalues are given in closed form, for more numerically at the reference parameter point. Args: character_table: Table from ``crystod.group.get_character_table``. l: Azimuthal quantum number of the parent atomic shell (``0`` to ``3``); the occupied irrep shells must occur in its ligand-field splitting. shells: ``[(irrep, n_electrons), ...]`` from ``crystod.group.parse_config``. terms: Terms ``(S, irrep, count)`` of the configuration, from ``crystod.group.shell_terms`` / ``couple_shells``. Returns: ``(params, energies, reference, reference_note)``: the parameter names (``["A", "B", "C"]`` for d), one ``TermEnergy`` per term in the order of ``terms`` (``linear`` holds the exact coefficients of a unique term, ``ci_mean`` and ``ci_quadratic`` the closed-form CI block of a doubly occurring term, ``numeric`` the eigenvalues at the reference point; ``describe()`` formats them), the reference parameter values, and a note describing them (``C/B = 4.5`` for d). Raises: SystemExit: A projector rank or the trace identity disagrees with the term list (``ValueError`` when called through ``crystod.group``). Example: >>> from crystod import group >>> from crystod.multiplet import _GroupClasses >>> ct = group.get_character_table("m-3m") >>> classes = _GroupClasses(ct) >>> shells = group.parse_config(["T2g3"], classes) >>> terms = group.shell_terms(classes, "T2g", 3) >>> params, energies, reference, note = group.compute_term_energies( ... ct, 2, shells, terms) >>> for (spin, irrep, _), entry in zip(terms, energies): ... print(f"^{int(2 * spin + 1)}{irrep}:", entry.describe()[0]) ^4A2g: 3A - 15B ^2Eg: 3A - 6B + 3C ^2T1g: 3A - 6B + 3C ^2T2g: 3A + 5C """ params = _PARAM_NAMES[l] n_params = len(params) shell_irreps = [name for name, _ in shells] occupations = [count for _, count in shells] bases, operations, class_labels, d_matrices, class_index = _shell_bases( character_table, l, shell_irreps ) basis = np.hstack(bases) # (2l+1) x M shell_dims = [b.shape[1] for b in bases] exact = real_two_electron_integrals(l) # transform the 4-index tensor into the shell-orbital basis, per parameter size = 2 * l + 1 integrals_by_param = [] for p in range(n_params): tensor = np.zeros((size, size, size, size)) for (a, b, c, d), coeffs in exact.items(): tensor[a, b, c, d] = float(coeffs[p]) transformed = np.einsum( "abcd,ap,bq,cr,ds->pqrs", tensor, basis, basis, basis, basis ) entries = {} m = basis.shape[1] for a in range(m): for b in range(m): for c in range(m): for d in range(m): value = transformed[a, b, c, d] if abs(value) > 1e-12: entries[(a, b, c, d)] = value integrals_by_param.append(entries) # orbital-space group action in the shell basis (block over shells) orbital_matrices = [] for dmat in d_matrices: blocks = [b.T @ dmat @ b for b in bases] m = basis.shape[1] u = np.zeros((m, m)) offset = 0 for block in blocks: k = block.shape[0] u[offset : offset + k, offset : offset + k] = block offset += k orbital_matrices.append(u) space = _DeterminantSpace(shell_dims, occupations) n_electrons = sum(occupations) order = len(operations) # reference point for numeric CI blocks and the ground-state selection: # typical physical parameter ratios of the shell reference = [0.0] * n_params if l == 2: reference[1] = 1.0 reference[2] = float(_TYPICAL_C_OVER_B) reference_note = "C/B = 4.5, typical for 3d ions" elif l == 3: # hydrogenic 4f ratios of the reduced parameters: # F4/F2 = 0.138, F6/F2 = 0.0151 reference[1] = 1.0 reference[2] = 0.138 reference[3] = 0.0151 reference_note = "hydrogenic 4f ratios F4/F2 = 0.138, F6/F2 = 0.0151" elif l == 1: reference[1] = 1.0 reference_note = "F2 = 1" else: reference[0] = 1.0 reference_note = "F0 = 1" results = [] sector_cache: dict[int, dict] = {} for spin, irrep, multiplicity in terms: sz2 = int(2 * spin) if sz2 not in sector_cache: sector = space.sector(sz2) h_matrices = _coulomb_hamiltonians(space, sector, integrals_by_param) s2 = _s2_matrix(space, sector) group = _group_action(space, sector, orbital_matrices) sector_cache[sz2] = { "sector": sector, "h": h_matrices, "s2": s2, "group": group, } cache = sector_cache[sz2] sector, h_matrices, s2 = cache["sector"], cache["h"], cache["s2"] # spin projector: polynomial in S^2 annihilating the other S values target = float(spin * (spin + 1)) projector = np.eye(len(sector)) s_value = Fraction(sz2, 2) possible = [] s_iter = s_value while s_iter <= Fraction(n_electrons, 2): possible.append(float(s_iter * (s_iter + 1))) s_iter += 1 for other in possible: if abs(other - target) < 1e-9: continue projector = projector @ (s2 - other * np.eye(len(sector))) / ( target - other ) # point-group character projector on the determinant sector characters = np.asarray( character_table["character_table"][irrep], dtype=float ) dim = int(round(characters[class_index["E"]])) pg_projector = np.zeros((len(sector), len(sector))) for label, gmat in zip(class_labels, cache["group"]): pg_projector += characters[class_index[label]] * gmat pg_projector *= dim / order combined = pg_projector @ projector combined = (combined + combined.T) / 2 eigenvalues, eigenvectors = np.linalg.eigh(combined) kept = eigenvectors[:, eigenvalues > 0.5] expected = multiplicity * dim if kept.shape[1] != expected: raise SystemExit( f"ERROR: projector rank {kept.shape[1]} != {expected} for " f"term ({spin}, {irrep}); please report this case." ) blocks = [kept.T @ h @ kept for h in h_matrices] entry = TermEnergy(spin, irrep, dim, multiplicity, params) entry.reference_note = reference_note if multiplicity == 1: entry.linear = tuple( _snap(np.trace(block) / dim) for block in blocks ) else: trace_lin = [_snap(np.trace(block) / dim) for block in blocks] reference_block = sum( r * block for r, block in zip(reference, blocks) ) block_eigen = np.linalg.eigvalsh(reference_block) grouped = sorted(block_eigen) entry.numeric = [ float(np.mean(grouped[i * dim : (i + 1) * dim])) for i in range(multiplicity) ] if multiplicity == 2: entry.ci_mean = tuple(value / 2 for value in trace_lin) # (E1 - E2)^2 = 2 tr(Hb^2)/dim - (tr Hb / dim)^2 quad = [ [Fraction(0)] * n_params for _ in range(n_params) ] for p in range(n_params): for q in range(n_params): second = _snap( np.trace(blocks[p] @ blocks[q]) / dim ) quad[p][q] = 2 * second - trace_lin[p] * trace_lin[q] entry.ci_quadratic = quad results.append(entry) _verify_trace(space, integrals_by_param, results, reference, n_params) return params, results, reference, reference_note
def _verify_trace(space, integrals_by_param, results, reference, n_params): """Trace identity: sum over terms (2S+1) dim E = tr(H_ee) per parameter.""" for p in range(n_params): total = 0.0 for det in space.dets: for a, b in combinations(det, 2): if a % 2 == b % 2: total += integrals_by_param[p].get( (a // 2, b // 2, a // 2, b // 2), 0.0 ) - integrals_by_param[p].get( (a // 2, b // 2, b // 2, a // 2), 0.0 ) else: total += integrals_by_param[p].get( (a // 2, b // 2, a // 2, b // 2), 0.0 ) term_sum = 0.0 for entry in results: weight = int(2 * entry.spin + 1) * entry.dim if entry.linear is not None: term_sum += weight * float(entry.linear[p]) elif entry.ci_mean is not None: term_sum += weight * 2 * float(entry.ci_mean[p]) else: unit = [1.0 if q == p else 0.0 for q in range(n_params)] # numeric-only blocks: skip exact per-parameter check term_sum = None break if term_sum is None: break if abs(term_sum - total) > 1e-6: raise SystemExit( "ERROR: multiplet-energy trace identity failed " f"(parameter {p}: {term_sum} vs {total}); please report this case." ) # --------------------------------------------------------------------------- # ground state # ---------------------------------------------------------------------------
[docs] def ground_state(results, reference): """Lowest term(s) of a configuration at the reference parameter point. The ``Ground-state Term Symbol`` block of ``crystod-group --multiplet --orbital``. Args: results: The ``TermEnergy`` list from ``compute_term_energies``. reference: The reference parameter values from the same call. Returns: ``(winners, unconditional)``: the ``TermEnergy`` entries with the lowest energy at the reference point, and whether that ordering is provably independent of the parameters (``True`` when a unique linear winner has coefficients no larger than those of every other linear term in every parameter beyond the first). Example: >>> from crystod import group >>> from crystod.multiplet import _GroupClasses >>> ct = group.get_character_table("m-3m") >>> classes = _GroupClasses(ct) >>> terms = group.shell_terms(classes, "T2g", 3) >>> params, energies, reference, note = group.compute_term_energies( ... ct, 2, [("T2g", 3)], terms) >>> winners, unconditional = group.ground_state(energies, reference) >>> [(str(w.spin), w.irrep) for w in winners], unconditional ([('3/2', 'A2g')], True) """ minima = [min(entry.reference_values(reference)) for entry in results] lowest = min(minima) winners = [ entry for entry, value in zip(results, minima) if value < lowest + 1e-9 ] unconditional = False if len(winners) == 1 and winners[0].linear is not None: winner = winners[0] unconditional = all( entry is winner or ( entry.linear is not None and all( entry.linear[p] >= winner.linear[p] for p in range(1, len(winner.params)) ) ) for entry in results ) return winners, unconditional
# --------------------------------------------------------------------------- # coupled-parent CI matrices (for comparison with the textbook tables) # --------------------------------------------------------------------------- def _partial_s2_matrix(space, sector, orbital_subset): """S^2 of the electrons in a subset of the shell orbitals.""" subset = set(orbital_subset) size = len(sector) matrix = np.zeros((size, size)) position = {space.dets[i]: row for row, i in enumerate(sector)} for row, det_index in enumerate(sector): det = space.dets[det_index] sz = sum( (0.5 if so % 2 == 0 else -0.5) for so in det if so // 2 in subset ) matrix[row, row] += sz * sz + sz for orb in subset: plus = _apply_one_body(det, 2 * orb, 2 * orb + 1) if plus is None: continue sign_p, det_p = plus for orb2 in subset: minus = _apply_one_body(det_p, 2 * orb2 + 1, 2 * orb2) if minus is None: continue sign_m, det_m = minus col = position.get(det_m) if col is not None: matrix[row, col] += sign_p * sign_m return matrix def coupled_parent_matrices( character_table: dict, l: int, shells: list[tuple[str, int]], shell_term_lists: list[list], terms: list[tuple[Fraction, str, int]], ): """CI matrices of the doubly-occurring terms of a two-shell configuration in the coupled-parent basis |shell1^n1(S1 Gamma1) shell2^n2(S2 Gamma2)>, i.e. the representation used by the Tanabe-Sugano/Griffith strong-field tables. Returns {term index: (parent labels, diag1, diag2, offdiag)}. diag1/diag2 are per-parameter Fraction tuples; offdiag is a pair (coeffs, radicand) meaning <1|H|2> = sqrt(radicand) * sum_q coeffs[q] P_q with rational coeffs and squarefree integer radicand (radicand == 1 for a purely rational form). The overall sign is a basis convention. A term whose block has no such closed form maps to None.""" if len(shells) != 2: return {} if not any(multiplicity == 2 for _, _, multiplicity in terms): return {} shell_irreps = [name for name, _ in shells] occupations = [count for _, count in shells] bases, operations, class_labels, d_matrices, class_index = _shell_bases( character_table, l, shell_irreps ) basis = np.hstack(bases) shell_dims = [b.shape[1] for b in bases] params = _PARAM_NAMES[l] n_params = len(params) exact = real_two_electron_integrals(l) size = 2 * l + 1 integrals_by_param = [] for p in range(n_params): tensor = np.zeros((size, size, size, size)) for (a, b, c, d), coeffs in exact.items(): tensor[a, b, c, d] = float(coeffs[p]) transformed = np.einsum( "abcd,ap,bq,cr,ds->pqrs", tensor, basis, basis, basis, basis ) entries = {} m = basis.shape[1] for a in range(m): for b in range(m): for c in range(m): for d in range(m): value = transformed[a, b, c, d] if abs(value) > 1e-12: entries[(a, b, c, d)] = value integrals_by_param.append(entries) def block_matrices(shell_index): matrices = [] for dmat in d_matrices: m = basis.shape[1] u = np.eye(m) offset = sum(shell_dims[:shell_index]) k = shell_dims[shell_index] u[offset:offset + k, offset:offset + k] = ( bases[shell_index].T @ dmat @ bases[shell_index] ) matrices.append(u) return matrices space = _DeterminantSpace(shell_dims, occupations) n_electrons = sum(occupations) order = len(operations) shell_orbitals = [ list(range(shell_dims[0])), list(range(shell_dims[0], shell_dims[0] + shell_dims[1])), ] results = {} sector_cache: dict[int, dict] = {} for index, (spin, irrep, multiplicity) in enumerate(terms): if multiplicity != 2: continue sz2 = int(2 * spin) if sz2 not in sector_cache: sector = space.sector(sz2) cache = { "sector": sector, "h": _coulomb_hamiltonians(space, sector, integrals_by_param), "s2": _s2_matrix(space, sector), } full_mats = [] for dmat in d_matrices: m = basis.shape[1] u = np.zeros((m, m)) offset = 0 for b in bases: k = b.shape[1] u[offset:offset + k, offset:offset + k] = b.T @ dmat @ b offset += k full_mats.append(u) cache["group"] = _group_action(space, sector, full_mats) cache["g1"] = _group_action(space, sector, block_matrices(0)) cache["g2"] = _group_action(space, sector, block_matrices(1)) cache["s2a"] = _partial_s2_matrix(space, sector, shell_orbitals[0]) cache["s2b"] = _partial_s2_matrix(space, sector, shell_orbitals[1]) sector_cache[sz2] = cache cache = sector_cache[sz2] sector = cache["sector"] n = len(sector) def spin_projector(s2_matrix, target_spin, max_spin): projector = np.eye(n) target = float(target_spin * (target_spin + 1)) s_iter = Fraction(0) if int(2 * max_spin) % 2 == 0 else Fraction(1, 2) while s_iter <= max_spin: other = float(s_iter * (s_iter + 1)) if abs(other - target) > 1e-9: projector = projector @ ( s2_matrix - other * np.eye(n) ) / (target - other) s_iter += 1 return projector def pg_projector(group_matrices, target_irrep): characters = np.asarray( character_table["character_table"][target_irrep], dtype=float ) dim = int(round(characters[class_index["E"]])) projector = np.zeros((n, n)) for label, gmat in zip(class_labels, group_matrices): projector += characters[class_index[label]] * gmat return projector * dim / order # total-term projector and its range total = pg_projector(cache["group"], irrep) @ spin_projector( cache["s2"], spin, Fraction(n_electrons, 2) ) total = (total + total.T) / 2 eigenvalues, eigenvectors = np.linalg.eigh(total) kept = eigenvectors[:, eigenvalues > 0.5] characters = np.asarray( character_table["character_table"][irrep], dtype=float ) dim = int(round(characters[class_index["E"]])) if kept.shape[1] != 2 * dim: continue blocks = [kept.T @ h @ kept for h in cache["h"]] # parent subspaces inside the kept space parents = [] for s1, g1, _ in shell_term_lists[0]: for s2m, g2m, _ in shell_term_lists[1]: q = ( pg_projector(cache["g1"], g1) @ spin_projector(cache["s2a"], s1, Fraction(occupations[0], 2)) @ pg_projector(cache["g2"], g2m) @ spin_projector(cache["s2b"], s2m, Fraction(occupations[1], 2)) ) m_q = kept.T @ ((q + q.T) / 2) @ kept q_eigenvalues, q_eigenvectors = np.linalg.eigh(m_q) columns = q_eigenvectors[:, q_eigenvalues > 0.5] if columns.shape[1] == dim: parents.append(((s1, g1, s2m, g2m), columns)) if len(parents) != 2: continue (label1, c1), (label2, c2) = parents try: diag1 = tuple( _snap_exact(float(np.trace(c1.T @ block @ c1)) / dim) for block in blocks ) diag2 = tuple( _snap_exact(float(np.trace(c2.T @ block @ c2)) / dim) for block in blocks ) # off-diagonal linear form up to a global sign: # tr(M_p M_q^T)/dim = c_p c_q for M_p = c1^T H_p c2. The products # c_p c_q are basis-independent and rational even when the # coefficients themselves are irrational (e.g. 5 sqrt(3) B in # (t2g)^3(eg)^1 of m-3m), so snap the Gram matrix and factor the # linear form as sqrt(radicand) * (rational coefficients). cross = [c1.T @ block @ c2 for block in blocks] gram = np.array([ [float(np.trace(cross[p] @ cross[q].T)) / dim for q in range(n_params)] for p in range(n_params) ]) pivot = int(np.argmax(np.abs(np.diag(gram)))) if gram[pivot, pivot] < 1e-12: offdiag = (tuple(Fraction(0) for _ in range(n_params)), 1) else: lead_sq = _snap_exact(gram[pivot, pivot]) # = c_pivot^2 > 0 if lead_sq <= 0: raise SnapError(gram[pivot, pivot]) row = [_snap_exact(gram[pivot, q]) for q in range(n_params)] sq_num, rad_num = _split_square(lead_sq.numerator) sq_den, rad_den = _split_square(lead_sq.denominator) radicand = rad_num * rad_den # c_q = row_q / sqrt(lead_sq) # = row_q * sq_den * rad_den / (sq_num * radicand) # * sqrt(radicand) factor = Fraction(sq_den * rad_den, sq_num * radicand) coeffs = tuple(value * factor for value in row) # rank-1 consistency: gram must equal c c^T, so the diagonal # entries have to reproduce as radicand * c_q^2 (this guards # both the snapping and the single-intertwiner assumption) if any( abs(float(radicand) * float(coeff) ** 2 - gram[q, q]) > _SNAP_TOL * max(1.0, abs(gram[q, q])) for q, coeff in enumerate(coeffs) ): raise SnapError(gram[pivot, pivot]) offdiag = (coeffs, radicand) except SnapError: # No exact closed form for this block: map the term to None so # the caller can say so, rather than aborting the whole run (the # term energies and the ground state do not depend on it). results[index] = None continue results[index] = ((label1, label2), diag1, diag2, offdiag) return results