"""Symmetry-mode analysis of a group-subgroup structure pair
(crystod-group --supergroup-cif/--subgroup-cif).
Given a high-symmetry (parent) structure and a low-symmetry (distorted)
structure of the same compound, the displacive distortion is decomposed into
symmetry-adapted modes of the parent space group: the output lists, for each
parent irrep, the k-vector, the order-parameter direction, the isotropy
subgroup, the number of independent modes, and the mode amplitude in
Angstrom -- the offline counterpart of AMPLIMODES of the Bilbao
Crystallographic Server. If you use this feature, please cite:
D. Orobengoa, C. Capillas, M. I. Aroyo and J. M. Perez-Mato, "AMPLIMODES:
symmetry-mode analysis on the Bilbao Crystallographic Server",
J. Appl. Cryst. 42, 820-833 (2009).
Method: both structures are spglib-standardized; the parent is converted to
the primitive setting of the bundled ISO-IR tables (CIR_data) used by
crystod's space-group irrep machinery (an origin-shift fit absorbs any
difference between the spglib and ISO-IR origin conventions, verified by
invariance of the structure under every tabulated operation). The subgroup translation lattice and the origin shift
are found by strain-tolerant lattice matching plus atom pairing (the
assignment minimizing the total distortion is chosen), the subgroup elements
are identified as the parent operations that leave the distorted structure
invariant, and the displacement field is projected onto every parent irrep
at the k points folding to the subgroup Gamma point with the full induced
irrep matrices (the same machinery as --supergroup). Amplitudes follow the
AMPLIMODES convention: A = sqrt(sum |u_atom|^2) over the primitive cell of
the distorted structure, with Cartesian displacements measured in the
strain-free parent-derived reference lattice. A completeness check
(sum of all projectors = identity on the displacement space) closes every
run.
"""
from __future__ import annotations
import argparse
import os
from fractions import Fraction
from itertools import product
import numpy as np
from .isotropy_subgroup import (
ComputedInducedRepresentation,
InducedRepresentation,
IsotropyAnalyzer,
_nullspace,
_projector,
)
from .spacegroup_product import DEN, SpaceGroupIrrepAlgebra
# An operation whose worst atom mismatch stays under this (Angstrom) leaves
# the idealized structure invariant to machine precision -- not "small", but
# exactly satisfied. It separates operations that genuinely survive from
# ones broken by a real, however tiny, displacement (a pseudo-symmetric
# child can break its own operations by as little as ~0.001 A).
_EXACT_MISMATCH = 1e-8
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="crystod-group --supergroup-cif",
description=(
"Symmetry-mode (AMPLIMODES-style) analysis of a supergroup-"
"subgroup structure pair."
),
)
parser.add_argument(
"--supergroup-cif",
dest="parent",
required=True,
metavar="FILE",
help="High-symmetry (parent) structure file (CIF or POSCAR).",
)
parser.add_argument(
"--subgroup-cif",
dest="child",
required=True,
metavar="FILE",
help="Low-symmetry (distorted) structure file (CIF or POSCAR).",
)
parser.add_argument(
"--tolerance",
type=float,
default=0.01,
help="Symmetry-detection tolerance (symprec) in Angstrom (default: 0.01).",
)
parser.add_argument(
"--conventional",
action="store_true",
help="Write the per-irrep mode VESTA files in the parent conventional "
"basis instead of the invariant-core (primitive-derived) cell "
"(file names get a _conv suffix).",
)
return parser
# ---------------------------------------------------------------------------
# structure loading and setting conversion
# ---------------------------------------------------------------------------
def _load_standardized(path: str, tolerance: float):
"""(conventional cell, primitive cell, dataset-number) via spglib."""
import spglib
if not os.path.isfile(path):
raise SystemExit(f"ERROR: structure file not found: {path}")
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from pymatgen.core import Structure
try:
structure = Structure.from_file(path)
except Exception:
try:
structure = Structure.from_str(open(path).read(), fmt="poscar")
except Exception as exc:
raise SystemExit(f"ERROR: could not read {path}: {exc}")
cell = (
np.asarray(structure.lattice.matrix),
np.asarray(structure.frac_coords),
[site.specie.Z for site in structure],
)
conventional = spglib.standardize_cell(
cell, to_primitive=False, no_idealize=False, symprec=tolerance
)
primitive = spglib.standardize_cell(
cell, to_primitive=True, no_idealize=False, symprec=tolerance
)
if conventional is None or primitive is None:
raise SystemExit(f"ERROR: spglib could not standardize {path}.")
dataset = spglib.get_symmetry_dataset(conventional, symprec=1e-5)
return conventional, primitive, dataset
def _dataset_field(dataset, name):
if isinstance(dataset, dict):
return dataset.get(name)
return getattr(dataset, name, None)
def _fit_origin_shift(algebra, spg_rotations, spg_translations):
"""delta with x_isoir = x_spg + delta: (I - W) delta = v_isoir - v_spg,
modulo the full conventional translation lattice (centring included).
Matches the ISO-IR conventional operations of the algebra against the
spglib conventional operations of the standardized structure. For
centred lattices the spglib conventional dataset carries every centring
copy of each operation, so the translation comparison must be made
modulo the centring translations."""
cdml = {}
for sym in algebra.table.symmetries:
cdml[np.rint(np.asarray(sym.R, dtype=float)).astype(np.int64).tobytes()] = (
np.asarray(sym.t, dtype=float) % 1.0
)
# centring translations in conventional fractional units. The phonopy
# primitive matrix is column-convention (columns = primitive vectors in
# conventional units), so centring vectors are integer combinations of
# the COLUMNS. Rows give the same mod-1 group for I/F (symmetric) and
# even for A/C centring, but NOT for rhombohedral R: rows generate
# (1/3,1/3,1/3) instead of the obverse (2/3,1/3,1/3) -- an origin fit
# against spglib's obverse copies then finds no solution at all.
M = algebra.primitive_matrix
centring = []
seen = set()
for z in product(range(3), repeat=3):
vector = np.round(M @ np.array(z, dtype=float), 6) % 1.0
key = tuple(vector)
if key not in seen:
seen.add(key)
centring.append(np.array(key))
pairs = []
for W, v in zip(spg_rotations, spg_translations):
key = np.rint(W).astype(np.int64).tobytes()
if key not in cdml:
raise SystemExit(
"ERROR: the parent operations do not match the ISO-IR setting "
"(different conventional cell); please report this case."
)
pairs.append((np.asarray(W, dtype=float), cdml[key] - np.asarray(v) % 1.0))
grid = np.arange(24) / 24.0
candidates = np.array(list(product(grid, grid, grid)))
for W, diff in pairs:
residual = candidates @ (np.eye(3) - W).T - diff
keep = np.zeros(len(candidates), dtype=bool)
for c in centring:
wrapped = (residual - c + 0.5) % 1.0 - 0.5
keep |= np.all(np.abs(wrapped) < 1e-6, axis=1)
candidates = candidates[keep]
if len(candidates) == 0:
raise SystemExit(
"ERROR: could not fit the ISO-IR origin shift; please report "
"this case."
)
return candidates[0]
def _to_algebra_primitive(algebra, conventional, delta):
"""Convert the spglib conventional structure to the algebra's primitive
setting; returns (L_prim rows, fractional positions, Z numbers, A) with
the row/column convention A resolved by an invariance check."""
lattice_conv, positions_conv, numbers_conv = conventional
M = algebra.primitive_matrix
shifted = (np.asarray(positions_conv) + delta) % 1.0
for A in (M, M.T):
# consistent row convention: L_prim = A L_conv (rows = primitive
# vectors in conventional units) requires x_p = x_c A^-1 for the SAME
# Cartesian point. Using A_inv.T here (the column convention) breaks
# the geometry whenever A is not symmetric -- C-centred groups! -- yet
# the damaged structure can still pass the group-invariance check
# below, because invariance is necessary but not sufficient.
A_inv = np.linalg.inv(A)
prim_positions = (shifted @ A_inv) % 1.0 # row: x_p = x_c A^-1
lattice_prim = A @ np.asarray(lattice_conv)
merged, merged_z = _merge_duplicates(prim_positions, numbers_conv)
if _invariant_under_algebra(algebra, merged, merged_z):
return lattice_prim, merged, merged_z, A
raise SystemExit(
"ERROR: could not express the parent structure in the ISO-IR primitive "
"setting; please report this case."
)
def _merge_duplicates(positions, numbers, tol=1e-4):
"""Remove duplicate sites (conventional cell contains centring copies)."""
kept, kept_z = [], []
for x, z in zip(positions, numbers):
duplicate = False
for y, zz in zip(kept, kept_z):
d = (np.asarray(x) - np.asarray(y) + 0.5) % 1.0 - 0.5
if zz == z and np.all(np.abs(d) < tol):
duplicate = True
break
if not duplicate:
kept.append(np.asarray(x))
kept_z.append(int(z))
return np.array(kept), kept_z
def _invariant_under_algebra(algebra, positions, numbers, tol=1e-4) -> bool:
for i in range(algebra.n_ops):
W = algebra.rotations[i]
v = np.array(algebra.translations[i], dtype=float) / DEN
for x, z in zip(positions, numbers):
image = (W @ x + v) % 1.0
hit = False
for y, zz in zip(positions, numbers):
d = (image - y + 0.5) % 1.0 - 0.5
if zz == z and np.all(np.abs(d) < tol):
hit = True
break
if not hit:
return False
return True
# ---------------------------------------------------------------------------
# sublattice and atom-mapping search
# ---------------------------------------------------------------------------
def _sublattice_candidates(L_parent, L_child, n, strain_tol=0.20):
"""Integer matrices S (rows: child primitive basis in parent primitive
units, det = n) whose principal strains against the child metric stay
within the tolerance. n comes from the exact atom-count ratio, so large
volume strains (strongly tilted structures) are handled correctly."""
G_child = L_child @ L_child.T
lengths = np.sqrt(np.diag(G_child))
max_coeff = int(np.ceil((1 + strain_tol) * np.max(lengths) / np.min(
np.linalg.norm(L_parent, axis=1)))) + 1
rng = range(-max_coeff, max_coeff + 1)
vectors = [np.array(v) for v in product(rng, rng, rng) if any(v)]
norms = {i: [] for i in range(3)}
for v in vectors:
length = np.linalg.norm(v @ L_parent)
for i in range(3):
if abs(length - lengths[i]) < strain_tol * lengths[i]:
norms[i].append(v)
G_parent = L_parent @ L_parent.T
candidates = []
for v1 in norms[0]:
for v2 in norms[1]:
for v3 in norms[2]:
S = np.array([v1, v2, v3])
if round(np.linalg.det(S)) != n:
continue
G = S @ G_parent @ S.T
# principal strains: sqrt(eig(G^-1 G_child)) - 1
try:
eigenvalues = np.linalg.eigvals(np.linalg.solve(G, G_child))
except np.linalg.LinAlgError:
continue
if np.any(eigenvalues.real <= 0):
continue
strains = np.sqrt(np.abs(eigenvalues.real)) - 1.0
if np.max(np.abs(strains)) < strain_tol:
candidates.append(S)
if not candidates:
raise SystemExit(
"ERROR: no integer sublattice of the parent matches the child "
f"lattice within {strain_tol:.0%} principal strain; is the child "
"really a distorted version of the parent structure?"
)
return candidates
def _invariant_core(S: np.ndarray, rotations) -> np.ndarray:
"""Largest sublattice of the row lattice of S invariant under all the
(integer, primitive-basis) point operations: {t : W t in T_H for all W}.
Since n Z^3 (n = det S) is contained in it, it is found by enumerating
the residues modulo n and taking the Hermite normal form of the
generators."""
from sympy import Matrix
from sympy.matrices.normalforms import hermite_normal_form
n = abs(int(round(np.linalg.det(S))))
S_inv = np.linalg.inv(S)
residues = []
for t in product(range(n), repeat=3):
vector = np.array(t, dtype=float)
ok = True
for W in rotations:
frac = ((W @ vector) @ S_inv + 0.5) % 1.0 - 0.5
if not np.all(np.abs(frac) < 1e-6):
ok = False
break
if ok:
residues.append(np.array(t, dtype=np.int64))
generators = residues + [n * e for e in np.eye(3, dtype=np.int64)]
H = np.array(
hermite_normal_form(Matrix(np.array(generators, dtype=np.int64).T))
).astype(np.int64)
return H.T # rows = invariant sublattice basis
def _translation_reps(S: np.ndarray) -> list[np.ndarray]:
"""Representatives of Z^3 / (rows of S) (parent lattice mod sublattice)."""
n = abs(int(round(np.linalg.det(S))))
S_inv = np.linalg.inv(S)
reps, seen = [], set()
bound = int(np.max(np.abs(S))) + 1
candidates = sorted(
product(range(-bound, bound + 1), repeat=3),
key=lambda t: (sum(abs(v) for v in t),
sum(1 for v in t if v < 0), t),
)
for t in candidates:
frac = (np.array(t, dtype=float) @ S_inv + 1e-9) % 1.0
key = tuple(np.round(frac, 6))
if key not in seen:
seen.add(key)
reps.append(np.array(t, dtype=np.int64))
if len(reps) == n:
break
if len(reps) != n:
raise SystemExit("ERROR: broken sublattice bookkeeping.")
return reps
class MappingResult:
def __init__(self, S, p, ref_frac, ref_orbit, child_frac, child_z, u_frac):
self.S = S # child primitive basis in parent prim units
self.p = p # origin shift (parent primitive fractional)
self.ref_frac = ref_frac # reference atoms, parent primitive frac
self.ref_orbit = ref_orbit # parent orbit id per reference atom
self.child_frac = child_frac # paired child atoms, parent primitive frac
self.child_z = child_z
self.u_frac = u_frac # displacements (parent primitive frac)
def _match_atoms(parent_positions, parent_numbers, parent_orbits,
child_positions, child_numbers, S, L_parent):
"""Pair the child atoms with parent atoms in the S-cell; returns the
best MappingResult for this S (or None)."""
reps = _translation_reps(S)
ref_frac, ref_z, ref_orbit = [], [], []
for x, z, orbit in zip(parent_positions, parent_numbers, parent_orbits):
for t in reps:
ref_frac.append(np.asarray(x, dtype=float) + t)
ref_z.append(int(z))
ref_orbit.append(orbit)
ref_frac = np.array(ref_frac)
if len(ref_frac) != len(child_positions):
return None
S_inv = np.linalg.inv(S)
child_par = np.asarray(child_positions) @ S # child frac -> parent frac
def pair_with(p):
"""Greedy nearest-unused pairing for an origin shift p."""
used = set()
pairs = []
total = 0.0
for y, zc in zip(child_par, child_numbers):
y_shift = y + p
best_j, best_d, best_u = None, None, None
for j, (xr, zr) in enumerate(zip(ref_frac, ref_z)):
if zr != zc or j in used:
continue
d_frac = y_shift - xr
d_frac = d_frac - np.rint(d_frac @ S_inv) @ S
dist = np.linalg.norm(d_frac @ L_parent)
if best_d is None or dist < best_d:
best_j, best_d, best_u = j, dist, d_frac
if best_j is None or best_d > 1.8:
return None
used.add(best_j)
pairs.append((best_j, best_u))
total += best_d**2
return total, pairs
# anchor candidates: the first child atom of EVERY species onto every
# same-species reference atom (the anchor species may itself be
# displaced, so the origin is refined continuously afterwards)
anchor_children = {}
for index, z in enumerate(child_numbers):
anchor_children.setdefault(z, index)
best = None
for z_anchor, child_index in anchor_children.items():
for x, z in zip(ref_frac, ref_z):
if z != z_anchor:
continue
p = x - child_par[child_index]
result = pair_with(p)
if result is None:
continue
# continuous origin refinement: subtract the mean displacement
# (for non-polar subgroups it is ~0; for polar ones this is the
# AMPLIMODES minimum-distortion origin) and re-pair once
for _ in range(2):
mean = np.mean([u for _, u in result[1]], axis=0)
if np.linalg.norm(mean @ L_parent) < 1e-8:
break
p = p - mean
refined = pair_with(p)
if refined is None:
break
result = refined
if result is None:
continue
total, pairs = result
if best is not None and total >= best[0]:
continue
u = np.zeros((len(ref_frac), 3))
child_sorted = np.zeros((len(ref_frac), 3))
child_z_sorted = [0] * len(ref_frac)
for i_child, (j_ref, d_frac) in enumerate(pairs):
u[j_ref] = d_frac
child_sorted[j_ref] = ref_frac[j_ref] + d_frac
child_z_sorted[j_ref] = child_numbers[i_child]
best = (
total,
MappingResult(S, p, ref_frac, ref_orbit, child_sorted,
child_z_sorted, u),
)
return best
# ---------------------------------------------------------------------------
# the analysis
# ---------------------------------------------------------------------------
[docs]
class SymmetryModeAnalysis:
"""Symmetry-mode decomposition of a parent-child structure pair.
The analysis behind ``crystod-group --supergroup-cif PARENT
--subgroup-cif CHILD``: the displacive distortion between a
high-symmetry (parent) structure and a low-symmetry (child) structure
of the same compound is decomposed into symmetry-adapted modes of the
parent space group, giving for every parent irrep the k vector, the
order-parameter direction, the isotropy subgroup, the number of
independent modes and the amplitude in Angstrom (AMPLIMODES
convention). All the work happens in the constructor; the results are
read from the attributes.
Args:
parent_file: High-symmetry structure file (CIF or POSCAR).
child_file: Low-symmetry (distorted) structure file (CIF or
POSCAR); its primitive cell must be an integer multiple of the
parent's.
tolerance: Symmetry-detection tolerance (symprec, Angstrom) for
both structures; the command's default is ``0.01``.
Attributes:
parent_number: Space-group number of the parent (``parent_symbol``
its Hermann-Mauguin symbol); ``child_number`` and
``child_symbol`` likewise for the child.
algebra: The ``SpaceGroupIrrepAlgebra`` of the parent.
L_parent: Parent primitive lattice vectors (rows, Angstrom) of the
strain-free reference in which every displacement is measured.
parent_positions: Parent atoms in the primitive setting of the
ISO-IR tables (fractional); ``parent_numbers`` their atomic
numbers.
size: Primitive-cell multiplication of the child relative to the
parent.
mapping: The atom pairing: ``mapping.S`` (child primitive basis in
parent primitive units, rows), ``mapping.p`` (origin shift),
``mapping.ref_frac`` (reference atoms), ``mapping.child_z``
(atomic numbers) and ``mapping.u_frac`` (displacements, parent
primitive fractional).
core_size: Multiplication of the invariant-core analysis cell; its
atoms are ``ref_frac`` (``n_atoms`` of them) with atomic
numbers ``ref_z`` and Cartesian displacements ``u_cart``.
subgroup_members: The ``(i, t)`` parent operations that leave the
distorted structure invariant.
stars: The parent k stars folding to the child Gamma point, one
dict per star with ``kname``, ``kvec`` (primitive basis) and
``kind`` (``"tabulated"`` or ``"computed"``).
modes: One entry per parent irrep with a nonzero number of modes,
carrying ``kname``, ``kvec``, ``irrep_name``, ``dim`` (number
of independent modes), ``amplitude`` (Angstrom),
``projected_u`` (the irrep-projected displacement field on the
core cell, shape ``(n_atoms, 3)``) and ``label_info()``, which
returns ``(direction label, subgroup info, index)``.
total_distortion: Total distortion amplitude (Angstrom), normalized
within the primitive cell of the distorted structure.
parent_formula: Reduced chemical formula of the parent (the command
names its ``sym_mode_<formula>`` table after it).
Raises:
SystemExit: The child cell is not an integer multiple of the parent
cell, the child cannot be mapped onto the parent, or an
internal consistency check (mode completeness) fails.
Example:
>>> from crystod import group
>>> analysis = group.SymmetryModeAnalysis("221.cif", "140.cif", 0.01)
>>> for mode in analysis.modes:
... label, info, index = mode.label_info()
... print(mode.irrep_name, label, info.international_short,
... mode.dim, round(mode.amplitude, 4))
"""
def __init__(self, parent_file: str, child_file: str, tolerance: float):
parent_conv, parent_prim, parent_ds = _load_standardized(
parent_file, tolerance
)
child_conv, child_prim, child_ds = _load_standardized(child_file, tolerance)
self.parent_number = int(_dataset_field(parent_ds, "number"))
self.child_number = int(_dataset_field(child_ds, "number"))
self.parent_symbol = str(
_dataset_field(parent_ds, "international")).replace("_", "")
self.child_symbol = str(
_dataset_field(child_ds, "international")).replace("_", "")
self.tolerance = tolerance
self.parent_conv = parent_conv
self.child_conv = child_conv
self.child_prim = child_prim
self.algebra = SpaceGroupIrrepAlgebra(str(self.parent_number))
delta = _fit_origin_shift(
self.algebra,
_dataset_field(parent_ds, "rotations"),
_dataset_field(parent_ds, "translations"),
)
(self.L_parent, self.parent_positions, self.parent_numbers,
self.A_setting) = _to_algebra_primitive(self.algebra, parent_conv, delta)
self.delta = delta
# parent orbits (element + Wyckoff) in the primitive setting
self.parent_orbits = self._parent_orbits()
# chemically ordered reduced formula of the parent (file names of the
# saved decomposition table); never fatal
try:
from .poscar2cif import chemical_formula_parts, format_chemical_formula
self.parent_formula = format_chemical_formula(
chemical_formula_parts(
parent_conv[2], _dataset_field(parent_ds, "equivalent_atoms")
)
)
except Exception:
from collections import Counter
from pymatgen.core.periodic_table import Element
counts = Counter(
Element.from_Z(int(z)).symbol for z in parent_conv[2]
)
divisor = 0
for value in counts.values():
divisor = int(np.gcd(divisor, value))
self.parent_formula = "".join(
f"{s}{n // divisor}" if n // divisor != 1 else s
for s, n in sorted(counts.items())
)
# child primitive structure, fractional in its own basis
L_child, child_positions, child_numbers = child_prim
if len(child_numbers) % len(self.parent_numbers) != 0:
raise SystemExit(
f"ERROR: the child primitive cell ({len(child_numbers)} atoms) "
"is not an integer multiple of the parent primitive cell "
f"({len(self.parent_numbers)} atoms)."
)
self.size = len(child_numbers) // len(self.parent_numbers)
candidates = _sublattice_candidates(self.L_parent, L_child, self.size)
best = None
for S in candidates:
result = _match_atoms(
self.parent_positions, self.parent_numbers, self.parent_orbits,
child_positions, child_numbers, S, self.L_parent,
)
if result is not None and (best is None or result[0] < best[0]):
best = result
if best is None:
raise SystemExit(
"ERROR: could not map the child structure onto the parent "
"(is it really a distorted version of the parent structure?)."
)
self.mapping = best[1]
# analysis cell: the largest G-invariant sublattice of T_H, so that
# the displacement space carries a full representation of the parent
# group (complete stars); amplitudes are rescaled back to the
# primitive cell of the distorted structure at the end
self.S_core = _invariant_core(self.mapping.S, self.algebra.rotations)
self.core_size = abs(int(round(np.linalg.det(self.S_core))))
self.n_parent = len(self.parent_numbers)
self._build_core_cell()
self.op_tables = self._op_tables()
self.subgroup_members = self._find_subgroup_members()
self._remove_acoustic_offset()
self.stars = self._folding_stars()
self.modes = self._decompose()
def _remove_acoustic_offset(self):
"""Continuous origin refinement: subtract the uniform-translation
component allowed by the polar directions of the subgroup, so that
the global distortion is minimal (the AMPLIMODES origin convention).
For a non-polar subgroup nothing changes."""
LT = self.L_parent.T
rows = []
seen = set()
for i, _ in self.subgroup_members:
if i in seen:
continue
seen.add(i)
W = self.algebra.rotations[i]
R = LT @ W @ np.linalg.inv(LT)
rows.append(R - np.eye(3))
free = _nullspace(np.vstack(rows))
self.polar_directions = free.shape[1]
if free.shape[1] == 0:
return
# the atom-pairing stage already refines the origin continuously, so
# the residual projected here is usually ~0; the flag above still
# records that the subgroup is polar (free origin) for the report
mean = self.u_cart.mean(axis=0)
shift = free @ (free.T @ mean)
if np.linalg.norm(shift) < 1e-10:
return
shift_frac = shift @ np.linalg.inv(self.L_parent)
self.core_u_frac = self.core_u_frac - shift_frac
self.u_cart = self.core_u_frac @ self.L_parent
self.core_child_frac = self.ref_frac + self.core_u_frac
self.mapping.u_frac = self.mapping.u_frac - shift_frac
self.mapping.child_frac = self.mapping.child_frac - shift_frac
def _build_core_cell(self):
"""Reference atoms and displacements on the invariant-core cell."""
mapping = self.mapping
S_H_inv = np.linalg.inv(mapping.S)
reps_core = _translation_reps(self.S_core)
# atom ordering (parent atom p major, core translation s minor) and
# this translation list are what the Fourier star blocks index by
self.reps_core = reps_core
ref_frac, ref_z, ref_orbit, u_frac = [], [], [], []
for x, z, orbit in zip(self.parent_positions, self.parent_numbers,
self.parent_orbits):
for t in reps_core:
position = np.asarray(x, dtype=float) + t
# displacement of the T_H-equivalent atom of the mapping
found = None
for j, xr in enumerate(mapping.ref_frac):
d = position - xr
if np.all(np.abs((d @ S_H_inv + 0.5) % 1.0 - 0.5) < 1e-6):
found = j
break
if found is None:
raise SystemExit(
"ERROR: broken invariant-core bookkeeping."
)
ref_frac.append(position)
ref_z.append(int(z))
ref_orbit.append(orbit)
u_frac.append(mapping.u_frac[found])
self.ref_frac = np.array(ref_frac)
self.ref_z = ref_z
self.ref_orbit = ref_orbit
self.core_u_frac = np.array(u_frac)
self.n_atoms = len(ref_frac)
self.u_cart = self.core_u_frac @ self.L_parent
# child structure on the core cell (for the subgroup search)
self.core_child_frac = self.ref_frac + self.core_u_frac
# -- parent orbits
def _parent_orbits(self):
algebra = self.algebra
n = len(self.parent_positions)
orbit = list(range(n))
for i in range(algebra.n_ops):
W = algebra.rotations[i]
v = np.array(algebra.translations[i], dtype=float) / DEN
for j in range(n):
image = (W @ self.parent_positions[j] + v) % 1.0
for m in range(n):
d = (image - self.parent_positions[m] + 0.5) % 1.0 - 0.5
if (self.parent_numbers[j] == self.parent_numbers[m]
and np.all(np.abs(d) < 1e-4)):
root = min(orbit[j], orbit[m])
orbit[j] = orbit[m] = root
break
return orbit
# -- parent-cell operation tables (image atom, integer offset, Cartesian R)
def _op_tables(self):
"""Per parent operation i: (img, tau, R) with W x_p + v = x_img[p] +
tau[p] (tau integer) and R the Cartesian rotation. This is the whole
geometric content of the displacement representation: the action on
any supercell follows by translation bookkeeping."""
algebra = self.algebra
LT = self.L_parent.T
tables = []
for i in range(algebra.n_ops):
W = algebra.rotations[i]
v = np.array(algebra.translations[i], dtype=float) / DEN
R = LT @ W @ np.linalg.inv(LT)
img = np.zeros(self.n_parent, dtype=np.int64)
tau = np.zeros((self.n_parent, 3), dtype=np.int64)
for p in range(self.n_parent):
image = W @ self.parent_positions[p] + v
target = None
for m in range(self.n_parent):
d = image - self.parent_positions[m]
if (self.parent_numbers[p] == self.parent_numbers[m]
and np.allclose(d, np.rint(d), atol=1e-4)):
target, offset = m, np.rint(d).astype(np.int64)
break
if target is None:
raise SystemExit(
"ERROR: broken parent-operation bookkeeping."
)
img[p] = target
tau[p] = offset
tables.append((img, tau, R))
return tables
# -- subgroup elements: parent operations preserving the child structure
def _find_subgroup_members(self):
"""Parent operations that survive in the child, selected adaptively.
Every (operation, translation) candidate gets a mismatch distance:
the worst atom-to-nearest-partner distance of the transformed child
structure. Genuine members of H sit at the numerical-noise level of
the standardized child, while broken operations sit at the scale of
the symmetry-BREAKING part of the distortion — which can be far
smaller than any fixed cutoff (pseudo-symmetric structures whose
breaking component is ~0.001 A while the fully symmetric component
is large) or far larger (strong tilts). A fixed threshold therefore
cannot work; instead, the child's own space group fixes how many
members MUST survive, and the threshold is placed at that point of
the sorted mismatch spectrum after checking the gap is clean.
The search runs on the child (T_H) cell; translations are the
representatives of Z^3 / T_H."""
import spglib
algebra = self.algebra
S = self.mapping.S
S_inv = np.linalg.inv(S)
reps = _translation_reps(S)
positions = self.mapping.ref_frac + self.mapping.u_frac
numbers = self.mapping.child_z
candidates = []
for i in range(algebra.n_ops):
W = algebra.rotations[i]
v = np.array(algebra.translations[i], dtype=float) / DEN
for t in reps:
worst = 0.0
for x, z in zip(positions, numbers):
image = W @ x + v + t
best = None
for y, zz in zip(positions, numbers):
if zz != z:
continue
d = image - y
d = d - np.rint(d @ S_inv) @ S
dist = np.linalg.norm(d @ self.L_parent)
if best is None or dist < best:
best = dist
worst = max(worst, best)
candidates.append(((i, np.asarray(t, dtype=np.int64)), worst))
candidates.sort(key=lambda entry: entry[1])
# expected factor-group order of the child on its own (T_H) cell --
# a LOWER bound only. Displacements are measured in the strain-free
# parent-derived reference lattice (the AMPLIMODES convention), so
# an operation broken by the child's METRIC alone, with no atomic
# counterpart, still leaves the displacement field invariant and
# belongs in H. That is a whole class of transitions: a purely
# ferroelastic one, where the distortion is the fully symmetric
# irrep plus a spontaneous strain (La3Ni2O7 I4/mmm -> Fmmm keeps
# every one of the 16 parent operations exactly while spglib reads
# the child as Fmmm, 8). Whenever at least `expected` operations
# sit at the numerical-noise level, they all survive.
child_ops = spglib.get_symmetry(self.child_prim, symprec=1e-5)
expected = len(child_ops["rotations"])
if expected >= len(candidates):
return [entry[0] for entry in candidates]
exact = sum(1 for _, worst in candidates if worst < _EXACT_MISMATCH)
if exact >= expected:
self.strain_only_operations = exact - expected
return [entry[0] for entry in candidates[:exact]]
low = candidates[expected - 1][1]
high = candidates[expected][1]
if high < 2.0 * low + 1e-6:
raise SystemExit(
"ERROR: cannot separate the surviving from the broken parent "
f"operations (mismatch gap {low:.2e} .. {high:.2e} A); the "
"child symmetry is ambiguous at this precision -- try a "
"different --tolerance."
)
return [entry[0] for entry in candidates[:expected]]
# -- k stars folding to the child Gamma point
def _folding_stars(self):
"""One record per distinct parent k star folding to the child Gamma.
The folding points are the reciprocal-lattice points of T_H inside
the parent zone (`size` of them). Points on tabulated special
stars keep their table entry; the others (symmetry lines, planes
or general points -- e.g. the SM point (1/4,1/4,0) of the Pbam
antiferroelectric of PbZrO3) are handled through spgrep small
irreps at the exact k, named with the ISO-IR line machinery."""
algebra = self.algebra
S_H = np.asarray(self.mapping.S, dtype=np.int64)
S_inv_T = np.linalg.inv(S_H).T
points = []
for z in _translation_reps(S_H.T):
k = (np.asarray(z, dtype=float) @ S_inv_T) % 1.0
k_int = k * DEN
if not np.allclose(k_int, np.rint(k_int), atol=1e-6):
raise SystemExit(
"ERROR: a k point folding to the child Gamma point "
f"({np.round(k, 6)}) is not on the 1/{DEN} grid of the "
"irrep tables; this cell multiplication is not "
"supported yet."
)
points.append(np.rint(k_int).astype(np.int64) % DEN)
if len(points) != self.size:
raise SystemExit("ERROR: broken folding-point enumeration.")
# tabulated stars, by arm membership
tabulated = {}
for kname in algebra.k_by_kname:
arms, _ = algebra.star(kname)
for arm in arms:
tabulated[tuple(int(v) % DEN for v in arm)] = kname
stars = []
assigned = set()
for point in points:
key = tuple(int(v) for v in point)
if key in assigned:
continue
kname = tabulated.get(key)
if kname is not None:
arms, _ = algebra.star(kname)
record = {
"kind": "tabulated",
"kname": kname,
"kvec": np.array(algebra.k_by_kname[kname], dtype=float)
/ DEN,
}
else:
arms, _ = algebra._star_of_vector(point)
canonical = np.array(
min(tuple(int(v) for v in arm) for arm in arms),
dtype=np.int64,
)
point_name, names, source = algebra._line_names(canonical)
record = {
"kind": "computed",
"kname": point_name,
"canonical": canonical,
"names": names,
"kvec": algebra.isoir_display_arm(canonical) / DEN,
}
arm_keys = {tuple(int(v) % DEN for v in arm) for arm in arms}
assigned |= arm_keys
record["sort_key"] = min(
tuple(np.round((np.array(k, dtype=float) / DEN) % 1.0, 6))
for k in arm_keys
)
stars.append(record)
return sorted(stars, key=lambda record: record["sort_key"])
# -- Fourier star blocks of the displacement representation
#
# Basis of one star block: (arm a, parent atom p, Cartesian mu) -> the
# plane-wave displacement e^{+2 pi i q_a.t/DEN} u_p / sqrt(n_t) on the
# sublattice copies of atom p over the invariant-core cell. In this
# basis every group element is block-sparse over the arms, the sizes are
# set by the star (3 n_parent m), and the core cell never appears as a
# matrix dimension -- the old dense route built 3N x 3N displacement
# matrices on the core cell, which is unusable already at the
# 4x4x4-fold invariant core of the Pbam antiferroelectric (N = 320).
def _star_dhat(self, arms, arm_index, i):
"""V^dagger D(i, t=0) V on the star block space."""
img, tau, R = self.op_tables[i]
m = len(arms)
np3 = 3 * self.n_parent
matrix = np.zeros((m * np3, m * np3), dtype=np.complex128)
W = self.algebra.rotations[i]
for a in range(m):
q_b = tuple(int(v) % DEN for v in (np.asarray(arms[a]) @ W))
b = arm_index[q_b]
phases = np.exp(-2j * np.pi * (tau @ np.asarray(arms[a])) / DEN)
for p in range(self.n_parent):
row = a * np3 + 3 * img[p]
col = b * np3 + 3 * p
matrix[row : row + 3, col : col + 3] = phases[p] * R
return matrix
def _star_translation_phases(self, arms, t):
"""Diagonal of V^dagger T(t) V (per-arm phases, repeated 3 n_p)."""
phases = np.exp(-2j * np.pi * (np.asarray(arms) @ np.asarray(t)) / DEN)
return np.repeat(phases, 3 * self.n_parent)
def _star_uhat(self, arms):
"""Fourier components of the displacement field over the core cell."""
n_t = len(self.reps_core)
u = self.u_cart.reshape(self.n_parent, n_t, 3)
t_matrix = np.asarray(self.reps_core, dtype=float) # (n_t, 3)
phases = np.exp(
-2j * np.pi * (np.asarray(arms) @ t_matrix.T) / DEN
) / np.sqrt(n_t) # (m, n_t)
# uhat[(a, p, mu)] = sum_s conj(f_a(s)) u[p, s, mu]
uhat = np.einsum("as,psm->apm", phases, u)
return uhat.reshape(-1)
def _star_to_core(self, arms, block_vector):
"""Real-space (core cell) displacement field of a star-block vector."""
n_t = len(self.reps_core)
t_matrix = np.asarray(self.reps_core, dtype=float)
phases = np.exp(
2j * np.pi * (np.asarray(arms) @ t_matrix.T) / DEN
) / np.sqrt(n_t) # (m, n_t)
blocks = block_vector.reshape(len(arms), self.n_parent, 3)
field = np.einsum("as,apm->psm", phases, blocks)
if np.max(np.abs(field.imag)) > 1e-6:
raise SystemExit(
"ERROR: non-real projected displacement field (internal bug)."
)
result = field.real.reshape(self.n_atoms, 3)
result[np.abs(result) < 1e-12] = 0.0 # no signed-zero noise in output
return result
def _star_p_hat_H(self, arms, arm_index, dhats):
"""Subgroup projector V^dagger P_H V on the star block space.
H = child operations x T_H translations; the T_H average is the
diagonal 0/1 projector onto the arms in the reciprocal lattice of
T_H, the rest is the average over the child-cell members."""
S_H = np.asarray(self.mapping.S)
keep = np.array(
[np.all((np.asarray(arm) @ S_H.T) % DEN == 0) for arm in arms],
dtype=float,
)
pi = np.repeat(keep, 3 * self.n_parent)
size = len(arms) * 3 * self.n_parent
P_H = np.zeros((size, size), dtype=np.complex128)
for i, t in self.subgroup_members:
phases = self._star_translation_phases(arms, t)
P_H += (phases * pi)[:, None] * dhats[i]
return P_H / len(self.subgroup_members)
# -- the mode decomposition
def _decompose(self):
algebra = self.algebra
# amplitudes: AMPLIMODES normalizes within the primitive cell of the
# distorted structure (T_H); the core cell repeats it core/size times
rescale = np.sqrt(self.size / self.core_size)
u = self.u_cart.reshape(-1)
total = np.linalg.norm(u)
modes = []
residual = u.copy()
for star in self.stars:
representations = self._star_representations(star)
if not representations:
continue
arms = representations[0][1].arms
for _, representation in representations[1:]:
if not np.array_equal(representation.arms, arms):
raise SystemExit(
"ERROR: inconsistent star-arm ordering (internal bug)."
)
arm_index = {
tuple(int(v) % DEN for v in arm): a
for a, arm in enumerate(arms)
}
negatives = [
arm_index.get(tuple(int(v) % DEN for v in (-np.asarray(arm))))
for arm in arms
]
self_conjugate = all(n is not None for n in negatives)
dhats = [
self._star_dhat(arms, arm_index, i)
for i in range(algebra.n_ops)
]
P_H = self._star_p_hat_H(arms, arm_index, dhats)
uhat = self._star_uhat(arms)
np3 = 3 * self.n_parent
completeness = np.zeros_like(P_H)
for irrep_name, representation in representations:
d_small = representation.dim_small
d_tau = representation.dimension
# complex isotypic projector P_tau = (d/n_ops) sum_i
# delta_a(i)* [row-arm-a blocks of D(i)]
P_c = np.zeros_like(P_H)
for i in range(algebra.n_ops):
diag = np.diagonal(representation.blocks[i])
delta = np.conj(
np.add.reduceat(diag, np.arange(0, d_tau, d_small))
)
P_c += np.repeat(delta, np3)[:, None] * dhats[i]
P_c *= d_tau / algebra.n_ops
completeness += P_c
# the real projector of the old dense route: Re chi* over a
# real displacement space = (P_tau + conj(P_tau)) / 2, with
# conj(P_tau) living on the -k arms
if self_conjugate:
swapped = np.zeros_like(P_c)
for a in range(len(arms)):
for b in range(len(arms)):
swapped[
a * np3 : (a + 1) * np3, b * np3 : (b + 1) * np3
] = np.conj(
P_c[
negatives[a] * np3 : (negatives[a] + 1) * np3,
negatives[b] * np3 : (negatives[b] + 1) * np3,
]
)
P = 0.5 * (P_c + swapped)
else:
P = 0.5 * P_c
dim = int(round(float(np.trace(P @ P_H).real)))
if dim <= 0:
continue
projected_hat = P @ uhat
projected = self._star_to_core(arms, projected_hat)
residual = residual - projected.reshape(-1)
amplitude = float(np.linalg.norm(projected_hat)) * rescale
modes.append(
_ModeEntry(self, star["kname"], star["kvec"], irrep_name,
representation, dim, amplitude, projected)
)
if not np.allclose(
completeness, np.eye(completeness.shape[0]), atol=1e-6
):
raise SystemExit(
f"ERROR: mode-projector completeness check failed at the "
f"{star['kname']} star; please report this case."
)
if np.linalg.norm(residual) > 1e-3 * max(1.0, total):
raise SystemExit(
"ERROR: the distortion is not fully captured by the listed "
"modes; please report this case."
)
self.total_distortion = total * rescale
return modes
def _star_representations(self, star):
"""[(irrep name, induced representation)] of one folding star."""
algebra = self.algebra
if star["kind"] == "tabulated":
return [
(irrep.name, InducedRepresentation(algebra, irrep.name))
for irrep in algebra.irreps_by_kname[star["kname"]]
]
canonical = star["canonical"]
smalls = algebra.computed_irreps_at(canonical)
names = star["names"]
if names is None:
names = [
f"{star['kname']}.{index + 1}" for index in range(len(smalls))
]
print(
f"NOTE: the small irreps at the non-tabulated point "
f"{star['kname']} could not be matched to ISO-IR labels; "
"positional names are used."
)
# conjugate partners (for the paired label of doubled irreps)
partners = []
for index, small in enumerate(smalls):
partner = None
for other, candidate in enumerate(smalls):
if other == index:
continue
if set(candidate["chi"]) == set(small["chi"]) and all(
abs(candidate["chi"][op] - np.conj(small["chi"][op]))
< 1e-6
for op in small["chi"]
):
partner = names[other]
break
partners.append(partner)
# preferred basis: the bundled ISO-IR matrices (tabulated arm order,
# deterministic across spgrep versions). All-or-nothing per star so
# every representation shares one arm ordering.
if star["names"] is not None:
try:
return [
(
names[index],
ComputedInducedRepresentation.from_isoir(
algebra, canonical, small, names[index],
star["kname"], partners[index],
),
)
for index, small in enumerate(smalls)
]
except (LookupError, FileNotFoundError, ValueError):
pass
return [
(
names[index],
ComputedInducedRepresentation(
algebra, canonical, small, names[index], star["kname"],
partners[index],
),
)
for index, small in enumerate(smalls)
]
class _ModeEntry:
def __init__(self, analysis, kname, kvec, irrep_name, representation,
dim, amplitude, projected_u):
self.analysis = analysis
self.kname = kname
self.kvec = np.asarray(kvec, dtype=float)
self.irrep_name = irrep_name
self.representation = representation
self.dim = dim
self.amplitude = amplitude
# irrep-projected displacement field on the core cell, (n_atoms, 3)
self.projected_u = projected_u
self._label_info = None
def label_info(self):
"""(direction label, subgroup info, index) via the isotropy machinery."""
if self._label_info is not None:
return self._label_info
analyzer = IsotropyAnalyzer.from_representation(
self.analysis.algebra, self.representation
)
# H on the representation's translation grid: the child-cell members
# extended by the T_H lattice modulo the grid (T_H translations act
# nontrivially on star arms outside the reciprocal lattice of T_H)
N = self.representation.grid_n
S_H = np.asarray(self.analysis.mapping.S, dtype=np.int64)
tau_set = {
tuple((np.asarray(z, dtype=np.int64) @ S_H) % N)
for z in product(range(N), repeat=3)
}
members = []
seen = set()
for i, t in self.analysis.subgroup_members:
for tau in tau_set:
shifted = tuple(
(np.asarray(t, dtype=np.int64) + np.asarray(tau)) % N
)
if (i, shifted) not in seen:
seen.add((i, shifted))
members.append((i, np.asarray(shifted, dtype=np.int64)))
fixed = analyzer.fixed_space(members)
projector = _projector(fixed)
label, _ = analyzer.direction_label(projector)
stabilizer = analyzer.stabilizer_of(projector)
info, size, index, *_ = analyzer.subgroup_of(stabilizer)
self._label_info = (label, info, index)
return self._label_info
# ---------------------------------------------------------------------------
# output
# ---------------------------------------------------------------------------
def _format_fraction(value: float) -> str:
fraction = Fraction(value).limit_denominator(24)
return str(fraction)
def _kvector_string(kvec) -> str:
return "(" + ",".join(
_format_fraction(v % 1.0) for v in np.asarray(kvec, dtype=float)
) + ")"
def _element_symbol(z: int) -> str:
from pymatgen.core.periodic_table import Element
return Element.from_Z(int(z)).symbol
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
analysis = SymmetryModeAnalysis(args.parent, args.child, args.tolerance)
print("\n* Supergroup (parent) structure *")
print(f"{analysis.parent_symbol} (No. {analysis.parent_number})")
print("\n* Subgroup (distorted) structure *")
print(f"{analysis.child_symbol} (No. {analysis.child_number})")
if getattr(analysis, "strain_only_operations", 0) > 0:
print(
f"note: {analysis.strain_only_operations} parent operations that "
"the child's own metric breaks leave its\natomic pattern exactly "
"invariant -- that part of the symmetry lowering is a "
"spontaneous\nstrain, which the displacive decomposition below "
"does not carry (mode amplitudes are\nmeasured in the strain-free "
"parent-derived reference lattice)."
)
mapping = analysis.mapping
print("\n* Cell relation *")
print("child primitive basis in parent primitive units (rows):")
for row in mapping.S:
print(" (" + ", ".join(str(int(v)) for v in row) + ")")
print(f"origin shift (parent primitive fractional): ("
+ ", ".join(_format_fraction(v % 1.0) for v in mapping.p) + ")")
print(f"primitive cell multiplication: {analysis.size}")
print("\n* Atom pairings and displacements (parent primitive setting) *")
print(f"{'atom':<5} {'reference':<28} {'displacement (frac)':<28} |u| (A)")
u_cart_cell = mapping.u_frac @ analysis.L_parent
max_u = 0.0
for j in range(len(mapping.ref_frac)):
z = mapping.child_z[j]
norm = np.linalg.norm(u_cart_cell[j])
max_u = max(max_u, norm)
ref = mapping.ref_frac[j]
line = (
f"{_element_symbol(z):<5} "
f"({ref[0]:.5f},{ref[1]:.5f},{ref[2]:.5f})".ljust(29)
+ f"({mapping.u_frac[j][0]:+.5f},{mapping.u_frac[j][1]:+.5f},"
f"{mapping.u_frac[j][2]:+.5f})".ljust(29)
+ f"{norm:.4f}"
)
print(line)
print(f"\nmaximum atomic displacement: {max_u:.4f} A")
print(f"total distortion amplitude : {analysis.total_distortion:.4f} A")
print("(normalized within the primitive cell of the distorted structure)")
if getattr(analysis, "polar_directions", 0) > 0:
print(
"note: the subgroup is polar; the free origin is placed at the "
"minimum of the total\ndistortion (AMPLIMODES convention, no "
"acoustic translation in the modes) -- programs\npinning the "
"origin differently (e.g. ISODISTORT) report different "
"amplitudes for the\npolar irreps."
)
table_lines = ["* Symmetry-mode decomposition *"]
table_lines.append(
f"{'k-vector':<16} {'irrep':<7} {'direction':<12} "
f"{'isotropy subgroup':<19} {'dim':<4} amplitude (A)"
)
for mode in analysis.modes:
label, info, index = mode.label_info()
subgroup = f"{info.number} {info.international_short}"
table_lines.append(
f"{_kvector_string(mode.kvec):<16} "
f"{mode.irrep_name:<7} {label:<12} {subgroup:<19} "
f"{mode.dim:<4} {mode.amplitude:.4f}"
)
if any(star["kind"] == "computed" for star in analysis.stars):
table_lines.append(
"(non-special k points: the order-parameter components are "
"expressed in the bundled\n ISO-IR matrix basis, whose phase "
"gauge may differ from the ISOTROPY web tables;\n the isotropy "
"subgroup, dimension and amplitude are gauge-independent)"
)
print("\n" + "\n".join(table_lines))
# the table again as a text file, named by the parent composition
table_path = f"sym_mode_{analysis.parent_formula}"
with open(table_path, "w", encoding="utf-8") as handle:
handle.write("\n".join(table_lines) + "\n")
print(f"\nDecomposition table saved to {table_path}")
print("\n* Normalized mode components (parent primitive fractional, per 1 A) *")
for mode in analysis.modes:
if mode.amplitude < 1e-4:
print(f"{mode.irrep_name}: amplitude 0 (allowed but not activated)")
continue
direction = mode.projected_u.reshape(-1)
# normalize to 1 A within the primitive cell of the distorted structure
direction = direction / np.linalg.norm(direction) * np.sqrt(
analysis.core_size / analysis.size
)
frac = (direction.reshape(-1, 3) @ np.linalg.inv(analysis.L_parent))
parts = []
for j in range(analysis.n_atoms):
if np.linalg.norm(direction.reshape(-1, 3)[j]) > 1e-6:
parts.append(
f"{_element_symbol(analysis.ref_z[j])}"
f"({analysis.ref_frac[j][0]:.3f},"
f"{analysis.ref_frac[j][1]:.3f},"
f"{analysis.ref_frac[j][2]:.3f}): "
f"({frac[j][0]:+.4f},{frac[j][1]:+.4f},{frac[j][2]:+.4f})"
)
print(f"{mode.irrep_name}:")
for part in parts:
print(f" {part}")
_export_mode_vesta_files(analysis, args.parent, conventional=args.conventional)
print("\nConventions and validation: AMPLIMODES (Bilbao Crystallographic "
"Server):")
print('D. Orobengoa, C. Capillas, M. I. Aroyo and J. M. Perez-Mato,')
print('"AMPLIMODES: symmetry-mode analysis on the Bilbao Crystallographic')
print('Server", J. Appl. Cryst. 42, 820-833 (2009).')
print()
_VESTA_ARROW_LENGTH = 1.5 # A, largest arrow per file (same as --vector)
def _conventional_display_cell(analysis):
"""Display supercell for --conventional VESTA output.
Returns (D, translations): D is the smallest diagonal multiple of the
parent conventional cell (rows in parent primitive units) whose lattice
is a sublattice of the invariant-core lattice, so every mode pattern is
periodic over the display cell; translations are the core-lattice
representatives that tile the display cell.
"""
from math import gcd
from .phonon_vector import get_conventional_matrix
conventional = get_conventional_matrix(analysis.parent_symbol[0])
S_core = np.asarray(analysis.S_core)
S_core_inv = np.linalg.inv(S_core)
sizes = []
for row in conventional:
multiple = 1
for component in np.asarray(row, dtype=float) @ S_core_inv:
denominator = Fraction(float(component)).limit_denominator(48).denominator
multiple = multiple * denominator // gcd(multiple, denominator)
sizes.append(multiple)
D = np.diag(sizes) @ conventional
D_inv = np.linalg.inv(D)
n_copies = int(round(abs(np.linalg.det(D)) / abs(np.linalg.det(S_core))))
bound = int(np.ceil(np.abs(np.asarray(D, dtype=float) @ S_core_inv).sum())) + 1
translations = []
seen = set()
for m in product(range(-bound, bound + 1), repeat=3):
t = np.asarray(m) @ S_core
frac = np.mod(np.round(t @ D_inv, 8), 1.0)
key = tuple(np.round(frac, 6))
if key in seen:
continue
seen.add(key)
translations.append(t)
if len(translations) == n_copies:
break
return D, translations
def _export_mode_vesta_files(analysis, parent_path: str,
conventional: bool = False) -> None:
"""Write one VESTA file per activated irrep showing its displacement
pattern: arrows of the irrep-projected distortion on the parent-derived
reference structure, in the invariant-core cell (default) or in the
parent conventional basis (--conventional, _conv suffix).
"""
from .phonon_vector import write_vesta_with_arrows
parent_base = os.path.basename(parent_path)
if parent_base.lower().endswith(".cif"):
parent_base = parent_base[: -len(".cif")]
if conventional:
D, translations = _conventional_display_cell(analysis)
lattice = D @ analysis.L_parent
D_inv = np.linalg.inv(D)
positions = []
atom_source = []
for j in range(analysis.n_atoms):
for t in translations:
positions.append(
np.mod((analysis.ref_frac[j] + t) @ D_inv, 1.0)
)
atom_source.append(j)
scaled_positions = np.array(positions)
suffix = "_conv"
cell_note = "parent conventional basis"
else:
lattice = analysis.S_core @ analysis.L_parent
scaled_positions = np.mod(
analysis.ref_frac @ np.linalg.inv(analysis.S_core), 1.0
)
atom_source = list(range(analysis.n_atoms))
suffix = ""
cell_note = "invariant-core cell"
symbols = [_element_symbol(analysis.ref_z[j]) for j in atom_source]
written = []
for mode in analysis.modes:
if mode.amplitude < 1e-4:
continue
arrows_core = mode.projected_u
peak = float(np.max(np.linalg.norm(arrows_core, axis=1)))
if peak < 1e-10:
continue
arrows = arrows_core[atom_source] * (_VESTA_ARROW_LENGTH / peak)
filename = f"{parent_base}_{mode.irrep_name}{suffix}.vesta"
write_vesta_with_arrows(
filepath=filename,
lattice=lattice,
scaled_positions=scaled_positions,
symbols=symbols,
arrows_cartesian=arrows,
title=(
f"{parent_base} {mode.irrep_name} mode "
f"(amplitude {mode.amplitude:.4f} A)"
),
)
written.append((filename, mode.amplitude))
if not written:
return
print(f"\n* Mode displacement VESTA files ({cell_note}) *")
if conventional:
print("display cell in parent primitive units (rows):")
for row in D:
print(" (" + ", ".join(str(int(v)) for v in row) + ")")
for filename, amplitude in written:
print(f" {filename} (amplitude {amplitude:.4f} A)")
print(f"Arrows are scaled so the largest displacement is "
f"{_VESTA_ARROW_LENGTH} A per file; adjust in VESTA via "
"Edit > Vectors if needed.")
if __name__ == "__main__":
main()