FourJ / src /fourj /visualization.py
aibergman's picture
Clean deploy to Hugging Face
bc2957c
Raw
History Blame Contribute Delete
14.2 kB
"""Visualization utilities for fourj spectra."""
from __future__ import annotations
import math
import os
from pathlib import Path
import numpy as np
from .constants import HARTREE_TO_MRY, MRY_TO_MEV
from .lsq import LSQFitResult
from .structure import CrystalStructure
from .transforms import ExchangeSpectrum, FrozenMagnonTransformer
def reciprocal_lattice_rows(lattice_rows: np.ndarray) -> np.ndarray:
return 2.0 * np.pi * np.linalg.inv(lattice_rows).T
def seekpath_labels(label: str) -> str:
replacements = {
"GAMMA": r"$\Gamma$",
"SIGMA": r"$\Sigma$",
"DELTA": r"$\Delta$",
"LAMBDA": r"$\Lambda$",
}
return replacements.get(label, label.replace("_", r"\_"))
class SeekPath:
"""Wrapper around Seekpath for original-cell high-symmetry paths."""
def __init__(self, structure: CrystalStructure, symprec: float = 1e-5) -> None:
self.structure = structure
self.symprec = symprec
def get(self) -> tuple[dict[str, np.ndarray], list[tuple[str, str]], str]:
"""Return point coordinates, path segments, and Bravais label."""
try:
import seekpath # type: ignore
except Exception as exc:
raise RuntimeError("Plotting along symmetry paths requires seekpath in the active Python environment") from exc
path_data = seekpath.get_path_orig_cell(self.structure.spglib_cell, symprec=self.symprec)
points = {label: np.asarray(coords, dtype=float) for label, coords in path_data["point_coords"].items()}
bravais = str(path_data.get("bravais_lattice_extended", path_data.get("bravais_lattice", "unknown")))
return points, list(path_data["path"]), bravais
def point_on_segment(q: np.ndarray, start: np.ndarray, end: np.ndarray, tol: float) -> tuple[bool, float]:
direction = end - start
denom = float(np.dot(direction, direction))
if denom <= tol * tol:
return np.linalg.norm(q - start) <= tol, 0.0
t = float(np.dot(q - start, direction) / denom)
if t < -tol or t > 1.0 + tol:
return False, t
return np.linalg.norm(q - (start + t * direction)) <= tol, min(1.0, max(0.0, t))
def find_existing_points_on_segment(q: np.ndarray, values: np.ndarray, start: np.ndarray, end: np.ndarray, tol: float) -> list[tuple[float, np.ndarray, float]]:
points = []
seen = set()
for qq, value in zip(q, values):
on_segment, t = point_on_segment(qq, start, end, tol)
if not on_segment:
continue
key = tuple(np.round(qq, 10))
if key in seen:
continue
seen.add(key)
points.append((t, qq, float(value)))
points.sort(key=lambda item: item[0])
return points
def make_seekpath_line(start: np.ndarray, end: np.ndarray, npoints: int) -> list[tuple[float, np.ndarray]]:
if npoints < 2:
raise ValueError("--lswt-path-points must be at least 2")
return [(float(t), start + float(t) * (end - start)) for t in np.linspace(0.0, 1.0, npoints)]
class SeekPathPlotter:
"""Matplotlib plots for DFT, full-FT, and LSQ spectra along Seekpath lines."""
def __init__(self, structure: CrystalStructure, output_prefix: Path, symprec: float = 1e-5) -> None:
self.structure = structure
self.output_prefix = output_prefix
self.symprec = symprec
def plot(
self,
q_raw: np.ndarray,
energy_raw_hartree: np.ndarray,
transformer: FrozenMagnonTransformer,
plot_kind: str = "magnon",
vectors: np.ndarray | None = None,
jij_mry: np.ndarray | None = None,
lsq_result: LSQFitResult | None = None,
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]]:
"""Create a Seekpath spectrum plot.
Args:
q_raw: Raw DFT q-points from the energy table.
energy_raw_hartree: Raw DFT energies in Hartree.
transformer: FourJ transformer defining theta and E0.
plot_kind: `magnon` or `energy`.
vectors: Real-space vectors for full-FT spectrum reconstruction.
jij_mry: Exchange constants for full-FT spectrum reconstruction.
lsq_result: Optional LSQ shell fit to overlay.
plot_lswt: Overlay the full-FT reconstructed spectrum.
lswt_moment: Optional moment for `4/M` meV scaling.
dense_path: Evaluate reconstructed curves on a dense Seekpath mesh.
path_points: Points per dense path segment.
tol: q-point matching tolerance for raw DFT points.
min_points: Minimum raw DFT points required for a segment.
Returns:
Plot path, sparse path-data path, and skipped segment names.
"""
sin2 = math.sin(math.radians(transformer.theta_degrees)) ** 2
if sin2 <= 1e-14:
raise ValueError("theta gives sin(theta)^2 too close to zero")
if plot_kind == "energy":
e0 = transformer.reference_energy(q_raw, energy_raw_hartree)
plot_values = (energy_raw_hartree - e0) * HARTREE_TO_MRY
ylabel = r"$E(q)-E_0$ (mRy)"
dft_column = "dft_value_mRy"
elif plot_kind == "magnon":
plot_values = transformer.dft_spectrum_mry(q_raw, energy_raw_hartree)
ylabel = r"$(E(q)-E_0)//\sin^2\theta$ (mRy)"
dft_column = "dft_magnon_mRy"
else:
raise ValueError(f"Unknown plot kind: {plot_kind}")
if plot_lswt:
if vectors is None or jij_mry is None:
raise ValueError("LSWT/exchange overlay requires extracted J_ij values")
if plot_kind != "magnon":
raise ValueError("--plot-lswt is only meaningful with --plot-kind magnon")
if lswt_moment is not None:
if lswt_moment <= 0.0:
raise ValueError("--lswt-moment must be positive")
plot_values = (4.0 / lswt_moment) * plot_values * MRY_TO_MEV
ylabel = rf"$4[J(0)-J(q)]/{lswt_moment:g}$ (meV)"
dft_column = "dft_lswt_meV"
_lswt_values, lswt_label, lswt_column = ExchangeSpectrum.scale(
ExchangeSpectrum.from_jij(q_raw, vectors, jij_mry),
lswt_moment,
)
else:
lswt_label = None
lswt_column = None
if lsq_result is not None and plot_kind != "magnon":
raise ValueError("LSQ plot overlay is only meaningful with --plot-kind magnon")
points, path_segments, bravais = SeekPath(self.structure, self.symprec).get()
recip = reciprocal_lattice_rows(self.structure.lattice_angstrom)
x_offset = 0.0
tick_positions = []
tick_labels = []
rows = []
plotted_segments = []
skipped_segments = []
mpl_config_dir = Path("/private/tmp/matplotlib")
mpl_config_dir.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLBACKEND", "Agg")
os.environ.setdefault("MPLCONFIGDIR", str(mpl_config_dir))
import matplotlib.pyplot as plt # type: ignore
fig, ax = plt.subplots(figsize=(8.0, 4.8))
for start_label, end_label in path_segments:
start = points[start_label]
end = points[end_label]
segment_points = find_existing_points_on_segment(q_raw, plot_values, start, end, tol)
if len(segment_points) < min_points:
skipped_segments.append(f"{start_label}-{end_label}")
continue
segment_length = float(np.linalg.norm((end - start) @ recip))
x = np.asarray([x_offset + t * segment_length for t, _, _ in segment_points], dtype=float)
y = np.asarray([value for _, _, value in segment_points], dtype=float)
dft_line = ax.plot(x, y, marker="o", markersize=3.5, linewidth=1.2, alpha=0.5, label="DFT frozen magnon")
y_secondary = None
if plot_lswt:
q_existing = np.asarray([qq for _, qq, _ in segment_points], dtype=float)
y_secondary = ExchangeSpectrum.scale(ExchangeSpectrum.from_jij(q_existing, vectors, jij_mry), lswt_moment)[0]
if dense_path:
line = make_seekpath_line(start, end, path_points)
q_line = np.asarray([qq for _, qq in line], dtype=float)
y_line = ExchangeSpectrum.scale(ExchangeSpectrum.from_jij(q_line, vectors, jij_mry), lswt_moment)[0]
x_line = np.asarray([x_offset + t * segment_length for t, _ in line], dtype=float)
else:
y_line = y_secondary
x_line = x
ax.plot(x_line, y_line, marker=None if dense_path else "s", markersize=3.0, linewidth=1.5 if dense_path else 1.1, linestyle="--", color=dft_line[0].get_color(), label=lswt_label)
if lsq_result is not None:
q_existing = np.asarray([qq for _, qq, _ in segment_points], dtype=float)
y_lsq_existing = ExchangeSpectrum.scale(lsq_result.spectrum(q_existing), lswt_moment if plot_lswt else None)[0]
if y_secondary is None:
y_secondary = y_lsq_existing
if dense_path:
line = make_seekpath_line(start, end, path_points)
q_line = np.asarray([qq for _, qq in line], dtype=float)
y_lsq = ExchangeSpectrum.scale(lsq_result.spectrum(q_line), lswt_moment if plot_lswt else None)[0]
x_lsq = np.asarray([x_offset + t * segment_length for t, _ in line], dtype=float)
else:
y_lsq = y_lsq_existing
x_lsq = x
ax.plot(x_lsq, y_lsq, marker=None, linewidth=1.4, linestyle=":", color=dft_line[0].get_color(), label=f"LSQ shell fit ({len(lsq_result.shells)} shells)")
tick_positions.append(x_offset)
tick_labels.append(seekpath_labels(start_label))
tick_positions.append(x_offset + segment_length)
tick_labels.append(seekpath_labels(end_label))
plotted_segments.append(f"{start_label}-{end_label}")
if y_secondary is None:
for xx, (t, qq, value) in zip(x, segment_points):
rows.append((start_label, end_label, xx, t, qq[0], qq[1], qq[2], value, None))
else:
for xx, (t, qq, value), secondary_value in zip(x, segment_points, y_secondary):
rows.append((start_label, end_label, xx, t, qq[0], qq[1], qq[2], value, secondary_value))
x_offset += segment_length
if not plotted_segments:
raise ValueError("No Seekpath segment had enough existing q points")
for xpos in tick_positions:
ax.axvline(xpos, color="0.85", linewidth=0.8, zorder=0)
ax.set_xlim(min(tick_positions), max(tick_positions))
ax.set_xticks(tick_positions)
ax.set_xticklabels(tick_labels)
ax.set_ylabel(ylabel)
ax.set_xlabel(r"Wave-vector distance ($\mathrm{\AA}^{-1}$)")
ax.set_title(f"Seekpath {bravais}; existing input q-points only")
ax.grid(axis="y", color="0.9", linewidth=0.8)
if plot_lswt or lsq_result is not None:
handles, labels = ax.get_legend_handles_labels()
unique = {}
for handle, label in zip(handles, labels):
unique.setdefault(label, handle)
ax.legend(unique.values(), unique.keys(), frameon=False, fontsize=8)
fig.tight_layout()
plot_path = self.output_prefix.with_suffix(f".seekpath_{plot_kind}.png")
data_path = self.output_prefix.with_suffix(f".seekpath_{plot_kind}.dat")
fig.savefig(plot_path, dpi=200)
plt.close(fig)
with data_path.open("w") as handle:
if lswt_column is None:
handle.write(f"# start_label end_label path_distance_1_per_A segment_fraction q1 q2 q3 {dft_column}\n")
else:
handle.write("# start_label end_label path_distance_1_per_A segment_fraction q1 q2 q3 " f"{dft_column} {lswt_column}\n")
handle.write("# plotted_segments " + " ".join(plotted_segments) + "\n")
if skipped_segments:
handle.write("# skipped_segments " + " ".join(skipped_segments) + "\n")
for row in rows:
if row[8] is None:
handle.write(f"{row[0]:>8s} {row[1]:>8s} {row[2]:16.8f} {row[3]:12.8f} {row[4]:12.8f} {row[5]:12.8f} {row[6]:12.8f} {row[7]:16.8f}\n")
else:
handle.write(f"{row[0]:>8s} {row[1]:>8s} {row[2]:16.8f} {row[3]:12.8f} {row[4]:12.8f} {row[5]:12.8f} {row[6]:12.8f} {row[7]:16.8f} {row[8]:16.8f}\n")
if plot_lswt and dense_path:
dense_path_file = self.output_prefix.with_suffix(f".seekpath_{plot_kind}_lswt_dense.dat")
with dense_path_file.open("w") as handle:
handle.write("# start_label end_label path_distance_1_per_A segment_fraction q1 q2 q3 " f"{lswt_column}\n")
x_offset = 0.0
for start_label, end_label in path_segments:
if f"{start_label}-{end_label}" in skipped_segments:
continue
start = points[start_label]
end = points[end_label]
segment_length = float(np.linalg.norm((end - start) @ recip))
line = make_seekpath_line(start, end, path_points)
q_line = np.asarray([qq for _, qq in line], dtype=float)
values = ExchangeSpectrum.scale(ExchangeSpectrum.from_jij(q_line, vectors, jij_mry), lswt_moment)[0]
for (t, qq), value in zip(line, values):
handle.write(f"{start_label:>8s} {end_label:>8s} {x_offset + t * segment_length:16.8f} {t:12.8f} {qq[0]:12.8f} {qq[1]:12.8f} {qq[2]:12.8f} {value:16.8f}\n")
x_offset += segment_length
return plot_path, data_path, skipped_segments