"""
__author__ = "Hiroki Koiso, Yasuhide Mochizuki"
__copyright__ = "Copyright 2026, Mochizuki group"
__version__ = "1.0"
__maintainer__ = "Hiroki Koiso, Yasuhide Mochizuki"
__email__ = "mochizuki@rs.tus.ac.jp"
__status__ = "Development"
__released_date__ = "November 2, 2024"
__last_update__= "June 29, 2026"
"""
from __future__ import annotations
from argparse import (
ArgumentDefaultsHelpFormatter,
ArgumentParser,
RawDescriptionHelpFormatter,
RawTextHelpFormatter,
)
import numpy as np
import warnings
from numpy.typing import NDArray
from .spglib_compat import ensure_spglib_compat
ensure_spglib_compat()
from phonopy import load
from phonopy.structure.cells import get_primitive_matrix_by_centring
from .irreptables_compat import load_irreptables
from .operations import snap_qpoint
from .runtime_compat import get_symmetry_dataset
IrrepTable, Irrep = load_irreptables()
class MyHelpFormatter(
RawTextHelpFormatter,
RawDescriptionHelpFormatter,
ArgumentDefaultsHelpFormatter,
):
pass
desc = """
This program identify the ISO-IR (ISOTROPY, Miller-Love) labels for the phonon irreducible representations.
POSCAR and FORCE_STES must exist in the directory where this code runs.
# Command Example:
python3 phonon_irreps.py --dim "2 2 2"
"""
def build_parser() -> ArgumentParser:
parser = ArgumentParser(description=desc, formatter_class=MyHelpFormatter)
parser.add_argument(
"--dim",
"-dim",
dest="dim",
required=True,
type=str,
help="Supercell dimension.",
)
parser.add_argument(
"--poscar",
"-poscar",
dest="poscar",
type=str,
default="POSCAR",
help="POSCAR.",
)
parser.add_argument(
"--readfc",
"-readfc",
dest="readfc",
action="store_true",
help="Read FORCE_CONSTANS.",
)
parser.add_argument(
"--tolerance",
"-tol",
dest="tol",
type=float,
default=1e-3,
help="Degeneracy tolerance.",
)
parser.add_argument(
"--all-irreps",
dest="all_irreps",
action="store_true",
help="Additionally label the phonon irreps at the midpoints of the\n"
"seekpath k-path segments (the symmetry lines DT, Z, SM, ...;\n"
"ISO-IR labels). Slower than the default special-points-only survey.",
)
return parser
def format_qpoint(q, decimals: int = 6) -> list[float]:
"""Format a q-point for yaml output using plain Python floats."""
return [float(np.round(float(value), decimals)) for value in q]
[docs]
def get_irt_special_points(irt_table, prim_mat) -> tuple[list[str], list[list[float]]]:
"""Unique special q points of the ISO-IR tables, in the primitive basis.
The ISO-IR tables list their irreps per special k point in the
conventional reciprocal basis; this collects the distinct points, converts
them to the primitive basis of the phonopy object, and returns them in
table order. ``crystod-phonon --irreps`` surveys exactly these points.
Coordinates are snapped to exact fractions (1/3 stays 1/3, not 0.333333):
decimal-rounded values break the little-group detection and the ISO-IR
table lookups downstream.
Args:
irt_table: ISO-IR irrep table of the space group, i.e.
``IrrepTable(number, spinor=False)`` with ``IrrepTable`` from
``crystod.irreptables_compat.load_irreptables()``.
prim_mat: Conventional-to-primitive matrix of the centring, e.g.
``phonopy.structure.cells.get_primitive_matrix_by_centring("P")``.
Returns:
``(q_names, q_list)``: the k-point labels (``"GM"``, ``"R"``, ...) and
their fractional coordinates in the primitive reciprocal basis, in the
order of the tables (Gamma first).
Example:
>>> from phonopy.structure.cells import get_primitive_matrix_by_centring
>>> from crystod import phonon
>>> from crystod.irreptables_compat import load_irreptables
>>> IrrepTable, _ = load_irreptables()
>>> table = IrrepTable(221, spinor=False) # Pm-3m
>>> prim_mat = get_primitive_matrix_by_centring("P")
>>> names, points = phonon.get_irt_special_points(table, prim_mat)
>>> names
['GM', 'R', 'X', 'M']
>>> points[1]
[0.5, 0.5, 0.5]
"""
q_list = []
q_names = []
for irrep in irt_table.irreps:
q_primitive = snap_qpoint(np.dot(irrep.k, prim_mat))
if q_primitive not in q_list:
q_list.append(q_primitive)
q_names.append(irrep.kpname)
return q_names, q_list
[docs]
def find_star_representative(
qpoint: list[float] | NDArray[np.float64],
rotations: NDArray[np.int_],
q_names: list[str],
q_list: list[list[float]],
) -> tuple[str, list[float]] | None:
"""Map a q point onto the tabulated arm of its star.
The ISO-IR tables list only one representative arm per special point
(e.g. only (1/2, 1/2, 0) for the three M arms of Pm-3m), so a direct
coordinate lookup fails for the other arms. Some space-group rotation R
sends q onto a tabulated point when ``q @ R`` equals it modulo
reciprocal-lattice translations; the spectra of star arms coincide band by
band, so the labels read at the representative apply to the modes at q.
This is how ``crystod-phonon --irreps``/``--vector`` and
:func:`label_phonon_modes` label a non-representative arm.
Args:
qpoint: Fractional coordinates of q in the primitive reciprocal basis.
rotations: Rotation parts of the space-group operations, shape
``(n_ops, 3, 3)``, in the same (primitive) basis as ``qpoint`` and
the tabulated points.
q_names: Labels of the tabulated special points.
q_list: Their coordinates, as returned by
:func:`get_irt_special_points`.
Returns:
``(label, representative_q)`` for the first tabulated point some
rotation maps q onto, or ``None`` when q lies in no tabulated star.
Example:
>>> from crystod import phonon
>>> from crystod.runtime_compat import get_symmetry_dataset
>>> names = ["GM", "R", "X", "M"]
>>> points = [[0, 0, 0], [0.5, 0.5, 0.5], [0, 0.5, 0], [0.5, 0.5, 0]]
>>> # ph: a phonopy.Phonopy object of cubic SrTiO3 (Pm-3m)
>>> rotations = get_symmetry_dataset(ph.primitive_symmetry)["rotations"]
>>> phonon.find_star_representative([0.5, 0, 0], rotations, names, points)
('X', [0, 0.5, 0])
"""
qpoint = np.asarray(qpoint, dtype=float)
for name, q_special in zip(q_names, q_list):
target = np.asarray(q_special, dtype=float)
for rotation in rotations:
diff = qpoint @ rotation - target
if (np.abs(diff - np.rint(diff)) < 1e-8).all():
return name, list(q_special)
return None
def get_irt_irreps_at_q(
q: list[float], irt_table, prim_mat, warn: bool = True
) -> list[Irrep]:
"""Get irreps at the q-point from the ISO-IR tables."""
irreps_at_q = []
prim_inv = np.linalg.inv(prim_mat)
conventional_q = np.array(q) @ prim_inv
for irrep_at_q in irt_table.irreps:
if np.allclose(irrep_at_q.k, conventional_q):
irreps_at_q.append(irrep_at_q)
if not irreps_at_q and warn:
warnings.warn(f"No irreps at {q} in the ISO-IR tables!", stacklevel=2)
return irreps_at_q
def get_mapping_to_irt(
irt_little_r: NDArray[np.int_],
found_little_r: NDArray[np.int_],
prim_mat,
) -> list[int]:
"""Get mapping from phonopy little-group rotations to the ISO-IR table order."""
conv_little_r = prim_mat @ found_little_r @ np.linalg.inv(prim_mat)
mapping_to_irt = []
for irt_r in irt_little_r:
for i, r in enumerate(conv_little_r):
if np.allclose(irt_r, r):
mapping_to_irt.append(i)
break
return mapping_to_irt
def _get_isoir_band_labels(
q: list[float],
phonon,
phonon_irreps,
) -> list[list[str] | None] | None:
"""ISO-IR (Miller-Love) labels per degenerate band set, or None.
Fallback for q points absent from the special-point irrep table; the
phonopy band-set characters are decomposed against the ISO-IR small
irreps (they can be reducible under accidental degeneracy).
"""
from .isoir import get_isoir_band_decompositions
primitive = phonon.primitive
cell = (primitive.cell, primitive.scaled_positions, primitive.numbers)
dataset = get_symmetry_dataset(phonon.primitive_symmetry)
rotations = getattr(phonon_irreps, "_rotations_at_q")
translations = getattr(phonon_irreps, "_translations_at_q")
decompositions = get_isoir_band_decompositions(
dataset["number"],
cell,
phonon.primitive_symmetry.tolerance,
q,
rotations,
translations,
list(phonon_irreps.characters),
)
labels: list[list[str] | None] = [
None if decomposed is None
else [f"{label}({dim})" for label, _, dim in decomposed[0]]
for decomposed in decompositions
]
return labels if any(label is not None for label in labels) else None
[docs]
def get_irrep_labels(
q: list[float],
phonon,
irt_table,
prim_mat,
degeneracy_tolerance: float,
) -> tuple[list[list[str] | None], list[list[int]], NDArray[np.float64]]:
"""Irrep labels, band indices, and frequencies of the phonon modes at q.
The labeling step of ``crystod-phonon --irreps``: phonopy's character
analysis (``Phonopy.set_irreps``) groups the bands at q into degenerate
sets and computes their characters, and each set is matched against the
ISO-IR small irreps of q by character overlap (a set is labeled when the
overlap exceeds 0.9). A q point outside the special-point table (a
symmetry line or plane, a generic q) is decomposed against the full ISO-IR
(ISOTROPY) tables instead, with Miller-Love labels. For a
non-representative arm of a star, map q onto the tabulated arm with
:func:`find_star_representative` first; :func:`label_phonon_modes` does
both steps in one call.
Args:
q: Fractional coordinates of q in the primitive reciprocal basis, as
tabulated (the representative arm of its star).
phonon: A ``phonopy.Phonopy`` object with force constants, built with
``primitive_matrix="auto"``.
irt_table: ISO-IR irrep table of the space group (see
:func:`get_irt_special_points`).
prim_mat: Conventional-to-primitive matrix of the centring.
degeneracy_tolerance: Frequency tolerance (THz) within which bands
count as degenerate; ``--tolerance`` of ``crystod-phonon --irreps``
(default 1e-3).
Returns:
``(labels, band_indices, frequencies)``: one entry of ``labels`` per
degenerate set, each a list of ``"R4+(3)"``-style labels (the irrep
name with its dimension) or ``None`` when no tabulated irrep matched;
``band_indices`` the 0-based band indices of each set; and
``frequencies`` the THz frequencies of all bands at q.
Raises:
ValueError: If the q point can be labeled from neither table.
Example:
>>> from phonopy.structure.cells import get_primitive_matrix_by_centring
>>> from crystod import phonon
>>> from crystod.irreptables_compat import load_irreptables
>>> IrrepTable, _ = load_irreptables()
>>> table = IrrepTable(221, spinor=False) # ph: cubic SrTiO3
>>> prim_mat = get_primitive_matrix_by_centring("P")
>>> labels, bands, freqs = phonon.get_irrep_labels(
... [0.5, 0.5, 0.5], ph, table, prim_mat, 1e-3)
>>> labels[0], bands[0], round(float(freqs[0]), 4)
(['R5-(3)'], [0, 1, 2], -1.0867)
"""
phonon.set_irreps(q=np.array(q), degeneracy_tolerance=degeneracy_tolerance)
phonon_irreps = phonon.irreps
irt_irreps = get_irt_irreps_at_q(np.array(q), irt_table, prim_mat, warn=False)
if not irt_irreps:
# Not in the special-point table (e.g. a symmetry line/plane or
# generic q): decompose against the full ISO-IR (ISOTROPY) tables,
# which cover every
# k-vector type. Labels then follow the Miller-Love convention.
isoir_labels = _get_isoir_band_labels(q, phonon, phonon_irreps)
if isoir_labels is not None:
band_indices = phonon_irreps.band_indices
frequencies = getattr(phonon_irreps, "frequencies", None)
if frequencies is None:
frequencies = getattr(phonon_irreps, "_freqs")
return isoir_labels, band_indices, frequencies
# only warn when the ISO-IR fallback could not label the q point
warnings.warn(f"No irreps at {q} in the ISO-IR tables!", stacklevel=2)
raise ValueError(f"no irrep labels available at {q}")
irt_little_r = [irt_table.symmetries[i - 1].R for i in irt_irreps[0].characters.keys()]
phonon_little_r = getattr(phonon_irreps, "_rotations_at_q")
mapping_to_irt = get_mapping_to_irt(irt_little_r, phonon_little_r, prim_mat)
band_indices = phonon_irreps.band_indices
frequencies = getattr(phonon_irreps, "frequencies", None)
if frequencies is None:
# phonopy >= 2.21 dropped the public property; fall back to the internal array.
frequencies = getattr(phonon_irreps, "_freqs")
phonon_irreps_characters = phonon_irreps.characters
labels: list[list[str] | None] = []
for phonon_irrep_charac in phonon_irreps_characters:
found = False
label = []
for irt_irrep in irt_irreps:
irt_irrep_character = np.array(list(irt_irrep.characters.values()))
overlap = np.dot(
phonon_irrep_charac[mapping_to_irt],
np.conjugate(irt_irrep_character),
) / irt_irrep.nsym
if overlap > 0.9:
label.append(f"{irt_irrep.name}({irt_irrep.dim})")
found = True
labels.append(label if found else None)
assert len(labels) == len(band_indices)
return labels, band_indices, frequencies
def _seekpath_path_midpoints(phonon) -> tuple[str | None, list[tuple[str, str, list[float]]]]:
"""Midpoints of the seekpath k-path segments, labeled via ISO-IR.
For every segment of the automatic seekpath k-path (e.g. GM-X of Pm-3m)
the midpoint of the two endpoint coordinates is computed (a point on the
connecting symmetry line, e.g. DT (0, 1/4, 0)) and labeled with its
ISO-IR k-vector-type letter. Returns (path string, [(label, segment,
midpoint), ...]); midpoints are skipped with a warning when the seekpath
primitive cell does not match the phonopy primitive cell.
"""
import seekpath
primitive = phonon.primitive
cell = (primitive.cell, primitive.scaled_positions, primitive.numbers)
path_data = seekpath.get_path(cell, symprec=1e-5)
if not np.allclose(primitive.cell, path_data["primitive_lattice"], atol=1e-4):
warnings.warn(
"The seekpath primitive cell does not match the phonopy primitive "
"cell; k-path midpoints are skipped.",
stacklevel=2,
)
return None, []
def display(name: str) -> str:
return "GM" if name == "GAMMA" else name
coords = path_data["point_coords"]
segments = path_data["path"]
# compress consecutive segments into a path string like GM-X-M-GM-R-X | R-M
parts: list[list[str]] = []
for start, end in segments:
if parts and parts[-1][-1] == start:
parts[-1].append(end)
else:
parts.append([start, end])
path_string = " | ".join("-".join(display(n) for n in part) for part in parts)
dataset = get_symmetry_dataset(phonon.primitive_symmetry)
from .isoir import get_isoir_kpoint_name
midpoints: list[tuple[str, str, list[float]]] = []
seen: set[tuple[float, ...]] = set()
for start, end in segments:
midpoint = snap_qpoint(
(np.asarray(coords[start], dtype=float) + np.asarray(coords[end], dtype=float)) / 2.0
)
key = tuple(np.round(midpoint, 8))
if key in seen:
continue
seen.add(key)
label = get_isoir_kpoint_name(
dataset["number"], cell, phonon.primitive_symmetry.tolerance, midpoint
)
if label is None:
label = "q" + "".join(f"_{value:g}" for value in midpoint)
midpoints.append((label, f"{display(start)}-{display(end)}", list(midpoint)))
return path_string, midpoints
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
supercell_mat = [float(n) for n in args.dim.split()]
if args.readfc:
force_stes = None
force_constans = "./FORCE_CONSTANTS"
else:
force_stes = "./FORCE_SETS"
force_constans = None
phonon = load(
supercell_matrix=supercell_mat,
primitive_matrix="auto",
unitcell_filename=args.poscar,
force_sets_filename=force_stes,
force_constants_filename=force_constans,
)
dataset = get_symmetry_dataset(phonon.symmetry)
irt_table = IrrepTable(dataset["number"], spinor=False)
prim_mat = get_primitive_matrix_by_centring(dataset["international"][0])
q_names, q_list = get_irt_special_points(irt_table, prim_mat)
path_string, path_midpoints = None, []
if args.all_irreps:
try:
path_string, path_midpoints = _seekpath_path_midpoints(phonon)
except Exception:
pass
yaml_name = "phonon_irreps_all.yaml" if args.all_irreps else "phonon_irreps.yaml"
with open(yaml_name, "w") as fp:
fp.write(f"space_group: {dataset['international']}\n")
fp.write("special_points:\n")
for qname, q in zip(q_names, q_list):
fp.write(f"- # {qname}\n")
fp.write(f" q_position: {format_qpoint(q)}\n")
fp.write("\n")
if path_midpoints:
fp.write(f"k_path: {path_string} # seekpath\n")
fp.write("path_midpoints: # midpoints of the k-path segments, ISO-IR k-vector types\n")
for label, segment, midpoint in path_midpoints:
fp.write(f"- # {label} (midpoint of {segment})\n")
fp.write(f" q_position: {format_qpoint(midpoint)}\n")
fp.write("\n")
fp.write("irreps:\n")
for qname, q in zip(q_names, q_list):
fp.write(f"- q_label: {qname}\n")
fp.write(f" q_position: {format_qpoint(q)}\n")
labels, band_indices, freqs = get_irrep_labels(
q=q,
phonon=phonon,
irt_table=irt_table,
prim_mat=prim_mat,
degeneracy_tolerance=args.tol,
)
for i, index in enumerate(band_indices):
fp.write(f" - # {' '.join([str(idx + 1) for idx in index])}\n")
fp.write(f" irrep_label: {labels[i]}\n")
fp.write(f" frequency: %14.10f\n" % (freqs[index[0]]))
fp.write("\n")
for label, segment, midpoint in path_midpoints:
fp.write(f"- q_label: {label}\n")
fp.write(f" segment: {segment}\n")
fp.write(f" q_position: {format_qpoint(midpoint)}\n")
try:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="No irreps at")
labels_mid, band_indices_mid, freqs_mid = get_irrep_labels(
q=midpoint,
phonon=phonon,
irt_table=irt_table,
prim_mat=prim_mat,
degeneracy_tolerance=args.tol,
)
except Exception:
fp.write(" # irrep labeling failed at this q point\n\n")
continue
for i, index in enumerate(band_indices_mid):
fp.write(f" - # {' '.join([str(idx + 1) for idx in index])}\n")
fp.write(f" irrep_label: {labels_mid[i]}\n")
fp.write(f" frequency: %14.10f\n" % (freqs_mid[index[0]]))
fp.write("\n")
print(f"Phonon irreps written to: {yaml_name}")
if __name__ == "__main__":
main()