"""
__author__ = "Hiroki Koiso, Yasuhide Mochizuki"
__copyright__ = "Copyright 2026, Mochizuki group"
__version__ = "3.1"
__maintainer__ = "Yasuhide Mochizuki"
__email__ = "mochizuki@rs.tus.ac.jp"
__status__ = "Development"
__released_date__ = "May 20, 2024"
__last_update__= "June 29, 2026"
"""
from __future__ import annotations
from .spglib_compat import ensure_spglib_compat
ensure_spglib_compat()
from phonopy.structure.atoms import PhonopyAtoms
from phonopy.interface.calculator import read_crystal_structure
from phonopy.structure.symmetry import Symmetry
from spglib import get_spacegroup_type_from_symmetry, standardize_cell
from .irreptables_compat import load_irreptables
from .runtime_compat import (
get_character,
get_chemical_symbols,
get_little_group,
get_scaled_positions,
get_symmetry_dataset,
)
from .operations import (
characterize_rotation,
conjugated_little_group_map,
find_star_arm,
get_seitz_symbol,
)
from argparse import ArgumentParser, RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter
import numpy as np
from numpy.typing import NDArray
from typing import Optional
import itertools
import re
from fractions import Fraction
IrrepTable, Irrep = load_irreptables()
### PARSER STRUCTURE ###
class MyHelpFormatter(RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter):
pass
desc = """
This program calculates the irreducible representations of elemental's crystal orbitals.
# Command Example:
python3 crystal_orbital.py --poscar POSCAR_ScF3_Pm-3m --element F --orbital p
"""
### --------------- ###
def build_parser() -> ArgumentParser:
parser = ArgumentParser(description=desc, formatter_class=MyHelpFormatter)
parser.add_argument("--poscar", "-poscar", dest="poscar", type=str, default="POSCAR",
help="POSCAR.")
parser.add_argument("--element", "-element", dest="element", required=True, type=str, default=None,
help="Element.")
parser.add_argument("--orbital", "-orbital", dest="orbital", required=True, type=str, default=None,
help="Orbital. You can choose from following; s, p, d, f, g, h, i.")
parser.add_argument("--kpoint", "-kpoint", dest="kpoint", type=float, nargs=3, required=False,
help="k-point, kx, ky, kz.")
parser.add_argument("--spinor", "-spinor", dest="spinor", action="store_true",
help="Calculate double space group representations.")
parser.add_argument("--tolerance", "-tolerance", dest="tolerance", type=float, default=0.00001,
help="Symmetry tolerance to search primitive cell.")
parser.add_argument("--show-irrep-table", "-show-table", dest="table", action="store_true",
help="Show table of irreps.")
return parser
def similarity_transformation(rot: NDArray[np.float64], mat: NDArray[np.float64]) -> NDArray[np.float64]:
"""Similarity transformation by R x M x R^-1."""
return rot @ mat @ np.linalg.inv(rot)
def _canonicalize_component(value: float, tol: float = 1e-6, max_denominator: int = 48) -> float:
"""Snap a float to a nearby simple rational when possible."""
nearest_integer = round(value)
if abs(value - nearest_integer) < tol:
return float(nearest_integer)
fraction = Fraction(value).limit_denominator(max_denominator)
snapped = float(fraction)
if abs(value - snapped) < tol:
return snapped
return float(value)
def canonicalize_kpoint(kpoint: list[float], tol: float = 1e-6, max_denominator: int = 48) -> list[float]:
"""Normalize k-point coordinates to stable simple fractions."""
normalized = [_canonicalize_component(float(value), tol=tol, max_denominator=max_denominator) for value in kpoint]
return [0.0 if abs(value) < tol else value for value in normalized]
def format_kpoint(kpoint: list[float], decimals: int = 2) -> list[float]:
"""Format a k-point for display using plain Python floats."""
return [float(np.round(float(value), decimals)) for value in kpoint]
def irrep_label_sort_key(label: str) -> tuple:
"""Natural ordering for physical irrep labels."""
pattern = re.compile(
r"^(?P<spinor>-?)(?P<kpoint>[A-Z]+)(?P<index>\d+)(?P<parity>[+-]?)(?:\((?P<dim>\d+)\))$"
)
match = pattern.match(label)
if not match:
return (label, 10**9, 10**9, 10**9, label)
spinor = 0 if match.group("spinor") == "-" else 1
kpoint = match.group("kpoint")
index = int(match.group("index"))
parity = match.group("parity")
dim = int(match.group("dim"))
parity_order = {"+": 0, "-": 1, "": 2}.get(parity, 3)
return (kpoint, spinor, index, parity_order, dim, label)
def get_label_overrides(
space_group_number: int,
kpoint_name: Optional[str],
spinor: bool,
) -> dict[str, str]:
"""Return manual label overrides for known naming mismatches in the ISO-IR tables."""
return {}
[docs]
class CrystalOrbital:
"""Irreps of the crystal orbitals built from one atomic shell (``crystod``).
The Bloch sums of one element's ``s``, ``p``, ``d``, ``f``, ``g``, ``h``
or ``i`` shell at a k point span a (reducible) representation of the
little group of ``k`` -- the site-symmetry induced representation, or
band representation, of that shell. Its character is the product of
the permutation character of the element's sites (with the Bloch phases
of ``k``) and the rotation character of the shell, and its
decomposition into the irreps of the little group is what
``crystod -c POSCAR --element EL --orbital ORB [--kpoint K]`` prints:
with ``--spinor`` the double-valued (spin-orbit) irreps, without
``--kpoint`` every special point of the space group. The spgrep irreps
are labelled with the ISO-IR (Miller-Love) names, ``GM3+(2)``,
``X5-(2)`` and so on, the number in parentheses being the dimension.
The input cell is reduced to the spglib primitive cell, and the
symmetry operations are reordered to match the ISO-IR tables (a
``ValueError`` is raised when that fails).
Args:
cell: The crystal structure as ``phonopy.structure.atoms.PhonopyAtoms``
(any setting; it is converted to the primitive cell).
symprec: Symmetry tolerance handed to spglib.
spior: ``True`` for the double-valued (spinor) irreps. The parameter
is spelled this way in the signature; the attribute is
``spinor``.
Attributes:
primitive_cell: The standardized primitive ``PhonopyAtoms`` cell that
every k point and atom index refers to.
spglib_dataset: The spglib symmetry dataset of the primitive cell
(``"international"``, ``"number"``, ``"wyckoffs"``,
``"site_symmetry_symbols"``, ...).
transformation_matrix: The spglib transformation matrix of the
primitive cell; it carries the ISO-IR k vectors and rotations,
tabulated in the conventional setting, onto the primitive basis.
rotations: Integer rotation matrices in the primitive basis, in the
ISO-IR table order.
translations: The matching fractional translations.
seitz_symbols: The Seitz symbol of every operation, same order.
spinor: Whether double-valued irreps are used.
symprec: The symmetry tolerance in use.
irt_character_table: The ISO-IR table of the space group
(double-valued when ``spinor``).
irt_kpoint_table: The single-valued ISO-IR table the special
k points are enumerated from.
labels_from_isoir: Set by :meth:`get_irrep_labels` at a k point the
table does not list: whether the labels came from the full
ISO-IR k-vector data.
Example:
>>> from phonopy.interface.calculator import read_crystal_structure
>>> from crystod import salc
>>> from crystod.examples import example_path
>>> cell, _ = read_crystal_structure(
... str(example_path("221_PPOSCAR_ScF3")), interface_mode="vasp")
>>> co = salc.CrystalOrbital(cell)
>>> co.get_irt_special_points()[0]
['GM', 'R', 'X', 'M']
>>> _, _, counts, labels = co.irreducible_decomposition(
... [0, 0, 0], "Sc", "d")
>>> {labels[key]: int(n) for key, n in counts.items() if n > 0}
{'GM3+(2)': 1, 'GM5+(3)': 1}
"""
def __init__(
self,
cell: PhonopyAtoms,
symprec: float = 0.00001,
spior: bool = False,
):
self._inputed_cell = cell
# Convert inputed cell to primitive cell.
(primitive_lattice, primitive_pos, primitive_numbers) = standardize_cell(cell.totuple(),
to_primitive=True,
symprec=symprec)
self.primitive_cell = PhonopyAtoms(numbers=primitive_numbers,
scaled_positions=primitive_pos,
cell=primitive_lattice)
print("\n ### Inputed cell was converted into primitive cell. ###")
self.spinor = spior
self.symprec = symprec
# Get symmetry informations
symmetry = Symmetry(self.primitive_cell)
dataset = get_symmetry_dataset(symmetry)
self.spglib_dataset = dataset
self._spglib_rotations = dataset['rotations']
self._spglib_translations = dataset['translations']
self.transformation_matrix = dataset['transformation_matrix'] # primitive matrix
self.irt_character_table = IrrepTable(dataset['number'], self.spinor)
# Special k-point names/coordinates do not depend on spinor; the
# bundled ISO-IR tables carry single-valued irreps only (the spinor
# table has no irreps), so enumerate special points from the
# single-valued table.
self.irt_kpoint_table = (
self.irt_character_table
if not self.spinor
else IrrepTable(dataset['number'], False)
)
self.rotations, self.translations = self._sort_symmetry_operations_in_order_of_irt(
self._spglib_rotations,
self._spglib_translations,
)
self.seitz_symbols = [get_seitz_symbol(r, self.transformation_matrix) for r in self.rotations]
def _sort_symmetry_operations_in_order_of_irt(
self,
spglib_R: NDArray[np.int_],
spglib_t: NDArray[np.float64],
) -> tuple[NDArray[np.int_], NDArray[np.float64]]:
"""Sort symmetry operations found by spglib in the ISO-IR table order."""
irt_conv_R = np.array([sym.R for sym in self.irt_character_table.symmetries], dtype=float)
irt_prim_R = similarity_transformation(np.linalg.inv(self.transformation_matrix), irt_conv_R)
sorted_R = []
sorted_t = []
used_indices = set()
for irt_R in np.rint(irt_prim_R).astype(int):
found = False
for i, R in enumerate(spglib_R):
if i in used_indices:
continue
if (irt_R == R).all():
sorted_R.append(R)
sorted_t.append(spglib_t[i])
used_indices.add(i)
found = True
break
if not found:
raise ValueError("Failed to sort symmetry operations in ISO-IR table order.")
return np.array(sorted_R), np.array(sorted_t)
[docs]
def get_irt_irreps_at_k(self, k: list[float]) -> list[Irrep]:
"""Tabulated ISO-IR irreps at a k point.
Args:
k: Three primitive reciprocal coordinates.
Returns:
The ISO-IR irrep records whose k vector equals ``k`` -- each with
``kpname``, ``k`` and its characters -- or an empty list when the
point is not tabulated as given (a non-tabulated arm of a star,
or no special point at all).
"""
k = canonicalize_kpoint(k)
trans_inv = np.linalg.inv(self.transformation_matrix)
conventional_k = np.array(k) @ trans_inv
irreps_at_k = []
for irrep_at_k in self.irt_character_table.irreps:
if np.allclose(irrep_at_k.k, conventional_k, atol=1e-6):
irreps_at_k.append(irrep_at_k)
return irreps_at_k
[docs]
def get_kpoint_name(self, k: list[float]) -> Optional[str]:
"""Name of a k point (``GM``, ``X``, ``M``, ...), or ``None``.
Any arm of a tabulated star is recognized, not only the tabulated
arm; a k point that is no special point receives the ISO-IR
k-vector type letters (``GP``, ``DT``, ...) when those data are
available.
Args:
k: Three primitive reciprocal coordinates.
Returns:
The name, or ``None`` when nothing matches.
"""
k = canonicalize_kpoint(k)
irreps_at_k = self.get_irt_irreps_at_k(k)
if irreps_at_k:
return irreps_at_k[0].kpname
arm = self._find_irt_star_arm(k)
if arm is not None:
irreps_at_rep = self.get_irt_irreps_at_k(arm[1])
if irreps_at_rep:
return irreps_at_rep[0].kpname
# non-special k: fall back to the ISO-IR k-vector type label
labeler = self._get_isoir_labeler()
if labeler is not None:
try:
return labeler.kpoint_name(k)
except Exception:
pass
return None
def _find_irt_star_arm(self, k: list[float]) -> Optional[tuple[int, list[float]]]:
"""(g_index, k_rep) mapping k onto the tabulated arm of its star, or None.
The operations are sorted in the ISO-IR table order, so g_index doubles
as the ISO-IR table operation index.
"""
_, special_points = self.get_irt_special_points()
return find_star_arm(k, self.rotations, special_points)
def _conjugated_character_indices(
self,
g_index: int,
k_rep: list[float],
mapping_little_group: NDArray[np.int_],
) -> Optional[tuple[list[int], NDArray[np.complex128]]]:
"""Character transport h -> g^-1 h g onto the little group of k_rep."""
return conjugated_little_group_map(
self.rotations, self.translations, g_index, k_rep, mapping_little_group
)
[docs]
def get_irt_special_points(self) -> tuple[list[str], list[list[float]]]:
"""Special k points of the space group from the ISO-IR tables.
Returns:
``(names, kpoints)``: the tabulated names (``GM``, ``R``, ``X``,
``M`` for Pm-3m) and their primitive reciprocal coordinates, one
entry per distinct point, in table order. These are the points
``crystod`` analyzes when ``--kpoint`` is omitted.
"""
primitive_kpoints = []
kpoint_names = []
for irrep in self.irt_kpoint_table.irreps:
primitive_k = canonicalize_kpoint(list(np.array(irrep.k) @ self.transformation_matrix))
if primitive_k not in primitive_kpoints:
primitive_kpoints.append(primitive_k)
kpoint_names.append(irrep.kpname)
return kpoint_names, primitive_kpoints
def _get_isoir_labeler(self):
"""Lazily construct the ISO-IR labeler (None if data unavailable)."""
if not hasattr(self, "_isoir_labeler"):
from .isoir import get_cached_labeler
self._isoir_labeler = get_cached_labeler(
self.spglib_dataset['number'],
self.primitive_cell.totuple(),
self.symprec,
)
return self._isoir_labeler
def _get_isoir_labels(
self,
k: list[float],
irreps,
mapping_little_group: NDArray[np.int_],
) -> Optional[dict[str, str]]:
"""Label spgrep irreps with ISO-IR (Miller-Love) labels, or None.
The ISO-IR tables contain single-valued irreps only, so the spinor
path never uses them.
"""
self.labels_from_isoir = False
if self.spinor:
return None
labeler = self._get_isoir_labeler()
if labeler is None:
return None
little_rotations = [self.rotations[idx] for idx in mapping_little_group]
little_translations = [self.translations[idx] for idx in mapping_little_group]
spgrep_characters = [get_character(irrep) for irrep in irreps]
try:
matched = labeler.label_characters(
k, little_rotations, little_translations, spgrep_characters
)
except Exception:
return None
if matched is None:
return None
label_map, kpoint_name = matched
self.labels_from_isoir = True
self.isoir_kpoint_name = kpoint_name
return {
f"irrep_{i+1}({irrep.shape[1]})": f"{label_map[i]}({irrep.shape[1]})"
for i, irrep in enumerate(irreps)
}
[docs]
def get_irrep_labels(
self,
k: list[float],
irreps,
mapping_little_group: NDArray[np.int_],
) -> dict[str, str]:
"""Map spgrep irreps at ``k`` to ISO-IR labels by comparing characters.
Args:
k: Three primitive reciprocal coordinates.
irreps: The spgrep irreps at ``k`` (arrays of shape
``(little_group_order, dim, dim)``), as returned by
:meth:`irreducible_decomposition`.
mapping_little_group: Indices into :attr:`rotations` of the
little-group operations, in the order of the irrep matrices.
Returns:
``{generic: label}`` with the generic key ``"irrep_i(dim)"`` of
every irrep and its ISO-IR label such as ``"GM3+(2)"``. A
k point the table does not list is first mapped onto the
tabulated arm of its star (characters transported by
conjugation), otherwise the full ISO-IR k-vector data are
consulted; an irrep that still finds no match keeps its generic
key as the label.
"""
k = canonicalize_kpoint(k)
irt_irreps = self.get_irt_irreps_at_k(k)
char_indices: list[int] = list(mapping_little_group)
char_phases = np.ones(len(char_indices), dtype=complex)
if not irt_irreps:
# k may be a non-tabulated arm of a special-point star: map it onto
# the tabulated arm and transport the characters by conjugation.
arm = self._find_irt_star_arm(k)
if arm is not None:
conjugated = self._conjugated_character_indices(arm[0], arm[1], mapping_little_group)
if conjugated is not None:
candidate_irreps = self.get_irt_irreps_at_k(arm[1])
if candidate_irreps and all(
(index + 1) in candidate_irreps[0].characters for index in conjugated[0]
):
irt_irreps = candidate_irreps
char_indices, char_phases = conjugated
if not irt_irreps:
# Not among the tabulated special-point irreps (e.g. a symmetry
# line/plane or generic k): fall back to the full ISO-IR (ISOTROPY)
# labeler, which covers every k-vector type. Labels then follow
# the Miller-Love convention.
isoir_labels = self._get_isoir_labels(k, irreps, mapping_little_group)
if isoir_labels is not None:
return isoir_labels
return {
f"irrep_{i+1}({irrep.shape[1]})": f"irrep_{i+1}({irrep.shape[1]})"
for i, irrep in enumerate(irreps)
}
def match_direct(
lhs: NDArray[np.complex128],
rhs: NDArray[np.complex128],
atol: float = 1e-5,
) -> Optional[NDArray[np.complex128]]:
if np.allclose(lhs, rhs, atol=atol):
return rhs
return None
def match_with_possible_conjugation(
lhs: NDArray[np.complex128],
rhs: NDArray[np.complex128],
atol: float = 1e-5,
) -> Optional[NDArray[np.complex128]]:
direct = match_direct(lhs, rhs, atol=atol)
if direct is not None:
return direct
rhs_conj = np.conj(rhs)
if np.allclose(lhs, rhs_conj, atol=atol):
return rhs_conj
return None
def resolve_spinor_phase_convention_block(
group_generic: list[str],
group_physical: list[str],
atol: float = 1e-5,
) -> Optional[dict[str, tuple[str, NDArray[np.complex128]]]]:
"""Resolve 1D spinor irreps allowing an operation-wise phase convention shift."""
if not group_generic or len(group_generic) != len(group_physical):
return None
def search_assignment(
generic_candidates: dict[str, list[tuple[str, NDArray[np.complex128]]]],
) -> Optional[dict[str, tuple[str, NDArray[np.complex128]]]]:
ordered = sorted(generic_candidates, key=lambda label: len(generic_candidates[label]))
used_physical = set()
assignment: dict[str, tuple[str, NDArray[np.complex128]]] = {}
def backtrack(index: int) -> bool:
if index == len(ordered):
return True
generic_label = ordered[index]
candidates = sorted(generic_candidates[generic_label], key=lambda item: item[0])
for physical_label, transformed_chars in candidates:
if physical_label in used_physical:
continue
used_physical.add(physical_label)
assignment[generic_label] = (physical_label, transformed_chars)
if backtrack(index + 1):
return True
used_physical.remove(physical_label)
del assignment[generic_label]
return False
if backtrack(0):
return assignment
return None
for anchor_generic in group_generic:
anchor_spgrep = spgrep_character_map[anchor_generic]
for anchor_physical in group_physical:
for anchor_use_conjugate in [0, 1]:
anchor_irt = (
np.conj(irt_character_map[anchor_physical])
if anchor_use_conjugate
else irt_character_map[anchor_physical]
)
nonzero_mask = np.abs(anchor_irt) > atol
if not np.any(nonzero_mask):
continue
phase_vector = np.ones_like(anchor_irt, dtype=complex)
phase_vector[nonzero_mask] = (
anchor_spgrep[nonzero_mask] / anchor_irt[nonzero_mask]
)
if not np.allclose(
np.abs(phase_vector[nonzero_mask]), 1.0, atol=atol
):
continue
phase_vector[nonzero_mask] /= np.abs(phase_vector[nonzero_mask])
generic_candidates: dict[str, list[tuple[str, NDArray[np.complex128]]]] = {}
is_consistent = True
for generic_label in group_generic:
generic_chars = spgrep_character_map[generic_label]
candidates = []
for physical_label in group_physical:
for use_conjugate in [0, 1]:
physical_chars = (
np.conj(irt_character_map[physical_label])
if use_conjugate
else irt_character_map[physical_label]
)
transformed_chars = phase_vector * physical_chars
if np.allclose(
generic_chars, transformed_chars, atol=atol
):
candidates.append((physical_label, transformed_chars))
if not candidates:
is_consistent = False
break
generic_candidates[generic_label] = candidates
if not is_consistent:
continue
assignment = search_assignment(generic_candidates)
if assignment is not None:
return assignment
return None
irt_character_map = {}
for irt_irrep in irt_irreps:
irt_label = f"{irt_irrep.name}({irt_irrep.dim})"
irt_character_map[irt_label] = (
np.array(
[irt_irrep.characters[idx + 1] for idx in char_indices],
dtype=complex,
)
* char_phases
)
spgrep_character_map = {}
label_map = {}
matched_character_map = {}
used_labels = set()
for i, irrep in enumerate(irreps):
generic_label = f"irrep_{i+1}({irrep.shape[1]})"
spgrep_character_map[generic_label] = np.array(get_character(irrep), dtype=complex)
label_map[generic_label] = generic_label
ordered_spgrep_labels = sorted(
spgrep_character_map.keys(),
key=lambda label: int(label.split("(")[1][:-1]),
reverse=True,
)
# First, try strict direct character matching without conjugation.
# For spinor irreps this is important: starting with conjugation-aware matching
# immediately makes conjugate pairs ambiguous even when an exact label match
# exists in the IRT table.
for generic_label in ordered_spgrep_labels:
spgrep_chars = spgrep_character_map[generic_label]
spgrep_dim = int(generic_label.split("(")[1][:-1])
matched_candidates = []
for irt_label, irt_chars in irt_character_map.items():
if irt_label in used_labels:
continue
irt_dim = int(irt_label.split("(")[1][:-1])
if irt_dim != spgrep_dim:
continue
transformed_chars = match_direct(spgrep_chars, irt_chars)
if transformed_chars is not None:
matched_candidates.append((irt_label, transformed_chars))
if len(matched_candidates) == 1:
irt_label, transformed_chars = matched_candidates[0]
label_map[generic_label] = irt_label
matched_character_map[generic_label] = transformed_chars
used_labels.add(irt_label)
if not self.spinor:
progress = True
while progress:
progress = False
unresolved_labels = [
label for label in ordered_spgrep_labels if label_map[label] == label
]
if not unresolved_labels:
break
unresolved_dims = sorted(
{int(label.split("(")[1][:-1]) for label in unresolved_labels},
reverse=True,
)
for irrep_dim in unresolved_dims:
group_generic = [
label for label in unresolved_labels
if int(label.split("(")[1][:-1]) == irrep_dim
]
group_physical = [
label for label in sorted(irt_character_map.keys())
if label not in used_labels and int(label.split("(")[1][:-1]) == irrep_dim
]
if len(group_generic) != len(group_physical) or not group_generic or len(group_generic) > 6:
continue
solutions = []
for perm in itertools.permutations(group_physical):
transformed = {
generic_label: irt_character_map[irt_label]
for generic_label, irt_label in zip(group_generic, perm)
}
is_consistent = True
for generic_label in group_generic:
for reference_label, reference_chars in matched_character_map.items():
spgrep_invariant = (
spgrep_character_map[generic_label]
* np.conj(spgrep_character_map[reference_label])
)
irt_invariant = (
transformed[generic_label]
* np.conj(reference_chars)
)
if not np.allclose(spgrep_invariant, irt_invariant, atol=1e-5):
is_consistent = False
break
if not is_consistent:
break
if not is_consistent:
continue
for i, left_label in enumerate(group_generic):
for right_label in group_generic[i + 1:]:
spgrep_invariant = (
spgrep_character_map[left_label]
* np.conj(spgrep_character_map[right_label])
)
irt_invariant = (
transformed[left_label]
* np.conj(transformed[right_label])
)
if not np.allclose(spgrep_invariant, irt_invariant, atol=1e-5):
is_consistent = False
break
if not is_consistent:
break
if is_consistent:
solutions.append((perm, transformed))
if not solutions:
continue
solutions.sort(key=lambda item: item[0])
perm, transformed = solutions[0]
for generic_label, irt_label in zip(group_generic, perm):
label_map[generic_label] = irt_label
matched_character_map[generic_label] = transformed[generic_label]
used_labels.add(irt_label)
progress = True
kpoint_name = self.get_kpoint_name(k)
overrides = get_label_overrides(
space_group_number=self.spglib_dataset["number"],
kpoint_name=kpoint_name,
spinor=self.spinor,
)
if overrides:
label_map = {
generic_label: overrides.get(resolved_label, resolved_label)
for generic_label, resolved_label in label_map.items()
}
unresolved_generic = [
label for label in ordered_spgrep_labels if label_map[label] == label
]
unresolved_physical = sorted(
[label for label in irt_character_map.keys() if label not in used_labels],
key=irrep_label_sort_key,
)
if unresolved_generic and len(unresolved_generic) == len(unresolved_physical):
unresolved_generic = sorted(
unresolved_generic,
key=lambda label: (
int(label.split("(")[1][:-1]),
int(label.split("_")[1].split("(")[0]),
),
)
for generic_label, physical_label in zip(unresolved_generic, unresolved_physical):
label_map[generic_label] = physical_label
return label_map
# For spinor irreps, direct character matching can fail because each irrep may
# differ by a projective gauge. Gauge-invariant quotients chi_a * chi_b^* are
# still comparable, so resolve the remaining labels by matching those quotient
# patterns against the ISO-IR tables.
progress = True
while progress:
progress = False
unresolved_labels = [
label for label in ordered_spgrep_labels if label_map[label] == label
]
if not unresolved_labels:
break
unresolved_dims = sorted(
{int(label.split("(")[1][:-1]) for label in unresolved_labels},
reverse=True,
)
for irrep_dim in unresolved_dims:
group_generic = [
label for label in unresolved_labels
if int(label.split("(")[1][:-1]) == irrep_dim
]
group_physical = [
label for label in sorted(irt_character_map.keys())
if label not in used_labels and int(label.split("(")[1][:-1]) == irrep_dim
]
if len(group_generic) != len(group_physical) or not group_generic:
continue
if len(group_generic) > 6:
continue
solutions = []
for perm in itertools.permutations(group_physical):
for conjugation_bits in itertools.product([0, 1], repeat=len(perm)):
transformed = {}
for generic_label, irt_label, use_conjugate in zip(
group_generic, perm, conjugation_bits
):
transformed[generic_label] = (
np.conj(irt_character_map[irt_label])
if use_conjugate
else irt_character_map[irt_label]
)
is_consistent = True
# Compare quotients against already-resolved references.
for generic_label in group_generic:
for reference_label, reference_chars in matched_character_map.items():
spgrep_invariant = (
spgrep_character_map[generic_label]
* np.conj(spgrep_character_map[reference_label])
)
irt_invariant = (
transformed[generic_label]
* np.conj(reference_chars)
)
if not np.allclose(spgrep_invariant, irt_invariant, atol=1e-5):
is_consistent = False
break
if not is_consistent:
break
if not is_consistent:
continue
# Compare pairwise quotients inside the unresolved block.
for i, left_label in enumerate(group_generic):
for right_label in group_generic[i + 1:]:
spgrep_invariant = (
spgrep_character_map[left_label]
* np.conj(spgrep_character_map[right_label])
)
irt_invariant = (
transformed[left_label]
* np.conj(transformed[right_label])
)
if not np.allclose(spgrep_invariant, irt_invariant, atol=1e-5):
is_consistent = False
break
if not is_consistent:
break
if is_consistent:
solutions.append((perm, conjugation_bits, transformed))
if not solutions:
continue
solutions.sort(key=lambda item: (item[0], item[1]))
perm, _, transformed = solutions[0]
for generic_label, irt_label in zip(group_generic, perm):
label_map[generic_label] = irt_label
matched_character_map[generic_label] = transformed[generic_label]
used_labels.add(irt_label)
progress = True
progress = True
while progress:
progress = False
unresolved_labels = [
label for label in ordered_spgrep_labels if label_map[label] == label
]
if not unresolved_labels:
break
unresolved_dims = sorted(
{int(label.split("(")[1][:-1]) for label in unresolved_labels},
reverse=True,
)
for irrep_dim in unresolved_dims:
if irrep_dim != 1:
continue
group_generic = [
label for label in unresolved_labels
if int(label.split("(")[1][:-1]) == irrep_dim
]
group_physical = [
label for label in sorted(irt_character_map.keys())
if label not in used_labels and int(label.split("(")[1][:-1]) == irrep_dim
]
if len(group_generic) != len(group_physical) or not group_generic:
continue
assignment = resolve_spinor_phase_convention_block(
group_generic=group_generic,
group_physical=group_physical,
)
if assignment is None:
continue
for generic_label, (physical_label, transformed_chars) in assignment.items():
label_map[generic_label] = physical_label
matched_character_map[generic_label] = transformed_chars
used_labels.add(physical_label)
progress = True
kpoint_name = self.get_kpoint_name(k)
overrides = get_label_overrides(
space_group_number=self.spglib_dataset["number"],
kpoint_name=kpoint_name,
spinor=self.spinor,
)
if overrides:
label_map = {
generic_label: overrides.get(resolved_label, resolved_label)
for generic_label, resolved_label in label_map.items()
}
return label_map
[docs]
def get_target_element_positions(self, element: str) -> list[int]:
"""Indices of the atoms of ``element`` in the primitive cell.
Args:
element: Chemical symbol, e.g. ``"Sc"``.
Returns:
The atom indices in cell order (a contiguous block in the
standardized primitive cell).
Raises:
ValueError: The element is not in the cell.
"""
all_chemical_symbols = get_chemical_symbols(self.primitive_cell)
if not element in all_chemical_symbols:
raise ValueError(
f"Element \"{element}\" is not in the inputed cell."
)
target_element_positions = [i for i, symbol in enumerate(all_chemical_symbols) if symbol == element]
return target_element_positions
def _get_rotations_at_k(
self,
rotations: NDArray[np.int_],
translations: NDArray[np.float64],
k: list[float],
) -> tuple[NDArray[np.int_], NDArray[np.float64]]:
"""Get little-group operations using a stable integer-lattice test."""
k = canonicalize_kpoint(k)
rotations_at_k = []
translations_at_k = []
for rotation, translation in zip(rotations, translations):
diff = np.dot(k, rotation) - k
if (abs(diff - np.rint(diff)) < 1e-5).all():
rotations_at_k.append(rotation)
normalized_translation = np.array(translation, dtype=float).copy()
for i in range(3):
if abs(normalized_translation[i] - 1.0) < 1e-5:
normalized_translation[i] = 0.0
translations_at_k.append(normalized_translation)
return np.array(rotations_at_k), np.array(translations_at_k)
[docs]
def get_modified_permutation_rep(self,
r: NDArray[np.int_],
t: NDArray[np.float64],
k: list[float, float, float]
) -> NDArray[np.complex128]:
"""Bloch-phased permutation matrix of one operation at ``k``.
Args:
r: Integer rotation matrix in the primitive basis.
t: Its fractional translation.
k: Three primitive reciprocal coordinates.
Returns:
Complex array of shape ``(n_atoms, n_atoms)`` whose entry
``[j, i]`` is the Bloch phase
``exp(2 pi i k . (R^-1 (x_j - t) - x_j))`` when the operation
sends atom ``i`` onto atom ``j`` (modulo lattice translations),
and 0 elsewhere.
"""
pos = get_scaled_positions(self.primitive_cell)
num_atom = len(pos)
matrix = np.zeros((num_atom, num_atom), dtype=complex)
for i, p1 in enumerate(pos):
p_rot = np.dot(r, p1) + t # i -> j
for j, p2 in enumerate(pos):
diff = p_rot - p2 # Rx_i + t - x_j
if (abs(diff - np.rint(diff)) < 1e-5).all():
phase_factor = np.dot(
k, np.dot(np.linalg.inv(r), p2 - t) - p2
)
matrix[j, i] = np.exp(2j * np.pi * phase_factor)
return matrix
[docs]
def get_permutation_reps_at_k(self,
little_rotations,
little_translations,
k: list[float]
) -> tuple[NDArray[np.int_], NDArray[np.float64], NDArray[np.complex128]]:
"""Permutation matrices of the little-group operations at ``k``.
Args:
little_rotations: Rotations of the little group of ``k``, shape
``(order, 3, 3)``.
little_translations: Their fractional translations, shape
``(order, 3)``.
k: Three primitive reciprocal coordinates.
Returns:
Complex array of shape ``(order, n_atoms, n_atoms)``: one
:meth:`get_modified_permutation_rep` matrix per operation.
"""
permutation_matrices = []
for r, t in zip(little_rotations, little_translations):
permutation_matrix = self.get_modified_permutation_rep(r, t, k)
permutation_matrices.append(permutation_matrix)
permutation_matrices = np.array(permutation_matrices)
return permutation_matrices
[docs]
def get_little_group(self, k: list[float]):
"""Little group of ``k``: the operations that leave ``k`` invariant.
Args:
k: Three primitive reciprocal coordinates.
Returns:
``(little_rotations, little_translations)`` in the ISO-IR
operation order (spgrep's ``get_little_group`` without the index
mapping, which :meth:`irreducible_decomposition` returns).
"""
k = canonicalize_kpoint(k)
little_rotations, little_translations, mapping_little_group = get_little_group(
rotations=self.rotations,
translations=self.translations,
kpoint=k
)
return little_rotations, little_translations
[docs]
def get_little_group_symbol(self,
k: list[float]
) -> str:
"""Space-group-type symbol of the little group of ``k``.
Args:
k: Three primitive reciprocal coordinates.
Returns:
The short Hermann-Mauguin symbol and number spglib assigns to the
little-group operations, e.g. ``"Pm-3m (221)"`` at Gamma or
``"P4/mmm (123)"`` at X of a cubic perovskite.
"""
little_rotations, little_translations = self._get_rotations_at_k(self.rotations, self.translations, k)
site_sym_k = get_spacegroup_type_from_symmetry(little_rotations, little_translations)
if hasattr(site_sym_k, "international_short"):
international_short = site_sym_k.international_short
else:
international_short = site_sym_k["international_short"]
if hasattr(site_sym_k, "number"):
number = site_sym_k.number
else:
number = site_sym_k["number"]
return f"{international_short} ({number})"
[docs]
def get_atomic_orbital_characters(self,
rotations: NDArray[np.int_],
orbital: str
) -> NDArray[np.float64]:
"""Rotation characters of one atomic shell.
The character of a proper rotation by ``alpha`` on the ``2l+1``
orbitals of shell ``l`` is ``sin((l + 1/2) alpha) / sin(alpha / 2)``
(``2l+1`` for the identity); an improper operation multiplies it by
``(-1)^l``. With :attr:`spinor` the double-group factor
``2 cos(alpha / 2)`` (2 for the identity) is included.
Args:
rotations: Integer rotation matrices, shape ``(n, 3, 3)``.
orbital: Shell letter ``"s"``, ``"p"``, ``"d"``, ``"f"``,
``"g"``, ``"h"`` or ``"i"``.
Returns:
Real array of the ``n`` characters.
Raises:
ValueError: An unknown shell letter.
"""
# azimuthal number (l)
orbital_azimuthal_num = {"s" : 0, "p" : 1, "d" : 2, "f" : 3, "g" : 4, "h" : 5, "i" : 6}
if not orbital in orbital_azimuthal_num.keys():
raise ValueError(
f"Orbital \"{orbital}\" cannot be analyzed"
)
characters = []
l = orbital_azimuthal_num[orbital]
for r in rotations:
is_proper, rot_order = characterize_rotation(r)
alpha = 2 * np.pi / rot_order
if rot_order == 1:
character = 2 * l + 1
else:
character = np.sin((l + 1/2) * alpha) / np.sin(alpha / 2)
if not is_proper:
character = character * (-1) ** l
if self.spinor:
# spinor representation
if rot_order == 1:
character = character * 2
else:
character = character * 2 * np.cos(alpha / 2)
characters.append(character)
return np.array(characters)
[docs]
def get_permutation_characters(self,
element: str,
permutation_matrices: NDArray[np.complex128]
) -> NDArray[np.complex128]:
"""Permutation characters restricted to the atoms of one element.
Args:
element: Chemical symbol.
permutation_matrices: Output of :meth:`get_permutation_reps_at_k`.
Returns:
Complex array with the trace of every matrix over the element's
block of atoms.
"""
elemet_positions = self.get_target_element_positions(element)
range_strat, range_end = elemet_positions[0], elemet_positions[-1]+1
permutation_characters = []
for i in range(permutation_matrices.shape[0]):
rep = permutation_matrices[i][range_strat : range_end, range_strat : range_end]
character = np.trace(rep)
permutation_characters.append(character)
return np.array(permutation_characters, dtype=complex)
[docs]
def calc_reducible_characters(self,
k: list[float],
element: str,
orbital: str,
mapping_little_group: NDArray[np.int_],
) -> NDArray[np.complex128]:
"""Characters of the crystal-orbital representation at ``k``.
Args:
k: Three primitive reciprocal coordinates.
element: Chemical symbol.
orbital: Shell letter (``"s"`` to ``"i"``).
mapping_little_group: Indices into :attr:`rotations` of the
little-group operations.
Returns:
Complex array, one entry per little-group operation: the product
of the permutation character of the element's atoms
(:meth:`get_permutation_characters`) and the rotation character
of the shell (:meth:`get_atomic_orbital_characters`).
"""
k = canonicalize_kpoint(k)
little_rotations = self.rotations[mapping_little_group]
little_translations = self.translations[mapping_little_group]
permutation_matrices = self.get_permutation_reps_at_k(little_rotations, little_translations, k)
a_o_charac = self.get_atomic_orbital_characters(little_rotations, orbital)
perm_charac = self.get_permutation_characters(element, permutation_matrices)
reducible_characters = perm_charac * a_o_charac
return reducible_characters
[docs]
def irreducible_decomposition(self,
k: list[float, float, float],
element: str,
orbital: str
) -> tuple[NDArray[np.int_], object, dict[str, float], dict[str, str]]:
"""Decompose the crystal orbitals of one shell at ``k`` into irreps.
The calculation behind every line ``crystod`` prints: the irreps of
the little group of ``k`` from spgrep (double-valued with
:attr:`spinor`), the reducible characters from
:meth:`calc_reducible_characters`, and the multiplicities from the
character inner product.
Args:
k: Three primitive reciprocal coordinates.
element: Chemical symbol, e.g. ``"Sc"``.
orbital: Shell letter ``"s"`` to ``"i"``, e.g. ``"d"``.
Returns:
``(mapping_little_group, irreps, counts, labels)``:
``mapping_little_group`` are the indices into :attr:`rotations`
of the little-group operations, ``irreps`` the spgrep irrep
matrices, ``counts`` maps the generic key ``"irrep_i(dim)"`` of
every irrep to its multiplicity (rounded to two decimals), and
``labels`` maps the same keys to the ISO-IR labels
(:meth:`get_irrep_labels`).
Raises:
ValueError: An element that is not in the cell, or an unknown
shell letter.
"""
k = canonicalize_kpoint(k)
# get irreps
if self.spinor:
from spgrep.core import get_spacegroup_spinor_irreps_from_primitive_symmetry
irreps, little_spinor_factor_system, little_unitary_rotations, mapping_little_group = get_spacegroup_spinor_irreps_from_primitive_symmetry(
lattice=self.primitive_cell.cell,
rotations=self.rotations,
translations=self.translations,
kpoint=k
)
else:
from spgrep.core import get_spacegroup_irreps_from_primitive_symmetry
irreps, mapping_little_group = get_spacegroup_irreps_from_primitive_symmetry(
rotations=self.rotations,
translations=self.translations,
kpoint=k
)
reducible_characters = self.calc_reducible_characters(
k=k,
element=element,
orbital=orbital,
mapping_little_group=mapping_little_group,
)
# irreducible decomposition
result = {}
for i, irrep in enumerate(irreps):
irrep_characters = get_character(irrep)
num = np.dot(
reducible_characters, np.conj(irrep_characters)
) / irrep.shape[0]
result[f'irrep_{i+1}({irrep.shape[1]})'] = np.round(num.real, 2)
irrep_labels = self.get_irrep_labels(k, irreps, mapping_little_group)
return mapping_little_group, irreps, result, irrep_labels
def make_irrep_table(crystal_orbital, irreps, mapping_little_group, irrep_labels):
from pandas import DataFrame, set_option
set_option('display.max_columns', 50)
set_option('display.width', 1000)
little_r_symbol = [crystal_orbital.seitz_symbols[idx] for idx in mapping_little_group]
irrep_table = DataFrame(columns=little_r_symbol)
for i, irrep in enumerate(irreps):
generic_label = f"irrep_{i+1}({irrep.shape[1]})"
resolved_label = irrep_labels[generic_label]
row_label = generic_label if resolved_label == generic_label else f"{generic_label} = {resolved_label}"
irrep_table.loc[row_label] = list(np.round(get_character(irrep), 4))
#irrep_table.to_csv("irrep_table.csv")
irrep_table.to_excel("irrep_table.xlsx")
print(irrep_table)
def sort_irrep_items(items):
"""Sort irrep labels by index, then parity (+ before -), then dimension."""
def sort_key(item):
label, _ = item
return irrep_label_sort_key(label)
return sorted(items, key=sort_key)
def main(argv: Optional[list[str]] = None) -> None:
args = build_parser().parse_args(argv)
from .star_of_k import read_poscar_or_exit
cell = read_poscar_or_exit(args.poscar)
crystal_orbital = CrystalOrbital(cell=cell, symprec=args.tolerance, spior=args.spinor)
elemet_positions = crystal_orbital.get_target_element_positions(args.element)
range_strat, range_end = elemet_positions[0], elemet_positions[-1]+1
wyckoff_letters = crystal_orbital.spglib_dataset['wyckoffs'][range_strat:range_end]
site_symmetry_symbols = crystal_orbital.spglib_dataset['site_symmetry_symbols'][range_strat:range_end]
if args.kpoint is None:
print(f"\n * Space group *\n {crystal_orbital.spglib_dataset['international']} ({crystal_orbital.spglib_dataset['number']})\n")
print(f" * Element (number of atoms) *\n {args.element} ({len(wyckoff_letters)})\n")
print(f" * Wyckoff letters and site symmetry letters *")
print(f" {wyckoff_letters}\n {site_symmetry_symbols}\n")
print(f" * Atomic Orbital *\n {args.orbital}\n")
print(" * Crystal Orbitals *")
kpoint_names, kpoints = crystal_orbital.get_irt_special_points()
for kpoint_name, kpoint in zip(kpoint_names, kpoints):
try:
mapping_little_group, irreps, bandreps, irrep_labels = crystal_orbital.irreducible_decomposition(
k=kpoint,
element=args.element,
orbital=args.orbital,
)
except Exception as error:
print(f"ERROR at {kpoint_name} {kpoint}")
print(f"{error}\n")
continue
display_bandreps = {irrep_labels[key]: value for key, value in bandreps.items()}
sorted_bandreps = sort_irrep_items(
[(key, value) for key, value in display_bandreps.items() if value > 0]
)
irreps_result = "+".join([f" {value} [{key}] " for key, value in sorted_bandreps])
little_group = crystal_orbital.get_little_group_symbol(kpoint)
rounded_kpoint = format_kpoint(kpoint)
print(f" k point (primitive): {kpoint_name} {rounded_kpoint}")
print(f" little group of k : {little_group}")
print(f" irreps : {irreps_result}\n")
return
print(f"\n * Space group *\n {crystal_orbital.spglib_dataset['international']} ({crystal_orbital.spglib_dataset['number']})\n")
print(f" * Orbital (number of atoms) *\n {args.element}_{args.orbital} ({len(wyckoff_letters)})\n")
print(f" * Position *")
print(f" wyckoff letters : {wyckoff_letters}")
print(f" site symmetry letters: {site_symmetry_symbols}\n")
print(f" * Spinor *\n {args.spinor}\n")
if args.spinor:
print(" (Double-valued irrep labels are not contained in the bundled ISO-IR")
print(" tables; spinor irreps are shown with generic labels.)\n")
mapping_little_group, irreps, bandreps, irrep_labels = crystal_orbital.irreducible_decomposition(
k=args.kpoint,
element=args.element,
orbital=args.orbital,
)
display_bandreps = {irrep_labels[key]: value for key, value in bandreps.items()}
kpoint_name = crystal_orbital.get_kpoint_name(args.kpoint)
formatted_input_kpoint = format_kpoint(args.kpoint)
if kpoint_name is None:
print(f" * k point (primitive) * \n {formatted_input_kpoint}\n")
else:
print(f" * k point (primitive) * \n {kpoint_name} {formatted_input_kpoint}\n")
if args.table:
little_group = crystal_orbital.get_little_group_symbol(args.kpoint)
print(" * IrRep Table * ")
print(f" little group: {little_group}")
print(" table:")
make_irrep_table(crystal_orbital, irreps, mapping_little_group, irrep_labels)
print("")
print(" * Atomic Band Irreducible Representations *")
sorted_bandreps = sort_irrep_items(
[(key, value) for key, value in display_bandreps.items() if value > 0]
)
irreps_result = "+".join([f" {value} [{key}] " for key, value in sorted_bandreps])
print(irreps_result + "\n")
if __name__ == "__main__":
main()