"""Isotropy subgroups of space-group irreps (crystod-group --supergroup).
Given a space group G and one of its irreps (ISO-IR label), a distortion that
transforms as that irrep reduces the symmetry to the isotropy subgroup
H(eta) = { g in G : D(g) eta = eta }
where D is the full (induced) representation and eta the order parameter.
Distinct order-parameter directions (a,0,0), (a,a,0), ... give distinct
isotropy subgroups; this module enumerates all of them (the strata of the
representation), or resolves a user-given direction, and identifies each
subgroup with spglib (symbol, number, cell size, index, and the basis /
origin of its conventional cell in the parent convention).
This is the offline counterpart of the ISOSUBGROUP tool of the ISOTROPY
Software Suite (https://iso.byu.edu), and is validated against its output.
If you use this feature, please cite: H. T. Stokes, S. van Orden and
B. J. Campbell, "Tool for Generating Isotropy Subgroups of Crystallographic
Space Groups", J. Appl. Cryst. 49, 1849-1853 (2016).
The order-parameter components refer to the real irrep basis produced by
spgrep; for multi-arm stars the components are grouped arm by arm. The basis
may differ from ISOTROPY's by an orthogonal change (direction labels can be
permuted/rotated relative to the ISOSUBGROUP listing), but the resulting
subgroups are convention-independent.
"""
from __future__ import annotations
import argparse
from fractions import Fraction
import numpy as np
from .spacegroup_product import DEN, SIGMA, SpaceGroupIrrepAlgebra
_PARAMETER_NAMES = "abcdefghijklmnopqrstuvwx"
# space-group types that exist as enantiomorphic pairs: the stabilizers of
# the mirror-image order parameters of one stratum are of partner types, so
# either member may appear as the representative of the stratum
ENANTIOMORPHIC_PAIRS = {
76: 78, 78: 76, 91: 95, 95: 91, 92: 96, 96: 92, 144: 145, 145: 144,
151: 153, 153: 151, 152: 154, 154: 152, 169: 170, 170: 169, 171: 172,
172: 171, 178: 179, 179: 178, 180: 181, 181: 180, 212: 213, 213: 212,
}
# Irrep labels that differ between the tables used by crystod (the ISO-IR
# 2011 data files, whose labels coincide with the Bilbao/DIRPRO reference
# set at every maximal k point) and the ISOTROPY/ISOSUBGROUP software at
# the same k point. Established table-for-table by the ISOSUBGROUP
# reference sweep (script/validate_isosubgroup.py); multi-label entries
# mean the corresponding ISOTROPY tables are identical.
ISOTROPY_LABELS = {
(64, "Y"): {"Y1+": "Y3+", "Y1-": "Y3-", "Y2+": "Y4+", "Y2-": "Y4-",
"Y3+": "Y1+", "Y3-": "Y1-", "Y4+": "Y2+", "Y4-": "Y2-"},
(67, "Y"): {"Y1+": "Y3+/Y4-", "Y1-": "Y3-/Y4+", "Y2+": "Y3-/Y4+",
"Y2-": "Y3+/Y4-", "Y3+": "Y1+/Y1-", "Y3-": "Y1+/Y1-",
"Y4+": "Y2+/Y2-", "Y4-": "Y2+/Y2-"},
(67, "T"): {"T1+": "T3+/T4-", "T1-": "T3-/T4+", "T2+": "T3-/T4+",
"T2-": "T3+/T4-", "T3+": "T1/T2", "T3-": "T1/T2",
"T4+": "T1/T2", "T4-": "T1/T2"},
(68, "Y"): {"Y1+": "Y3-/Y4+", "Y1-": "Y3+/Y4-", "Y2+": "Y3+/Y4-",
"Y2-": "Y3-/Y4+", "Y3+": "Y2+/Y2-", "Y3-": "Y2+/Y2-",
"Y4+": "Y1+/Y1-", "Y4-": "Y1+/Y1-"},
(68, "T"): {"T1": "T2", "T2": "T1"},
(108, "P"): {"P3": "P4P4", "P4": "P3P3"},
(140, "P"): {"P1": "P2P4", "P2": "P1P3", "P3": "P2P4", "P4": "P1P3"},
(141, "X"): {"X1": "X2", "X2": "X1"},
(142, "X"): {"X1": "X2", "X2": "X1"},
(230, "N"): {"N1": "N2", "N2": "N1"},
}
# --------------------------------------------------------------- representation
[docs]
class InducedRepresentation:
"""Full (induced) irrep of a space group as explicit real matrices.
The representation of the order parameter of one ISO-IR irrep, used by
``crystod-group --supergroup`` (through ``IsotropyAnalyzer``) and by the
symmetry-mode analysis. The basis index is ``(arm a, small-irrep row
p)`` and the group elements are parametrized as (coset representative
``i``, lattice translation ``t``)::
D(g_i + t) = T(t) B_i, T(t) = diag_a exp(SIGMA*2j*pi q_a.t) (x) 1_d
The small-irrep matrices come from spgrep and are matched to the
tabulated ISO-IR characters (with an origin-shift search where the
conventions differ); the induced blocks are verified against the
independently induced characters. The matrices are finally brought to
the real, physically irreducible form: real-type irreps by a similarity
transform, complex- and pseudoreal-type irreps as the doubled real form
of ``D + D*`` (the paired ISOTROPY entries such as ``P1P2``).
Args:
algebra: The ``SpaceGroupIrrepAlgebra`` of the space group.
irrep_label: ISO-IR irrep label, e.g. ``"R4+"``.
Attributes:
algebra: The algebra the representation was built from.
irrep: The tabulated irrep record (``name``, ``dim``, ``kpname``).
k: k vector of the irrep (primitive basis, units of ``1/DEN``).
arms: Star arms, shape ``(n_arms, 3)``; ``representatives`` holds
the coset-representative index generating each arm.
n_arms: Number of star arms.
dim_small: Dimension of the small irrep.
dimension: Dimension of the order parameter (``n_arms * dim_small``,
doubled for complex- and pseudoreal-type irreps).
blocks: The matrices ``B_i`` of the coset representatives, before
realification.
elements: All distinct group elements as ``(i, t, matrix)`` with the
real matrix of ``D(g_i + t)``; ``t`` runs over the translation
grid of period ``grid_n``.
doubled: ``True`` when the physically irreducible form is
``D + D*``; ``fs_type`` then names the type (``"complex"`` or
``"pseudoreal"``).
grid_n: Period of the lattice-translation grid on which the
matrices are distinct.
Raises:
SystemExit: Unknown irrep label, or a tabulated entry that cannot be
matched to any spgrep small irrep.
Example:
>>> from crystod import group
>>> algebra = group.SpaceGroupIrrepAlgebra("Pm-3m")
>>> rep = group.InducedRepresentation(algebra, "R4+")
>>> rep.dimension, rep.n_arms, rep.dim_small, rep.label, rep.doubled
(3, 1, 3, 'R4+', False)
"""
def __init__(self, algebra: SpaceGroupIrrepAlgebra, irrep_label: str):
self.algebra = algebra
self.irrep = algebra.find_irrep(irrep_label)
kpname = self.irrep.kpname
if kpname not in algebra.k_by_kname:
raise SystemExit(f"ERROR: unknown k point for irrep {irrep_label}.")
self.k = algebra.k_by_kname[kpname]
self.arms, self.representatives = algebra.star(kpname)
self.n_arms = len(self.arms)
small_matrices, little = self._small_matrices()
self.little = little
self.dim_small = small_matrices[next(iter(small_matrices))].shape[0]
self.dimension = self.n_arms * self.dim_small
self.blocks = self._induce(small_matrices)
self._verify()
# materialize all distinct elements (coset rep i, lattice translation t)
self.elements = [
(i, t, self.translation_phases(t)[:, None] * self.blocks[i])
for i in range(algebra.n_ops)
for t in self._translation_grid()
]
self._realify()
# -- small irrep matrices matched to the ISO-IR label
def _small_matrices(self):
algebra, irrep = self.algebra, self.irrep
from spgrep.core import get_spacegroup_irreps_from_primitive_symmetry
try:
irreps, mapping = get_spacegroup_irreps_from_primitive_symmetry(
rotations=algebra.rotations,
translations=np.array(algebra.translations, dtype=float) / DEN,
kpoint=np.array(self.k, dtype=float) / DEN,
)
except Exception as exc:
raise SystemExit(
f"ERROR: spgrep could not compute the small irreps at "
f"{irrep.kpname}: {exc}"
) from exc
mapping = [int(m) for m in np.asarray(mapping).ravel()]
# reference characters of the requested irrep (spgrep-refined)
table = {int(key) - 1: complex(v) for key, v in irrep.characters.items()}
try:
refined = algebra._refine_small_characters(np.asarray(self.k), table)
except SystemExit:
refined = None
if refined is None:
# defensive fallback: identify the tabulated irrep up to an
# origin-shift gauge e^(2 pi i k.(W-1)x0) (not expected to be
# needed with the ISO-IR tables, whose asymmetric points are
# resolved inside _refine_small_characters)
refined = self._match_with_origin_shift(table)
if refined is None:
raise SystemExit(
f"ERROR: the tabulated characters of {irrep.name} are not those "
"of a single allowed small irrep; isotropy-subgroup analysis is "
"not available for this entry."
)
matches = []
for matrices in irreps:
matrices = np.asarray(matrices)
chi = {op: complex(np.trace(matrices[j])) for j, op in enumerate(mapping)}
if set(chi) == set(refined) and all(
abs(chi[op] - refined[op]) < 1e-3 for op in refined
):
matches.append(matrices)
if len(matches) != 1:
raise SystemExit(
f"ERROR: could not match {irrep.name} to a unique computed small "
f"irrep ({len(matches)} candidates); possibly a paired (physically "
"combined) irrep, which is not supported yet."
)
matrices = matches[0]
small = {op: np.asarray(matrices[j]) for j, op in enumerate(mapping)}
# when 2k = 0 (mod reciprocal lattice) the translation phases are
# real, so a real small irrep gives a real induced rep in the natural
# arm-blocked basis (nice, arm-grouped order-parameter components)
if np.all((2 * np.asarray(self.k)) % DEN == 0):
small = _realify_matrix_set(small) or small
return small, sorted(mapping)
def _match_with_origin_shift(self, table: dict) -> dict | None:
"""Identify the tabulated small irrep among the spgrep candidates up
to an origin-shift gauge, returning the candidate's exact (gauge-
consistent) characters."""
algebra = self.algebra
k = np.asarray(self.k, dtype=float) / DEN
try:
candidates = algebra.computed_irreps_at(np.asarray(self.k))
except SystemExit:
return None
candidates = [c for c in candidates if set(c["chi"]) == set(table)]
if not candidates:
return None
shifts = [
np.array([x1, x2, x3]) / 8.0
for x1 in range(8)
for x2 in range(8)
for x3 in range(8)
]
for conjugate in (False, True):
reference = (
{op: np.conj(v) for op, v in table.items()} if conjugate else table
)
for x0 in shifts:
matched = []
for candidate in candidates:
chi = candidate["chi"]
ok = True
for op, value in reference.items():
W = algebra.rotations[op]
gauge = np.exp(
2j * np.pi * float(k @ ((W - np.eye(3)) @ x0))
)
if abs(chi[op] * gauge - value) > 5e-3:
ok = False
break
if ok:
matched.append(candidate["chi"])
if len(matched) == 1:
return {op: complex(v) for op, v in matched[0].items()}
if len(matched) == 2 and all(
abs(matched[0][op] - np.conj(matched[1][op])) < 1e-6
for op in matched[0]
):
return {op: complex(v) for op, v in matched[0].items()}
return None
def _induce(self, small: dict) -> list[np.ndarray]:
algebra = self.algebra
d, m = self.dim_small, self.n_arms
arm_index = {tuple(arm): a for a, arm in enumerate(self.arms)}
blocks = []
for i in range(algebra.n_ops):
matrix = np.zeros((m * d, m * d), dtype=np.complex128)
for b, s_b in enumerate(self.representatives):
# row arm: q_a = q_b . W_i^{-1}
q_a = tuple((self.arms[b] @ algebra.inverse_rotations[i]) % DEN)
a = arm_index[q_a]
s_a = self.representatives[a]
W_sa_inv = algebra.inverse_rotations[s_a]
v_sa_inv = -W_sa_inv @ algebra.translations[s_a]
# h = s_a^{-1} (g_i s_b)
W_gs = algebra.rotations[i] @ algebra.rotations[s_b]
v_gs = algebra.rotations[i] @ algebra.translations[s_b] + algebra.translations[i]
W_h = W_sa_inv @ W_gs
tau_h = W_sa_inv @ v_gs + v_sa_inv
mindex = algebra._rotation_index[algebra._key(W_h)]
if mindex not in small:
raise SystemExit("ERROR: broken induction bookkeeping.")
t_extra = tau_h - algebra.translations[mindex]
if np.any(t_extra % DEN != 0):
raise SystemExit("ERROR: non-lattice residue in induction.")
phase = np.exp(
SIGMA * 2j * np.pi * float(self.k @ (t_extra // DEN)) / DEN
)
matrix[a * d : (a + 1) * d, b * d : (b + 1) * d] = phase * small[mindex]
blocks.append(matrix)
return blocks
[docs]
def translation_phases(self, t: np.ndarray) -> np.ndarray:
"""Diagonal of ``T(t)`` for a lattice translation.
Args:
t: Lattice translation, integer vector in primitive units.
Returns:
The phases ``exp(SIGMA * 2j * pi * q_a . t)``, one entry per
(arm, small-irrep row) in the basis order of ``blocks``.
"""
phases = np.exp(
SIGMA * 2j * np.pi * (self.arms @ np.asarray(t, dtype=np.int64)) / DEN
)
return np.repeat(phases, self.dim_small)
def _realify(self) -> None:
"""Transform to the real physically irreducible form.
Real-type irreps get a similarity transform to real matrices; complex-
and pseudoreal-type irreps (Frobenius-Schur indicator 0 / -1, e.g. at
non-symmorphic zone-boundary points) get the doubled real form of
D + D* -- the representation of the physical (real) order parameter,
matching the paired entries of ISOTROPY (P1P2, ...)."""
self.doubled = False
if all(np.allclose(matrix.imag, 0, atol=1e-8) for _, _, matrix in self.elements):
self.elements = [
(i, t, matrix.real.copy()) for i, t, matrix in self.elements
]
return
# Frobenius-Schur indicator over the finite factor group:
# +1 real type, 0 complex type, -1 pseudoreal type
fs = float(
np.mean([np.trace(matrix @ matrix) for _, _, matrix in self.elements]).real
)
if fs < 0.5:
# complex or pseudoreal type: realification (Re v, Im v), i.e.
# the real form of D + D* (physically irreducible, dimension 2n);
# permuted to arm-major component order (arm 1: Re rows, Im rows;
# arm 2: ...) so the direction labels keep the arm grouping
n = self.dimension
d, m = self.dim_small, self.n_arms
perm = np.zeros((2 * n, 2 * n))
slot = 0
for a in range(m):
for p in range(d):
perm[slot, a * d + p] = 1.0 # Re(arm a, row p)
slot += 1
for p in range(d):
perm[slot, n + a * d + p] = 1.0 # Im(arm a, row p)
slot += 1
self.elements = [
(
i,
t,
perm
[docs]
@ np.block(
[[matrix.real, -matrix.imag], [matrix.imag, matrix.real]]
)
@ perm.T,
)
for i, t, matrix in self.elements
]
self.doubled = True
self.fs_type = "complex" if abs(fs) < 0.5 else "pseudoreal"
self.dimension *= 2
return
# real (orthogonal) form via an antilinear real structure: with the
# intertwiner S (D* S = S D, from the group average), J v = S* v*
# commutes with every D(g) (conjugate the intertwining relation);
# for a real-type irrep S S* = c > 0, so J^2 = 1 after normalization,
# and the fixed points of J span a real basis in which every D(g) is
# real. (J v = S v* would only work when S^2 is a scalar.)
rng = np.random.default_rng(7)
n = self.dimension
A = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
S = np.zeros((n, n), dtype=np.complex128)
for _, _, D in self.elements:
S += np.conj(D) @ A @ D.conj().T
c_matrix = S @ np.conj(S)
c = c_matrix[0, 0]
if not np.allclose(c_matrix, c * np.eye(n), atol=1e-6 * max(1, abs(c))) or c.real <= 0:
raise SystemExit(
f"ERROR: could not realify {self.irrep.name} (internal bug: "
"the Frobenius-Schur indicator says real type)."
)
S_bar = np.conj(S) / np.sqrt(c.real)
# real basis: orthonormalize J-fixed vectors v + S* v*. Standard
# basis vectors (and i x them) are tried first, so the real basis
# stays adapted to the (arm, row) channels -- sparse direction labels
trials = [
vec
for j in range(n)
for vec in (np.eye(n)[j] + 0j, 1j * np.eye(n)[j])
] + [rng.normal(size=n) + 1j * rng.normal(size=n) for _ in range(20 * n)]
basis: list[np.ndarray] = []
for v in trials:
if len(basis) == n:
break
w = v + S_bar @ np.conj(v)
for prior in basis:
w = w - prior * np.real(np.vdot(prior, w))
norm = np.linalg.norm(w)
if norm > 1e-3:
basis.append(w / norm)
if len(basis) < n:
raise SystemExit(f"ERROR: could not realify {self.irrep.name}.")
T = np.column_stack(basis)
T_inv = np.linalg.inv(T)
new_elements = []
for i, t, matrix in self.elements:
transformed = T_inv @ matrix @ T
if not np.allclose(transformed.imag, 0, atol=1e-6):
raise SystemExit(f"ERROR: could not realify {self.irrep.name}.")
new_elements.append((i, t, transformed.real.copy()))
self.elements = new_elements
def conjugate_partner(self) -> str | None:
"""ISO-IR label of the complex-conjugate partner irrep.
The partner lives at the same k star, or at the -k star for +-k
pairs such as P/PA; it is identified through the induced characters
(ours taken directly from the induced blocks).
Returns:
The partner label, or ``None`` when the irrep is self-conjugate
or no partner is tabulated.
"""
# when the tabulated entry was matched in the conjugate gauge, our
# blocks already realize the partner, so test both orientations
traces = np.array([np.trace(block) for block in self.blocks])
targets = [np.conj(traces), traces]
knames = [self.irrep.kpname] + [
kname
for kname in self.algebra.irreps_by_kname
if kname != self.irrep.kpname
and np.array_equal(
(-np.asarray(self.algebra.k_by_kname[self.irrep.kpname])) % DEN,
np.asarray(self.algebra.k_by_kname[kname]) % DEN,
)
]
for kname in knames:
for other in self.algebra.irreps_by_kname[kname]:
if other.name == self.irrep.name:
continue
try:
_, C_other = self.algebra.induced_characters(other)
except SystemExit:
continue
chi_other = np.sum(C_other, axis=1)
if any(
np.allclose(chi_other, target, atol=1e-6) for target in targets
):
return other.name
# fallback (conjugate-gauge tabulations, e.g. P/PA of I-42d): compare
# the tabulated small characters directly
table_self = {
int(key) - 1: complex(v) for key, v in self.irrep.characters.items()
}
for kname in knames:
for other in self.algebra.irreps_by_kname[kname]:
if other.name == self.irrep.name:
continue
table_other = {
int(key) - 1: complex(v) for key, v in other.characters.items()
}
if set(table_other) == set(table_self) and all(
abs(table_other[op] - np.conj(table_self[op])) < 1e-3
for op in table_self
):
return other.name
return None
@property
def label(self) -> str:
"""Irrep label; the ISOTROPY-style pair label (``P1P2``) when doubled."""
if self.doubled:
partner = self.conjugate_partner()
if partner is not None and self.fs_type == "complex":
return "".join(sorted([self.irrep.name, partner]))
return self.irrep.name
@property
def arm_chunks(self) -> list[int]:
"""Number of order-parameter components per star arm.
ISOTROPY separates arms by ``;`` and components within one arm by
``,`` in the direction labels.
"""
per_arm = self.dim_small * (2 if self.doubled else 1)
return [per_arm] * self.n_arms
def _translation_grid(self) -> list[np.ndarray]:
denominators = [int(DEN // np.gcd(int(v), DEN)) if v else 1 for v in self.k]
N = 1
for d in denominators:
N = int(np.lcm(N, d))
self.grid_n = N
return [
np.array([t1, t2, t3], dtype=np.int64)
for t1 in range(N)
for t2 in range(N)
for t3 in range(N)
]
def _verify(self) -> None:
"""Traces must reproduce the validated induced characters."""
try:
arms, C = self.algebra.induced_characters(self.irrep)
except SystemExit:
# conjugate-gauge tabulation (P/PA pairs): the tabulated induced
# characters are unavailable; the induction itself is exact
return
for i in (0, min(3, self.algebra.n_ops - 1), self.algebra.n_ops - 1):
expected = np.sum(C[i])
actual = np.trace(self.blocks[i])
if abs(expected - actual) > 1e-6:
raise SystemExit(
"ERROR: induced-matrix construction disagrees with the "
"validated induced characters (internal bug)."
)
# -- group elements of the image, with bookkeeping
[docs]
def image_elements(self):
"""All group elements of the representation.
Returns:
The ``elements`` list, one ``(i, t, matrix)`` triple per group
element (coset-representative index, lattice translation, real
matrix).
"""
return self.elements
class _ComputedIrrepInfo:
"""Shim irrep record for a representation at a non-tabulated k point."""
def __init__(self, name: str, kpname: str, dim: int):
self.name = name
self.kpname = kpname
self.dim = dim
class ComputedInducedRepresentation(InducedRepresentation):
"""Full induced irrep at a NON-tabulated k point (symmetry line, plane
or general point on the 1/24 grid).
The small-irrep matrices come from spgrep at the exact k (an entry of
``SpaceGroupIrrepAlgebra.computed_irreps_at``), the ISO-IR (Miller-Love)
name from the line-labeling machinery. Everything downstream of the
small matrices -- induction over the star, the translation grid, the
physically-irreducible realification -- is inherited unchanged.
"""
def __init__(self, algebra: SpaceGroupIrrepAlgebra, k_int, small: dict,
name: str, kpname: str, partner_name: str | None = None):
self.algebra = algebra
self.k = np.mod(np.asarray(k_int, dtype=np.int64), DEN)
self.arms, self.representatives = algebra._star_of_vector(self.k)
self.n_arms = len(self.arms)
self.irrep = _ComputedIrrepInfo(name, kpname, int(small["dim"]))
self._partner_name = partner_name
self._small_chi = dict(small["chi"])
matrices = {op: np.asarray(m) for op, m in small["small"].items()}
if np.all((2 * self.k) % DEN == 0):
matrices = _realify_matrix_set(matrices) or matrices
self.little = sorted(matrices)
self.dim_small = matrices[next(iter(matrices))].shape[0]
self.dimension = self.n_arms * self.dim_small
self.blocks = self._induce(matrices)
self._verify()
self.elements = [
(i, t, self.translation_phases(t)[:, None] * self.blocks[i])
for i in range(algebra.n_ops)
for t in self._translation_grid()
]
self._realify()
def _verify(self) -> None:
"""Traces must reproduce the characters induced from the same small
irrep through the independent character-only route."""
arms, C = self.algebra.induced_characters_at(
self.k, {"chi": self._small_chi}
)
for i in (0, min(3, self.algebra.n_ops - 1), self.algebra.n_ops - 1):
if abs(np.sum(C[i]) - np.trace(self.blocks[i])) > 1e-6:
raise SystemExit(
"ERROR: induced-matrix construction at a non-tabulated "
"k point disagrees with the induced characters "
"(internal bug)."
)
def conjugate_partner(self) -> str | None:
return self._partner_name
@classmethod
def from_isoir(cls, algebra: SpaceGroupIrrepAlgebra, k_int, small: dict,
name: str, kpname: str, partner_name: str | None = None):
"""Build the representation from the bundled ISO-IR matrices.
The CIR tables store, for every line irrep, the full-star matrices
with parametrized k vectors, so the order-parameter basis is the
ISOTROPY one (up to the phase gauge of the realification) and the
arm order is the tabulated one -- deterministic across spgrep
versions. Raises LookupError when the entry cannot be used (the
caller falls back to the spgrep-basis construction).
"""
import re
from .isoir import load_isoir_irreps
minus = False
base = name
match = re.match(r"^([A-Z]+)A(\d.*)$", name)
if match and not any(
ir.label == name for ir in load_isoir_irreps(algebra.sg_type.number)
):
# 'A'-suffixed name: the tabulated entry sits at the -k star
minus = True
base = match.group(1) + match.group(2)
entries = [
ir
for ir in load_isoir_irreps(algebra.sg_type.number)
if ir.label == base and not ir.special
]
if not entries:
raise LookupError(f"no ISO-IR entry for {name}")
entry = entries[0]
M = algebra.primitive_matrix
M_inv = np.linalg.inv(M)
sign = -1 if minus else 1
arms_star, _ = algebra._star_of_vector(
np.mod(np.asarray(k_int, dtype=np.int64), DEN)
)
fit = None
for arm in arms_star:
k_conv = sign * (np.asarray(arm, dtype=float) / DEN) @ M_inv
matched = entry.match_k(k_conv)
if matched is not None and matched[0] == 0:
fit = matched[1]
break
if fit is None:
raise LookupError(f"no ISO-IR parametrization for {name}")
karms_conv = np.array(
[entry.arm_k(a, fit) for a in range(entry.narms)]
)
arms_scaled = sign * (karms_conv @ M) * DEN
arms = np.rint(arms_scaled).astype(np.int64)
if not np.allclose(arms_scaled, arms, atol=1e-6):
raise LookupError(f"ISO-IR arms of {name} leave the 1/{DEN} grid")
arms = np.mod(arms, DEN)
arm_set = {tuple(int(v) for v in a) for a in arms}
if arm_set != {tuple(int(v) % DEN for v in a) for a in arms_star}:
raise LookupError(f"ISO-IR star of {name} disagrees")
# conventional operations in the algebra's operation order
table_R = [
np.rint(np.asarray(sym.R, dtype=float)).astype(np.int64)
for sym in algebra.table.symmetries
]
if not all(
np.array_equal(table_R[i], algebra.rotations[i])
for i in range(algebra.n_ops)
):
raise LookupError("conventional-table order mismatch")
blocks = []
for i in range(algebra.n_ops):
j = entry.find_operator(table_R[i])
if j is None:
raise LookupError(f"operator missing from ISO-IR {name}")
v_conv = M @ (np.array(algebra.translations[i], dtype=float) / DEN)
dt = v_conv - entry.translations[j]
phases = np.exp(2j * np.pi * karms_conv @ (dt + entry.irtrans[j]))
block = phases[:, None] * entry.matrices[j]
# ISO-IR phase convention exp(+2 pi i k.t) is the conjugate of
# the spgrep convention this machinery uses throughout
blocks.append(np.conj(block) if not minus else block)
rep = cls.__new__(cls)
rep.algebra = algebra
rep.k = arms[0].copy()
rep.arms = arms
rep.n_arms = len(arms)
rep.representatives = None
rep.irrep = _ComputedIrrepInfo(name, kpname, int(small["dim"]))
rep._partner_name = partner_name
rep._small_chi = dict(small["chi"])
rep.dim_small = entry.small_dim
rep.dimension = entry.dim
rep.blocks = blocks
try:
rep._verify()
except SystemExit:
raise LookupError(
f"ISO-IR matrices of {name} disagree with the computed "
"characters"
)
rep.elements = [
(i, t, rep.translation_phases(t)[:, None] * rep.blocks[i])
for i in range(algebra.n_ops)
for t in rep._translation_grid()
]
rep._realify()
return rep
[docs]
class CoupledRepresentation:
"""Direct sum of several induced irreps (coupled order parameters).
A distortion condensing several irreps simultaneously transforms as this
reducible representation; its isotropy subgroups are the stabilizers of
the coupled order parameter ``(eta_1, eta_2, ...)``. This is what
``crystod-group --supergroup SG --irrep X3- X2+`` analyzes. The
components group irrep by irrep (then arm by arm within each irrep), and
because the matrices are block diagonal every fixed subspace is a direct
sum of per-irrep subspaces: the amplitudes of different irreps are
always independent free parameters.
Args:
algebra: The ``SpaceGroupIrrepAlgebra`` of the space group.
irrep_labels: ISO-IR labels of the coupled irreps, e.g.
``["X3-", "X2+"]``.
Attributes:
parts: The ``InducedRepresentation`` of every irrep, in input order.
dims: Dimension of every part; ``dimension`` is their sum.
name: The combined label, e.g. ``"X3- + X2+"``.
grid_n: Period of the common lattice-translation grid (least common
multiple of the parts' periods).
elements: All distinct group elements as ``(i, t, matrix)`` with the
block-diagonal real matrix.
Raises:
SystemExit: A label is not tabulated for this space group.
"""
def __init__(self, algebra: SpaceGroupIrrepAlgebra, irrep_labels: list[str]):
self.algebra = algebra
self.parts = [InducedRepresentation(algebra, label) for label in irrep_labels]
self.dims = [part.dimension for part in self.parts]
self.dimension = sum(self.dims)
self.name = " + ".join(part.label for part in self.parts)
# combined translation grid: lcm of the parts' grids (each part's
# matrices are periodic in t with its own grid period)
N = 1
for part in self.parts:
N = int(np.lcm(N, part.grid_n))
self.grid_n = N
lookups = [
{(i, tuple(int(x) for x in t)): matrix for i, t, matrix in part.elements}
for part in self.parts
]
self.elements = []
for i in range(algebra.n_ops):
for t1 in range(N):
for t2 in range(N):
for t3 in range(N):
t = np.array([t1, t2, t3], dtype=np.int64)
blocks = [
lookups[j][(i, tuple(int(x) for x in t % part.grid_n))]
for j, part in enumerate(self.parts)
]
self.elements.append((i, t, _block_diag(blocks)))
[docs]
def image_elements(self):
"""All group elements of the coupled representation.
Returns:
The ``elements`` list of ``(i, t, matrix)`` triples.
"""
return self.elements
def _block_diag(blocks: list[np.ndarray]) -> np.ndarray:
n = sum(b.shape[0] for b in blocks)
matrix = np.zeros((n, n))
row = 0
for b in blocks:
matrix[row : row + b.shape[0], row : row + b.shape[1]] = b
row += b.shape[0]
return matrix
# ------------------------------------------------------------------ stabilizers
def _nullspace(matrix: np.ndarray, tol: float = 1e-8) -> np.ndarray:
_, sing, Vh = np.linalg.svd(matrix)
rank = int(np.sum(sing > tol)) if len(sing) else 0
return Vh[rank:].T.conj()
def _projector(basis: np.ndarray) -> np.ndarray:
if basis.shape[1] == 0:
return np.zeros((basis.shape[0], basis.shape[0]))
Q, _ = np.linalg.qr(basis)
return Q @ Q.T.conj()
[docs]
class IsotropyAnalyzer:
"""Isotropy subgroups of a space-group irrep (or of coupled irreps).
The machinery behind ``crystod-group --supergroup SG --irrep IR``: it
builds the real induced representation of the order parameter,
enumerates the order-parameter directions (the strata of the
representation), finds the stabilizer of any direction, and identifies
the resulting space group with spglib, including the conventional basis
and origin of the subgroup in the parent convention. The data-level
function ``crystod.group.isotropy_subgroups`` returns the same results
as ``IsotropySubgroup`` records; use this class when the matrices, the
subgroup elements or a custom direction are needed.
Args:
space_group: International short symbol (``"Pm-3m"``) or number
(``"221"``) of the parent space group.
irrep_labels: One ISO-IR label (``"R4+"``) or a list of labels for
coupled order parameters (``["X3-", "X2+"]``).
Attributes:
algebra: The ``SpaceGroupIrrepAlgebra`` of the parent space group.
representation: The ``InducedRepresentation`` (one label) or
``CoupledRepresentation`` (several labels) of the order
parameter.
elements: The group elements ``(i, t, matrix)`` of the
representation (``representation.image_elements()``).
Raises:
SystemExit: Unknown space group, or an irrep label that is not
tabulated for it (the labels of symmetry lines and planes, e.g.
``DT5``, have no entries in the tables).
Example:
>>> from crystod import group
>>> analyzer = group.IsotropyAnalyzer("Pm-3m", "R4+")
>>> for projector, members in analyzer.enumerate_directions():
... label, _ = analyzer.direction_label(projector)
... info, size, index, *_ = analyzer.subgroup_of(members)
... print(label, info.number, info.international_short, size, index)
(a,b,c) 2 P-1 2 48
(0,0,a) 140 I4/mcm 2 6
(0,a,b) 12 C2/m 2 24
(a,a,a) 167 R-3c 2 8
(0,a,a) 74 Imma 2 12
(a,a,b) 15 C2/c 2 24
"""
def __init__(self, space_group: str, irrep_labels: str | list[str]):
if isinstance(irrep_labels, str):
irrep_labels = [irrep_labels]
self.algebra = SpaceGroupIrrepAlgebra(space_group)
if len(irrep_labels) == 1:
self.representation = InducedRepresentation(self.algebra, irrep_labels[0])
else:
self.representation = CoupledRepresentation(self.algebra, irrep_labels)
self.elements = self.representation.image_elements()
[docs]
@classmethod
def from_representation(cls, algebra, representation):
"""Analyzer over an already-built representation.
Args:
algebra: The ``SpaceGroupIrrepAlgebra`` the representation was
built from.
representation: An ``InducedRepresentation`` or
``CoupledRepresentation`` (anything with
``image_elements()``, ``dimension`` and ``grid_n``).
Returns:
A new ``IsotropyAnalyzer`` sharing the algebra.
"""
analyzer = cls.__new__(cls)
analyzer.algebra = algebra
analyzer.representation = representation
analyzer.elements = representation.image_elements()
return analyzer
# -- stabilizer of a direction (subspace)
[docs]
def stabilizer_of(self, projector: np.ndarray):
"""Group elements acting as the identity on a subspace.
Args:
projector: Orthogonal projector onto the subspace of order
parameters, shape ``(dimension, dimension)``.
Returns:
The ``(i, t)`` pairs (coset-representative index, lattice
translation) whose matrices fix every vector of the subspace.
"""
return [
(i, t)
for i, t, matrix in self.elements
if np.allclose(matrix @ projector, projector, atol=1e-6)
]
[docs]
def fixed_space(self, members) -> np.ndarray:
"""Common fixed subspace of a set of group elements.
Args:
members: ``(i, t)`` pairs as returned by ``stabilizer_of``.
Returns:
An orthonormal basis of the fixed subspace as columns, shape
``(dimension, n_free)``; the identity when ``members`` is empty.
"""
n = self.representation.dimension
stack = []
member_keys = {(i, tuple(t)) for i, t in members}
for i, t, matrix in self.elements:
if (i, tuple(t)) in member_keys:
stack.append(matrix - np.eye(n))
if not stack:
return np.eye(n)
return _nullspace(np.vstack(stack))
# -- enumerate strata (order-parameter direction types)
[docs]
def enumerate_directions(self):
"""Enumerate the order-parameter direction types (strata).
Seeds the search with the fixed spaces of every group element and
closes the set under pairwise intersection; keeps the isotropy
subspaces (``V == Fix(Stab(V))``) and one representative per group
orbit (the one with the simplest direction label). This is the
listing of ``crystod-group --supergroup`` without
``--order-parameter``.
Returns:
A list of ``(projector, members)`` pairs, one per stratum, with
the orthogonal projector onto the subspace of the stratum and
the ``(i, t)`` elements of its stabilizer (the isotropy
subgroup).
"""
n = self.representation.dimension
seen: dict[bytes, np.ndarray] = {}
def add(basis: np.ndarray):
if basis.shape[1] == 0:
return None
projector = _projector(basis)
key = _projector_key(projector)
if key not in seen:
seen[key] = projector
return projector
return None
# seed: fixed spaces of every group element, and the full space
seeds = []
for _, _, matrix in self.elements:
basis = _nullspace(matrix - np.eye(n))
if add(basis) is not None:
seeds.append(_projector(basis))
add(np.eye(n))
# closure under pairwise intersection
frontier = list(seen.values())
while frontier:
new = []
for P in frontier:
for Q in list(seen.values()):
intersection = _nullspace(
np.vstack([P - np.eye(n), Q - np.eye(n)])
)
result = add(intersection)
if result is not None:
new.append(result)
frontier = new
# keep isotropy subspaces: V == Fix(Stab(V)); dedupe by group orbit
strata = []
used = set()
for key, projector in seen.items():
if key in used:
continue
members = self.stabilizer_of(projector)
fixed = self.fixed_space(members)
if not np.allclose(_projector(fixed), projector, atol=1e-6):
continue
# orbit dedup; keep the orbit member with the prettiest label
orbit_keys = set()
orbit_projectors = []
for _, _, matrix in self.elements:
image = np.real_if_close(matrix @ projector @ matrix.T.conj())
image_key = _projector_key(image)
if image_key not in orbit_keys:
orbit_keys.add(image_key)
orbit_projectors.append(image)
if orbit_keys & used:
continue
used |= orbit_keys
best = min(
orbit_projectors,
key=lambda P: _label_rank(self.direction_label(P)[0]),
)
strata.append((best, self.stabilizer_of(best)))
return strata
# -- subgroup identification
[docs]
def subgroup_of(self, members):
"""Space-group type of the isotropy subgroup with the given elements.
The pure lattice translations among the members span the sublattice
of the subgroup; the operations are re-expressed in that sublattice
basis and identified with spglib through a generic-orbit structure.
Args:
members: ``(i, t)`` pairs of the subgroup (from ``stabilizer_of``
or ``enumerate_directions``).
Returns:
``(info, size, index, B, rotations, translations, lattice)``:
the spglib space-group type of the subgroup (``number``,
``international_short``, ...), the primitive-cell multiplication
``size``, the index of the subgroup in the parent, the
sublattice basis ``B`` (rows, parent primitive units), the
subgroup operations in that basis, and the sublattice vectors
(rows, Cartesian, in an invariant parent lattice).
Raises:
SystemExit: spglib could not identify the subgroup.
"""
import spglib
from sympy import Matrix
from sympy.matrices.normalforms import hermite_normal_form
algebra = self.algebra
# translation lattice: t with T(t) acting as identity on eta happens
# exactly for members with i == identity
identity_index = algebra._rotation_index[algebra._key(np.eye(3))]
pure = [t for i, t in members if i == identity_index and not np.any(
algebra.translations[identity_index])] or [np.zeros(3, dtype=np.int64)]
grid_n = self.representation.grid_n
generators = [t for t in pure] + [grid_n * e for e in np.eye(3, dtype=np.int64)]
H = np.array(
hermite_normal_form(Matrix(np.array(generators, dtype=np.int64).T))
).astype(np.int64)
B = H.T # rows = sublattice basis in parent primitive units
size = abs(int(round(np.linalg.det(B))))
# one representative (W, v + t) per coset-rep index
chosen: dict[int, np.ndarray] = {}
for i, t in members:
if i not in chosen:
chosen[i] = np.asarray(t, dtype=np.int64)
# ops in the sublattice basis (column-vector convention)
B_inv_T = np.linalg.inv(B.T)
rotations, translations = [], []
for i, t in chosen.items():
W = B_inv_T @ algebra.rotations[i] @ B.T
W_int = np.rint(W).astype(np.int64)
if not np.allclose(W, W_int, atol=1e-8):
raise SystemExit("ERROR: subgroup operation is incompatible with its lattice.")
v = B_inv_T @ (np.array(algebra.translations[i], dtype=float) / DEN + t)
rotations.append(W_int)
translations.append(np.mod(v, 1.0))
lattice_parent = self._invariant_lattice()
lattice = B @ lattice_parent
info = self._identify_type(rotations, translations, lattice)
n_point = len({algebra._key(np.rint(r).astype(np.int64)) for r in rotations})
index = algebra.n_ops * size // n_point
return info, size, index, B, rotations, translations, lattice
def _identify_type(self, rotations, translations, lattice):
"""Space-group type of the operation set, via a generic-orbit
structure standardized by spglib.
Identification through a structure is much more robust than
spglib.get_spacegroup_type_from_symmetry, which fails to detect the
centring when the subgroup axes lie along diagonals of the sublattice
cell (e.g. several isotropy subgroups of the L and W irreps of
Fd-3c).
"""
import spglib
from .runtime_compat import get_spacegroup_type
positions = []
numbers = []
for species, x0 in enumerate(
(np.array([0.1234, 0.2345, 0.3178]), np.array([0.4321, 0.0567, 0.1873]))
):
orbit = []
for W, v in zip(rotations, translations):
x = np.mod(W @ x0 + v, 1.0)
if not any(np.allclose(x, p, atol=1e-6) for p in orbit):
orbit.append(x)
positions.extend(orbit)
numbers.extend([species + 1] * len(orbit))
dataset = spglib.get_symmetry_dataset(
(lattice, np.array(positions), numbers), symprec=1e-4
)
hall = None
if dataset is not None:
if isinstance(dataset, dict):
hall = dataset.get("hall_number")
else:
hall = getattr(dataset, "hall_number", None)
if hall:
try:
return get_spacegroup_type(spglib.get_spacegroup_type(hall_number=hall))
except Exception:
pass
# fallback: direct identification from the operations
try:
info = spglib.get_spacegroup_type_from_symmetry(
np.array(rotations), np.array(translations), lattice=lattice,
symprec=1e-5,
)
except Exception as exc:
raise SystemExit(f"ERROR: spglib could not identify the subgroup: {exc}")
if info is None:
raise SystemExit("ERROR: spglib could not identify the subgroup.")
return get_spacegroup_type(info)
[docs]
def conventional_setting(self, B, rotations, translations, lattice, info):
"""Conventional basis and origin of the subgroup (parent convention).
Built from a generic-orbit structure with exactly the subgroup
symmetry, standardized by spglib.
Args:
B: Sublattice basis from ``subgroup_of``.
rotations: Subgroup rotations from ``subgroup_of``.
translations: Subgroup translations from ``subgroup_of``.
lattice: Sublattice vectors from ``subgroup_of``.
info: Space-group type from ``subgroup_of``.
Returns:
``(basis, origin)`` rounded to six decimals: the rows of the
child conventional basis and its origin, both in parent
conventional units (as printed by ``--order-parameter``);
``None`` when spglib could not standardize the subgroup.
"""
import spglib
positions = []
numbers = []
for species, x0 in enumerate(
(np.array([0.1234, 0.2345, 0.3178]), np.array([0.4321, 0.0567, 0.1873]))
):
orbit = []
for W, v in zip(rotations, translations):
x = np.mod(W @ x0 + v, 1.0)
if not any(np.allclose(x, p, atol=1e-6) for p in orbit):
orbit.append(x)
positions.extend(orbit)
numbers.extend([species + 1] * len(orbit))
dataset = spglib.get_symmetry_dataset(
(lattice, np.array(positions), numbers), symprec=1e-4
)
def field(name): # spglib < 2.4 returns a dict, >= 2.4 an object
if dataset is None:
return None
if isinstance(dataset, dict):
return dataset.get(name)
return getattr(dataset, name, None)
if dataset is None or field("number") != info.number:
return None
P = np.array(field("transformation_matrix"), dtype=float)
shift = np.array(field("origin_shift"), dtype=float)
# child conventional lattice rows in cartesian: L_c = (P^-1)^T L_input
L_child_conv = np.linalg.inv(P).T @ lattice
# parent conventional lattice rows: A_p = M^T A_c (phonopy convention)
M = self.algebra.primitive_matrix
L_parent_prim = self._invariant_lattice()
L_parent_conv = np.linalg.inv(M).T @ L_parent_prim
basis = L_child_conv @ np.linalg.inv(L_parent_conv)
# child origin: x_std = P x + p -> the child cell origin (x_std = 0)
# sits at x = -P^-1 p (input = subgroup-primitive coords)
origin_sub = -np.linalg.inv(P) @ shift
origin_cart = origin_sub @ lattice
origin = origin_cart @ np.linalg.inv(L_parent_conv)
return np.round(basis, 6), np.round(origin, 6)
def _invariant_lattice(self) -> np.ndarray:
"""A parent primitive lattice (rows) with the full point symmetry."""
g0 = np.diag([1.0, 1.07, 1.13])
g = np.zeros((3, 3))
for W in self.algebra.rotations:
g += W.T @ g0 @ W
g /= self.algebra.n_ops
# rows a_i with a_i . a_j = g_ij: the lower-triangular Cholesky
# factor itself (L L^T = g), NOT its transpose
return np.linalg.cholesky(g)
# -- direction formatting / parsing
[docs]
def direction_label(
self, projector: np.ndarray, letter_offset: int = 0
) -> tuple[str, np.ndarray]:
"""Direction label of a stratum and a generic representative.
Args:
projector: Orthogonal projector onto the subspace of the stratum.
letter_offset: Shift of the free-parameter letters (used for the
single-irrep tables of a coupled run, so that every irrep
keeps its own letters: ``X3-(a,b) + X2-(c,d)``).
Returns:
``(label, generic)``: the ISOTROPY-style pattern such as
``"(a,a,0)"`` (``;`` separates star arms, ``,`` components
within one arm; coupled runs give ``"X3-(a,b) X2-(c,d)"``) and
a generic order-parameter vector inside the stratum.
"""
basis = _orth_basis(projector)
n_free = basis.shape[1]
# RREF + integer prettification (same style as the molecular SALCs)
from .molecular_salc import _pretty_coefficients, _rref_orthogonal
rows = _rref_orthogonal([basis[:, j] for j in range(n_free)])
generic = np.zeros(self.representation.dimension)
magnitudes = [1.0, 0.6180339887, 0.4142135624, 0.2928932188,
0.2360679775, 0.1926, 0.1573, 0.1235,
0.1044, 0.0862, 0.0715, 0.0593] + [
0.05 * float(np.exp(-0.4811 * j)) for j in range(12)]
for j, row in enumerate(rows):
generic = generic + magnitudes[j] * np.asarray(row)
pretty_rows = []
for row in rows:
coefficients, _ = _pretty_coefficients(np.asarray(row))
coefficients = np.asarray(coefficients, dtype=float)
if np.max(np.abs(coefficients)) > 6.5:
# spurious large-integer rationalization of an arbitrary
# basis angle -- show normalized decimals instead
coefficients = np.asarray(row, dtype=float)
coefficients = coefficients / np.max(np.abs(coefficients))
coefficients[np.abs(coefficients) < 1e-8] = 0.0
pretty_rows.append(coefficients)
# parameter letters: every irrep keeps its own letter range (offset =
# total dimension of the preceding irreps), so a coupled direction
# reads X3-(a,b) X2-(c,d) -- the amplitudes of different irreps are
# independent (the RREF rows never mix chunks, since every fixed
# space is a direct sum of per-irrep subspaces)
if isinstance(self.representation, CoupledRepresentation):
bounds = np.cumsum([0] + list(self.representation.dims))
counters = [0] * len(self.representation.dims)
letters = []
for row in pretty_rows:
first = int(np.argmax(np.abs(row) > 1e-8))
chunk = int(np.searchsorted(bounds, first, side="right") - 1)
letters.append(_PARAMETER_NAMES[int(bounds[chunk]) + counters[chunk]])
counters[chunk] += 1
else:
letters = [
_PARAMETER_NAMES[letter_offset + j] for j in range(len(pretty_rows))
]
components = []
for slot in range(self.representation.dimension):
terms = []
for j, row in enumerate(pretty_rows):
value = row[slot]
if abs(value) < 1e-8:
continue
terms.append(_format_coefficient(value) + letters[j])
components.append("+".join(terms).replace("+-", "-") if terms else "0")
def arm_join(piece, arm_chunks):
# ISOTROPY separators: ';' between star arms, ',' within one arm
arms = []
start = 0
for size in arm_chunks:
arms.append(",".join(piece[start : start + size]))
start += size
return ";".join(arms)
if isinstance(self.representation, CoupledRepresentation):
chunks = []
start = 0
for part in self.representation.parts:
piece = components[start : start + part.dimension]
chunks.append(f"{part.label}({arm_join(piece, part.arm_chunks)})")
start += part.dimension
return " ".join(chunks), generic
return (
"(" + arm_join(components, self.representation.arm_chunks) + ")",
generic,
)
[docs]
def resolve_direction(self, tokens: list[str]) -> np.ndarray:
"""Order parameter from ``--order-parameter`` tokens.
Args:
tokens: One token per component, e.g. ``["0", "0", "a"]`` or
``["a", "a", "0"]``; letters are free parameters (equal
letters mean equal components), numbers and fractions are
taken literally, a leading ``-`` flips the sign.
Returns:
A representative order-parameter vector of length
``dimension``.
Raises:
SystemExit: Wrong number of components, or an all-zero order
parameter.
"""
n = self.representation.dimension
if len(tokens) != n:
name = (
self.representation.name
if isinstance(self.representation, CoupledRepresentation)
else self.representation.label
)
raise SystemExit(
f"ERROR: --order-parameter needs {n} components for "
f"{name} (dim {n})."
)
values = np.zeros(n)
symbol_values: dict[str, float] = {}
magnitudes = [1.0, 0.6180339887, 0.4142135624, 0.2928932188,
0.2360679775, 0.1926, 0.1573, 0.1235]
for slot, token in enumerate(tokens):
token = token.strip()
sign = 1.0
if token.startswith("-"):
sign, token = -1.0, token[1:]
if token in ("0", "0.0", ""):
continue
try:
values[slot] = sign * float(Fraction(token))
continue
except ValueError:
pass
if token not in symbol_values:
symbol_values[token] = magnitudes[len(symbol_values) % len(magnitudes)]
values[slot] = sign * symbol_values[token]
if not np.any(values):
raise SystemExit("ERROR: the order parameter must not be zero.")
return values
def _realify_matrix_set(matrices: dict) -> dict | None:
"""Similarity-transform a set of unitary matrices to real form, when a
real form exists (real-type rep); returns None otherwise."""
keys = list(matrices)
if all(np.allclose(np.asarray(matrices[key]).imag, 0, atol=1e-8) for key in keys):
return {key: np.asarray(matrices[key]).real.copy() for key in keys}
rng = np.random.default_rng(7)
n = np.asarray(matrices[keys[0]]).shape[0]
A = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
S = np.zeros((n, n), dtype=np.complex128)
for key in keys:
D = np.asarray(matrices[key])
S += np.conj(D) @ A @ D.conj().T
c_matrix = S @ np.conj(S)
c = c_matrix[0, 0]
if not np.allclose(c_matrix, c * np.eye(n), atol=1e-6 * max(1, abs(c))) or c.real <= 0:
return None
S_bar = np.conj(S) / np.sqrt(c.real)
# standard basis vectors first, so the real basis stays adapted to the
# tabulated components (no arbitrary rotation angle in the labels)
trials = [
vec for j in range(n) for vec in (np.eye(n)[j] + 0j, 1j * np.eye(n)[j])
] + [rng.normal(size=n) + 1j * rng.normal(size=n) for _ in range(20 * n)]
basis: list[np.ndarray] = []
for v in trials:
if len(basis) == n:
break
w = v + S_bar @ np.conj(v)
for prior in basis:
w = w - prior * np.real(np.vdot(prior, w))
norm = np.linalg.norm(w)
if norm > 1e-3:
basis.append(w / norm)
if len(basis) < n:
return None
T = np.column_stack(basis)
T_inv = np.linalg.inv(T)
result = {}
for key in keys:
transformed = T_inv @ np.asarray(matrices[key]) @ T
if not np.allclose(transformed.imag, 0, atol=1e-6):
return None
result[key] = transformed.real.copy()
# canonicalize the (rotation-ambiguous) real basis: align it with the
# eigenvectors of a reflection-like element (symmetric, traceless), so
# the matrices become signed permutations and the order-parameter
# direction labels stay clean (no arbitrary rotation angle)
for key in keys:
M = result[key]
if (
np.allclose(M, M.T, atol=1e-8)
and abs(np.trace(M)) < 1e-6
and not np.allclose(M, np.eye(n), atol=1e-8)
):
values, vectors = np.linalg.eigh(M)
order = np.argsort(-values)
O = vectors[:, order]
for j in range(O.shape[1]):
pivot = np.argmax(np.abs(O[:, j]))
if O[pivot, j] < 0:
O[:, j] = -O[:, j]
result = {k: O.T @ result[k] @ O for k in keys}
break
return result
def _orth_basis(projector: np.ndarray) -> np.ndarray:
values, vectors = np.linalg.eigh((projector + projector.T.conj()) / 2)
return np.real_if_close(vectors[:, values > 0.5])
def _projector_key(projector: np.ndarray) -> bytes:
rounded = np.round(np.real(projector), 6) + 0.0 # normalize -0.0
return rounded.tobytes()
def _label_rank(label: str) -> tuple:
"""Ordering that prefers simple direction labels ((a,a,0) over (a,-b,b))."""
return (label.count("."), label.count("-"), len(label), label)
def _format_setting_value(value: float) -> str:
fraction = Fraction(float(value)).limit_denominator(12)
if abs(float(fraction) - float(value)) < 1e-4:
if fraction.denominator == 1:
return str(fraction.numerator)
return f"{fraction.numerator}/{fraction.denominator}"
return f"{float(value):.4g}"
def _format_coefficient(value: float) -> str:
if abs(value - 1.0) < 1e-6:
return ""
if abs(value + 1.0) < 1e-6:
return "-"
return f"{value:.3g}"
# ---------------------------------------------------------------------- report
def format_subgroup_line(analyzer, label, info, size, index) -> str:
return (
f"{label:<18} {info.number:>4} {info.international_short:<10} "
f"size {size} index {index}"
)
def _direction_results(analyzer: IsotropyAnalyzer, letter_offset: int = 0):
"""(index, n_free, label, info, size) per direction type, sorted; for a
coupled representation only the directions condensing every irrep."""
representation = analyzer.representation
coupled = isinstance(representation, CoupledRepresentation)
results = []
for projector, members in analyzer.enumerate_directions():
label, generic = analyzer.direction_label(projector, letter_offset)
if not coupled:
label = representation.label + label
if coupled:
# a zero chunk means that irrep does not condense at all --
# those are the single-irrep tables, not coupled directions
bounds = np.cumsum([0] + list(representation.dims))
if any(
np.linalg.norm(generic[bounds[j] : bounds[j + 1]]) < 1e-8
for j in range(len(representation.dims))
):
continue
exact_members = [
(i, t)
for i, t, matrix in analyzer.elements
if np.allclose(matrix @ generic, generic, atol=1e-6)
]
info, size, index, B, *_ = analyzer.subgroup_of(exact_members)
n_free = _orth_basis(projector).shape[1]
results.append((index, n_free, label, info, size))
results.sort(key=lambda r: (r[1], r[0], r[3].number))
return results
def _print_direction_table(results) -> None:
width = max([20] + [len(label) + 1 for _, _, label, _, _ in results])
print(f"{'irrep':<{width}} {'subgroup':<18} {'size':<5} {'index':<5}")
for index, n_free, label, info, size in results:
subgroup = f"{info.number} {info.international_short}"
print(f"{label:<{width}} {subgroup:<18} {size:<5} {index:<5}")
def _enantiomorph_note(numbers) -> None:
pairs = sorted({
tuple(sorted((n, ENANTIOMORPHIC_PAIRS[n])))
for n in numbers
if n in ENANTIOMORPHIC_PAIRS
})
if not pairs:
return
print()
text = ", ".join(f"{a} <-> {b}" for a, b in pairs)
print(f"note: {text} are enantiomorphic partner types: the mirror-image")
print("order parameter of the same stratum gives the partner, so the")
print("ISOTROPY listing may show either one.")
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Isotropy subgroups of a space-group irrep."
)
parser.add_argument("--supergroup", required=True, help='e.g. "Pm-3m" or 221.')
parser.add_argument(
"--irrep",
required=True,
nargs="+",
help="ISO-IR irrep label(s), e.g. GM4-; several labels (e.g. X3- X2+) "
"enumerate the isotropy subgroups of the coupled order parameters.",
)
parser.add_argument(
"--order-parameter",
nargs="+",
default=None,
help='components, e.g. "0 0 a" or "a a 0" (symbols = free parameters).',
)
args = parser.parse_args(argv)
import spglib
spglib_version = tuple(int(x) for x in spglib.__version__.split(".")[:2])
if spglib_version < (2, 4):
print(
"WARNING: spglib >= 2.4 is recommended for reliable subgroup "
f"identification (found {spglib.__version__}).",
)
analyzer = IsotropyAnalyzer(args.supergroup, args.irrep)
representation = analyzer.representation
algebra = analyzer.algebra
coupled = isinstance(representation, CoupledRepresentation)
parts = representation.parts if coupled else [representation]
irrep_name = representation.name if coupled else representation.label
print()
print("* Supergroup *")
print(f"{algebra.sg_type.international_short} (No. {algebra.sg_type.number})")
print()
print("* Irrep *" if not coupled else "* Coupled irreps *")
for part in parts:
if part.doubled:
star_note = (
f" (star of {part.n_arms} arm(s) x small dim {part.dim_small} x 2;"
f" {part.fs_type}-type irrep -> physically irreducible real form)"
)
elif part.n_arms > 1:
star_note = (
f" (star of {part.n_arms} arm(s) x small dim {part.dim_small})"
)
else:
star_note = ""
print(f"{part.label}: order parameter dimension {part.dimension}{star_note}")
if coupled:
print(
f"coupled order parameter dimension {representation.dimension} "
f"({' + '.join(str(d) for d in representation.dims)})"
)
label_notes = []
for part in parts:
mapping = ISOTROPY_LABELS.get((algebra.sg_type.number, part.irrep.kpname))
if mapping and part.irrep.name in mapping:
label_notes.append(
f"crystod {part.irrep.name} = ISOTROPY {mapping[part.irrep.name]}"
)
if label_notes:
print()
print("note: the irrep labels at this k point differ between the ISO-IR")
print("data files (used by crystod) and the ISOTROPY/ISOSUBGROUP software:")
print(f"{'; '.join(label_notes)} (see SUBGROUP/VALIDATION.md).")
print()
if args.order_parameter:
eta = analyzer.resolve_direction(args.order_parameter)
projector = _projector(eta[:, None])
members = analyzer.stabilizer_of(projector)
# the direction may be non-generic in its own fixed space; use the
# exact stabilizer of eta itself
members = [
(i, t)
for i, t, matrix in analyzer.elements
if np.allclose(matrix @ eta, eta, atol=1e-6)
]
info, size, index, B, rotations, translations, lattice = analyzer.subgroup_of(members)
def arm_join(piece, arm_chunks):
arms, start = [], 0
for size in arm_chunks:
arms.append(",".join(piece[start : start + size]))
start += size
return ";".join(arms)
if coupled:
chunks, start = [], 0
for part in parts:
piece = args.order_parameter[start : start + part.dimension]
chunks.append(f"{part.label}({arm_join(piece, part.arm_chunks)})")
start += part.dimension
direction = " ".join(chunks)
header = direction
else:
direction = (
"(" + arm_join(args.order_parameter, representation.arm_chunks) + ")"
)
header = f"{irrep_name}{direction}"
print("* Isotropy subgroup *")
print(f"{header} -> {info.international_short} (No. {info.number})")
print(f"cell size {size}, index {index}")
basis_rows = ", ".join("(" + ",".join(str(int(x)) for x in row) + ")" for row in B)
print(f"sublattice basis (parent primitive units): {basis_rows}")
setting = analyzer.conventional_setting(B, rotations, translations, lattice, info)
if setting is not None:
basis, origin = setting
rows = ", ".join(
"(" + ",".join(_format_setting_value(x) for x in row) + ")" for row in basis
)
origin_text = "(" + ",".join(_format_setting_value(x) for x in origin) + ")"
print(f"conventional basis (parent conventional units): {rows}")
print(f"origin: {origin_text}")
_enantiomorph_note([info.number])
else:
if coupled:
offset = 0
all_numbers = []
for part in parts:
sub = IsotropyAnalyzer.from_representation(algebra, part)
print("* Order parameter directions and isotropy subgroups "
f"({part.label} alone) *")
part_results = _direction_results(sub, letter_offset=offset)
_print_direction_table(part_results)
print()
offset += part.dimension
all_numbers += [info.number for _, _, _, info, _ in part_results]
print("* Order parameter directions and isotropy subgroups (coupled) *")
coupled_results = _direction_results(analyzer)
_print_direction_table(coupled_results)
all_numbers += [info.number for _, _, _, info, _ in coupled_results]
_enantiomorph_note(all_numbers)
print()
ranges = []
offset = 0
for part in parts:
letters = _PARAMETER_NAMES[offset : offset + part.dimension]
ranges.append(f"{part.label}: {', '.join(letters)}")
offset += part.dimension
print("Every irrep of the coupled table condenses with a nonzero")
print("amplitude; every irrep keeps its own independent free")
print(f"parameters ({'; '.join(ranges)}).")
else:
print("* Order parameter directions and isotropy subgroups *")
results = _direction_results(analyzer)
_print_direction_table(results)
_enantiomorph_note([info.number for _, _, _, info, _ in results])
print()
print("Conventions and validation: ISOSUBGROUP (https://iso.byu.edu):")
print('H. T. Stokes, S. van Orden and B. J. Campbell, "Tool for Generating')
print('Isotropy Subgroups of Crystallographic Space Groups",')
print("J. Appl. Cryst. 49, 1849-1853 (2016).")
if __name__ == "__main__":
main()