FourJ / src /fourj /workflow.py
aibergman's picture
Clean deploy to Hugging Face
bc2957c
Raw
History Blame Contribute Delete
10 kB
"""High-level class-based FourJ workflow."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from .io import EnergyTable, ResultWriter, VectorSet
from .lsq import LSQExchangeFitter, LSQFitResult, LSQWriter, ShellBuilder
from .mesh import QMeshSymmetrizer, infer_nested_mesh
from .structure import CrystalStructure, ElkInputParser
from .transforms import ExchangeTransformResult, FrozenMagnonTransformer, symmetrize_real_space
from .visualization import SeekPathPlotter
@dataclass(frozen=True)
class WorkflowConfig:
"""Configuration for a complete FourJ analysis.
Attributes:
energy_path: Path to the `energy_vs_q.dat` table.
elk_path: Path to an Elk input or temporary file containing `scale`,
`avec`, and `atoms` blocks.
vectors_path: Optional `jfile`-like list of real-space vectors.
rmax: Radius in Angstrom used to generate all direct-lattice R
vectors up to a cutoff when no explicit vector file is given. If
omitted, vectors are inferred from the nested q mesh dimensions.
theta: Spin-spiral cone angle in degrees.
symmetry: q-space symmetry mode: `none`, `time-reversal`, `lattice`,
or `spglib`.
e0: Reference-energy mode, either `q0` or `min`.
output_prefix: Prefix used for generated output files.
tol: Numerical tolerance used for mesh and internal symmetry checks.
round_decimals: Number of decimals used when grouping q-points.
symprec: Symmetry tolerance passed to spglib and Seekpath.
realspace_pair_symmetry: If true, enforce
:math:`J(R)=J(-R)^*` after the inverse transform.
"""
energy_path: Path = Path("energy_vs_q.dat")
elk_path: Path = Path("elk.tmp")
vectors_path: Path | None = None
rmax: float | None = None
theta: float = 90.0
symmetry: str = "time-reversal"
e0: str = "q0"
output_prefix: Path = Path("fourj")
tol: float = 1e-8
round_decimals: int = 10
symprec: float = 1e-5
realspace_pair_symmetry: bool = True
class FrozenMagnonWorkflow:
"""High-level orchestration for parsing, transforms, fitting, and plotting.
The workflow keeps the individual computational steps available as methods
so scripts and notebooks can run only the parts they need. The CLI is a thin
layer over this class.
Args:
config: Workflow configuration.
"""
def __init__(self, config: WorkflowConfig) -> None:
self.config = config
self.structure: CrystalStructure | None = None
self.energy_table: EnergyTable | None = None
self.transform_result: ExchangeTransformResult | None = None
self.symmetry_used: str | None = None
self.lsq_result: LSQFitResult | None = None
self.vector_source: str | None = None
def load(self) -> None:
"""Read the Elk structure and frozen-magnon energy table."""
self.structure = ElkInputParser().parse(self.config.elk_path)
self.energy_table = EnergyTable.from_file(self.config.energy_path)
def run_transform(self) -> ExchangeTransformResult:
"""Run q-space symmetrization and inverse Fourier transform.
Returns:
The full transform result, including real-space `J_ij`, sampled
`J(0)-J(q)`, q-points, and integration weights.
"""
if self.structure is None or self.energy_table is None:
self.load()
assert self.structure is not None
assert self.energy_table is not None
q, energy, weights, symmetry_used = QMeshSymmetrizer(
self.structure,
tol=self.config.tol,
decimals=self.config.round_decimals,
symprec=self.config.symprec,
).build_samples(self.energy_table.q, self.energy_table.energy_hartree, self.config.symmetry)
self.symmetry_used = symmetry_used
vectors_path = self.config.vectors_path
if vectors_path is None and Path("jfile").exists():
vectors_path = Path("jfile")
if vectors_path is not None:
vectors = VectorSet.from_jfile(vectors_path).vectors
self.vector_source = f"jfile: {vectors_path}"
elif self.config.rmax is not None:
vectors = VectorSet.from_radius(self.structure, self.config.rmax).vectors
self.vector_source = f"direct-lattice translations with |R| <= {self.config.rmax:g} A"
else:
dims, _steps = infer_nested_mesh(self.energy_table.q, self.config.tol)
full_vectors = VectorSet.from_mesh_dimensions(dims).vectors
distances = np.linalg.norm(full_vectors @ self.structure.lattice_angstrom, axis=1)
default_rmax = 0.5 * float(np.max(distances))
vectors = full_vectors[distances <= default_rmax + self.config.tol]
self.vector_source = f"centered direct-lattice grid inferred from q mesh {dims}, |R| <= {default_rmax:.6g} A (half mesh max)"
result = FrozenMagnonTransformer(self.config.theta, self.config.e0).inverse_transform(q, energy, weights, vectors)
if self.config.realspace_pair_symmetry:
vectors_sym, jij_sym = symmetrize_real_space(result.vectors, result.jij_mry)
result = ExchangeTransformResult(vectors_sym, jij_sym, result.jq_delta_mry, result.q, result.energy_hartree, result.weights)
self.transform_result = result
return result
def write_transform_outputs(self) -> tuple[Path, Path, Path]:
"""Write `J_ij`, `J_ij` versus distance, and `J(0)-J(q)` tables."""
if self.transform_result is None:
self.run_transform()
assert self.structure is not None
assert self.transform_result is not None
return ResultWriter(self.config.output_prefix).write_exchange_outputs(
self.transform_result.vectors,
self.transform_result.jij_mry,
self.structure,
self.transform_result.q,
self.transform_result.jq_delta_mry,
self.transform_result.weights,
)
def fit_lsq(self, max_shells: int | None = None, rmax: float | None = None, distance_tol: float = 1e-6) -> LSQFitResult:
"""Fit finite symmetry shells to the transformed `J(0)-J(q)` data.
Args:
max_shells: Maximum number of shortest shells to include.
rmax: Optional real-space cutoff in Angstrom.
distance_tol: Tolerance in Angstrom for radius-based selection.
Returns:
Least-squares fit result.
"""
if self.transform_result is None:
self.run_transform()
assert self.structure is not None
assert self.transform_result is not None
shells, rotation_source = ShellBuilder(self.structure, self.config.tol, self.config.symprec).build(
self.transform_result.vectors,
max_shells=max_shells,
rmax=rmax,
distance_tol=distance_tol,
)
self.lsq_result = LSQExchangeFitter().fit(
self.transform_result.q,
self.transform_result.jq_delta_mry,
shells,
rotation_source,
)
return self.lsq_result
def write_lsq_outputs(self) -> tuple[Path, Path]:
"""Write shell coefficients and q-resolved LSQ residuals."""
if self.lsq_result is None:
raise ValueError("Run fit_lsq before writing LSQ outputs")
assert self.transform_result is not None
return LSQWriter(self.config.output_prefix).write(
self.lsq_result,
self.transform_result.q,
self.transform_result.jq_delta_mry,
)
def plot_seekpath(
self,
plot_kind: str = "magnon",
plot_lswt: bool = False,
lswt_moment: float | None = None,
dense_path: bool = False,
path_points: int = 202,
tol: float = 1e-7,
min_points: int = 2,
) -> tuple[Path, Path, list[str]]:
"""Plot DFT, full-FT, and optional LSQ spectra along Seekpath segments.
Args:
plot_kind: `magnon` for rescaled fourj energies or `energy`
for raw energy differences.
plot_lswt: Overlay the spectrum reconstructed from full `J_ij`.
lswt_moment: Optional magnetic moment for `4[J(0)-J(q)]/M` meV
scaling.
dense_path: Evaluate reconstructed spectra on a dense Seekpath mesh.
path_points: Points per Seekpath segment for dense curves.
tol: Tolerance for matching existing DFT q-points to path segments.
min_points: Minimum DFT points required to draw a segment.
Returns:
Tuple with plot path, tabulated path-data path, and skipped segment
names.
"""
if self.transform_result is None:
self.run_transform()
if self.energy_table is None or self.structure is None or self.transform_result is None:
raise RuntimeError("Workflow has not loaded inputs")
return SeekPathPlotter(self.structure, self.config.output_prefix, self.config.symprec).plot(
self.energy_table.q,
self.energy_table.energy_hartree,
FrozenMagnonTransformer(self.config.theta, self.config.e0),
plot_kind=plot_kind,
vectors=self.transform_result.vectors,
jij_mry=self.transform_result.jij_mry,
lsq_result=self.lsq_result,
plot_lswt=plot_lswt,
lswt_moment=lswt_moment,
dense_path=dense_path,
path_points=path_points,
tol=tol,
min_points=min_points,
)
def max_imag_jij(self) -> float:
"""Return the maximum absolute imaginary component of fitted `J_ij`."""
if self.transform_result is None or len(self.transform_result.jij_mry) == 0:
return 0.0
return float(np.max(np.abs(self.transform_result.jij_mry.imag)))