"""Chiral octahedral group O (order 24) and its 5 irreps.
This is used in the Wigner-Eckart experiment to attribute predictive power
to angular momentum channels (l = 0, 0, 2, 1, 2 for A_1, A_2, E, T_1, T_2).
The 24 rotation matrices are built explicitly: identity, 6 face rotations
(±90°, 180° about coordinate axes), 8 vertex rotations (±120° about body
diagonals), 6 edge rotations (180° about edge midpoints).
"""
from __future__ import annotations
from typing import List, Tuple
import numpy as np
def _axis_angle(axis: np.ndarray, angle: float) -> np.ndarray:
"""Rodrigues rotation matrix."""
axis = axis / np.linalg.norm(axis)
K = np.array([[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]])
return np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
def octahedral_rotations() -> List[np.ndarray]:
R = [np.eye(3)]
# Face rotations: ±90°, 180° about each coordinate axis (9 total)
for axis in [np.array([1, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 1])]:
for angle in [np.pi / 2, np.pi, -np.pi / 2]:
R.append(_axis_angle(axis.astype(float), angle))
# Vertex rotations: ±120° about each body diagonal (8 total)
diags = [np.array([1, 1, 1]), np.array([1, 1, -1]),
np.array([1, -1, 1]), np.array([-1, 1, 1])]
for axis in diags:
for angle in [2 * np.pi / 3, -2 * np.pi / 3]:
R.append(_axis_angle(axis.astype(float), angle))
# Edge rotations: 180° about each edge midpoint (6 total)
edges = [np.array([1, 1, 0]), np.array([1, -1, 0]),
np.array([1, 0, 1]), np.array([1, 0, -1]),
np.array([0, 1, 1]), np.array([0, 1, -1])]
for axis in edges:
R.append(_axis_angle(axis.astype(float), np.pi))
assert len(R) == 24, f"expected 24 rotations, got {len(R)}"
return R
def _round_to_int(M: np.ndarray, tol: float = 1e-6) -> np.ndarray:
Mr = np.round(M)
if np.max(np.abs(M - Mr)) < tol:
return Mr.astype(int)
return M
def octahedral_group() -> Tuple[np.ndarray, List[np.ndarray]]:
"""Return (multiplication_table, list_of_24_rotation_matrices)."""
R = octahedral_rotations()
R_int = [_round_to_int(M) for M in R]
n = 24
T = np.zeros((n, n), dtype=int)
for i in range(n):
for j in range(n):
P = R[i] @ R[j]
# find which element this is
best = -1
for k in range(n):
if np.allclose(P, R[k], atol=1e-6):
best = k
break
if best < 0:
raise RuntimeError(f"product R[{i}] R[{j}] not in group")
T[i, j] = best
return T, R
def octahedral_irreps():
"""Construct the 5 irreps of O and assemble F_G.
A_1: trivial (1-d, l=0)
A_2: sign of permutation / determinant of orientation (1-d, l=0)
E: 2-d (l=2 component)
T_1: 3-d (l=1, the rotation matrices themselves)
T_2: 3-d (l=2, traceless symmetric tensor part)
Returns (F_G, [d_rho]) with F_G of shape (24, 24): each row g is the
concatenated row-vectorization of [ρ_1(g), ρ_2(g), ..., ρ_5(g)].
"""
R = octahedral_rotations()
n = 24
# A_1: trivial
A1 = np.ones((n,))
# A_2: distinguishes proper rotations conjugate to S_4 vs the rest
# In chiral octahedral, A_2 ≡ A_1 because all elements have det +1.
# To get a true second 1-d irrep we use the sign of the permutation
# representation on the four body diagonals.
diag_action = []
diags = np.array([[1, 1, 1], [1, 1, -1], [1, -1, 1], [-1, 1, 1]])
for Rg in R:
permuted = (Rg @ diags.T).T
idx = np.zeros(4, dtype=int)
for i, p in enumerate(permuted):
for j, d in enumerate(diags):
if np.allclose(p, d, atol=1e-6) or np.allclose(p, -d, atol=1e-6):
idx[i] = j
break
diag_action.append(idx)
A2 = np.array([_perm_sign(p) for p in diag_action], dtype=float)
# T_1: the rotation matrices (3-d)
T1 = [R[g] for g in range(n)]
# E and T_2: from the 5-d symmetric traceless rank-2 representation
# acting on Sym^2(R^3) / trace, decomposed into 2-d (E) + 3-d (T_2)
# We build the 5-d representation, then orthogonally split via projection.
Sym2 = [_sym2_rep(Rg) for Rg in R] # each (5, 5)
# Use eigenstructure of the operator (1/|G|) Σ ρ(g) ρ_test(g)^T to
# find the 2-d invariant subspace (E), for the symmetric traceless
# representation, the standard split gives: E spanned by
# diag(1, -1, 0)/√2 and (2 zz - xx - yy)/√6; T_2 spanned by xy, xz, yz.
E_basis = np.array([
[1.0, -1.0, 0.0, 0.0, 0.0], # xx - yy
[-1.0, -1.0, 2.0, 0.0, 0.0] / np.sqrt(3.0), # 2 zz - xx - yy (normalized)
]) / np.sqrt(2.0)
T2_basis = np.array([
[0.0, 0.0, 0.0, 1.0, 0.0], # xy
[0.0, 0.0, 0.0, 0.0, 1.0], # xz
# yz is implicit; for the chiral octahedral group the basis is
# (xy, xz, yz). We'll add yz below if available.
])
# Sym^2 ordering: (xx, yy, zz, xy, xz, yz), but we used 5 because trace-free.
# For simplicity we use the explicit 5-d basis (xx-yy, 2zz-xx-yy, xy, xz, yz).
# Reconstruct projections.
full_basis_5d = np.array([
[1, -1, 0, 0, 0, 0], # xx-yy
[-1, -1, 2, 0, 0, 0], # 2zz-xx-yy
[0, 0, 0, 1, 0, 0], # xy
[0, 0, 0, 0, 1, 0], # xz
[0, 0, 0, 0, 0, 1], # yz
], dtype=float)
# Normalize
norms = np.linalg.norm(full_basis_5d, axis=1, keepdims=True)
full_basis_5d = full_basis_5d / norms
# Build 6-d symmetric tensor representation, then project to 5-d traceless
Sym2_6 = [_sym2_rep_full(Rg) for Rg in R] # each (6, 6)
Sym2_5 = [full_basis_5d @ M @ full_basis_5d.T for M in Sym2_6]
# E corresponds to first two basis elements; T_2 to the last three
E = [M[:2, :2] for M in Sym2_5]
T2 = [M[2:, 2:] for M in Sym2_5]
# Assemble F_G row-by-row
rows = []
for g in range(n):
row = []
row.append(A1[g])
row.append(A2[g])
row.extend(E[g].flatten().tolist())
row.extend(T1[g].flatten().tolist())
row.extend(T2[g].flatten().tolist())
rows.append(row)
F = np.array(rows, dtype=complex) / np.sqrt(n)
irrep_dims = [1, 1, 2, 3, 3]
assert F.shape == (24, 24)
return F, irrep_dims
def _perm_sign(perm: np.ndarray) -> int:
"""Sign of a permutation given as an array of target indices."""
inversions = 0
for i in range(len(perm)):
for j in range(i + 1, len(perm)):
if perm[i] > perm[j]:
inversions += 1
return 1 if inversions % 2 == 0 else -1
def _sym2_rep(R: np.ndarray) -> np.ndarray:
"""5-d symmetric traceless representation acting on Sym^2(R^3) / trace."""
Mfull = _sym2_rep_full(R)
full_basis_5d = np.array([
[1, -1, 0, 0, 0, 0],
[-1, -1, 2, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1],
], dtype=float)
full_basis_5d = full_basis_5d / np.linalg.norm(full_basis_5d, axis=1, keepdims=True)
return full_basis_5d @ Mfull @ full_basis_5d.T
def _sym2_rep_full(R: np.ndarray) -> np.ndarray:
"""6-d symmetric tensor representation (xx, yy, zz, xy, xz, yz)."""
M = np.zeros((6, 6))
pairs = [(0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)]
for i, (a, b) in enumerate(pairs):
for j, (c, d) in enumerate(pairs):
if c == d:
M[i, j] = R[a, c] * R[b, d]
else:
M[i, j] = R[a, c] * R[b, d] + R[a, d] * R[b, c]
return M