Source code for crystod.phonon_subgroups

"""Isotropy subgroups from phonon modes (Python API).

This module is the programmatic bridge between phonon irrep labeling
(``phonon_irreps``) and isotropy-subgroup enumeration
(``isotropy_subgroup``), designed for downstream packages that follow
imaginary phonon modes to lower-symmetry structures (e.g. the
``macer phonopy tree`` structure search):

    from crystod.phonon import scan_imaginary_modes
    results = scan_imaginary_modes(phonon)         # a phonopy.Phonopy object
    for result in results:
        print(result.mode.frequency, result.mode.labels)
        for sub in result.subgroups:
            print(sub.irrep, sub.direction, sub.number, sub.symbol)

The phonopy object should be built with ``primitive_matrix="auto"`` so
that instabilities appear at their true q points of the primitive cell
(e.g. R of a cubic perovskite) instead of being folded onto the supercell
Gamma point, where mode labeling is not possible.

Four layers are provided:

- ``isotropy_subgroups(space_group, irrep)`` -- pure group theory, no
  phonopy object needed: enumerate the isotropy subgroups of a
  space-group irrep (the API form of ``crystod-group --supergroup``).
- ``label_phonon_modes(phonon, qpoint)`` -- label the phonon modes of a
  live phonopy object at one q point with ISO-IR irrep labels,
  encapsulating the table/star-arm boilerplate of ``phonon_irreps``.
- ``imaginary_mode_subgroups(phonon, qpoint)`` -- combine the two: for
  every imaginary (negative-frequency) degenerate level at q, return the
  irrep label and all isotropy subgroups with their order-parameter
  directions, conventional bases, and origins.
- ``scan_imaginary_modes(phonon)`` -- run ``imaginary_mode_subgroups``
  over every q point commensurate with the phonon supercell (the q set a
  supercell calculation can resolve), deduplicating star arms.

All band/mode indices in this module are 1-based, following the CrystOD
convention used everywhere else in the package and its outputs.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field

import numpy as np

_IRREP_DIM_SUFFIX = re.compile(r"\((\d+)\)$")


# ---------------------------------------------------------------------------
# isotropy subgroups of a space-group irrep (no phonopy needed)
# ---------------------------------------------------------------------------

[docs] @dataclass(frozen=True) class IsotropySubgroup: """One isotropy subgroup of a space-group irrep. One row of the ``crystod-group --supergroup`` table, as returned by :func:`isotropy_subgroups` and carried by :class:`crystod.phonon.ImaginaryModeResult`. Attributes: irrep: ISO-IR label of the irrep, e.g. ``"R4+"`` (``"X3-+X2+"`` for a coupled order parameter). direction: Order-parameter direction, e.g. ``"(a,0,0)"``; the components are grouped arm by arm (``","`` inside an arm, ``";"`` between arms). label: Full label, e.g. ``"R4+(a,0,0)"``. number: Space-group number of the subgroup. symbol: International short symbol of the subgroup, e.g. ``"I4/mcm"``. size: Primitive-cell multiplication of the subgroup relative to the parent. index: Index of the subgroup in the parent, [G:H]. n_free: Number of free order-parameter components. basis: Rows of the conventional cell of the subgroup in parent conventional units, exactly as printed by ``crystod-group --supergroup --order-parameter``; ``None`` when the setting could not be standardized or ``with_settings=False`` was passed. origin: Origin of that cell in parent conventional coordinates, or ``None`` likewise. """ irrep: str # e.g. "R4+" ("X3-+X2+" for coupled input) direction: str # order-parameter direction, e.g. "(a,0,0)" label: str # full label, e.g. "R4+(a,0,0)" number: int # space-group number of the subgroup symbol: str # international short symbol, e.g. "I4/mcm" size: int # primitive-cell multiplication vs the parent index: int # index of the subgroup in the parent n_free: int # number of free order-parameter components basis: np.ndarray | None = field(default=None, compare=False) origin: np.ndarray | None = field(default=None, compare=False) def __str__(self) -> str: return f"{self.label} -> {self.symbol} (No. {self.number}), size {self.size}, index {self.index}"
def _subgroup_from_members(analyzer, irrep_name, label, direction, n_free, members, with_settings): info, size, index, B, rotations, translations, lattice = ( analyzer.subgroup_of(members) ) basis = origin = None if with_settings: try: setting = analyzer.conventional_setting( B, rotations, translations, lattice, info ) except Exception: setting = None if setting is not None: basis, origin = setting return IsotropySubgroup( irrep=irrep_name, direction=direction, label=label, number=int(info.number), symbol=str(info.international_short), size=int(size), index=int(index), n_free=int(n_free), basis=basis, origin=origin, )
[docs] def isotropy_subgroups( space_group, irrep, order_parameter=None, *, with_settings: bool = True, ): """Isotropy subgroups of a space-group irrep, as data. Programmatic counterpart of ``crystod-group --supergroup``: the order-parameter directions of the irrep (or of the coupled order parameter of several irreps) are enumerated with :class:`crystod.group.IsotropyAnalyzer`, and each direction is returned with the space group it condenses into. No phonopy object is needed; the function is also exported as ``crystod.group.isotropy_subgroups``. Args: space_group (str | int): International short symbol (e.g. ``"Pm-3m"``) or number (e.g. ``221``) of the parent. irrep (str | list[str]): ISO-IR irrep label (e.g. ``"R4+"``); a list of labels enumerates the subgroups of the coupled order parameter. order_parameter (list[str] | None): If given (e.g. ``["a", "0", "0"]``), resolve only this direction and return a single-element list. Components are plain numbers or parameter names; composite entries of the enumerated table such as ``"0.282a"`` are rejected. with_settings: Also compute the conventional ``basis``/``origin`` of each subgroup in the parent convention (slightly slower; on by default). Returns: List of :class:`IsotropySubgroup`, sorted like the ``--supergroup`` table (free components, index, subgroup number). Raises: ValueError: For an unknown space group, an irrep that is not tabulated for it (the labels of symmetry lines and planes, e.g. ``DT5``, have no isotropy subgroups in the tables), or an invalid order parameter. Example: >>> from crystod import phonon >>> for sub in phonon.isotropy_subgroups("Pm-3m", "R4+"): ... print(sub) R4+(0,0,a) -> I4/mcm (No. 140), size 2, index 6 R4+(a,a,a) -> R-3c (No. 167), size 2, index 8 R4+(0,a,a) -> Imma (No. 74), size 2, index 12 R4+(0,a,b) -> C2/m (No. 12), size 2, index 24 R4+(a,a,b) -> C2/c (No. 15), size 2, index 24 R4+(a,b,c) -> P-1 (No. 2), size 2, index 48 >>> sub = phonon.isotropy_subgroups(221, "R4+", ["0", "0", "a"])[0] >>> sub.symbol, sub.basis.tolist() ('I4/mcm', [[-1.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 2.0, 0.0]]) """ # The implementation modules report bad input the way a command line # wants it -- by raising SystemExit -- which would tear down a program # that merely called this function. Translate it into a normal exception. try: return _isotropy_subgroups( space_group, irrep, order_parameter, with_settings=with_settings ) except SystemExit as exc: message = " ".join(str(exc).split()) raise ValueError(message.removeprefix("ERROR: ") or "invalid input") from None
def _isotropy_subgroups(space_group, irrep, order_parameter, *, with_settings): from .isotropy_subgroup import ( CoupledRepresentation, IsotropyAnalyzer, _projector, ) if isinstance(space_group, (int, np.integer)): space_group = str(int(space_group)) if not irrep or (not isinstance(irrep, str) and not list(irrep)): raise ValueError("at least one irrep label is required") analyzer = IsotropyAnalyzer(space_group, irrep) representation = analyzer.representation coupled = isinstance(representation, CoupledRepresentation) irrep_name = representation.name if coupled else representation.label if order_parameter is not None: components = [str(c) for c in order_parameter] _reject_composite_components(components) eta = analyzer.resolve_direction(components) members = [ (i, t) for i, t, matrix in analyzer.elements if np.allclose(matrix @ eta, eta, atol=1e-6) ] # same direction string as crystod-group --supergroup: components are # grouped arm by arm ("," inside an arm, ";" between arms) if coupled: chunks, start = [], 0 for part in representation.parts: piece = components[start: start + part.dimension] chunks.append(f"{part.label}({_arm_join(piece, part.arm_chunks)})") start += part.dimension direction = " ".join(chunks) label = direction else: direction = "(" + _arm_join(components, representation.arm_chunks) + ")" label = f"{irrep_name}{direction}" # free parameters are the distinct symbols the direction actually # carries, read exactly as resolve_direction reads them n_free = len({ symbol for symbol in map(_free_symbol, components) if symbol }) return [ _subgroup_from_members( analyzer, irrep_name, label, direction, n_free, members, with_settings, ) ] results = [] for projector, members in analyzer.enumerate_directions(): label, generic = analyzer.direction_label(projector) direction = label if not coupled: label = representation.label + label else: # skip the single-irrep strata of a coupled representation, # exactly as crystod-group --supergroup does 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) ] n_free = _orth_rank(projector) results.append( _subgroup_from_members( analyzer, irrep_name, label, direction, n_free, exact_members, with_settings, ) ) results.sort(key=lambda s: (s.n_free, s.index, s.number)) return results def _arm_join(components, arm_chunks) -> str: """Group order-parameter components arm by arm, as the tables print them.""" arms, start = [], 0 for size in arm_chunks: arms.append(",".join(components[start: start + size])) start += size return ";".join(arms) def _reject_composite_components(components) -> None: """Refuse order-parameter tokens the direction resolver cannot honour. ``resolve_direction`` understands a number or a bare parameter name, and silently treats anything else -- ``0.282a``, ``0.888a+0.46b`` -- as a new independent parameter, which resolves to a *different*, more generic direction and therefore a different (wrong) subgroup. Those composite directions do occur in the enumerated tables of hexagonal and trigonal irreps; they can be read from :func:`isotropy_subgroups` but not fed back into it, so they are rejected here instead of quietly giving a wrong answer. """ from fractions import Fraction for component in components: token = str(component).strip() if token.startswith("-"): token = token[1:] if token in ("", "0", "0.0") or (len(token) == 1 and token.isalpha()): continue try: Fraction(token) except ValueError: raise SystemExit( f"ERROR: order-parameter component {component!r} is not a plain " "number or parameter name. Composite directions such as " "'0.282a' appear in the enumerated table but cannot be resolved " "from components; take that entry from isotropy_subgroups() " "without --order-parameter instead." ) from None def _free_symbol(token: str) -> str | None: """The free-parameter name a component carries, or None if it is fixed. Mirrors ``IsotropyAnalyzer.resolve_direction``: one leading minus sign belongs to the amplitude rather than the name, empty and zero components are fixed, and anything ``Fraction`` accepts is a number. """ from fractions import Fraction token = str(token).strip() if token.startswith("-"): token = token[1:] if token in ("", "0", "0.0"): return None try: Fraction(token) return None except ValueError: return token def _orth_rank(projector: np.ndarray) -> int: return int(round(float(np.real(np.trace(projector))))) # --------------------------------------------------------------------------- # phonon mode labeling from a phonopy object # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class PhononMode: """One degenerate phonon level at a q point. Returned by :func:`label_phonon_modes`; ``crystod-phonon --subgroup`` prints one such level per imaginary mode. ``str(mode)`` gives the one-line form ``"modes 1,2,3: -1.0867 THz R5-"``. Attributes: band_indices: 1-based band indices of the level (CrystOD convention). frequency: Frequency in THz; negative means imaginary. labels: ISO-IR irrep label(s) of the level, without the dimension suffix that ``phonon_irreps.yaml`` appends (``"R4+"``, not ``"R4+(3)"``); empty when the level could not be labeled. qpoint: The q point that was asked for, in fractional coordinates of the primitive reciprocal basis. qpoint_label: Tabulated name of its star (e.g. ``"R"``), or ``None`` for a q point outside every tabulated star. representative_q: The tabulated arm of the star at which the labels were read (equal to ``qpoint`` when that is the tabulated arm). """ band_indices: tuple[int, ...] frequency: float # THz; negative = imaginary labels: tuple[str, ...] qpoint: tuple[float, float, float] qpoint_label: str | None # tabulated name of the star (e.g. "R"), if any representative_q: tuple[float, float, float] @property def degeneracy(self) -> int: """Number of bands in the level.""" return len(self.band_indices) @property def is_imaginary(self) -> bool: """``True`` when the frequency is negative (no threshold applied).""" return self.frequency < 0.0 def __str__(self) -> str: bands = ",".join(str(b) for b in self.band_indices) label = "+".join(self.labels) if self.labels else "?" return f"modes {bands}: {self.frequency:.4f} THz {label}"
def _strip_dim_suffix(label: str) -> str: return _IRREP_DIM_SUFFIX.sub("", label)
[docs] def label_phonon_modes( phonon, qpoint=(0.0, 0.0, 0.0), *, degeneracy_tolerance: float = 1e-4, ): """ISO-IR irrep labels of the phonon modes of ``phonon`` at ``qpoint``. The API form of ``crystod-phonon --irreps`` for one q point: the ISO-IR table of the space group is loaded, q is mapped onto the tabulated arm of its star with :func:`find_star_representative` (the spectra of star arms coincide band by band), and the degenerate levels are labeled with :func:`get_irrep_labels`. The levels come back as :class:`PhononMode` records with 1-based band indices. Args: phonon: A live ``phonopy.Phonopy`` object with force constants available (e.g. from ``phonopy.load``), built with ``primitive_matrix="auto"`` so that a zone-boundary instability appears at its own q point instead of being folded onto the supercell Gamma point, where it cannot be labeled. qpoint: Fractional coordinates of q in the primitive reciprocal basis; any arm of a star is accepted. degeneracy_tolerance: Frequency tolerance (THz) within which bands count as degenerate. Returns: List of :class:`PhononMode`, one per degenerate level, ordered by band index. Raises: RuntimeError: If the space-group tables cannot label this q point at all. Example: >>> import phonopy >>> from crystod import phonon >>> from crystod.examples import example_path >>> ph = phonopy.load( ... unitcell_filename=example_path("221_PPOSCAR_SrTiO3"), ... force_sets_filename=example_path("FORCE_SETS_SrTiO3"), ... supercell_matrix=[4, 4, 4], primitive_matrix="auto") >>> for mode in phonon.label_phonon_modes(ph, [0.5, 0.5, 0.5]): ... print(mode) modes 1,2,3: -1.0867 THz R5- modes 4,5,6: 3.9891 THz R4- modes 7,8,9: 11.6887 THz R5+ modes 10,11,12: 12.6621 THz R4- modes 13,14: 14.8133 THz R3- modes 15: 23.2302 THz R2- """ from phonopy.structure.cells import get_primitive_matrix_by_centring from .irreptables_compat import load_irreptables from .phonon_irreps import ( find_star_representative, get_irrep_labels, get_irt_special_points, ) from .runtime_compat import get_symmetry_dataset IrrepTable, _ = load_irreptables() qpoint = tuple(float(x) for x in qpoint) dataset = get_symmetry_dataset(phonon.symmetry) try: irt_table = IrrepTable(dataset["number"], spinor=False) prim_mat = get_primitive_matrix_by_centring(dataset["international"][0]) label_q = list(qpoint) qpoint_label = None q_names, q_list = get_irt_special_points(irt_table, prim_mat) rotations = get_symmetry_dataset(phonon.primitive_symmetry)["rotations"] representative = find_star_representative( qpoint, rotations, q_names, q_list ) if representative is not None: qpoint_label, label_q = representative labels, band_indices, frequencies = get_irrep_labels( q=label_q, phonon=phonon, irt_table=irt_table, prim_mat=prim_mat, degeneracy_tolerance=degeneracy_tolerance, ) except (Exception, SystemExit) as exc: # SystemExit: the table machinery reports some failures the way a # command line wants them; a library caller gets an exception reason = " ".join(str(exc).split()) or type(exc).__name__ raise RuntimeError( f"could not label the phonon modes at q = {list(qpoint)}: {reason}" ) from None modes = [] for label, indices in zip(labels, band_indices): clean = tuple(_strip_dim_suffix(text) for text in label) if label else () modes.append( PhononMode( band_indices=tuple(int(i) + 1 for i in indices), frequency=float(frequencies[indices[0]]), labels=clean, qpoint=qpoint, qpoint_label=qpoint_label, representative_q=tuple(float(x) for x in label_q), ) ) modes.sort(key=lambda m: m.band_indices[0]) return modes
# --------------------------------------------------------------------------- # imaginary modes -> isotropy subgroups (the macer `phonopy tree` use case) # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class ImaginaryModeResult: """Isotropy subgroups reachable from one imaginary phonon level. One block of the ``crystod-phonon --subgroup`` report, as returned by :func:`imaginary_mode_subgroups` and :func:`scan_imaginary_modes`. Attributes: mode: The imaginary level, a :class:`PhononMode`. space_group: International short symbol of the parent space group. space_group_number: Its number. subgroups: The :class:`IsotropySubgroup` records of every irrep label of the level, in table order; empty when no label has tabulated subgroups. errors: Maps an irrep label to the reason its subgroup enumeration failed (e.g. the label of a symmetry line or plane, which has no isotropy subgroups in the tables; ``"?"`` when the level carries no label at all). Empty on success. """ mode: PhononMode space_group: str # parent international short symbol space_group_number: int subgroups: tuple[IsotropySubgroup, ...] errors: dict = field(default_factory=dict, compare=False)
# errors maps an irrep label to the reason its subgroup enumeration # failed (e.g. a k point outside the tabulated set); empty on success
[docs] def imaginary_mode_subgroups( phonon, qpoint=(0.0, 0.0, 0.0), *, threshold: float = -0.1, degeneracy_tolerance: float = 1e-4, with_settings: bool = True, ): """Isotropy subgroups of every imaginary phonon level at ``qpoint``. The symmetry-lowering step of a structure search, and the API form of ``crystod-phonon --subgroup --qpoint``: every degenerate level with frequency below ``threshold`` is labeled with :func:`label_phonon_modes`, and the isotropy subgroups of its irrep are enumerated with :func:`isotropy_subgroups`, covering all order-parameter directions, including the ones a single frozen-in modulation would miss. Args: phonon: A live ``phonopy.Phonopy`` object with force constants, built with ``primitive_matrix="auto"`` (see :func:`label_phonon_modes`). qpoint: Fractional coordinates of q in the primitive reciprocal basis. threshold: Frequency (THz) below which a level counts as imaginary; the -0.1 default matches the instability criterion of structure-search workflows. degeneracy_tolerance: Frequency tolerance (THz) within which bands count as degenerate. with_settings: Also compute the conventional ``basis``/``origin`` of every subgroup (see :func:`isotropy_subgroups`). Returns: List of :class:`ImaginaryModeResult`, one per imaginary level in band order. A level that could not be labeled, or whose irrep has no tabulated subgroups, yields a result with empty ``subgroups`` and the reason in ``errors``; an empty list means no level lies below ``threshold``. Raises: RuntimeError: If the q point cannot be labeled at all (from :func:`label_phonon_modes`). Example: >>> from crystod import phonon >>> # ph: the SrTiO3 object of the label_phonon_modes example >>> for result in phonon.imaginary_mode_subgroups(ph, [0.5, 0.5, 0.5]): ... print(result.mode) ... for sub in result.subgroups: ... print(" ", sub.label, "->", sub.symbol) modes 1,2,3: -1.0867 THz R5- R5-(0,0,a) -> I4/mcm R5-(a,a,a) -> R-3c R5-(0,a,a) -> Imma R5-(0,a,b) -> C2/m R5-(a,a,b) -> C2/c R5-(a,b,c) -> P-1 """ from .runtime_compat import get_symmetry_dataset dataset = get_symmetry_dataset(phonon.symmetry) number = int(dataset["number"]) symbol = str(dataset["international"]) modes = label_phonon_modes( phonon, qpoint, degeneracy_tolerance=degeneracy_tolerance ) # label -> (subgroups, error message); the error is cached too, so a label # that fails once still explains itself on every later level that carries it cache: dict[str, tuple[list[IsotropySubgroup], str | None]] = {} results = [] for mode in modes: if mode.frequency >= threshold: continue subgroups: list[IsotropySubgroup] = [] errors: dict[str, str] = {} if not mode.labels: errors["?"] = "the level could not be labeled with an irrep" for label in mode.labels: if label not in cache: try: cache[label] = ( isotropy_subgroups(number, label, with_settings=with_settings), None, ) except (Exception, SystemExit) as exc: # SystemExit: the subgroup machinery reports some failures # that way; KeyboardInterrupt must still propagate reason = " ".join(str(exc).split()) or type(exc).__name__ cache[label] = ([], reason) found, reason = cache[label] subgroups.extend(found) if reason is not None: errors[label] = reason results.append( ImaginaryModeResult( mode=mode, space_group=symbol, space_group_number=number, subgroups=tuple(subgroups), errors=errors, ) ) return results
[docs] def commensurate_qpoints(phonon): """q points of the primitive cell commensurate with the phonon supercell. These are exactly the q points a supercell calculation resolves (the ones that fold onto Gamma of the supercell): for a supercell matrix S in the primitive basis, the ``det(S)`` distinct vectors ``m @ inv(S).T`` modulo reciprocal-lattice translations. ``crystod-phonon --subgroup`` without ``--qpoint`` scans this set. Args: phonon: A ``phonopy.Phonopy`` object; only its primitive cell and supercell are read. Returns: List of ``(q1, q2, q3)`` tuples in fractional coordinates of the primitive reciprocal basis, snapped to exact fractions and sorted by length, so that Gamma comes first. Raises: ValueError: If the supercell is not an integer multiple of the primitive cell. Example: >>> from crystod import phonon >>> qpoints = phonon.commensurate_qpoints(ph) # ph: 4x4x4 supercell >>> len(qpoints), qpoints[0], qpoints[1] (64, (0.0, 0.0, 0.0), (0.0, 0.0, 0.25)) """ supercell_in_primitive = ( np.asarray(phonon.supercell.cell) @ np.linalg.inv(np.asarray(phonon.primitive.cell)) ) S = np.rint(supercell_in_primitive).astype(np.int64) if not np.allclose(supercell_in_primitive, S, atol=1e-6): raise ValueError( "the phonon supercell is not an integer multiple of the " "primitive cell; cannot enumerate commensurate q points" ) n_classes = abs(int(round(np.linalg.det(S)))) inv_t = np.linalg.inv(S).T from itertools import product as _product from .operations import snap_qpoint qpoints: list[tuple[float, float, float]] = [] search = max(2, n_classes) # the commensurate q of an N-fold supercell have denominator up to N, so # the snapping limit has to cover it (the 48 default would rewrite the q # of a supercell longer than 48 cells into an unrelated fraction) max_denominator = max(48, n_classes) for m in _product(range(-search, search + 1), repeat=3): frac = (np.array(m, dtype=float) @ inv_t) % 1.0 frac = np.where(frac > 1.0 - 1e-8, 0.0, frac) frac = tuple(float(x) for x in snap_qpoint(frac, max_denominator)) if frac not in qpoints: qpoints.append(frac) if len(qpoints) == n_classes: break if len(qpoints) != n_classes: raise ValueError( f"found {len(qpoints)} commensurate q points, expected {n_classes}" ) qpoints.sort(key=lambda q: (np.linalg.norm(q), q)) return qpoints
def _star_key(qpoint, rotations) -> tuple: """Canonical member of the star of q, for deduplication. Works for any q, tabulated or not: the whole orbit under the primitive rotations (k' = k R, modulo reciprocal-lattice translations) is generated and its smallest member returned. """ q = np.asarray(qpoint, dtype=float) images = set() for rotation in rotations: image = np.mod(q @ rotation, 1.0) image = np.where(image > 1.0 - 1e-6, 0.0, image) images.add(tuple(np.round(image, 6) + 0.0)) # +0.0 normalizes -0.0 return min(images) if images else tuple(np.round(np.mod(q, 1.0), 6))
[docs] def scan_imaginary_modes( phonon, qpoints=None, *, threshold: float = -0.1, degeneracy_tolerance: float = 1e-4, with_settings: bool = True, ): """Isotropy subgroups of all imaginary modes at the resolvable q points. Runs :func:`imaginary_mode_subgroups` over ``qpoints`` and merges the results; the API form of ``crystod-phonon --subgroup`` without ``--qpoint``. Star arms are deduplicated (all arms of a star carry the same levels), and a q point whose modes cannot be labeled is skipped with a warning rather than aborting the scan. Args: phonon: A live ``phonopy.Phonopy`` object with force constants, built with ``primitive_matrix="auto"`` (see :func:`label_phonon_modes`). qpoints: q points to scan, in fractional coordinates of the primitive reciprocal basis; ``None`` means :func:`commensurate_qpoints` of the phonon supercell. threshold: Frequency (THz) below which a level counts as imaginary. degeneracy_tolerance: Frequency tolerance (THz) within which bands count as degenerate. with_settings: Also compute the conventional ``basis``/``origin`` of every subgroup (see :func:`isotropy_subgroups`). Returns: List of :class:`ImaginaryModeResult` sorted most unstable first (ascending frequency); empty when no level lies below ``threshold``. Example: >>> from crystod import phonon >>> results = phonon.scan_imaginary_modes(ph) # ph: SrTiO3, 4x4x4 >>> [(r.mode.qpoint_label, round(r.mode.frequency, 4)) for r in results] [('R', -1.0867)] """ import warnings from .runtime_compat import get_symmetry_dataset if qpoints is None: qpoints = commensurate_qpoints(phonon) rotations = get_symmetry_dataset(phonon.primitive_symmetry)["rotations"] results = [] seen_stars: set = set() for qpoint in qpoints: try: star = _star_key(qpoint, rotations) if star in seen_stars: continue found = imaginary_mode_subgroups( phonon, qpoint, threshold=threshold, degeneracy_tolerance=degeneracy_tolerance, with_settings=with_settings, ) except RuntimeError as exc: warnings.warn(str(exc), stacklevel=2) continue seen_stars.add(star) results.extend(found) results.sort(key=lambda r: r.mode.frequency) return results
# --------------------------------------------------------------------------- # command line (crystod-phonon --subgroup) # --------------------------------------------------------------------------- def build_parser(): import argparse parser = argparse.ArgumentParser( description="Isotropy subgroups reachable from the imaginary phonon modes." ) parser.add_argument("--poscar", default="POSCAR", help="unit-cell file.") parser.add_argument("--dim", default=None, help='supercell dimension, e.g. "4 4 4".') parser.add_argument( "--readfc", action="store_true", help="read FORCE_CONSTANTS instead of FORCE_SETS.", ) parser.add_argument( "--yaml", default=None, help="phonopy_params.yaml holding cell, supercell and force constants.", ) parser.add_argument( "--qpoint", nargs="+", default=None, help="q point (label such as R, or three coordinates); " "without it every q point commensurate with the supercell is scanned.", ) parser.add_argument( "--threshold", type=float, default=-0.1, help="frequency below which a mode counts as imaginary (THz).", ) parser.add_argument( "--tolerance", type=float, default=1e-3, help="degeneracy tolerance of the irrep labeling (THz), as in --irreps.", ) parser.add_argument( "--modulate", action="store_true", help="also generate the distorted structure of every order-parameter " "direction (the --modulation step, run automatically).", ) parser.add_argument( "--amplitude", type=float, default=0.3, help="modulation amplitude in Angstroms for --modulate (default: 0.3).", ) return parser def _generate_structures_for( phonon, result, amplitude, symprec=1e-5, source="", seen=None ) -> None: """Write one distorted structure per order-parameter direction and report.""" from .modulation import generate_direction_structures from .runtime_compat import get_symmetry_dataset from .star_of_k import compute_star mode = result.mode dataset = get_symmetry_dataset(phonon.primitive_symmetry) arms = compute_star(dataset["rotations"], dataset["translations"], list(mode.qpoint)) targets = [ { "label": sub.label, "number": sub.number, "symbol": sub.symbol, "size": sub.size, "index": sub.index, } for sub in result.subgroups ] if not targets: return q_tag = mode.qpoint_label or "q_" + "_".join(f"{x:g}" for x in mode.qpoint) prefix = f"MPOSCAR_{q_tag}" if seen is not None: # two levels at one q can carry the same irrep, and their direction # lists are then identical: the second would overwrite the first repeats = seen.get((q_tag, "+".join(mode.labels)), 0) seen[(q_tag, "+".join(mode.labels))] = repeats + 1 if repeats: prefix = f"{prefix}_mode{mode.band_indices[0]}" try: generated, missing = generate_direction_structures( phonon, [arm["kpoint"] for arm in arms], [index - 1 for index in mode.band_indices], targets, amplitude=amplitude, symprec=symprec, prefix=prefix, ) except (RuntimeError, ValueError) as exc: print(f" note: no structures generated: {' '.join(str(exc).split())}") print() return if generated: print(f"* Distorted structures (amplitude {amplitude} A) *") for entry in generated: reproduce = " ".join( f"--qpoint{i} " + " ".join(f"{x:g}" for x in q) + f" --mode{i} " + " ".join(str(m) for m in modes) # .10g, not .4g: 0.3 * 0.5478 needs five digits, and a rounded # amplitude regenerates a *different* structure + f" --amplitude{i} " + " ".join(f"{a:.10g}" for a in amps) for i, (q, modes, amps) in enumerate( zip(entry.qpoints, entry.modes, entry.amplitudes), start=1 ) ) if len(entry.qpoints) == 1: reproduce = reproduce.replace("--qpoint1", "--qpoint").replace( "--mode1", "--mode" ).replace("--amplitude1", "--amplitude") print(f"{entry.label:<22} {entry.symbol:<10} -> {entry.path}") print(f" crystod-phonon --modulation {source}{reproduce}") for target in missing: print(f" note: no candidate reproduced {target['label']} " f"({target['symbol']}); generate it with --modulation by hand.") print() def _modulation_source_options(args) -> str: """The input selection of this run, as options for --modulation. The printed reproduce command has to name the same input the --subgroup run read, or pasting it lands on the POSCAR default and fails. """ if args.yaml: return f"--yaml {args.yaml} " source = f"-c {args.poscar} " if args.dim: source += f'--dim "{args.dim}" ' if args.readfc: source += "--readfc " return source def main(argv: list[str] | None = None) -> None: import sys args = build_parser().parse_args(argv) import phonopy try: if args.yaml: phonon = phonopy.load(args.yaml, primitive_matrix="auto") else: if not args.dim: raise SystemExit("ERROR: --subgroup requires --dim (or --yaml).") phonon = phonopy.load( supercell_matrix=[float(n) for n in args.dim.split()], primitive_matrix="auto", unitcell_filename=args.poscar, force_sets_filename=None if args.readfc else "./FORCE_SETS", force_constants_filename="./FORCE_CONSTANTS" if args.readfc else None, ) except FileNotFoundError as exc: if exc.filename: raise SystemExit(f"ERROR: {exc.filename} not found.") raise SystemExit(f"ERROR: {' '.join(str(exc).split())}") if phonon.force_constants is None: raise SystemExit( "ERROR: no force constants available; --subgroup needs FORCE_SETS " "(or FORCE_CONSTANTS with --readfc, or --yaml phonopy_params.yaml)." ) from .runtime_compat import get_symmetry_dataset dataset = get_symmetry_dataset(phonon.symmetry) print() print("* Parent structure *") print(f"{dataset['international']} (No. {dataset['number']})") print() if args.qpoint: from phonopy.structure.cells import get_primitive_matrix_by_centring from .irreptables_compat import load_irreptables from .phonon_irreps import get_irt_special_points from .phonon_vector import resolve_qpoint IrrepTable, _ = load_irreptables() try: 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) except Exception: q_names, q_list = [], [] rotations = get_symmetry_dataset(phonon.primitive_symmetry)["rotations"] try: _, qpoint = resolve_qpoint(args.qpoint, q_names, q_list, rotations) except ValueError as exc: raise SystemExit(f"ERROR: {exc}") try: results = imaginary_mode_subgroups( phonon, qpoint, threshold=args.threshold, degeneracy_tolerance=args.tolerance, ) except RuntimeError as exc: raise SystemExit(f"ERROR: {' '.join(str(exc).split())}") else: results = scan_imaginary_modes( phonon, threshold=args.threshold, degeneracy_tolerance=args.tolerance, ) if not results: where = "at this q point" if args.qpoint else "at any commensurate q point" print(f"No imaginary mode below {args.threshold} THz {where}.") return generated_levels: dict[tuple[str, str], int] = {} for result in results: mode = result.mode q_text = ", ".join(f"{x:g}" for x in mode.qpoint) name = f" ({mode.qpoint_label})" if mode.qpoint_label else "" bands = ", ".join(str(b) for b in mode.band_indices) irrep = "+".join(mode.labels) if mode.labels else "unlabeled" print(f"* Imaginary mode at q = ({q_text}){name} *") print(f"mode {bands}: {mode.frequency:.6f} THz, irrep {irrep}" f" (degeneracy {mode.degeneracy})") for label, reason in result.errors.items(): print(f" note: no subgroups for {label}: {reason}") if not result.subgroups: print() continue width = max([20] + [len(s.label) + 1 for s in result.subgroups]) print() print(f"{'irrep':<{width}} {'subgroup':<18} {'size':<5} {'index':<5}") for sub in result.subgroups: subgroup = f"{sub.number} {sub.symbol}" print(f"{sub.label:<{width}} {subgroup:<18} {sub.size:<5} {sub.index:<5}") print() if args.modulate: _generate_structures_for( phonon, result, args.amplitude, source=_modulation_source_options(args), seen=generated_levels, ) if not args.modulate: print("The distortion of each order-parameter direction can be generated with") print("crystod-phonon --modulation, or by adding --modulate here.") 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).") sys.stdout.flush() if __name__ == "__main__": main()