"""
Phonon eigenvector visualization workflow for crystod.
Given POSCAR + FORCE_SETS (or FORCE_CONSTANTS), diagonalize the dynamical
matrix at a selected q-point, list the modes with their irrep labels, and
export selected eigenvectors as VESTA files with displacement arrows.
The VESTA export follows the approach of Phonopy_VESTA
(A. P. Roy et al., Phys. Rev. Lett. 132, 026701 (2024),
https://doi.org/10.1103/PhysRevLett.132.026701): a complete VESTA file is
written (VESTA ignores vectors in files missing its style sections) with
VECTR/VECTT arrow entries placed between SITET and SPLAN.
"""
from __future__ import annotations
from argparse import (
ArgumentDefaultsHelpFormatter,
ArgumentParser,
RawDescriptionHelpFormatter,
RawTextHelpFormatter,
)
import re
from fractions import Fraction
from math import gcd
from pathlib import Path
import numpy as np
from numpy.typing import NDArray
import warnings
from .spglib_compat import ensure_spglib_compat
ensure_spglib_compat()
from phonopy import load
from phonopy.structure.atoms import PhonopyAtoms
from phonopy.structure.cells import get_primitive_matrix_by_centring, get_supercell
from spgrep.representation import project_to_irrep
from .irreptables_compat import load_irreptables
from .modulation import _find_intertwiner, _irrep_filename_tag
from .operations import parse_qpoint_token
from .phonon_irreps import find_star_representative, get_irrep_labels, get_irt_special_points
from .runtime_compat import get_qpoints_result, get_symmetry_dataset
from .vibration_modes import SymmetryOnlyVibrations
IrrepTable, Irrep = load_irreptables()
# sqrt(eV/A^2/AMU) -> THz, same constant as crystod-phonon --modulation.
FREQUENCY_CONVERSION_THZ = 15.633302
class MyHelpFormatter(
RawTextHelpFormatter,
RawDescriptionHelpFormatter,
ArgumentDefaultsHelpFormatter,
):
pass
desc = """
Visualize phonon eigenvectors as VESTA files with displacement arrows.
POSCAR and FORCE_SETS (or FORCE_CONSTANTS with --readfc) must exist in the
directory where this code runs, exactly as in --phonon-irrep mode.
# Command Examples:
crystod-phonon --vector --dim "4 4 4" -c 227_PPOSCAR_Si --qpoint GM
crystod-phonon --vector --dim "4 4 4" -c 227_PPOSCAR_Si --qpoint GM # all modes
crystod-phonon --vector --dim "4 4 4" -c 227_PPOSCAR_Si --qpoint GM --mode 4 5 6 # summed pattern
crystod-phonon --vector --dim "4 4 4" -c 227_PPOSCAR_Si --qpoint 0.5 0 0.5 --mode 1
"""
GAMMA_ALIASES = {"G", "GM", "GAMMA", "Γ"}
# VESTA default per-element atomic radius and color (from VESTA's elements.ini).
VESTA_ELEMENTS: dict[str, tuple[float, int, int, int]] = {
"H": (0.46, 255, 204, 204),
"D": (0.46, 204, 204, 255),
"He": (1.22, 252, 233, 207),
"Li": (1.57, 134, 224, 116),
"Be": (1.12, 95, 216, 123),
"B": (0.81, 32, 162, 15),
"C": (0.77, 129, 73, 41),
"N": (0.74, 176, 186, 230),
"O": (0.74, 255, 3, 0),
"F": (0.72, 176, 186, 230),
"Ne": (1.60, 255, 56, 181),
"Na": (1.91, 250, 221, 61),
"Mg": (1.60, 252, 124, 22),
"Al": (1.43, 129, 179, 214),
"Si": (1.18, 27, 59, 250),
"P": (1.10, 193, 156, 195),
"S": (1.04, 255, 250, 0),
"Cl": (0.99, 50, 252, 3),
"Ar": (1.92, 207, 254, 197),
"K": (2.35, 161, 34, 247),
"Ca": (1.97, 91, 150, 190),
"Sc": (1.64, 182, 99, 172),
"Ti": (1.47, 120, 202, 255),
"V": (1.35, 230, 26, 0),
"Cr": (1.29, 0, 0, 158),
"Mn": (1.37, 169, 9, 158),
"Fe": (1.26, 181, 114, 0),
"Co": (1.25, 0, 0, 175),
"Ni": (1.25, 184, 188, 190),
"Cu": (1.28, 34, 71, 221),
"Zn": (1.37, 143, 144, 130),
"Ga": (1.53, 159, 228, 116),
"Ge": (1.22, 126, 111, 166),
"As": (1.21, 117, 208, 87),
"Se": (1.04, 154, 239, 16),
"Br": (1.14, 127, 49, 3),
"Kr": (1.98, 250, 193, 243),
"Rb": (2.50, 255, 0, 153),
"Sr": (2.15, 0, 255, 39),
"Y": (1.82, 103, 152, 142),
"Zr": (1.60, 0, 255, 0),
"Nb": (1.47, 76, 179, 118),
"Mo": (1.40, 180, 134, 176),
"Tc": (1.35, 205, 175, 203),
"Ru": (1.34, 207, 184, 174),
"Rh": (1.34, 206, 210, 171),
"Pd": (1.37, 194, 196, 185),
"Ag": (1.44, 184, 188, 190),
"Cd": (1.52, 243, 31, 220),
"In": (1.67, 215, 129, 187),
"Sn": (1.58, 155, 143, 186),
"Sb": (1.41, 216, 131, 80),
"Te": (1.37, 173, 162, 82),
"I": (1.33, 143, 31, 139),
"Xe": (2.18, 155, 161, 248),
"Cs": (2.72, 15, 255, 185),
"Ba": (2.24, 30, 240, 45),
"La": (1.88, 90, 196, 73),
"Ce": (1.82, 209, 253, 6),
"Pr": (1.82, 253, 226, 6),
"Nd": (1.82, 252, 142, 7),
"Pm": (1.81, 0, 0, 245),
"Sm": (1.81, 253, 6, 125),
"Eu": (2.06, 251, 8, 213),
"Gd": (1.79, 192, 4, 255),
"Tb": (1.77, 113, 4, 254),
"Dy": (1.77, 49, 6, 253),
"Ho": (1.76, 7, 66, 251),
"Er": (1.75, 73, 115, 59),
"Tm": (1.00, 0, 0, 224),
"Yb": (1.94, 39, 253, 244),
"Lu": (1.72, 38, 253, 181),
"Hf": (1.59, 180, 180, 89),
"Ta": (1.47, 183, 155, 86),
"W": (1.41, 142, 138, 128),
"Re": (1.37, 179, 177, 142),
"Os": (1.35, 201, 177, 121),
"Ir": (1.36, 201, 207, 115),
"Pt": (1.39, 204, 198, 191),
"Au": (1.44, 254, 179, 56),
"Hg": (1.55, 211, 184, 204),
"Tl": (1.71, 150, 137, 109),
"Pb": (1.75, 83, 83, 91),
"Bi": (1.82, 210, 48, 248),
"Po": (1.77, 0, 0, 255),
"At": (0.62, 0, 0, 255),
"Rn": (0.80, 255, 255, 0),
"Fr": (1.00, 0, 0, 0),
"Ra": (2.35, 110, 170, 89),
"Ac": (2.03, 100, 158, 115),
"Th": (1.80, 38, 254, 120),
"Pa": (1.63, 41, 251, 53),
"U": (1.56, 122, 162, 170),
"Np": (1.56, 76, 76, 76),
"Pu": (1.64, 76, 76, 76),
"Am": (1.73, 76, 76, 76),
}
DEFAULT_ELEMENT = (0.80, 76, 76, 76)
def build_parser() -> ArgumentParser:
parser = ArgumentParser(description=desc, formatter_class=MyHelpFormatter)
parser.add_argument(
"--dim",
required=True,
type=str,
help="Supercell dimension used for the force calculation.",
)
parser.add_argument(
"--poscar",
type=str,
default="POSCAR",
help="POSCAR path.",
)
parser.add_argument(
"--readfc",
action="store_true",
help="Read FORCE_CONSTANTS instead of FORCE_SETS.",
)
parser.add_argument(
"--qpoint",
nargs="+",
required=True,
help="Either a high-symmetry label such as GM/X/L or three primitive reciprocal coordinates.",
)
parser.add_argument(
"--mode",
nargs="+",
type=int,
default=None,
help="Mode number(s) (1-based, sorted by frequency) to export as one summed VESTA "
"file. When omitted, ALL modes are exported as individual VESTA files.",
)
parser.add_argument(
"--amplitude",
type=float,
default=1.5,
help="Arrow length in Angstroms given to the largest atomic displacement of each mode.",
)
parser.add_argument(
"--conventional",
action="store_true",
help="Output the VESTA file in the conventional cell instead of the primitive cell.",
)
parser.add_argument(
"--keep-q-coords",
dest="keep_q_coords",
action="store_true",
help="Name output files of a non-special q with its coordinates "
"(q_<coords>) instead of the ISO-IR k-vector-type label, so scans "
"along one symmetry line do not overwrite each other.",
)
parser.add_argument(
"--output",
default=None,
help="Output .vesta path (auto-generated as POSCAR_<formula>_<qlabel>_mode<N>.vesta when omitted).",
)
parser.add_argument(
"--tolerance",
"-tol",
dest="tol",
type=float,
default=1e-3,
help="Degeneracy tolerance for irrep labeling.",
)
return parser
def _parse_coordinate(value: str) -> float:
try:
return parse_qpoint_token(value)
except ValueError:
return float(value)
[docs]
def resolve_qpoint(
raw_qpoint: list[str],
q_names: list[str],
q_list: list[list[float]],
rotations: NDArray[np.int_] | None = None,
isoir_context: tuple | None = None,
) -> tuple[str, list[float]]:
"""Resolve ``--qpoint`` tokens into a label and primitive-basis coordinates.
This is how ``crystod-phonon --vector`` and ``--subgroup`` read their
``--qpoint`` argument: a single token is the label of a tabulated special
point (``GM``, ``G``, ``GAMMA`` and the Greek capital gamma all mean
Gamma); three tokens are coordinates in the primitive reciprocal basis,
fractions such as ``1/3`` allowed. Coordinates are labeled with the name
of the special point they coincide with. With ``rotations``, any arm of a
tabulated star is labeled with the star's name, not only the tabulated
arm; with ``isoir_context``, a q point outside every tabulated star is
labeled with its ISO-IR k-vector type (e.g. ``U``, ``B``, ``DT``) instead
of ``q_<coords>``.
Args:
raw_qpoint: The tokens, one label or three coordinate strings.
q_names: Labels of the tabulated special points.
q_list: Their coordinates, as returned by
:func:`crystod.phonon.get_irt_special_points`.
rotations: Space-group rotations in the primitive basis, shape
``(n_ops, 3, 3)``, or ``None`` to label exact matches only.
isoir_context: ``(space-group number, primitive cell tuple, symprec)``
for the ISO-IR fallback label, or ``None`` for ``q_<coords>``.
Returns:
``(label, qpoint)`` with ``qpoint`` a list of three floats. The
coordinates are the ones that were given (not the tabulated arm), so
the label names the star while the modes are computed at the
requested q.
Raises:
ValueError: For an unknown label, or a token count other than one or
three.
Example:
>>> from crystod import phonon
>>> names = ["GM", "R", "X", "M"]
>>> points = [[0, 0, 0], [0.5, 0.5, 0.5], [0, 0.5, 0], [0.5, 0.5, 0]]
>>> phonon.resolve_qpoint(["R"], names, points)
('R', [0.5, 0.5, 0.5])
>>> # rotations: the primitive-cell rotations of the space group
>>> phonon.resolve_qpoint(["1/2", "0", "0"], names, points, rotations)
('X', [0.5, 0.0, 0.0])
"""
if len(raw_qpoint) == 1:
requested = raw_qpoint[0].strip().upper()
if requested in GAMMA_ALIASES:
requested = "GM"
for name, q in zip(q_names, q_list):
if name.upper() == requested:
return name, list(q)
available = ", ".join(q_names)
raise ValueError(
f"Unknown q-point label '{raw_qpoint[0]}'. Available labels: {available}"
)
if len(raw_qpoint) != 3:
raise ValueError("--qpoint must be either one label or three coordinates.")
qpoint = [_parse_coordinate(value) for value in raw_qpoint]
for name, q in zip(q_names, q_list):
if np.allclose(qpoint, q, atol=1e-8):
return name, qpoint
if rotations is not None:
representative = find_star_representative(qpoint, rotations, q_names, q_list)
if representative is not None:
return representative[0], qpoint
if isoir_context is not None:
# non-special q: fall back to the ISO-IR k-vector type label
from .isoir import get_isoir_kpoint_name
sgnum, cell, symprec = isoir_context
isoir_name = get_isoir_kpoint_name(sgnum, cell, symprec, qpoint)
if isoir_name is not None:
return isoir_name, qpoint
label = "q" + "".join(f"_{value:g}" for value in qpoint).replace("/", "o")
return label, qpoint
def reduced_formula(symbols: list[str]) -> str:
"""Reduced chemical formula preserving the first-appearance order, e.g. Si, SrTiO3."""
unique: list[str] = []
counts: dict[str, int] = {}
for symbol in symbols:
if symbol not in counts:
unique.append(symbol)
counts[symbol] = 0
counts[symbol] += 1
divisor = 0
for symbol in unique:
divisor = gcd(divisor, counts[symbol])
parts = []
for symbol in unique:
count = counts[symbol] // divisor
parts.append(symbol if count == 1 else f"{symbol}{count}")
return "".join(parts)
def get_conventional_matrix(centring: str) -> NDArray[np.int_]:
"""Integer primitive-to-conventional matrix; rows are the conventional
lattice vectors expressed in the primitive basis (e.g. cubic-F:
[[-1, 1, 1], [1, -1, 1], [1, 1, -1]]).
phonopy's primitive matrix M acts on COLUMN-vector lattices,
(a_p, b_p, c_p) = (a_c, b_c, c_c) M, i.e. L_p = M^T L_c for the
row-vector lattices used throughout CrystOD -- so the row matrix here
is inv(M)^T, NOT inv(M). The two coincide for the symmetric P/F/I
matrices, but bare inv(M) drew a wrong (non-conventional) cell for R
centring (an oblique det-3 supercell instead of the hexagonal cell)
and axis-swapped cells for A/C."""
primitive_matrix = np.array(get_primitive_matrix_by_centring(centring), dtype=float)
conventional = np.linalg.inv(primitive_matrix).T
conventional_int = np.rint(conventional).astype(int)
if not np.allclose(conventional, conventional_int, atol=1e-8):
raise ValueError(f"Non-integer primitive-to-conventional matrix for centring '{centring}'.")
return conventional_int
[docs]
def get_commensurate_supercell_matrix(
qpoint: list[float],
base_matrix: NDArray[np.int_],
) -> NDArray[np.int_]:
"""Smallest diagonal multiple of ``base_matrix`` commensurate with q.
``crystod-phonon --vector`` draws a mode on the smallest supercell over
which its Bloch phase is periodic: each base-cell axis is multiplied by
the denominator of the corresponding component of q in the base
reciprocal basis (denominators up to 12).
Args:
qpoint: Fractional coordinates of q in the primitive reciprocal basis.
base_matrix: Rows are the base-cell (primitive or conventional)
lattice vectors in the primitive basis: the identity for the
primitive cell, or the matrix of ``get_conventional_matrix`` for
the conventional cell.
Returns:
Integer matrix ``S`` whose rows are the supercell lattice vectors in
the primitive basis (``L_super = S @ L_primitive``), satisfying
``q . S_row in Z`` for every row.
Example:
>>> import numpy as np
>>> from crystod import phonon
>>> base = np.eye(3, dtype=int)
>>> phonon.get_commensurate_supercell_matrix([0.0, 0.5, 0.0], base)
array([[1, 0, 0],
[0, 2, 0],
[0, 0, 1]])
"""
q_base = np.array(base_matrix, dtype=float) @ np.array(qpoint, dtype=float)
sizes = []
for component in q_base:
if abs(component - round(component)) < 1e-10:
sizes.append(1)
else:
sizes.append(Fraction(float(component)).limit_denominator(12).denominator)
return np.diag(sizes) @ np.array(base_matrix, dtype=int)
def _lattice_parameters(lattice: NDArray[np.float64]) -> tuple[float, float, float, float, float, float]:
lengths = np.linalg.norm(lattice, axis=1)
a, b, c = lengths
alpha = np.degrees(np.arccos(np.dot(lattice[1], lattice[2]) / (b * c)))
beta = np.degrees(np.arccos(np.dot(lattice[0], lattice[2]) / (a * c)))
gamma = np.degrees(np.arccos(np.dot(lattice[0], lattice[1]) / (a * b)))
return a, b, c, alpha, beta, gamma
_VESTA_HEADER_SECTIONS = """GROUP
1 1 P 1
SYMOP
0.000000 0.000000 0.000000 1 0 0 0 1 0 0 0 1 1
-1.0 -1.0 -1.0 0 0 0 0 0 0 0 0 0
TRANM 0
0.000000 0.000000 0.000000 1 0 0 0 1 0 0 0 1
LTRANSL
-1
0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
LORIENT
-1 0 0 0 0
1.000000 0.000000 0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000 0.000000 1.000000
LMATRIX
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000000 0.000000 0.000000"""
_VESTA_TAIL_SECTIONS = """SPLAN
0 0 0 0
LBLAT
-1
LBLSP
-1
DLATM
-1
DLBND
-1
DLPLY
-1
PLN2D
0 0 0 0"""
_VESTA_STYLE_SECTIONS = """SCENE
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000 0.000
0.000
1.000
HBOND 0 2
STYLE
DISPF 37753794
MODEL 0 1 0
SURFS 0 1 1
SECTS 96 1
FORMS 0 1
ATOMS 0 0 1
BONDS 1
POLYS 1
VECTS 1.000000
FORMP
1 1.0 0 0 0
ATOMP
24 24 0 50 2.0 0
BONDP
1 16 0.250 2.000 127 127 127
POLYP
204 1 1.000 180 180 180
ISURF
0 0 0 0
TEX3P
1 0.00000E+000 1.00000E+000
SECTP
1 0.00000E+000 1.00000E+000 0.00000E+000
HKLPP
92 0 1.000 0 128 255
UCOLP
0 1 1.000 0 0 0
COMPS 1
LABEL 1 12 1.000 0
PROJT 0 0.962
BKGRC
255 255 255
DPTHQ 1 -0.5000 3.5000
LIGHT0 1
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000000 0.000000 20.000000 0.000000
0.000000 0.000000 -1.000000
26 26 26 255
179 179 179 255
255 255 255 255
LIGHT1
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000000 0.000000 20.000000 0.000000
0.000000 0.000000 -1.000000
0 0 0 0
0 0 0 0
0 0 0 0
LIGHT2
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000000 0.000000 20.000000 0.000000
0.000000 0.000000 -1.000000
0 0 0 0
0 0 0 0
0 0 0 0
LIGHT3
1.000000 0.000000 0.000000 0.000000
0.000000 1.000000 0.000000 0.000000
0.000000 0.000000 1.000000 0.000000
0.000000 0.000000 0.000000 1.000000
0.000000 0.000000 20.000000 0.000000
0.000000 0.000000 -1.000000
0 0 0 0
0 0 0 0
0 0 0 0
ATOMM
204 204 204 255
25.600
BONDM
255 255 255 255
128.000
POLYM
255 255 255 255
128.000
SURFM
0 0 0 255
128.000
FORMM
255 255 255 255
128.000
HKLPM
255 255 255 255
128.000"""
[docs]
def write_vesta_with_arrows(
filepath: str,
lattice: NDArray[np.float64],
scaled_positions: NDArray[np.float64],
symbols: list[str],
arrows_cartesian: NDArray[np.float64],
title: str,
arrow_rgb: tuple[int, int, int] = (255, 0, 0),
arrow_radius: float = 0.5,
) -> None:
"""Write a complete VESTA file with per-atom displacement arrows.
The output of ``crystod-phonon --vector``: the structure is written as a
P1 cell with one VECTR/VECTT arrow per atom, following the Phonopy_VESTA
approach (A. P. Roy et al., Phys. Rev. Lett. 132, 026701 (2024)): a
complete file including VESTA's style sections, since VESTA ignores
vectors in files that lack them. Arrow components are written in the
VESTA vector convention: values along the a/b/c axis directions with the
modulus in Angstroms (equal to Cartesian components for cubic cells).
Args:
filepath: Output path (``.vesta``).
lattice: Lattice vectors as rows, in Angstroms, shape ``(3, 3)``.
scaled_positions: Fractional atomic coordinates, shape
``(n_atoms, 3)``.
symbols: Chemical symbols of the atoms (radius and color are taken
from VESTA's element defaults).
arrows_cartesian: Cartesian arrow vectors in Angstroms, shape
``(n_atoms, 3)``.
title: The VESTA title line.
arrow_rgb: Arrow color as an RGB triple (default red).
arrow_radius: Arrow radius in VESTA units.
Returns:
None. The file is written to ``filepath``.
Example:
Draw the first R-point mode of SrTiO3 on its 2x2x2 supercell (``ph``
as in :func:`crystod.phonon.label_phonon_modes`)::
import numpy as np
from crystod import phonon
from crystod.phonon_vector import get_supercell_displacement_field
q = [0.5, 0.5, 0.5]
modes = phonon.build_symmetry_adapted_modes(ph, q)
supercell_matrix = phonon.get_commensurate_supercell_matrix(
q, np.eye(3, dtype=int))
supercell, field = get_supercell_displacement_field(
ph, q, supercell_matrix, modes[0][1])
arrows = field.real * (1.5 / np.linalg.norm(field.real, axis=1).max())
phonon.write_vesta_with_arrows(
"SrTiO3_R_mode1.vesta", np.array(supercell.cell),
np.array(supercell.scaled_positions), list(supercell.symbols),
arrows, title="SrTiO3 R mode 1")
"""
a, b, c, alpha, beta, gamma = _lattice_parameters(lattice)
axis_lengths = np.array([a, b, c], dtype=float)
arrows_axis = (arrows_cartesian @ np.linalg.inv(lattice)) * axis_lengths
lines: list[str] = []
lines.append("#VESTA_FORMAT_VERSION 3.3.0")
lines.append("")
lines.append("")
lines.append("CRYSTAL")
lines.append("")
lines.append("TITLE")
lines.append(title)
lines.append("")
lines.append(_VESTA_HEADER_SECTIONS)
lines.append("CELLP")
lines.append(f" {a:10.6f} {b:10.6f} {c:10.6f} {alpha:10.6f} {beta:10.6f} {gamma:10.6f}")
lines.append(" 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000")
site_names: list[str] = []
site_counter: dict[str, int] = {}
for symbol in symbols:
site_counter[symbol] = site_counter.get(symbol, 0) + 1
site_names.append(f"{symbol}{site_counter[symbol]}")
lines.append("STRUC")
for index, (symbol, site_name, frac) in enumerate(zip(symbols, site_names, scaled_positions), start=1):
lines.append(
f" {index} {symbol:9s} {site_name:4s} 1.0000 "
f"{frac[0]:10.6f} {frac[1]:10.6f} {frac[2]:10.6f} 1a 1"
)
lines.append(" 0.000000 0.000000 0.000000 0.00")
lines.append(" 0 0 0 0 0 0 0")
lines.append("THERI 0")
for index, site_name in enumerate(site_names, start=1):
lines.append(f" {index} {site_name:>10s} 1.000000")
lines.append(" 0 0 0")
lines.append("SHAPE")
lines.append(" 0 0 0 0 0.000000 0 192 192 192 192")
lines.append("BOUND")
lines.append(" 0 1 0 1 0 1")
lines.append(" 0 0 0 0 0")
lines.append("SBOND")
lines.append(" 0 0 0 0")
lines.append("SITET")
for index, (symbol, site_name) in enumerate(zip(symbols, site_names), start=1):
radius, red, green, blue = VESTA_ELEMENTS.get(symbol, DEFAULT_ELEMENT)
lines.append(
f" {index} {site_name:>10s} {radius:.4f} {red:3d} {green:3d} {blue:3d} "
f"{red:3d} {green:3d} {blue:3d} 204 0"
)
lines.append(" 0 0 0 0 0 0")
lines.append("VECTR")
for index, arrow in enumerate(arrows_axis, start=1):
lines.append(f"{index:5d}{arrow[0]:10.5f}{arrow[1]:10.5f}{arrow[2]:10.5f}")
lines.append(f"{index:5d} 0 0 0 0")
lines.append(" 0 0 0 0 0")
lines.append("0 0 0 0 0")
lines.append("VECTT")
red, green, blue = arrow_rgb
for index in range(1, len(arrows_axis) + 1):
lines.append(f"{index:5d} {arrow_radius:.3f} {red:3d} {green:3d} {blue:3d} 0")
lines.append("0 0 0 0 0")
lines.append(_VESTA_TAIL_SECTIONS)
lines.append("ATOMT")
seen: list[str] = []
for symbol in symbols:
if symbol in seen:
continue
seen.append(symbol)
radius, red, green, blue = VESTA_ELEMENTS.get(symbol, DEFAULT_ELEMENT)
lines.append(
f" {len(seen)} {symbol:>10s} {radius:.4f} {red:3d} {green:3d} {blue:3d} "
f"{red:3d} {green:3d} {blue:3d} 204"
)
lines.append(" 0 0 0 0 0 0")
lines.append(_VESTA_STYLE_SECTIONS)
lines.append("")
with open(filepath, "w") as fp:
fp.write("\n".join(lines))
def _output_path(
output: str | None,
formula: str,
q_label: str,
mode_file_string: str,
irrep_tag: str,
conventional: bool = False,
) -> str:
if output is not None:
path = Path(output)
if path.suffix.lower() != ".vesta":
path = path.with_suffix(path.suffix + ".vesta") if path.suffix else path.with_suffix(".vesta")
return str(path)
suffix = "_conv" if conventional else ""
irrep_part = f"_{irrep_tag}" if irrep_tag else ""
return f"POSCAR_{formula}_{q_label}_mode{mode_file_string}{irrep_part}{suffix}.vesta"
[docs]
def build_symmetry_adapted_modes(
phonon,
qpoint: list[float],
symprec: float = 1e-5,
) -> list[tuple[float, NDArray[np.complex128]]]:
"""Symmetry-adapted eigenvectors of the dynamical matrix at q.
The dynamical matrix is block-diagonalized in the spgrep irrep-projected
basis of :class:`SymmetryOnlyVibrations` (the same construction as
``crystod-phonon --modulation``), so that degenerate modes come out along
symmetry-dictated directions instead of the arbitrary linear combinations
a plain eigensolver returns. ``crystod-phonon --vector`` exports these
vectors as VESTA arrows. The result is verified against the plain phonopy
solution: the frequencies must match, and every vector must be an
eigenvector of the dynamical matrix.
Args:
phonon: A ``phonopy.Phonopy`` object with force constants; its
primitive cell is used as-is (no standardization), so that the
projected basis and the dynamical matrix share one phase
convention.
qpoint: Fractional coordinates of q in the primitive reciprocal basis.
symprec: Symmetry tolerance of the spglib/spgrep analysis.
Returns:
List of ``(frequency_THz, mode_vector)`` sorted by frequency (negative
for imaginary modes); ``mode_vector`` has ``3 * n_atoms`` complex
components in the mass-weighted phonopy convention.
Raises:
RuntimeError: If the construction cannot reproduce the phonopy
spectrum (the projection does not span the vibration space, or
coupled irrep spaces are not equivalent).
Example:
>>> from crystod import phonon
>>> # ph: the SrTiO3 object of the label_phonon_modes example
>>> modes = phonon.build_symmetry_adapted_modes(ph, [0.5, 0.5, 0.5])
>>> [round(frequency, 4) for frequency, _ in modes[:4]]
[-1.0867, -1.0867, -1.0867, 3.9891]
>>> modes[0][1].shape
(15,)
"""
primitive = phonon.primitive
cell = PhonopyAtoms(
numbers=primitive.numbers,
scaled_positions=primitive.scaled_positions,
cell=primitive.cell,
)
# standardize=False keeps phonopy's atom positions so that the projected
# basis and the dynamical matrix share one phase convention.
vibrations = SymmetryOnlyVibrations(cell=cell, symprec=symprec, standardize=False)
irreps, vibration_rep, _ = vibrations.get_vibration_rep(qpoint)
spaces: list[NDArray[np.complex128]] = []
for irrep in irreps:
spaces.extend(project_to_irrep(vibration_rep, irrep))
dynamical_matrix = phonon.dynamical_matrix
dynamical_matrix.run(qpoint)
matrix = dynamical_matrix.dynamical_matrix.copy()
dims = [space.shape[0] for space in spaces]
if sum(dims) != matrix.shape[0]:
raise RuntimeError("Irrep projection does not span the full vibration space.")
offsets = np.cumsum([0] + dims)
n_spaces = len(spaces)
stacked = np.vstack(spaces)
block_matrix = stacked @ matrix @ stacked.conj().T
# Spaces carrying equivalent irreps may couple; group them into clusters.
coupled = np.zeros((n_spaces, n_spaces), dtype=bool)
for s in range(n_spaces):
for t in range(n_spaces):
sub = block_matrix[offsets[s] : offsets[s + 1], offsets[t] : offsets[t + 1]]
coupled[s, t] = bool(np.abs(sub).max() > 1e-6)
clusters: list[list[int]] = []
seen: set[int] = set()
for s in range(n_spaces):
if s in seen:
continue
stack, cluster = [s], []
while stack:
u = stack.pop()
if u in seen:
continue
seen.add(u)
cluster.append(u)
stack.extend(v for v in range(n_spaces) if coupled[u, v] and v not in seen)
clusters.append(sorted(cluster))
modes: list[tuple[float, NDArray[np.complex128]]] = []
for cluster in clusters:
dim = dims[cluster[0]]
if any(dims[index] != dim for index in cluster):
raise RuntimeError("Coupled irrep spaces with different dimensions.")
multiplicity = len(cluster)
aligned = [spaces[cluster[0]]]
for index in cluster[1:]:
intertwiner = _find_intertwiner(vibration_rep, spaces[index], spaces[cluster[0]])
if intertwiner is None:
raise RuntimeError("Coupled irrep spaces are not equivalent.")
aligned.append(intertwiner.conj().T @ spaces[index])
# After alignment every coupling block is a scalar multiple of the
# identity (Schur), so the cluster reduces to one multiplicity-sized
# Hermitian matrix shared by all irrep components.
coupling = np.zeros((multiplicity, multiplicity), dtype=complex)
for a in range(multiplicity):
for b in range(multiplicity):
sub = aligned[a] @ matrix @ aligned[b].conj().T
if np.abs(sub - np.eye(dim) * np.trace(sub) / dim).max() > 1e-6:
raise RuntimeError("Coupling between irrep spaces is not scalar.")
coupling[a, b] = np.trace(sub) / dim
eigenvalues, eigenvectors = np.linalg.eigh(coupling)
eigenvalues = eigenvalues.real
# Preserve the symmetry-adapted basis when the cluster is numerically
# degenerate, exactly as crystod-phonon --modulation does.
if multiplicity > 1 and np.allclose(eigenvalues, eigenvalues.mean(), atol=1e-10, rtol=1e-8):
eigenvalues = np.full(multiplicity, eigenvalues.mean())
eigenvectors = np.eye(multiplicity, dtype=complex)
for w in range(multiplicity):
frequency = float(
np.sign(eigenvalues[w]) * np.sqrt(abs(eigenvalues[w])) * FREQUENCY_CONVERSION_THZ
)
for component in range(dim):
# Rows of the projected basis are bras; eigenvectors of the
# dynamical matrix are their complex conjugates.
vector = np.zeros(matrix.shape[0], dtype=complex)
for a in range(multiplicity):
vector += eigenvectors[a, w] * np.conj(aligned[a][component])
modes.append((frequency, vector))
order = np.argsort([mode[0] for mode in modes], kind="stable")
modes = [modes[index] for index in order]
# Verify against the plain phonopy solution before trusting the result.
reference = np.sort(np.linalg.eigvalsh(matrix).real)
reference = np.sign(reference) * np.sqrt(np.abs(reference)) * FREQUENCY_CONVERSION_THZ
if not np.allclose([m[0] for m in modes], reference, atol=1e-3):
raise RuntimeError("Symmetry-adapted frequencies do not match the phonopy spectrum.")
for frequency, vector in modes:
eigenvalue = np.sign(frequency) * (frequency / FREQUENCY_CONVERSION_THZ) ** 2
if np.linalg.norm(matrix @ vector - eigenvalue * vector) > 1e-6:
raise RuntimeError("A symmetry-adapted mode is not an eigenvector of the dynamical matrix.")
return modes
def get_supercell_displacement_field(
phonon,
qpoint: list[float],
supercell_matrix: NDArray[np.int_],
mode_vector: NDArray[np.complex128],
):
"""Complex displacement field of one mode on the commensurate supercell.
supercell_matrix rows are the supercell lattice vectors in the primitive
basis (L_super = S @ L_primitive). Follows phonopy's Modulation convention:
u_j(l) = e_j exp(2 pi i q.(R_l + tau_j)) / sqrt(m_j), with the overall
phase chosen to maximize the real part.
Returns (supercell, displacements) where displacements has shape (n_atoms, 3).
"""
primitive = phonon.primitive
cell = PhonopyAtoms(
numbers=primitive.numbers,
scaled_positions=primitive.scaled_positions,
cell=primitive.cell,
)
matrix = np.array(supercell_matrix, dtype=int)
# phonopy's get_supercell builds L_super = M.T @ L_primitive.
supercell = get_supercell(cell, matrix.T)
s2uu = [supercell.u2u_map[x] for x in supercell.s2u_map]
# positions in the primitive basis: x_primitive = x_super @ S
coefs = np.exp(
2j * np.pi * np.dot(np.dot(supercell.scaled_positions, matrix), qpoint)
) / np.sqrt(supercell.masses)
u = np.array(
[mode_vector[3 * s2uu[i] : 3 * s2uu[i] + 3] * coefs[i] for i in range(len(supercell.masses))]
)
# Global phase maximizing the real-part norm (phonopy MODULATION-style).
sum_of_squares = np.sum(u * u)
if abs(sum_of_squares) > 1e-12:
u = u * np.exp(-0.5j * np.angle(sum_of_squares))
return supercell, u
def _get_mode_labels(
qpoint: list[float],
phonon,
dataset,
degeneracy_tolerance: float,
) -> tuple[NDArray[np.float64], list[str]]:
"""Frequencies and per-mode irrep labels at q; labels fall back to '-' silently."""
n_modes = 3 * len(phonon.primitive)
try:
irt_table = IrrepTable(dataset["number"], spinor=False)
prim_mat = get_primitive_matrix_by_centring(dataset["international"][0])
# The ISO-IR tables tabulate one arm per star; map q onto that arm so every
# arm gets labels. The spectra of star arms are identical band by band,
# so the labels at the representative apply to the modes at q.
label_q = list(qpoint)
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:
label_q = representative[1]
labels, band_indices, frequencies = get_irrep_labels(
q=label_q,
phonon=phonon,
irt_table=irt_table,
prim_mat=prim_mat,
degeneracy_tolerance=degeneracy_tolerance,
)
mode_labels = ["-"] * n_modes
for label, indices in zip(labels, band_indices):
text = ", ".join(label) if label else "-"
for band_index in indices:
mode_labels[band_index] = text
return frequencies, mode_labels
except Exception:
phonon.run_qpoints([qpoint])
frequencies = get_qpoints_result(phonon).frequencies[0]
return frequencies, ["-"] * n_modes
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_sets = None
force_constants = "./FORCE_CONSTANTS"
else:
force_sets = "./FORCE_SETS"
force_constants = None
phonon = load(
supercell_matrix=supercell_mat,
primitive_matrix="auto",
unitcell_filename=args.poscar,
force_sets_filename=force_sets,
force_constants_filename=force_constants,
)
dataset = get_symmetry_dataset(phonon.symmetry)
prim_mat = get_primitive_matrix_by_centring(dataset["international"][0])
try:
irt_table = IrrepTable(dataset["number"], spinor=False)
q_names, q_list = get_irt_special_points(irt_table, prim_mat)
except Exception:
q_names, q_list = [], []
formula = reduced_formula(list(phonon.primitive.symbols))
# the header + mode table is also saved as a text file (report_lines)
report_lines: list[str] = []
def echo(line: str = "") -> None:
print(line)
report_lines.append(line)
if q_names:
echo(f"Space group: {dataset['international']} (#{dataset['number']})")
echo("Available high-symmetry q-points:")
for name, q in zip(q_names, q_list):
echo(f" {name:8s} {np.round(q, 6).tolist()}")
try:
primitive_rotations = get_symmetry_dataset(phonon.primitive_symmetry)["rotations"]
except Exception:
primitive_rotations = None
if args.keep_q_coords:
isoir_context = None
else:
try:
primitive = phonon.primitive
isoir_context = (
dataset["number"],
(primitive.cell, primitive.scaled_positions, primitive.numbers),
phonon.primitive_symmetry.tolerance,
)
except Exception:
isoir_context = None
q_label, qpoint = resolve_qpoint(
args.qpoint, q_names, q_list,
rotations=primitive_rotations, isoir_context=isoir_context,
)
echo(f"\nSelected q-point: {q_label} = {qpoint}")
# Symmetry-adapted eigenvectors: degenerate modes are aligned along
# symmetry-dictated directions (same construction as crystod-phonon --modulation)
# instead of the arbitrary combinations a plain eigensolver returns.
try:
symmetry_adapted_modes = build_symmetry_adapted_modes(phonon, qpoint)
frequencies = np.array([frequency for frequency, _ in symmetry_adapted_modes])
mode_vectors = [vector for _, vector in symmetry_adapted_modes]
except Exception as exc:
warnings.warn(
f"Symmetry-adapted mode construction failed ({exc}); falling back to plain "
"phonopy eigenvectors, which may look tilted within degenerate subspaces.",
stacklevel=2,
)
dynamical_matrix = phonon.dynamical_matrix
dynamical_matrix.run(qpoint)
eigenvalues, eigenvector_matrix = np.linalg.eigh(dynamical_matrix.dynamical_matrix)
eigenvalues = eigenvalues.real
frequencies = np.sign(eigenvalues) * np.sqrt(np.abs(eigenvalues)) * FREQUENCY_CONVERSION_THZ
mode_vectors = [eigenvector_matrix[:, band] for band in range(len(frequencies))]
_, mode_labels = _get_mode_labels(qpoint, phonon, dataset, args.tol)
echo(f"\nPhonon modes at q = {q_label}")
echo(f"{'Mode':>5s} {'Freq (THz)':>12s} Irrep")
echo("-" * 40)
for mode_index, frequency in enumerate(frequencies):
echo(f"{mode_index + 1:5d} {frequency:12.4f} {mode_labels[mode_index]}")
report_path = f"phonon_modes_{formula}_{q_label}.txt"
with open(report_path, "w") as handle:
handle.write("\n".join(report_lines) + "\n")
print(f"\nMode table written to: {report_path}")
n_modes = len(frequencies)
if args.mode is None:
if args.output:
raise SystemExit("ERROR: --output requires --mode (one summed output file).")
mode_groups = [[index] for index in range(n_modes)]
print(f"\nNo --mode given; exporting all {n_modes} modes as individual VESTA files.")
else:
selected: list[int] = []
for value in args.mode:
if value < 1 or value > n_modes:
raise SystemExit(
f"ERROR: mode number {value} is out of range [1, {n_modes}] (numbering is 1-based)."
)
selected.append(value - 1)
mode_groups = [selected]
if args.conventional:
centring = dataset["international"][0]
base_matrix = get_conventional_matrix(centring)
print(f"\nConventional-cell output (centring {centring}); primitive-to-conventional matrix:")
for row in base_matrix:
print(f" {row.tolist()}")
else:
base_matrix = np.eye(3, dtype=int)
supercell_matrix = get_commensurate_supercell_matrix(qpoint, base_matrix)
if not np.array_equal(supercell_matrix, base_matrix):
multiples = np.rint(np.diag(supercell_matrix @ np.linalg.inv(base_matrix))).astype(int)
print(
f"\nCommensurate supercell for visualization: "
f"{multiples[0]}x{multiples[1]}x{multiples[2]} "
f"{'conventional' if args.conventional else 'primitive'} cells"
)
elif not args.conventional:
print("\nCommensurate supercell for visualization: 1x1x1")
# As in --modulation, multiple modes selected with --mode are summed into
# one displacement pattern (each mode with unit weight). Without --mode,
# every mode is exported individually.
pad = len(str(n_modes)) # zero-pad mode numbers in file names so ls sorts them
for group in mode_groups:
supercell = None
total_displacements = None
for mode_index in group:
supercell, complex_field = get_supercell_displacement_field(
phonon=phonon,
qpoint=qpoint,
supercell_matrix=supercell_matrix,
mode_vector=mode_vectors[mode_index],
)
displacements = complex_field.real
max_norm = float(np.max(np.linalg.norm(displacements, axis=1)))
if max_norm < 1e-12:
print(f"Mode {mode_index + 1}: real part of the eigenvector vanished; contributes nothing.")
print(
f" + mode {mode_index + 1}: {mode_labels[mode_index]}, "
f"{frequencies[mode_index]:.4f} THz"
)
total_displacements = displacements if total_displacements is None else total_displacements + displacements
max_norm = float(np.max(np.linalg.norm(total_displacements, axis=1)))
if max_norm < 1e-12:
print("The summed displacement pattern vanished; nothing to write.")
continue
arrows = total_displacements * (args.amplitude / max_norm)
mode_numbers = [mode_index + 1 for mode_index in group]
mode_string = "+".join(str(number) for number in mode_numbers)
mode_file_string = "+".join(f"{number:0{pad}d}" for number in mode_numbers)
unique_labels: list[str] = []
for mode_index in group:
if mode_labels[mode_index] not in unique_labels:
unique_labels.append(mode_labels[mode_index])
if len(group) == 1:
title = (
f"{formula} {q_label} mode {mode_string} "
f"[{unique_labels[0]}] {frequencies[group[0]]:.4f} THz"
)
else:
title = f"{formula} {q_label} modes {mode_string} [{', '.join(unique_labels)}]"
if args.conventional:
title += " (conventional cell)"
output_path = _output_path(
args.output,
formula,
q_label,
mode_file_string,
_irrep_filename_tag(unique_labels),
args.conventional,
)
write_vesta_with_arrows(
filepath=output_path,
lattice=np.array(supercell.cell, dtype=float),
scaled_positions=np.array(supercell.scaled_positions, dtype=float),
symbols=list(supercell.symbols),
arrows_cartesian=arrows,
title=title,
)
if len(group) == 1:
print(f"Mode {mode_string} written to: {output_path}")
else:
print(f"Sum of modes {mode_string} written to: {output_path}")
print(
f"\nArrows are scaled so the largest displacement is {args.amplitude:g} A; "
"adjust arrow size in VESTA via Edit > Vectors or Properties > Vectors if needed."
)
if __name__ == "__main__":
main()