File size: 14,233 Bytes
bc2957c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""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