Source code for crystod.spin_basis

"""
Symmetry-adapted spin (magnetic) basis workflow for crystod.

Treats the spins on the sites of a selected element as axial-vector degrees
of freedom, decomposes them into irreps of the space group at a q-point by
projection (SALC), separates ferromagnetic (net-moment) from
antiferromagnetic (net-zero) combinations, assigns cluster-multipole ranks
(dipole, octupole, ...), and exports each basis vector as a VESTA file with
spin arrows.

The method follows the cluster multipole / symmetry-adapted multipole moment
(SAMM) framework of M.-T. Suzuki et al., Phys. Rev. B 95, 094406 (2017) and
Phys. Rev. B 99, 174407 (2019).
"""

from __future__ import annotations

from argparse import (
    ArgumentDefaultsHelpFormatter,
    ArgumentParser,
    RawDescriptionHelpFormatter,
    RawTextHelpFormatter,
)
from fractions import Fraction

import numpy as np
from numpy.typing import NDArray

from .spglib_compat import ensure_spglib_compat

ensure_spglib_compat()

from spgrep.core import get_spacegroup_irreps_from_primitive_symmetry
from spgrep.representation import project_to_irrep

from .phonon_vector import _find_intertwiner, reduced_formula, write_vesta_with_arrows
from .vibration_modes import SymmetryOnlyVibrations


class MyHelpFormatter(
    RawTextHelpFormatter,
    RawDescriptionHelpFormatter,
    ArgumentDefaultsHelpFormatter,
):
    pass


desc = """
Construct symmetry-adapted spin bases (cluster multipoles / SAMM) for the
sites of a selected element: the 3N-dimensional axial-vector (spin) space is
decomposed into space-group irreps at a q-point, ferromagnetic (dipole) and
antiferromagnetic (net moment = 0) combinations are separated, and each basis
vector is exported as a VESTA file with spin arrows.

# Command Examples:
crystod-mag -c 221_PPOSCAR_AlNi3 --element Ni --qpoint 0 0 0
crystod-mag -c 221_PPOSCAR_AlNi3 --element Ni --qpoint GM --format qe
"""

#: Names of the magnetic multipole ranks used in the ``crystod-mag`` output
#: (rank ``p`` -> ``2**p``-pole): ``{1: "dipole", 3: "octupole", ...}``.
MULTIPOLE_NAMES = {
    0: "monopole",
    1: "dipole",
    2: "quadrupole",
    3: "octupole",
    4: "hexadecapole",
    5: "32pole",
    6: "64pole",
}


def build_parser() -> ArgumentParser:
    parser = ArgumentParser(description=desc, formatter_class=MyHelpFormatter)
    parser.add_argument("--poscar", default="POSCAR", help="POSCAR path.")
    parser.add_argument(
        "--element",
        required=True,
        help="Magnetic element whose sites carry the spins, e.g. Ni.",
    )
    parser.add_argument(
        "--qpoint",
        nargs="+",
        default=None,
        help="Either a high-symmetry label such as GM/X/M/R or three primitive reciprocal coordinates. "
        "When omitted, the spin-multipole irreps at all special k points are listed (as in --salc).",
    )
    parser.add_argument(
        "--show-spin-direction",
        action="store_true",
        help="Print the spin direction [x, y, z] of every atom for each basis vector "
        "(with a noncollinear magnetization input in the selected --format).",
    )
    parser.add_argument(
        "--format",
        choices=["vasp", "qe"],
        default="vasp",
        help="Format of the noncollinear magnetization input printed with "
        "--show-spin-direction: vasp = MAGMOM line, qe = Quantum ESPRESSO "
        "starting_magnetization/angle1/angle2 (&SYSTEM).",
    )
    parser.add_argument(
        "--amplitude",
        type=float,
        default=1.5,
        help="Arrow length in Angstroms given to the largest spin of each basis vector.",
    )
    parser.add_argument(
        "--tolerance",
        type=float,
        default=1e-5,
        help="Symmetry tolerance.",
    )
    parser.add_argument(
        "--conventional",
        action="store_true",
        help="Export the spin structures in the conventional cell instead of the "
        "primitive cell (primitive-to-conventional matrix from the detected "
        "centring, as in crystod-phonon --vector).",
    )
    return parser


