"""ISO-IR (ISOTROPY Software Suite) irreducible-representation tables.
Parses the ISO-IR data files of Stokes & Campbell (2011 version)
CIR_data.txt : complex irreducible representations
PIR_data.txt : physically irreducible representations
and evaluates small-representation (little-group) characters at arbitrary
k points, including non-special k vectors (symmetry lines, planes and the
general point) that carry free parameters alpha/beta/gamma.
The tables store, for every irrep, the FULL space-group representation
matrices (all arms of the star) for the coset representatives in the
standard conventional setting used by ISOTROPY (orthorhombic axes abc,
monoclinic axes a(b)c cell choice 1, origin choice 2, hexagonal axes).
The small representation at one arm is the diagonal block of that arm,
multiplied by the translation phase exp(+2*pi*i k.t) [ISO-IR convention;
note spgrep uses exp(-2*pi*i k.t), so spgrep characters are matched
against the COMPLEX CONJUGATE of the ISO-IR characters].
Used as a labeling fallback for k points that are absent from the
Bilbao-convention `irreptables` character tables (which contain only the
maximal k points). The resulting labels follow the Miller-Love /
ISOTROPY convention (e.g. T1..T5, DT5, LD3, GP1).
Data location: the gzip-compressed table ``CIR_data.txt.gz`` is bundled
inside the crystod package directory itself. The lookup order is the
environment variable ``CRYSTOD_ISOIR_PATH`` first, then the package
directory, then ``<repository root>/ISOTROPY`` (the original ISO-IR
download layout with ``CIR_data/CIR_data.txt``).
File format (from CIR_data.f / PIR_data.f):
header line:
irnum sgnum "sgsymbol" "irlabel" irdim irtype kcount pmkcount opcount
k vectors (CIR: kcount arms, PIR: pmkcount arms), 16 ints per arm,
column-major kvec(4,4):
col 1 = (x, y, z, denominator) constant part
cols 2..4 = alpha/beta/gamma coefficient columns (x, y, z, denom)
per operator (opcount of them):
16 ints: 4x4 augmented operator matrix, ROW-major, common
denominator at [3][3]
[only if k is non-special] 4 ints: IR-translation (x, y, z, denom)
irdim^2 IR-matrix entries, row-major
CIR: complex tokens "(re,im)"
PIR: bare real tokens
"""
from __future__ import annotations
import gzip
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import numpy as np
_HEADER_RE = re.compile(
r'^\s*(\d+)\s+(\d+)\s+"([^"]*)"\s+"([^"]*)"'
r"\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$"
)
# centering translations (conventional basis) by first letter of the HM symbol
_CENTERING_TRANSLATIONS = {
"P": [],
"A": [(0.0, 0.5, 0.5)],
"B": [(0.5, 0.0, 0.5)],
"C": [(0.5, 0.5, 0.0)],
"F": [(0.0, 0.5, 0.5), (0.5, 0.0, 0.5), (0.5, 0.5, 0.0)],
"I": [(0.5, 0.5, 0.5)],
"R": [(2 / 3, 1 / 3, 1 / 3), (1 / 3, 2 / 3, 2 / 3)],
}
_KTYPE_RE = re.compile(r"^([A-Z]+)")
@dataclass
class IsoIrrep:
"""One irrep block of an ISO-IR data file."""
irnum: int
sgnum: int
sgsymbol: str
label: str
dim: int
irtype: int
kcount: int
pmkcount: int
opcount: int
kvecs: np.ndarray = field(repr=False, default=None) # (narms, 4, 4) int
special: bool = True
rotations: np.ndarray = field(repr=False, default=None) # (nop, 3, 3) int
translations: np.ndarray = field(repr=False, default=None) # (nop, 3)
irtrans: np.ndarray = field(repr=False, default=None) # (nop, 3)
matrices: np.ndarray = field(repr=False, default=None) # (nop, dim, dim)
@property
def centering(self) -> str:
return self.sgsymbol[0]
@property
def narms(self) -> int:
return len(self.kvecs)
@property
def small_dim(self) -> int:
return self.dim // self.narms
@property
def ktype(self) -> str:
"""k-vector type label, e.g. 'T' for 'T5', 'GM' for 'GM1+'."""
return _KTYPE_RE.match(self.label).group(1)
@property
def num_free_params(self) -> int:
used = 0
for p in range(3):
col = self.kvecs[0][p + 1]
if col[3] != 0 and any(col[j] != 0 for j in range(3)):
used += 1
return used
def arm_k(self, arm: int, params) -> np.ndarray:
"""k vector of an arm (conventional reciprocal basis) at parameters."""
kv = self.kvecs[arm]
k = np.array([kv[0][j] / kv[0][3] for j in range(3)], dtype=float)
for p in range(3): # alpha, beta, gamma
col = kv[p + 1]
if col[3] != 0:
k = k + params[p] * np.array(
[col[j] / col[3] for j in range(3)], dtype=float
)
return k
def match_k(self, k, atol: float = 1e-6) -> Optional[tuple[int, np.ndarray]]:
"""Find (arm, params) with arm_k(arm, params) == k modulo the
reciprocal lattice of the (possibly centered) crystal lattice.
`k` is in conventional fractional reciprocal coordinates. Offsets
G are tried in order of increasing norm so that, when possible,
the parameters describe `k` itself rather than a translated copy
(the small-irrep phases are only correct for the untranslated
parametrization in non-symmorphic groups).
"""
k = np.asarray(k, dtype=float)
offsets = sorted(
(
(gx, gy, gz)
for gx in range(-2, 3)
for gy in range(-2, 3)
for gz in range(-2, 3)
),
key=lambda g: abs(g[0]) + abs(g[1]) + abs(g[2]),
)
for arm in range(self.narms):
kv = self.kvecs[arm]
const = np.array([kv[0][j] / kv[0][3] for j in range(3)])
cols = []
for p in range(3):
c = kv[p + 1]
if c[3] != 0 and any(c[j] != 0 for j in range(3)):
cols.append((p, np.array([c[j] / c[3] for j in range(3)])))
A = np.column_stack([c for _, c in cols]) if cols else None
for gx, gy, gz in offsets:
G = np.array([gx, gy, gz], dtype=float)
if not _is_reciprocal_lattice_vector(G, self.centering):
continue
rhs = k + G - const
if A is None:
if np.allclose(rhs, 0, atol=atol):
return arm, np.zeros(3)
continue
sol = np.linalg.lstsq(A, rhs, rcond=None)[0]
if np.allclose(A @ sol - rhs, 0, atol=atol):
params = np.zeros(3)
for (p, _), v in zip(cols, sol):
params[p] = v
return arm, params
return None
def find_operator(self, rotation) -> Optional[int]:
for i in range(self.opcount):
if np.array_equal(self.rotations[i], rotation):
return i
return None
def small_character(self, rotation, translation, arm: int, params) -> complex:
"""Character of the small representation at the given arm/params for
the conventional-setting operator {rotation|translation}, in the
ISO-IR phase convention exp(+2*pi*i k.t).
"""
i = self.find_operator(rotation)
if i is None:
raise LookupError(f"operator not found in ISO-IR {self.label}")
dt = np.asarray(translation, dtype=float) - self.translations[i]
if not _is_lattice_translation(dt, self.centering):
raise LookupError(
f"translation mismatch for ISO-IR {self.label}: {dt}"
)
tph = dt + (self.irtrans[i] if not self.special else 0.0)
kk = self.arm_k(arm, params)
phase = np.exp(2j * np.pi * np.dot(kk, tph))
nb = self.small_dim
block = self.matrices[i][
arm * nb : (arm + 1) * nb, arm * nb : (arm + 1) * nb
]
return phase * np.trace(block)
def in_little_group(self, rotation, arm: int, params) -> bool:
"""Is {rotation|*} in the little group of the arm's k? (R^-T k = k
modulo the reciprocal lattice of the centered crystal lattice.)
"""
kk = self.arm_k(arm, params)
kp = np.linalg.inv(np.asarray(rotation, dtype=float).T) @ kk
d = kp - kk
di = np.rint(d)
if not np.allclose(d, di, atol=1e-6):
return False
return _is_reciprocal_lattice_vector(di, self.centering)
def _is_reciprocal_lattice_vector(G, centering: str) -> bool:
"""Is the integer vector G (conventional reciprocal basis) a reciprocal
lattice vector of the centered crystal lattice? True iff G.t is an
integer for every centering translation t.
"""
for t in _CENTERING_TRANSLATIONS[centering]:
s = float(np.dot(G, t))
if not np.isclose(s - round(s), 0.0, atol=1e-9):
return False
return True
def _is_lattice_translation(t, centering: str, atol: float = 1e-6) -> bool:
"""Is t (conventional basis) a translation of the centered lattice?"""
for c in [np.zeros(3)] + [np.array(v) for v in _CENTERING_TRANSLATIONS[centering]]:
d = np.asarray(t) - c
if np.allclose(d - np.rint(d), 0, atol=atol):
return True
return False
_COMPLEX_TOKEN_RE = re.compile(r"\(([^,]+),([^)]+)\)")
def _parse_complex_token(token: str) -> complex:
m = _COMPLEX_TOKEN_RE.fullmatch(token)
if not m:
raise ValueError(f"bad complex token in ISO-IR data: {token!r}")
return complex(float(m.group(1)), float(m.group(2)))
class _TokenStream:
"""Whitespace-token stream over the lines following a header line."""
def __init__(self, line_iter):
self._lines = line_iter
self._buf: list[str] = []
self._pos = 0
def next_tokens(self, n: int) -> list[str]:
out: list[str] = []
while len(out) < n:
if self._pos >= len(self._buf):
self._buf = next(self._lines).split()
self._pos = 0
continue
out.append(self._buf[self._pos])
self._pos += 1
return out
def _isoir_data_file(data_dir: Path, kind: str) -> Optional[Path]:
"""Path of the (possibly gzip-compressed) data file of one kind.
Two layouts are accepted: the flat package layout (``CIR_data.txt.gz``
directly inside ``data_dir``, as bundled with the crystod package) and
the original ISO-IR distribution layout (``CIR_data/CIR_data.txt``).
"""
name = f"{kind.upper()}_data.txt"
for base in (data_dir / name, data_dir / f"{kind.upper()}_data" / name):
for path in (base, base.parent / (base.name + ".gz")):
if path.is_file():
return path
return None
def find_isoir_data_dir() -> Optional[Path]:
"""Locate the ISO-IR data directory.
Search order: the ``CRYSTOD_ISOIR_PATH`` environment variable, the
crystod package directory itself (bundled ``CIR_data.txt.gz``), then
``<repository root>/ISOTROPY`` (original ISO-IR download layout).
"""
env = os.environ.get("CRYSTOD_ISOIR_PATH")
candidates = []
if env:
candidates.append(Path(env))
package_dir = Path(__file__).resolve().parent
candidates.append(package_dir)
candidates.append(package_dir.parent / "ISOTROPY")
for cand in candidates:
if _isoir_data_file(cand, "cir") is not None:
return cand
return None
_CACHE: dict[tuple[str, int, str], list[IsoIrrep]] = {}
[docs]
def load_isoir_irreps(sgnum: int, kind: str = "cir",
data_dir: Optional[Path] = None) -> list[IsoIrrep]:
"""Parse all irreps of one space group from an ISO-IR data file.
Direct access to the tables behind every ISO-IR label that CrystOD
prints: for each irrep the full space-group matrices over the coset
representatives of the ISOTROPY standard setting, the (parametrized) k
vectors of every star arm, and the operator translations. Results are
cached per (file, space group).
Args:
sgnum: Space-group number (1-230).
kind: ``"cir"`` for the complex irreps (``CIR_data``, bundled with
the package) or ``"pir"`` for the physically irreducible ones
(``PIR_data``, not bundled).
data_dir: Directory holding the data file; by default the lookup
order of ``find_isoir_data_dir`` (``CRYSTOD_ISOIR_PATH``, the
package directory, ``<repository root>/ISOTROPY``).
Returns:
One ``IsoIrrep`` record per irrep, in table order, with ``label``
(``"GM4-"``, ``"DT5"``, ...), ``dim`` (full dimension), ``narms``,
``small_dim``, ``ktype``, ``num_free_params``, the arrays
``kvecs``, ``rotations``, ``translations``, ``irtrans`` and
``matrices``, and the methods ``arm_k``, ``match_k``,
``small_character`` and ``in_little_group``.
Raises:
ValueError: ``kind`` is neither ``"cir"`` nor ``"pir"``, or the file
is malformed.
FileNotFoundError: No data directory or data file found.
Example:
>>> from crystod import group
>>> irreps = group.load_isoir_irreps(221)
>>> len(irreps)
72
>>> [ir.label for ir in irreps if ir.ktype == "R"]
['R1+', 'R2+', 'R3+', 'R4+', 'R5+', 'R1-', 'R2-', 'R3-', 'R4-', 'R5-']
"""
if kind not in ("cir", "pir"):
raise ValueError(f"kind must be 'cir' or 'pir', got {kind!r}")
if data_dir is None:
data_dir = find_isoir_data_dir()
if data_dir is None:
raise FileNotFoundError(
"ISO-IR data directory not found (set CRYSTOD_ISOIR_PATH or place "
"the ISOTROPY directory next to the crystod package)"
)
path = _isoir_data_file(data_dir, kind)
if path is None:
raise FileNotFoundError(
f"ISO-IR {kind.upper()} data file not found under {data_dir}"
)
key = (str(path), sgnum, kind)
if key in _CACHE:
return _CACHE[key]
opener = gzip.open if path.suffix == ".gz" else open
irreps: list[IsoIrrep] = []
with opener(path, "rt") as f:
lines = iter(f)
for line in lines:
if '"' not in line:
continue
m = _HEADER_RE.match(line)
if not m:
continue
sg = int(m.group(2))
if sg > sgnum:
break
if sg != sgnum:
continue
irnum = int(m.group(1))
sgsym = m.group(3).strip()
irlabel = m.group(4).strip()
dim = int(m.group(5))
irtype = int(m.group(6))
kcount = int(m.group(7))
pmkcount = int(m.group(8))
nop = int(m.group(9))
# CIR stores the full star of k, PIR only the star of +/-k
narms = kcount if kind == "cir" else pmkcount
ts = _TokenStream(lines)
kints = [int(t) for t in ts.next_tokens(16 * narms)]
kvecs = np.array(kints, dtype=int).reshape(narms, 4, 4)
special = True
for arm in range(narms):
for col in (1, 2, 3):
if any(kvecs[arm][col][j] != 0 for j in range(3)):
special = False
rotations = np.zeros((nop, 3, 3), dtype=int)
translations = np.zeros((nop, 3), dtype=float)
irtrans = np.zeros((nop, 3), dtype=float)
matrices = np.zeros((nop, dim, dim), dtype=complex)
for i in range(nop):
a = np.array(
[int(t) for t in ts.next_tokens(16)], dtype=int
).reshape(4, 4)
denom = a[3][3]
rotations[i] = a[:3, :3] // denom
if not np.array_equal(rotations[i] * denom, a[:3, :3]):
raise ValueError(
f"non-integer rotation in ISO-IR irrep {irnum}"
)
translations[i] = a[:3, 3] / denom
if not special:
v = [int(t) for t in ts.next_tokens(4)]
irtrans[i] = np.array(v[:3], dtype=float) / v[3]
tokens = ts.next_tokens(dim * dim)
if kind == "cir":
values = [_parse_complex_token(t) for t in tokens]
else:
values = [float(t) for t in tokens]
matrices[i] = np.array(values, dtype=complex).reshape(dim, dim)
irreps.append(
IsoIrrep(
irnum, sg, sgsym, irlabel, dim, irtype, kcount,
pmkcount, nop, kvecs, special, rotations, translations,
irtrans, matrices,
)
)
_CACHE[key] = irreps
return irreps
def isoir_available() -> bool:
return find_isoir_data_dir() is not None
_ISO_HALL_CACHE: dict[int, int] = {}
def iso_hall_number(sgnum: int) -> int:
"""spglib Hall number of the ISO-IR standard setting of a space group.
ISOTROPY's preferences: origin choice 2, orthorhombic axes abc,
monoclinic axes a(b)c cell choice 1, hexagonal axes. In terms of the
spglib `choice` strings this means, in order of preference:
'2' (origin choice 2), '' (unique standard setting), 'b1' (monoclinic),
'H' (rhombohedral on hexagonal axes), '1' (origin choice 1 only).
"""
if not _ISO_HALL_CACHE:
import spglib
by_sg: dict[int, list[tuple[int, str]]] = {}
for hall in range(1, 531):
t = spglib.get_spacegroup_type(hall)
number = t['number'] if isinstance(t, dict) else t.number
choice = t['choice'] if isinstance(t, dict) else t.choice
by_sg.setdefault(number, []).append((hall, choice))
for number, entries in by_sg.items():
chosen = entries[0][0]
for preferred in ("2", "", "b1", "H", "1"):
hits = [h for h, c in entries if c == preferred]
if hits:
chosen = hits[0]
break
_ISO_HALL_CACHE[number] = chosen
return _ISO_HALL_CACHE[sgnum]
[docs]
class IsoIRLabeler:
"""Label spgrep small representations with ISO-IR (Miller-Love) labels.
The labeling engine shared by every CrystOD command (crystal orbitals,
phonons, spin bases, ``crystod-group --table --sg``): spgrep computes
the small irreps of the little group of k in the primitive basis of the
user's cell, and this class matches their characters against the
ISO-IR tables in the ISOTROPY standard setting (origin choice 2,
orthorhombic axes abc, monoclinic axes a(b)c cell choice 1, hexagonal
axes), at tabulated k points as well as on symmetry lines, planes and
the general point. Because ISO-IR uses the phase convention
``exp(+2 pi i k.t)`` and spgrep ``exp(-2 pi i k.t)``, spgrep characters
are compared with the complex conjugate of the ISO-IR characters.
Args:
sgnum: Space-group number (1-230).
transformation_matrix: spglib-style transformation ``P`` into the
ISO-IR setting (``x_conventional = P x_primitive +
origin_shift``); give it together with ``origin_shift`` when
``cell`` is omitted.
origin_shift: The origin shift of that transformation.
cell: Alternatively, the primitive cell ``(lattice,
scaled_positions, numbers)`` whose operations feed spgrep; the
transformation is then computed with spglib for the Hall number
of the ISO-IR setting.
symprec: Symmetry tolerance for spglib when ``cell`` is given.
Attributes:
sgnum: The space-group number.
P: The transformation matrix into the ISO-IR setting; ``Pinv`` its
inverse and ``origin_shift`` the accompanying shift.
irreps: The ``IsoIrrep`` records of the space group, from
``load_isoir_irreps``.
Raises:
ValueError: spglib could not standardize ``cell`` to the ISO-IR
setting.
FileNotFoundError: The ISO-IR data file is not available.
Example:
>>> import numpy as np
>>> from crystod import group
>>> labeler = group.IsoIRLabeler(221, transformation_matrix=np.eye(3),
... origin_shift=np.zeros(3))
>>> labeler.kpoint_name([0.5, 0.5, 0.4]), labeler.kpoint_name([0, 0, 0])
('T', 'GM')
"""
def __init__(self, sgnum: int, transformation_matrix=None,
origin_shift=None, cell=None, symprec: float = 1e-5):
self.sgnum = sgnum
if cell is not None:
import spglib
try:
dataset = spglib.get_symmetry_dataset(
cell, symprec=symprec, hall_number=iso_hall_number(sgnum)
)
except Exception as exc: # spglib raises its own SpglibError family
raise ValueError(
"spglib standardization to the ISO-IR setting failed"
) from exc
if dataset is None:
raise ValueError(
"spglib standardization to the ISO-IR setting failed"
)
number = (
dataset['number'] if isinstance(dataset, dict)
else dataset.number
)
if number != sgnum:
raise ValueError(
"spglib standardization to the ISO-IR setting failed"
)
transformation_matrix = (
dataset['transformation_matrix'] if isinstance(dataset, dict)
else dataset.transformation_matrix
)
origin_shift = (
dataset['origin_shift'] if isinstance(dataset, dict)
else dataset.origin_shift
)
self.P = np.asarray(transformation_matrix, dtype=float)
self.Pinv = np.linalg.inv(self.P)
self.origin_shift = np.asarray(origin_shift, dtype=float)
self.irreps = load_isoir_irreps(sgnum, "cir")
# -- setting conversion --------------------------------------------------
[docs]
def conventional_k(self, k_primitive) -> np.ndarray:
"""k vector in the ISO-IR conventional reciprocal basis.
Args:
k_primitive: k vector in the primitive reciprocal basis.
Returns:
``k_primitive @ P^-1`` as a float array.
"""
return np.asarray(k_primitive, dtype=float) @ self.Pinv
[docs]
def conventional_operations(self, rotations, translations):
"""Map primitive-basis operations into the ISO-IR conventional setting.
Args:
rotations: Integer rotation matrices in the primitive basis.
translations: Their fractional translations.
Returns:
``[(R_c, t_c), ...]`` with ``R_c = P R P^-1`` and
``t_c = P t + (1 - R_c) origin_shift``.
"""
conv = []
for R_p, t_p in zip(rotations, translations):
R_c = np.rint(self.P @ R_p @ self.Pinv).astype(int)
t_c = self.P @ np.asarray(t_p, dtype=float) + (
np.eye(3) - R_c
) @ self.origin_shift
conv.append((R_c, t_c))
return conv
[docs]
def kpoint_name(self, k_primitive) -> Optional[str]:
"""Most specific ISO-IR k-vector type label containing a k point.
Args:
k_primitive: k vector in the primitive reciprocal basis.
Returns:
The type label with the fewest free parameters, e.g. ``"T"``
for ``(1/2, 1/2, 0.4)`` in Pm-3m; ``None`` when no entry
matches.
"""
k_conv = self.conventional_k(k_primitive)
best = None
for ir in self.irreps:
if ir.match_k(k_conv) is not None:
if best is None or ir.num_free_params < best.num_free_params:
best = ir
return best.ktype if best is not None else None
# -- labeling ------------------------------------------------------------
[docs]
def label_characters(
self,
k_primitive,
little_rotations,
little_translations,
spgrep_characters,
atol: float = 1e-5,
) -> Optional[tuple[dict[int, str], str]]:
"""Match spgrep small-irrep characters against the ISO-IR tables.
Args:
k_primitive: k vector in the primitive reciprocal basis.
little_rotations: Rotations of the little group of k in the
primitive basis.
little_translations: Their fractional translations.
spgrep_characters: One character vector per spgrep irrep,
aligned with the little-group operations and computed with
the spgrep phase convention ``exp(-2 pi i k.t)``.
atol: Tolerance of the character comparison.
Returns:
``({spgrep irrep index: ISO-IR label}, k-type label)``, e.g.
``({0: "R1+", ...}, "R")``, or ``None`` when no consistent
assignment exists.
"""
k_conv = self.conventional_k(k_primitive)
conv_ops = self.conventional_operations(
little_rotations, little_translations
)
for ktype, family in self._matched_families(k_conv):
result = self._try_family(family, conv_ops, spgrep_characters, atol)
if result is not None:
return result, ktype
return None
[docs]
def decompose_characters(
self,
k_primitive,
little_rotations,
little_translations,
reducible_characters,
atol: float = 1e-3,
) -> Optional[tuple[list[tuple[str, int, int]], str]]:
"""Decompose a reducible character vector into ISO-IR irreps.
Used for phonopy band sets, whose characters can be reducible under
accidental degeneracy.
Args:
k_primitive: k vector in the primitive reciprocal basis.
little_rotations: Rotations of the little group of k in the
primitive basis.
little_translations: Their fractional translations.
reducible_characters: The character vector, aligned with the
little-group operations, in the spgrep/phonopy phase
convention ``exp(-2 pi i k.t)``.
atol: Tolerance of the multiplicity check.
Returns:
``([(label, multiplicity, small_dim), ...], k-type label)``, or
``None`` when no consistent decomposition exists.
"""
results = self.decompose_characters_many(
k_primitive, little_rotations, little_translations,
[reducible_characters], atol=atol,
)
return results[0]
[docs]
def decompose_characters_many(
self,
k_primitive,
little_rotations,
little_translations,
character_vectors,
atol: float = 1e-3,
) -> list[Optional[tuple[list[tuple[str, int, int]], str]]]:
"""Decompose several reducible character vectors at one k point.
Same as ``decompose_characters``, but the candidate-family search
(the expensive part) is done once and shared by all vectors; use
this for phonopy band sets, which all live at the same q.
Args:
k_primitive: k vector in the primitive reciprocal basis.
little_rotations: Rotations of the little group of k in the
primitive basis.
little_translations: Their fractional translations.
character_vectors: The reducible character vectors, each
aligned with the little-group operations (phase convention
``exp(-2 pi i k.t)``).
atol: Tolerance of the multiplicity check.
Returns:
One entry per input vector, each
``([(label, multiplicity, small_dim), ...], k-type label)`` or
``None`` when no consistent decomposition exists.
"""
n_vectors = len(character_vectors)
k_conv = self.conventional_k(k_primitive)
conv_ops = self.conventional_operations(
little_rotations, little_translations
)
e_index = None
for j, (R_c, t_c) in enumerate(conv_ops):
if np.array_equal(R_c, np.eye(3, dtype=int)) and np.allclose(
np.asarray(t_c) - np.rint(t_c), 0, atol=1e-6
):
e_index = j
break
if e_index is None:
return [None] * n_vectors
for ktype, family in self._matched_families(k_conv):
iso_chars = self._family_characters_checked(family, conv_ops)
if iso_chars is None:
continue
results: list[Optional[tuple[list[tuple[str, int, int]], str]]] = []
for reducible in character_vectors:
counts = self._decompose_against(
family, iso_chars, np.asarray(reducible, dtype=complex),
e_index, len(conv_ops), atol,
)
results.append((counts, ktype) if counts is not None else None)
# the family validity checks are vector-independent, so per-vector
# failures here are numerical; keep them as None entries
if any(result is not None for result in results):
return results
return [None] * n_vectors
@staticmethod
def _decompose_against(family, iso_chars, red, e_index, n_ops, atol):
"""Multiplicities of one reducible character vector in a family, or
None when they are not consistent non-negative integers.
The multiplicity of the irrep with characters conj(chi_iso) in red is
n = (1/|G|) sum_g conj(conj(chi_iso(g))) red(g).
"""
total_dim = int(round(red[e_index].real))
counts: list[tuple[str, int, int]] = []
dim_sum = 0
for (ir, _, _), chi in zip(family, iso_chars):
n = complex(np.dot(chi, red)) / n_ops
ni = int(round(n.real))
if abs(n - ni) > atol or ni < 0:
return None
if ni:
counts.append((ir.label, ni, ir.small_dim))
dim_sum += ni * ir.small_dim
if dim_sum != total_dim:
return None
return counts
def _matched_families(self, k_conv):
"""Candidate irreps whose star contains k, grouped by k-type and
sorted most specific k type (fewest free parameters) first."""
families: dict[str, list[tuple[IsoIrrep, int, np.ndarray]]] = {}
for ir in self.irreps:
matched = ir.match_k(k_conv)
if matched is not None:
families.setdefault(ir.ktype, []).append(
(ir, matched[0], matched[1])
)
return sorted(
families.items(), key=lambda item: item[1][0][0].num_free_params
)
def _family_characters_checked(self, family, conv_ops):
"""ISO-IR character vectors of a family, or None when the family does
not describe the little group of these operations."""
# all little-group operations must belong to the family's little group
for ir, arm, params in family:
for R_c, _ in conv_ops:
if not ir.in_little_group(R_c, arm, params):
return None
# the family must exhaust the little group
if sum(ir.small_dim**2 for ir, _, _ in family) != len(conv_ops):
return None
try:
return self._family_characters(family, conv_ops)
except LookupError:
# setting mismatch (operator or translation not found): the
# transformation into the ISO-IR setting is wrong -- fail safe
return None
def _try_family(self, family, conv_ops, spgrep_characters, atol):
iso_chars = self._family_characters_checked(family, conv_ops)
if iso_chars is None:
return None
label_map: dict[int, str] = {}
used: set[str] = set()
for m, chi in enumerate(spgrep_characters):
hits = [
ir.label
for (ir, _, _), chi_iso in zip(family, iso_chars)
if ir.label not in used
and np.allclose(chi, np.conj(chi_iso), atol=atol)
]
if len(hits) != 1:
return None
label_map[m] = hits[0]
used.add(hits[0])
return label_map
@staticmethod
def _family_characters(family, conv_ops):
iso_chars = []
for ir, arm, params in family:
iso_chars.append(
np.array(
[
ir.small_character(R_c, t_c, arm, params)
for R_c, t_c in conv_ops
]
)
)
return iso_chars
# --------------------------------------------------------------- shared helpers
_LABELER_CACHE: dict = {}
def get_cached_labeler(sgnum: int, cell, symprec: float = 1e-5):
"""Cached IsoIRLabeler for a primitive cell, or None when unavailable.
``cell`` is a spglib tuple (lattice, scaled_positions, numbers) of the
primitive cell whose symmetry operations feed spgrep. Returns None when
the ISO-IR data files are missing or standardization fails, so callers
can fall through to their existing generic labels.
"""
lattice, positions, numbers = cell
key = (
int(sgnum),
float(symprec),
np.asarray(lattice, dtype=float).round(10).tobytes(),
np.asarray(positions, dtype=float).round(10).tobytes(),
np.asarray(numbers, dtype=int).tobytes(),
)
if key not in _LABELER_CACHE:
try:
_LABELER_CACHE[key] = IsoIRLabeler(sgnum, cell=cell, symprec=symprec)
except Exception:
_LABELER_CACHE[key] = None
return _LABELER_CACHE[key]
[docs]
def get_isoir_label_map(
sgnum: int,
cell,
symprec: float,
kpoint,
little_rotations,
little_translations,
spgrep_characters,
) -> Optional[tuple[dict[int, str], str]]:
"""Label spgrep small irreps at a k point with ISO-IR labels.
One-stop entry point shared by every labeling path of CrystOD (crystal
orbitals, orbital hybridization, spin bases, phonons): builds (and
caches) the ``IsoIRLabeler`` of the cell and calls its
``label_characters``. Never raises: any failure yields ``None`` so
that callers can fall back to generic labels.
Args:
sgnum: Space-group number (1-230).
cell: Primitive cell as an spglib tuple ``(lattice,
scaled_positions, numbers)`` whose operations feed spgrep.
symprec: Symmetry tolerance for spglib.
kpoint: k vector in the primitive reciprocal basis.
little_rotations: Rotations of the little group of k in the
primitive basis.
little_translations: Their fractional translations.
spgrep_characters: One character vector per spgrep irrep, aligned
with the little-group operations (phase convention
``exp(-2 pi i k.t)``).
Returns:
``({spgrep irrep index: label}, k-type label)`` such as
``({0: "Q1"}, "Q")``, or ``None`` when the ISO-IR data are
unavailable or no consistent assignment exists.
"""
labeler = get_cached_labeler(sgnum, cell, symprec)
if labeler is None:
return None
try:
return labeler.label_characters(
kpoint, little_rotations, little_translations, spgrep_characters
)
except Exception:
return None
def get_isoir_kpoint_name(sgnum: int, cell, symprec: float, kpoint) -> Optional[str]:
"""Most specific ISO-IR k-vector type label for a k point, or None."""
labeler = get_cached_labeler(sgnum, cell, symprec)
if labeler is None:
return None
try:
return labeler.kpoint_name(kpoint)
except Exception:
return None
def get_isoir_band_decomposition(
sgnum: int,
cell,
symprec: float,
kpoint,
little_rotations,
little_translations,
reducible_characters,
) -> Optional[tuple[list[tuple[str, int, int]], str]]:
"""Decompose one reducible character vector into ISO-IR irreps.
Fallback for phonopy band sets (whose characters can be reducible under
accidental degeneracy). Returns ([(label, multiplicity, dim), ...],
k-type label) or None. Never raises.
"""
labeler = get_cached_labeler(sgnum, cell, symprec)
if labeler is None:
return None
try:
return labeler.decompose_characters(
kpoint, little_rotations, little_translations, reducible_characters
)
except Exception:
return None
def get_isoir_band_decompositions(
sgnum: int,
cell,
symprec: float,
kpoint,
little_rotations,
little_translations,
character_vectors,
) -> list[Optional[tuple[list[tuple[str, int, int]], str]]]:
"""Decompose several reducible character vectors at one k point.
Batch version of get_isoir_band_decomposition: the candidate-family
search is done once for all vectors (phonopy band sets share the q).
Returns one entry per vector. Never raises.
"""
labeler = get_cached_labeler(sgnum, cell, symprec)
if labeler is None:
return [None] * len(character_vectors)
try:
return labeler.decompose_characters_many(
kpoint, little_rotations, little_translations, character_vectors
)
except Exception:
return [None] * len(character_vectors)