"""Multi-electron terms of point-group configurations (crystod-group --multiplet).
Given a point group and an electron configuration over its irrep shells
(e.g. (T2g)^2 in m-3m), computes the LS-coupling terms: the allowed
many-electron states with their spin multiplicities 2S+1 and spatial irreps,
respecting the Pauli principle, e.g.
(T2g)^2 = ^3T1g + ^1A1g + ^1Eg + ^1T2g
Theory: for n equivalent electrons in a shell spanning irrep Gamma, the
totally antisymmetric n-electron states split by permutation symmetry: the
orbital part transforms as the Schur functor S^lambda(Gamma) where lambda is
a partition of n with at most two columns, and the spin part carries the
conjugate partition [n-k, k] (at most two rows for spin-1/2), fixing the
total spin S = (n-2k)/2. The character of the symmetrized power follows
from the Frobenius formula
chi_{S^lambda}(g) = sum_{rho |- n} chi^lambda_{S_n}(rho) / z_rho
* prod_i chi_Gamma(g^{rho_i}),
with the symmetric-group characters chi^lambda_{S_n} evaluated by the
Murnaghan-Nakayama rule and the class of g^j found from the explicit
rotation matrices of the point group. Electrons in different (inequivalent)
shells carry no mutual Pauli restriction: their terms couple by the direct
product of the spatial parts and angular-momentum addition of the spins,
S = |S1-S2|, ..., S1+S2.
"""
from __future__ import annotations
import re
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from fractions import Fraction
from math import comb, factorial
import numpy as np
from .decompose_irrep import decompose, get_character_table
from .ligand_field import ORBITAL_AZIMUTHAL_NUMBER, get_orbital_characters
def build_parser() -> ArgumentParser:
parser = ArgumentParser(
prog="crystod-group --multiplet",
description=(
"Multi-electron terms (spin multiplicities and spatial irreps) of "
"an electron configuration over point-group irrep shells."
),
formatter_class=RawDescriptionHelpFormatter,
epilog=(
"Examples (T2g2 = T2g^2; the ^-free form needs no quoting in zsh):\n"
" crystod-group --multiplet T2g2 --point-group m-3m\n"
" crystod-group --multiplet T2g2 Eg1 --point-group m-3m --orbital d\n"
" crystod-group --multiplet E2 --point-group 3m"
),
)
parser.add_argument(
"--point-group",
"-pg",
dest="point_group",
required=True,
help="Point group label, e.g. m-3m.",
)
parser.add_argument(
"--config",
nargs="+",
required=True,
metavar="IRREP^N",
help="Shell occupations, e.g. T2g^2 or the quoting-free T2g2; several "
"shells: T2g2 Eg1 (bare IRREP means one electron).",
)
parser.add_argument(
"--orbital",
default=None,
help="Parent atomic orbital (s/p/d/f/...); checks that every occupied "
"shell occurs in its ligand-field splitting and prints the splitting.",
)
parser.add_argument(
"--visualize",
action="store_true",
help="Write the exact term eigenstates as an interactive HTML page "
"(orbital box diagrams with up/down arrows and the Slater-determinant "
"expansion coefficients); requires --orbital.",
)
parser.add_argument(
"--output",
default=None,
metavar="FILE",
help="Output HTML path for --visualize "
"(default: Multiplet_{pg}_{config}.html).",
)
return parser
# ---------------------------------------------------------------------------
# symmetric-group characters (Murnaghan-Nakayama rule)
# ---------------------------------------------------------------------------
def _partitions(n: int, largest: int | None = None):
"""All partitions of n as weakly decreasing tuples."""
if largest is None:
largest = n
if n == 0:
yield ()
return
for first in range(min(n, largest), 0, -1):
for rest in _partitions(n - first, first):
yield (first,) + rest
def _z_order(rho: tuple[int, ...]) -> int:
"""Order of the centralizer of a permutation of cycle type rho."""
z = 1
for part in set(rho):
count = rho.count(part)
z *= part**count * factorial(count)
return z
def _sn_character(lam: tuple[int, ...], rho: tuple[int, ...]) -> int:
"""Character chi^lam of S_n at cycle type rho (Murnaghan-Nakayama rule,
border strips removed on a padded beta-set / abacus)."""
if not rho:
return 1 if not lam else 0
strip, rest = rho[0], rho[1:]
n_beads = len(lam) + strip
beta = [
(lam[i] if i < len(lam) else 0) + (n_beads - 1 - i) for i in range(n_beads)
]
beta_set = set(beta)
total = 0
for position in beta:
moved = position - strip
if moved < 0 or moved in beta_set:
continue
crossed = sum(1 for other in beta if moved < other < position)
new_beta = sorted(
[other for other in beta if other != position] + [moved], reverse=True
)
new_lam = tuple(
value
for value in (new_beta[i] - (n_beads - 1 - i) for i in range(n_beads))
if value > 0
)
total += (-1) ** crossed * _sn_character(new_lam, rest)
return total
# ---------------------------------------------------------------------------
# point-group classes and characters of powers
# ---------------------------------------------------------------------------
class _GroupClasses:
"""Conjugacy classes of the point group with power-map support."""
def __init__(self, character_table: dict):
self.ct = character_table
self.names = list(character_table["rotation_list"])
self.matrices = [
np.asarray(character_table["mapping_table"][name])
for name in self.names
]
self.sizes = [mats.shape[0] for mats in self.matrices]
self.order = int(sum(self.sizes))
def class_of(self, matrix: np.ndarray) -> int:
for index, mats in enumerate(self.matrices):
if any(np.allclose(matrix, m, atol=1e-8) for m in mats):
return index
raise SystemExit(
"ERROR: symmetry-operation power fell outside the group "
"(inconsistent character table)."
)
def power_classes(self, max_power: int) -> list[list[int]]:
"""power_classes[c][j-1] = class index of g^j for g in class c."""
result = []
for mats in self.matrices:
g = np.asarray(mats[0], dtype=float)
powers = []
p = np.eye(3)
for _ in range(max_power):
p = p @ g
powers.append(self.class_of(p))
result.append(powers)
return result
def irrep_characters(self, irrep: str) -> np.ndarray:
return np.asarray(self.ct["character_table"][irrep], dtype=float)
def irrep_dimension(self, irrep: str) -> int:
return int(round(self.irrep_characters(irrep)[self.names.index("E")]))
def _schur_functor_characters(
classes: _GroupClasses,
irrep: str,
lam: tuple[int, ...],
power_classes: list[list[int]],
) -> np.ndarray:
"""Characters (per class) of the Schur functor S^lam applied to the irrep."""
n = sum(lam)
chi = classes.irrep_characters(irrep)
values = np.zeros(len(classes.names))
for rho in _partitions(n):
coefficient = Fraction(_sn_character(lam, rho), _z_order(rho))
if coefficient == 0:
continue
for c in range(len(classes.names)):
product = 1.0
for part in rho:
product *= chi[power_classes[c][part - 1]]
values[c] += float(coefficient) * product
return values
# ---------------------------------------------------------------------------
# terms of one shell and coupling of shells
# ---------------------------------------------------------------------------
[docs]
def shell_terms(
classes: _GroupClasses, irrep: str, n_electrons: int
) -> list[tuple[Fraction, str, int]]:
"""Pauli-allowed terms of n equivalent electrons in one irrep shell.
The single-shell step of ``crystod-group --multiplet IRREP^N --pg PG``:
for every total spin ``S = n/2, n/2 - 1, ...`` the orbital part
transforms as the Schur functor of the two-column partition, whose
characters follow from the Frobenius formula with Murnaghan-Nakayama
symmetric-group characters; each is reduced into point-group irreps.
Args:
classes: Conjugacy classes of the point group, built as
``crystod.multiplet._GroupClasses(character_table)`` from the
table of ``crystod.group.get_character_table`` (the same object
that ``parse_config`` takes).
irrep: Label of the shell irrep, e.g. ``"T2g"``.
n_electrons: Number of electrons in the shell, ``1`` to twice the
irrep dimension.
Returns:
Terms as ``(S, irrep, count)`` with the total spin ``S`` a
``Fraction`` (``2S+1`` is the multiplicity of the term symbol) and
``count`` the number of times the term occurs.
Raises:
SystemExit: ``n_electrons`` outside ``1 .. 2 * dim`` (``ValueError``
when called through ``crystod.group``).
Example:
>>> from crystod import group
>>> from crystod.multiplet import _GroupClasses
>>> classes = _GroupClasses(group.get_character_table("m-3m"))
>>> for spin, irrep, count in group.shell_terms(classes, "T2g", 2):
... print(f"^{int(2 * spin + 1)}{irrep}", count)
^3T1g 1
^1A1g 1
^1Eg 1
^1T2g 1
"""
dim = classes.irrep_dimension(irrep)
capacity = 2 * dim
if not 1 <= n_electrons <= capacity:
raise SystemExit(
f"ERROR: shell {irrep} (dimension {dim}) holds 1 to {capacity} "
f"electrons; got {n_electrons}."
)
multiplicities = classes.sizes
power_classes = classes.power_classes(n_electrons)
terms: list[tuple[Fraction, str, int]] = []
for k in range(n_electrons // 2 + 1):
rows = n_electrons - k
if rows > dim:
continue # Schur functor vanishes: too many antisymmetrized rows
lam = tuple([2] * k + [1] * (n_electrons - 2 * k))
spin = Fraction(n_electrons - 2 * k, 2)
characters = _schur_functor_characters(classes, irrep, lam, power_classes)
counts = decompose(list(characters), classes.ct, multiplicities)
for name, count in counts.items():
if count > 0:
terms.append((spin, name, count))
return terms
[docs]
def couple_shells(
classes: _GroupClasses,
terms_a: list[tuple[Fraction, str, int]],
terms_b: list[tuple[Fraction, str, int]],
) -> list[tuple[Fraction, str, int]]:
"""Couple the term sets of two inequivalent shells.
The multi-shell step of ``crystod-group --multiplet T2g2 Eg1 --pg PG``:
electrons in different shells carry no mutual Pauli restriction, so the
spatial parts couple by the direct product and the spins by
angular-momentum addition ``S = |S1 - S2|, ..., S1 + S2``.
Args:
classes: Conjugacy classes of the point group (see ``shell_terms``).
terms_a: Terms of the first shell, as returned by ``shell_terms``.
terms_b: Terms of the second shell.
Returns:
Terms ``(S, irrep, count)`` of the coupled configuration; couple a
third shell by calling again with this result.
"""
multiplicities = classes.sizes
combined: dict[tuple[Fraction, str], int] = {}
for spin_a, irrep_a, count_a in terms_a:
for spin_b, irrep_b, count_b in terms_b:
characters = classes.irrep_characters(irrep_a) * classes.irrep_characters(
irrep_b
)
counts = decompose(list(characters), classes.ct, multiplicities)
spin = abs(spin_a - spin_b)
while spin <= spin_a + spin_b:
for name, count in counts.items():
if count > 0:
key = (spin, name)
combined[key] = combined.get(key, 0) + count_a * count_b * count
spin += 1
return [(spin, name, count) for (spin, name), count in combined.items()]
# ---------------------------------------------------------------------------
# configuration parsing and formatting
# ---------------------------------------------------------------------------
def _split_shell_token(token: str, available: list[str]) -> tuple[str, int]:
"""One shell token -> (irrep, n). Accepted forms: IRREP^N, (IRREP)^N,
bare IRREP (one electron), and the shell-quoting-free IRREPN (e.g. T2g2,
since an unquoted ^ is a glob character in zsh)."""
match = re.fullmatch(r"\(?([A-Za-z][A-Za-z0-9'\"]*?)\)?\^(\d+)", token)
if match:
name, count = match.group(1), int(match.group(2))
if name not in available:
raise SystemExit(
f"ERROR: '{name}' is not an irrep of this point group.\n"
f"Choose from: {', '.join(available)}"
)
return name, count
bare = token.strip("()")
if bare in available:
return bare, 1
splits = [
(bare[:i], int(bare[i:]))
for i in range(1, len(bare))
if bare[:i] in available and bare[i:].isdigit()
]
if len(splits) == 1:
return splits[0]
if len(splits) > 1:
choices = " / ".join(f"{name}^{count}" for name, count in splits)
raise SystemExit(
f"ERROR: shell token '{token}' is ambiguous ({choices}); "
"use the explicit IRREP^N form."
)
raise SystemExit(
f"ERROR: '{token}' is not an irrep of this point group "
"(shell tokens: IRREP^N or IRREPN, e.g. T2g^2 or T2g2).\n"
f"Choose from: {', '.join(available)}"
)
[docs]
def parse_config(tokens: list[str], classes: _GroupClasses) -> list[tuple[str, int]]:
"""Parse shell tokens into ``(irrep, n_electrons)`` pairs.
The argument parser of ``crystod-group --multiplet``: accepts
``IRREP^N``, ``(IRREP)^N``, the quoting-free ``IRREPN`` (``T2g2``; an
unquoted ``^`` is a glob character in zsh) and a bare ``IRREP`` for one
electron.
Args:
tokens: Shell tokens, e.g. ``["T2g2", "Eg1"]``.
classes: Conjugacy classes of the point group (see ``shell_terms``);
its table supplies the valid irrep labels.
Returns:
``[(irrep, n_electrons), ...]`` in input order.
Raises:
SystemExit: A token names no irrep of the point group, or is
ambiguous (``ValueError`` when called through ``crystod.group``).
Example:
>>> from crystod import group
>>> from crystod.multiplet import _GroupClasses
>>> classes = _GroupClasses(group.get_character_table("m-3m"))
>>> group.parse_config(["T2g2", "Eg"], classes)
[('T2g', 2), ('Eg', 1)]
"""
available = list(classes.ct["character_table"].keys())
return [_split_shell_token(token, available) for token in tokens]
def _term_symbol(spin: Fraction, irrep: str) -> str:
return f"^{int(2 * spin + 1)}{irrep}"
def format_terms(
classes: _GroupClasses, terms: list[tuple[Fraction, str, int]]
) -> str:
irrep_order = {name: i for i, name in enumerate(classes.ct["character_table"])}
ordered = sorted(terms, key=lambda t: (-t[0], irrep_order[t[1]]))
parts = []
for spin, name, count in ordered:
symbol = _term_symbol(spin, name)
parts.append(symbol if count == 1 else f"{count}({symbol})")
return " + ".join(parts)
def _config_label(shells: list[tuple[str, int]]) -> str:
return " ".join(f"({name})^{count}" for name, count in shells)
[docs]
def hund_candidates(
classes: _GroupClasses, terms: list[tuple[Fraction, str, int]]
) -> list[tuple[Fraction, str, int]]:
"""Hund's-rule ground-state candidates among the terms.
The ``Ground-state Term Symbol (Hund's rules)`` block of ``crystod-group
--multiplet`` without ``--orbital``: maximal ``2S+1`` first, then
maximal orbital dimension.
Args:
classes: Conjugacy classes of the point group (see ``shell_terms``).
terms: Terms ``(S, irrep, count)`` from ``shell_terms`` or
``couple_shells``.
Returns:
The candidate terms (more than one when the rules tie); the exact
ordering needs the Coulomb energies of
``crystod.group.compute_term_energies``.
"""
max_spin = max(term[0] for term in terms)
candidates = [term for term in terms if term[0] == max_spin]
max_dim = max(classes.irrep_dimension(term[1]) for term in candidates)
return [
term
for term in candidates
if classes.irrep_dimension(term[1]) == max_dim
]
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
if args.visualize and not args.orbital:
raise SystemExit(
"ERROR: --visualize needs --orbital (s/p/d/f) to identify the "
"real orbitals of each shell (e.g. --orbital d for T2g/Eg)."
)
if args.output and not args.visualize:
raise SystemExit("ERROR: --output is only used with --visualize.")
character_table = get_character_table(args.point_group)
classes = _GroupClasses(character_table)
shells = parse_config(args.config, classes)
print(f"\n* Point group *\n{args.point_group}\n")
if args.orbital:
orbital = args.orbital.strip().lower()
if orbital not in ORBITAL_AZIMUTHAL_NUMBER:
raise SystemExit(
f"ERROR: orbital '{args.orbital}' is not supported. "
f"Choose from: {', '.join(ORBITAL_AZIMUTHAL_NUMBER)}"
)
orbital_characters = get_orbital_characters(orbital, character_table)
splitting = decompose(
list(orbital_characters.values()), character_table, classes.sizes
)
split_irreps = [name for name, count in splitting.items() if count > 0]
print(f"* Ligand-field splitting of the {orbital} orbital *")
print(
" + ".join(
f"{count}({name})" for name, count in splitting.items() if count > 0
)
+ "\n"
)
for name, _ in shells:
if name not in split_irreps:
raise SystemExit(
f"ERROR: {name} does not occur in the {orbital}-orbital "
f"splitting ({' + '.join(split_irreps)})."
)
print(f"* Configuration *\n{_config_label(shells)}\n")
terms: list[tuple[Fraction, str, int]] | None = None
shell_term_lists = []
for name, count in shells:
current = shell_terms(classes, name, count)
shell_term_lists.append(current)
terms = current if terms is None else couple_shells(classes, terms, current)
total_states = 1
for name, count in shells:
total_states *= comb(2 * classes.irrep_dimension(name), count)
term_states = sum(
int(2 * spin + 1) * classes.irrep_dimension(name) * count
for spin, name, count in terms
)
print("* Term Symbols *")
print(f"{_config_label(shells)} = {format_terms(classes, terms)}\n")
print(
f"check: {term_states} states = "
+ " x ".join(
f"C({2 * classes.irrep_dimension(name)},{count})" for name, count in shells
)
+ f" = {total_states}"
)
if term_states != total_states:
print("WARNING: state count mismatch - please report this case.")
print()
irrep_order = {name: i for i, name in enumerate(classes.ct["character_table"])}
ordered_terms = sorted(terms, key=lambda t: (-t[0], irrep_order[t[1]]))
if args.orbital:
from .multiplet_energy import (
compute_term_energies,
format_linear,
ground_state,
)
l = ORBITAL_AZIMUTHAL_NUMBER[orbital]
params, energies, reference, reference_note = compute_term_energies(
classes.ct, l, shells, ordered_terms
)
if l == 2:
header = "Racah parameters A, B, C"
else:
header = f"Slater-Condon parameters {', '.join(params)} (reduced)"
print(f"* Multiplet Energies ({header}; Coulomb part only) *")
width = max(len(_term_symbol(s, n)) for s, n, _ in ordered_terms)
for (spin, name, count), entry in zip(ordered_terms, energies):
symbol = _term_symbol(spin, name).ljust(width)
for line in entry.describe():
print(f"{symbol}: {line}")
if any(entry.numeric is not None for entry in energies):
print(f"(numeric CI blocks evaluated at {reference_note})")
print(
"(the one-electron / crystal-field part is an additive constant "
"within the configuration)\n"
)
if len(shells) == 2 and any(
entry.multiplicity == 2 for entry in energies
):
from .multiplet_energy import (
coupled_parent_matrices,
format_offdiag,
)
parent_matrices = coupled_parent_matrices(
classes.ct, l, shells, shell_term_lists, ordered_terms
)
if parent_matrices:
print("* CI matrices in the coupled-parent basis "
"(strong-field/Tanabe-Sugano tables) *")
for index, entry in sorted(parent_matrices.items()):
spin, name, _ = ordered_terms[index]
symbol = _term_symbol(spin, name)
if entry is None:
print(f"{symbol}: (no exact closed form for this "
"block; entry omitted)")
continue
labels, diag1, diag2, offdiag = entry
def parent_label(parent):
s1, g1, s2, g2 = parent
return (
f"{shells[0][0]}^{shells[0][1]}"
f"({_term_symbol(s1, g1)}) "
f"{shells[1][0]}^{shells[1][1]}"
f"({_term_symbol(s2, g2)})"
)
print(f"{symbol}:")
print(f" |1> = {parent_label(labels[0])}")
print(f" |2> = {parent_label(labels[1])}")
print(f" <1|H|1> = {format_linear(diag1, params)}")
print(f" <2|H|2> = {format_linear(diag2, params)}")
print(f" <1|H|2> = +-{format_offdiag(offdiag, params)}"
" (sign is a basis convention)")
print()
winners, unconditional = ground_state(energies, reference)
print("* Ground-state Term Symbol (within this configuration) *")
symbols = ", ".join(
_term_symbol(entry.spin, entry.irrep) for entry in winners
)
if unconditional:
condition = (
"(lowest for any B > 0, C > 0)"
if l == 2
else "(lowest for any positive Slater parameters)"
)
else:
condition = f"(lowest at {reference_note})"
print(f"{symbols} {condition}")
if args.visualize:
import os
import re as _re
from .multiplet_visualize import (
compute_term_states,
write_term_state_html,
)
shell_info, term_states = compute_term_states(
classes.ct, l, shells, ordered_terms, reference
)
config_tag = "_".join(
f"{name}{count}" for name, count in shells
)
default_name = _re.sub(
r"[^A-Za-z0-9_.-]", "", f"Multiplet_{args.point_group}_{config_tag}"
) + ".html"
output_path = args.output or default_name
write_term_state_html(
output_path,
args.point_group,
_config_label(shells),
shell_info,
ordered_terms,
term_states,
energies=energies,
ground_symbols={(e.spin, e.irrep) for e in winners},
reference_note=reference_note,
l=l,
)
print(f"\nTerm-state viewer written to {output_path}")
else:
candidates = hund_candidates(classes, ordered_terms)
print("* Ground-state Term Symbol (Hund's rules) *")
if len(candidates) == 1:
print(_term_symbol(candidates[0][0], candidates[0][1]))
else:
print(
"candidates: "
+ ", ".join(_term_symbol(s, n) for s, n, _ in candidates)
+ " (2S+1 and orbital-dimension tie)"
)
print(
"(exact Coulomb multiplet energies in Racah/Slater parameters: "
"add --orbital s|p|d|f of the parent shell)"
)
print()
if __name__ == "__main__":
main()