def _parse_coordinate(value: str) -> float:
    try:
        return float(Fraction(value))
    except ValueError:
        return float(value)


# ---------------------------------------------------------------------------
# Axial-vector (spin) representation on the selected sites
# ---------------------------------------------------------------------------

[docs] def get_spin_representation( vibrations: SymmetryOnlyVibrations, site_indices: list[int], qpoint: list[float], ): """Irreps and the axial-vector (spin) representation on selected sites at q. This is the first step of ``crystod-mag`` (at the ``--qpoint`` given, or at every special k point in survey mode): the spins of the ``site_indices`` atoms of the primitive cell are treated as axial vectors, and the little group of ``qpoint`` acts on the 3N-dimensional spin space by the same construction as the vibration (polar-vector) representation of ``crystod-phonon``, with the Cartesian rotation ``R`` replaced by ``det(R) * R`` and the site permutation restricted to the selected sites. The Bloch-phase convention is that of spgrep, so the returned matrices can be projected onto the returned irreps directly with ``spgrep.representation.project_to_irrep``. Args: vibrations: Symmetry container of the primitive cell (``crystod.phonon.SymmetryOnlyVibrations``); its ``rotations``, ``translations`` and ``primitive_cell`` define the space group. site_indices: Indices (into ``vibrations.primitive_cell``) of the N atoms that carry a spin, e.g. every atom of the magnetic element. qpoint: The q point in primitive reciprocal coordinates, e.g. ``[0, 0, 0]`` or ``[0.5, 0.5, 0.5]``. Returns: tuple: ``(irreps, spin_rep, mapping)``. ``irreps`` is the list of spgrep irreps of the little group at ``qpoint`` (each an array of shape ``(order, dim, dim)``); ``spin_rep`` is the complex array of shape ``(order, 3N, 3N)`` holding the spin-representation matrix of every little-group operation, in the order of the irrep matrices; ``mapping`` is the integer array that indexes those operations in ``vibrations.rotations`` (what ``get_irrep_labels`` and ``get_multipole_rank_lists`` expect). Example: >>> from phonopy.interface.vasp import read_vasp >>> from crystod import mag, phonon >>> from crystod.examples import example_path >>> cell = read_vasp(example_path("221_PPOSCAR_SrTiO3")) >>> vib = phonon.SymmetryOnlyVibrations(cell, standardize=False) >>> symbols = vib.primitive_cell.symbols >>> sites = [i for i, s in enumerate(symbols) if s == "O"] # 3 sites >>> q = [0, 0, 0] >>> irreps, spin_rep, mapping = mag.get_spin_representation(vib, sites, q) >>> spin_rep.shape (48, 9, 9) >>> vib.get_irrep_labels(q, irreps, mapping)[6] 'GM4+(3)' """ positions = vibrations.primitive_cell.scaled_positions irreps, mapping = get_spacegroup_irreps_from_primitive_symmetry( rotations=vibrations.rotations, translations=vibrations.translations, kpoint=qpoint, ) little_rotations = vibrations.rotations[mapping] little_translations = vibrations.translations[mapping] n_sites = len(site_indices) representation = [] for rotation, translation in zip(little_rotations, little_translations): permutation = np.zeros((n_sites, n_sites), dtype=complex) for a, index_a in enumerate(site_indices): transformed = rotation @ positions[index_a] + translation for b, index_b in enumerate(site_indices): diff = transformed - positions[index_b] if (np.abs(diff - np.rint(diff)) < 1e-5).all(): phase = np.dot( qpoint, np.dot(np.linalg.inv(rotation), positions[index_b] - translation) - positions[index_b], ) permutation[b, a] = np.exp(2j * np.pi * phase) break representation.append(permutation) # Cartesian axial part per little-group operation lattice_t = np.transpose(vibrations.primitive_cell.cell) lattice_t_inv = np.linalg.inv(lattice_t) spin_rep = [] for op_index, rotation in enumerate(little_rotations): cartesian = lattice_t @ rotation @ lattice_t_inv axial = np.linalg.det(cartesian) * cartesian spin_rep.append(np.kron(representation[op_index], axial)) return irreps, np.array(spin_rep, dtype=complex), mapping
# --------------------------------------------------------------------------- # Magnetic multipole ranks per irrep (Suzuki PRB 95, Table III logic) # ---------------------------------------------------------------------------
[docs] def get_multipole_rank_lists( vibrations: SymmetryOnlyVibrations, little_rotations: NDArray[np.int_], irreps, max_rank: int = 9, ) -> list[list[int]]: """Ranks at which each irrep occurs in the magnetic multipole representations. ``crystod-mag`` uses these lists at q = 0 to name the symmetry-adapted spin bases: the spaces of an irrep are named in the order of the ranks in its list, so the ferromagnetic combination (cluster dipole, rank 1) comes first and the antiferromagnetic ones follow as octupole, ... (the logic of Table III of M.-T. Suzuki et al., Phys. Rev. B 95, 094406). The rank-``p`` magnetic (time-odd, axial) multipole transforms as the angular-momentum- ``p`` representation with parity ``(-1)**(p + 1)`` under inversion: the dipole (``p = 1``) and octupole (``p = 3``) are parity-even, the magnetic quadrupole (``p = 2``) is parity-odd, and so on. The multiplicity of every irrep is obtained by character orthogonality against the rotation characters ``sin((p + 1/2) theta) / sin(theta / 2)`` of the proper part of every little-group operation. Args: vibrations: Symmetry container of the primitive cell (``crystod.phonon.SymmetryOnlyVibrations``); its lattice converts the integer rotations to Cartesian form. little_rotations: Integer rotation matrices of the little-group operations, in the order of the irrep matrices: normally ``vibrations.rotations[mapping]`` with the ``mapping`` returned by ``get_spin_representation``. irreps: The spgrep irreps of the little group, as returned by ``get_spin_representation``. max_rank: Highest rank ``p`` included in the search. Returns: One list per irrep (same order as ``irreps``), holding the ranks ``1 <= p <= max_rank`` at which the irrep appears in the rank-``p`` magnetic multipole representation, in increasing order and repeated according to the multiplicity. ``MULTIPOLE_NAMES`` translates ranks to names. Example: >>> # vib, sites, q: see get_spin_representation (O sites of SrTiO3) >>> irreps, spin_rep, mapping = mag.get_spin_representation(vib, sites, q) >>> labels = vib.get_irrep_labels(q, irreps, mapping) >>> rotations = vib.rotations[mapping] >>> ranks = mag.get_multipole_rank_lists(vib, rotations, irreps) >>> dict(zip(labels, ranks))["GM4+(3)"] [1, 3, 5, 5, 7, 7, 9, 9, 9] """ from .runtime_compat import get_character lattice_t = np.transpose(vibrations.primitive_cell.cell) lattice_t_inv = np.linalg.inv(lattice_t) operations = [lattice_t @ rotation @ lattice_t_inv for rotation in little_rotations] characters = [np.array(get_character(irrep), dtype=complex) for irrep in irreps] order = len(operations) rank_lists: list[list[int]] = [[] for _ in irreps] for p in range(1, max_rank + 1): chi_p = [] for operation in operations: determinant = float(np.sign(np.linalg.det(operation))) proper = operation * determinant cos_theta = np.clip((np.trace(proper) - 1.0) / 2.0, -1.0, 1.0) theta = float(np.arccos(cos_theta)) if theta < 1e-8: base = 2 * p + 1 else: base = np.sin((p + 0.5) * theta) / np.sin(theta / 2.0) parity = ((-1) ** (p + 1)) if determinant < 0 else 1 chi_p.append(parity * base) chi_p = np.array(chi_p) for index, character in enumerate(characters): count = int(round(float(np.real(np.dot(chi_p, np.conj(character)))) / order)) rank_lists[index].extend([p] * max(count, 0)) return rank_lists
# --------------------------------------------------------------------------- # Basis post-processing # --------------------------------------------------------------------------- def _realify(space: NDArray[np.complex128]) -> NDArray[np.float64] | None: """Return a real orthonormal basis of the row space, or None when the space is genuinely complex. First each row is rotated by a global phase. When that fails (e.g. the circular-basis partners (x + iy, x - iy) of a 2-dim irrep), the rows are recombined unitarily: Gram-Schmidt over the real and imaginary parts gives a real basis whenever the space is closed under complex conjugation. Collapsing such a space with np.real instead would fold both partners onto the same vector and lose a dimension. """ rows = [] for row in space: pivot = row[np.argmax(np.abs(row))] aligned = row * np.exp(-1j * np.angle(pivot)) if np.abs(aligned.imag).max() > 1e-8: rows = None break rows.append(aligned.real) if rows is not None: return np.array(rows) dim = space.shape[0] basis: list[NDArray[np.float64]] = [] for row in space: for part in (np.real(row), np.imag(row)): residual = np.array(part, dtype=float) for vector in basis: residual -= np.dot(vector, residual) * vector norm = np.linalg.norm(residual) if norm > 1e-6: basis.append(residual / norm) if len(basis) == dim: break if len(basis) == dim: break if len(basis) < dim: return None candidate = np.array(basis) # the real basis must span the same complex space (closure under conjugation) projection = space @ candidate.T @ candidate if np.abs(projection - np.asarray(space)).max() > 1e-6: return None return candidate def _net_moments(space: NDArray[np.float64], n_sites: int) -> NDArray[np.float64]: return np.array( [np.sum(space[c].reshape(n_sites, 3), axis=0) for c in range(space.shape[0])] )
[docs] def separate_ferro_combination( spaces: list[NDArray[np.float64]], representation: NDArray[np.complex128], n_sites: int, ) -> tuple[NDArray[np.float64] | None, list[NDArray[np.float64]]]: """Split the ferromagnetic combination off the spaces of one irrep. At q = 0 an irrep can occur several times in the spin representation (``2 x GM4+`` for the three Ni sites of AlNi3); ``crystod-mag`` calls this function once per irrep label with all of its projected spaces. The spaces are first aligned to a common partner basis (an intertwiner with ``representation`` is found for every space, and complex circular bases are recombined into real ones), then the net moments ``sum_i S_i`` of the aligned spaces are combined into one unit vector: its unitary completion gives the unique combination that carries a net moment (the cluster dipole, printed as ``FM``) and the orthogonal combinations with ``sum_i S_i = 0`` (``AFM``: octupoles and higher). When no space carries a net moment, or the completion does not separate cleanly, the aligned spaces are returned unchanged as antiferromagnetic. Args: spaces: Real basis arrays of shape ``(dim, 3N)`` (one row per partner of the irrep; ``3N`` = three Cartesian components of N spins) that all carry the same irrep, e.g. the output of ``spgrep.representation.project_to_irrep`` for one irrep. representation: The spin-representation matrices of shape ``(order, 3N, 3N)`` returned by ``get_spin_representation``; used to align the spaces to a common basis. n_sites: The number N of spin-carrying sites. Returns: tuple: ``(ferro, afm)``. ``ferro`` is the basis array of shape ``(dim, 3N)`` with a net moment (``None`` when no combination has one) and ``afm`` is the list of the remaining net-zero basis arrays; together they span the same space as the input. Example: >>> # spin_rep, irreps, sites: see get_spin_representation (SrTiO3, O) >>> import numpy as np >>> from spgrep.representation import project_to_irrep >>> gm4p = irreps[6] # GM4+, twice >>> spaces = [np.real(s) for s in project_to_irrep(spin_rep, gm4p)] >>> ferro, afm = mag.separate_ferro_combination(spaces, spin_rep, 3) >>> ferro.shape, len(afm) ((3, 9), 1) >>> bool(np.abs(afm[0].reshape(3, 3, 3).sum(axis=1)).max() < 1e-8) True """ if len(spaces) == 1: nets = _net_moments(spaces[0], n_sites) if np.abs(nets).max() < 1e-8: return None, [spaces[0]] return spaces[0], [] aligned = [spaces[0]] for space in spaces[1:]: intertwiner = _find_intertwiner(representation, space, spaces[0]) if intertwiner is None: aligned.append(space) else: realified = _realify(intertwiner.conj().T @ space) aligned.append(realified if realified is not None else space) # net-moment magnitude of each space (same per component after alignment) net_vectors = np.array([_net_moments(space, n_sites)[0] for space in aligned]) norms = np.linalg.norm(net_vectors, axis=1) if norms.max() < 1e-8: return None, aligned reference = net_vectors[np.argmax(norms)] / norms.max() coefficients = net_vectors @ reference # signed magnitudes coefficients = coefficients / np.linalg.norm(coefficients) # unitary completion: first row = FM combination, others = AFM matrix = np.eye(len(aligned)) matrix[0] = coefficients q_matrix, _ = np.linalg.qr(matrix.T) q_matrix = q_matrix.T if np.dot(q_matrix[0], coefficients) < 0: q_matrix[0] *= -1 combos = [ np.array([sum(q_matrix[m, a] * aligned[a][c] for a in range(len(aligned))) for c in range(aligned[0].shape[0])]) for m in range(len(aligned)) ] ferro = combos[0] afm = [] for combo in combos[1:]: if np.abs(_net_moments(combo, n_sites)).max() > 1e-6: # could not cleanly separate; keep as-is return None, aligned afm.append(combo) return ferro, afm
def _direction_tag(space_component: NDArray[np.float64], n_sites: int) -> tuple[str, float] | None: """(tag, sign) with an x/y/z/111-style tag when all spins of the component are collinear; sign canonicalizes the basis vector so that the common axis has its first nonzero Cartesian component positive.""" vectors = space_component.reshape(n_sites, 3) norms = np.linalg.norm(vectors, axis=1) active = vectors[norms > 1e-8] if len(active) == 0: return None axis = active[0] / np.linalg.norm(active[0]) for vector in active[1:]: cross = np.linalg.norm(np.cross(axis, vector / np.linalg.norm(vector))) if cross > 1e-6: return None # canonical sign: first nonzero component of the axis positive sign = 1.0 for value in axis: if abs(value) > 1e-8: if value < 0: sign = -1.0 axis = -axis break # nice integer direction scaled = axis / np.max(np.abs(axis)) integers = [] for value in scaled: fraction = Fraction(float(value)).limit_denominator(6) if abs(float(fraction) - value) > 1e-6: return None integers.append(fraction) denominator = np.lcm.reduce([f.denominator for f in integers]) integers = [int(f * denominator) for f in integers] if integers == [1, 0, 0]: return "x", sign if integers == [0, 1, 0]: return "y", sign if integers == [0, 0, 1]: return "z", sign return "".join(str(v) for v in integers).replace("-", "m"), sign # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def _print_qe_noncollinear(symbols: list[str], atom_spins: list, element: str) -> None: """Print pw.x noncollinear magnetization lines (&SYSTEM) for one basis vector. Quantum ESPRESSO specifies noncollinear moments per atom TYPE via starting_magnetization / angle1 / angle2, so the magnetic element is split into one type per distinct spin direction and the atom membership of each type is printed as a comment. """ type_keys: list[tuple] = [] type_of_atom: list[int] = [] for symbol, spin in zip(symbols, atom_spins): if symbol == element: key = (symbol, tuple(np.round(spin, 4))) else: key = (symbol,) if key not in type_keys: type_keys.append(key) type_of_atom.append(type_keys.index(key) + 1) labels = [] magnetic_counter = 0 for key in type_keys: if len(key) == 2: magnetic_counter += 1 labels.append(f"{element}{magnetic_counter}") else: labels.append(key[0]) print(" Quantum ESPRESSO noncollinear magnetization (&SYSTEM):") print(" noncolin = .true.") for type_index, key in enumerate(type_keys, start=1): label = labels[type_index - 1] members = [ f"{symbols[i]}{i + 1}" for i, atom_type in enumerate(type_of_atom) if atom_type == type_index ] if len(key) == 1: print(f" ! type {type_index} ({label}): {' '.join(members)} -- non-magnetic") continue spin = np.array(key[1], dtype=float) magnitude = float(np.linalg.norm(spin)) if magnitude < 1e-8: theta = phi = 0.0 else: theta = float(np.degrees(np.arccos(np.clip(spin[2] / magnitude, -1.0, 1.0)))) phi = float(np.degrees(np.arctan2(spin[1], spin[0]))) print( f" ! type {type_index} ({label}): {' '.join(members)} " f"S = [{spin[0]:.4f}, {spin[1]:.4f}, {spin[2]:.4f}]" ) print(f" starting_magnetization({type_index}) = {magnitude:.4f}") print(f" angle1({type_index}) = {theta:.4f}") print(f" angle2({type_index}) = {phi:.4f}") print( " ! angle1 = polar angle from the z axis (deg); " "angle2 = azimuth from the x axis in the xy plane (deg)." ) print( f" ! Split {element} into the types above in ATOMIC_SPECIES / " "ATOMIC_POSITIONS to realize this spin arrangement." ) def main(argv: list[str] | None = None) -> None: args = build_parser().parse_args(argv) from .star_of_k import read_poscar_or_exit cell = read_poscar_or_exit(args.poscar) vibrations = SymmetryOnlyVibrations(cell=cell, symprec=args.tolerance, standardize=False) primitive = vibrations.primitive_cell symbols = list(primitive.symbols) positions = primitive.scaled_positions lattice = np.array(primitive.cell) site_indices = [i for i, s in enumerate(symbols) if s == args.element] if not site_indices: available = ", ".join(sorted(set(symbols))) raise SystemExit(f"ERROR: element '{args.element}' is not in this POSCAR ({available}).") n_sites = len(site_indices) print(f"Space group: {vibrations.spglib_dataset['international']} " f"(#{vibrations.spglib_dataset['number']})") print(f"Magnetic sites: {args.element} x {n_sites}") for k, index in enumerate(site_indices): print(f" {args.element}{k + 1}: {np.round(positions[index], 6).tolist()}") if args.qpoint is None: # survey mode (as in --salc without --kpoint): spin-multipole irreps # at every special k point of the space group 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 .runtime_compat import get_character IrrepTable, _ = load_irreptables() dataset = vibrations.spglib_dataset irt_table = IrrepTable(dataset["number"], spinor=False) primitive_matrix = get_primitive_matrix_by_centring(dataset["international"][0]) q_names, q_list = get_irt_special_points(irt_table, primitive_matrix) print(f"\n * Spin (axial-vector) Irreducible Representations of {args.element} sites *") for name, q in zip(q_names, q_list): irreps, spin_rep, mapping = get_spin_representation(vibrations, site_indices, q) labels = vibrations.get_irrep_labels(q, irreps, mapping) rep_characters = np.array([np.trace(matrix) for matrix in spin_rep]) terms = [] for irrep, label in zip(irreps, labels): characters = np.array(get_character(irrep), dtype=complex) count = int(round(float(np.real(np.dot(rep_characters, np.conj(characters)))) / len(spin_rep))) if count > 0: terms.append(f"{count}.0 [{label}]") print(f"\n * k point (primitive) * \n {name} {np.round(q, 6).tolist()}") print(f" {' + '.join(terms)}") print("\nUse --qpoint to obtain the symmetry-adapted spin bases, MAGMOM lines, and VESTA files at one k point.") return qpoint_label, qpoint = vibrations.resolve_qpoint(args.qpoint) is_gamma = np.allclose(qpoint, 0.0) print(f"\nSelected q-point: {qpoint_label} = {qpoint}") irreps, spin_rep, mapping = get_spin_representation(vibrations, site_indices, qpoint) irrep_labels = vibrations.get_irrep_labels(qpoint, irreps, mapping) raw_spaces: list[NDArray] = [] raw_labels: list[str] = [] raw_irrep_indices: list[int] = [] for irrep_index, (irrep, label) in enumerate(zip(irreps, irrep_labels)): for space in project_to_irrep(spin_rep, irrep): realified = _realify(space) raw_spaces.append(realified if realified is not None else space) raw_labels.append(label) raw_irrep_indices.append(irrep_index) total_dim = sum(space.shape[0] for space in raw_spaces) print(f"\nSpin (axial-vector) representation on {args.element} sites: " f"{3 * n_sites} dimensions") counted: dict[str, int] = {} for label, space in zip(raw_labels, raw_spaces): counted[label] = counted.get(label, 0) + 1 print("Decomposition: " + " + ".join( (f"{count} x {label}" if count > 1 else label) for label, count in counted.items() )) if total_dim != 3 * n_sites: print(f"WARNING: projected dimensions ({total_dim}) do not span the full space.") # FM/AFM separation (Gamma only) and multipole-rank naming entries = [] # (label, kind, rank_name, space) if is_gamma: rank_lists = get_multipole_rank_lists( vibrations, vibrations.rotations[mapping], irreps ) label_to_irrep_index: dict[str, int] = {} for label, irrep_index in zip(raw_labels, raw_irrep_indices): label_to_irrep_index.setdefault(label, irrep_index) for label in dict.fromkeys(raw_labels): group = [space for space, l in zip(raw_spaces, raw_labels) if l == label] ferro, afm_list = separate_ferro_combination(group, spin_rep, n_sites) ordered = [] if ferro is not None: ordered.append(("FM", np.real(ferro))) ordered.extend(("AFM", np.real(afm)) for afm in afm_list) # assign multipole ranks of this irrep in increasing order # (FM = lowest rank = dipole, then octupole, ...) ranks = rank_lists[label_to_irrep_index[label]] for position, (kind, space) in enumerate(ordered): rank = ranks[position] if position < len(ranks) else None rank_name = MULTIPOLE_NAMES.get(rank, f"rank{rank}") if rank else "multipole" entries.append((label, kind, rank_name, space)) else: for label, space in zip(raw_labels, raw_spaces): entries.append((label, "AFM (q != 0)", None, space)) print("\nSymmetry-adapted spin bases:") for label, kind, rank_name, space in entries: rank_text = f", {rank_name}" if rank_name else "" print(f" {label}: dim {space.shape[0]} [{kind}{rank_text}]") # commensurate (magnetic) supercell for q != 0 if is_gamma: supercell_sizes = np.array([1, 1, 1], dtype=int) else: supercell_sizes = np.array( [ 1 if abs(component) < 1e-10 else Fraction(float(component)).limit_denominator(12).denominator for component in qpoint ], dtype=int, ) print( f"\nMagnetic (commensurate) supercell: " f"{supercell_sizes[0]}x{supercell_sizes[1]}x{supercell_sizes[2]}" ) # Display cell for the VESTA/MAGMOM output: rows of cell_matrix are the # display-cell lattice vectors in primitive units. With --conventional the # conventional cell is used (times commensurate multiples for q != 0); # wrapping an atom by a display-cell vector leaves its Bloch phase # unchanged because q . S_row is an integer by construction. if args.conventional: from .phonon_vector import get_commensurate_supercell_matrix, get_conventional_matrix centring = vibrations.spglib_dataset["international"][0] base_matrix = get_conventional_matrix(centring) cell_matrix = np.array(get_commensurate_supercell_matrix(qpoint, base_matrix), dtype=int) multiples = np.rint( np.diag(cell_matrix @ np.linalg.inv(np.array(base_matrix, dtype=float))) ).astype(int) print( f"Display cell: conventional ({centring} centring), " f"{multiples[0]}x{multiples[1]}x{multiples[2]} cells" ) else: cell_matrix = np.diag(supercell_sizes) inverse_cell = np.linalg.inv(np.array(cell_matrix, dtype=float)) corner_shifts = [np.zeros(3)] for axis in range(3): corner_shifts = corner_shifts + [shift + cell_matrix[axis] for shift in corner_shifts] corner_array = np.array(corner_shifts, dtype=float) t_low = np.floor(corner_array.min(axis=0)).astype(int) - 1 t_high = np.ceil(corner_array.max(axis=0)).astype(int) + 1 coset_eps = 1e-8 cell_translations = [] for t1 in range(t_low[0], t_high[0] + 1): for t2 in range(t_low[1], t_high[1] + 1): for t3 in range(t_low[2], t_high[2] + 1): translation = np.array([t1, t2, t3], dtype=float) frac = translation @ inverse_cell if np.all(frac > -coset_eps) and np.all(frac < 1 - coset_eps): cell_translations.append(translation) supercell_lattice = np.array(cell_matrix, dtype=float) @ lattice supercell_symbols: list[str] = [] supercell_positions: list[NDArray[np.float64]] = [] supercell_site_of_atom: list[int | None] = [] # magnetic-site index or None for translation in cell_translations: for i, symbol in enumerate(symbols): supercell_symbols.append(symbol) frac_cell = (positions[i] + translation) @ inverse_cell supercell_positions.append(frac_cell - np.floor(frac_cell)) supercell_site_of_atom.append(site_indices.index(i) if i in site_indices else None) supercell_positions = np.array(supercell_positions) cell_phases = [np.exp(2j * np.pi * np.dot(qpoint, t)) for t in cell_translations] # detailed output + VESTA export formula = reduced_formula(symbols) used_names: set[str] = set() print() for label, kind, rank_name, space in entries: short_label = label.split("(")[0].strip() component_tags = [] component_vectors = [] for c in range(space.shape[0]): vector = np.asarray(space[c]) result = _direction_tag(np.real(vector), n_sites) if np.abs(np.imag(vector)).max() < 1e-8 else None if result is None: component_tags.append(f"comp{c}") component_vectors.append(vector) else: tag, sign = result component_tags.append(tag) component_vectors.append(sign * vector) header = f"{label} [{kind}" + (f", {rank_name}]" if rank_name else "]") print(f"=== {header} ===") export_list = list(zip(component_tags, component_vectors)) # combined x+y+z configuration for 3-dim spaces with x/y/z components if sorted(component_tags) == ["x", "y", "z"]: combined = np.sum(component_vectors, axis=0) / np.sqrt(len(component_vectors)) export_list.append(("111", combined)) for tag, vector in export_list: # spin field over the magnetic supercell: S_i(T) = Re(S_i exp(2 pi i q.T)) field = np.array( [ np.asarray(vector).reshape(n_sites, 3) * phase for phase in cell_phases ] ) # (n_cells, n_sites, 3) sum_of_squares = np.sum(field * field) if abs(sum_of_squares) > 1e-12: field = field * np.exp(-0.5j * np.angle(sum_of_squares)) field = np.real(field) max_norm = float(np.max(np.linalg.norm(field, axis=2))) if max_norm < 1e-12: print(f" component {tag}: vanishing real spin field; skipped.") continue field = field / max_norm print(f" component {tag}:") for k in range(n_sites): s = field[0, k] print(f" {args.element}{k + 1}: S = [{s[0]: .4f}, {s[1]: .4f}, {s[2]: .4f}]") if is_gamma: net = np.sum(field[0], axis=0) print(f" net moment: {np.round(net, 6).tolist()}") else: net = np.sum(field, axis=(0, 1)) print(f" net moment over the magnetic supercell: {np.round(net, 6).tolist()}") if args.show_spin_direction: magmom_parts = [] atom_spins = [] for atom_index, site in enumerate(supercell_site_of_atom): if site is None: magmom_parts.append("0 0 0") atom_spins.append(np.zeros(3)) else: cell_index = atom_index // len(symbols) s = field[cell_index, site] magmom_parts.append(f"{s[0]:.4f} {s[1]:.4f} {s[2]:.4f}") atom_spins.append(np.array(s, dtype=float)) if args.conventional: cell_note = " (conventional cell)" if is_gamma else " (conventional magnetic cell)" else: cell_note = "" if is_gamma else f" ({supercell_sizes.tolist()} magnetic supercell)" print(f" spin directions (all atoms, POSCAR order){cell_note}:") for atom_index, s_text in enumerate(magmom_parts): print(f" {supercell_symbols[atom_index]}{atom_index + 1}: " f"[{s_text.replace(' ', ', ')}]") if args.format == "qe": _print_qe_noncollinear(supercell_symbols, atom_spins, args.element) else: print(f" MAGMOM = {' '.join(magmom_parts)}") # VESTA export rank_tag = rank_name if rank_name else ("FM" if kind == "FM" else qpoint_label) conv_tag = "_conv" if args.conventional else "" name = f"POSCAR_{formula}_spin_{short_label}_{rank_tag}_{tag}{conv_tag}.vesta" if name in used_names: stem = name[: -len(".vesta")] suffix = 2 while f"{stem}_{suffix}.vesta" in used_names: suffix += 1 name = f"{stem}_{suffix}.vesta" used_names.add(name) arrows = np.zeros((len(supercell_symbols), 3)) for atom_index, site in enumerate(supercell_site_of_atom): if site is not None: cell_index = atom_index // len(symbols) arrows[atom_index] = field[cell_index, site] * args.amplitude write_vesta_with_arrows( filepath=name, lattice=supercell_lattice, scaled_positions=supercell_positions, symbols=supercell_symbols, arrows_cartesian=arrows, title=f"{formula} spin basis: {label} {rank_tag} {tag}", ) print(f" written to: {name}") print() if is_gamma: print("All AFM bases satisfy sum_i S_i = 0; FM entries carry the cluster dipole moment.") print("Reference: M.-T. Suzuki et al., PRB 95, 094406 (2017); PRB 99, 174407 (2019).") if __name__ == "__main__": main()