File size: 34,764 Bytes
0b2f44d
 
 
 
d25c692
 
0b2f44d
 
 
 
 
 
 
810e428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b2f44d
 
 
 
 
 
 
 
 
 
 
d25c692
c251e1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ffcdc5
c251e1b
 
2ffcdc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71c8b9f
 
 
 
 
 
2ffcdc5
 
 
 
 
 
 
 
 
 
 
c251e1b
 
 
 
 
 
 
 
 
 
 
 
 
 
d03b8f4
 
 
d25c692
 
 
 
 
 
 
 
429323f
 
d25c692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b2f44d
 
 
 
 
 
 
 
 
 
 
d25c692
0b2f44d
d25c692
 
 
0b2f44d
 
 
 
 
d25c692
0b2f44d
d25c692
 
 
0b2f44d
 
 
 
 
 
 
 
 
 
 
d25c692
0b2f44d
 
d25c692
0b2f44d
 
 
 
d25c692
0b2f44d
 
429323f
 
 
 
 
d25c692
0b2f44d
d25c692
0b2f44d
 
 
d25c692
0b2f44d
 
d25c692
 
 
 
 
 
 
0b2f44d
d03b8f4
 
0b2f44d
 
 
d25c692
0b2f44d
71c8b9f
 
 
0b2f44d
 
d25c692
6e1bb63
0b2f44d
71c8b9f
0b2f44d
 
 
6e1bb63
0b2f44d
 
429323f
 
 
 
 
 
d25c692
0b2f44d
 
6e1bb63
0b2f44d
 
3ab1010
0b2f44d
 
 
 
429323f
 
 
3ab1010
0b2f44d
 
6e1bb63
0b2f44d
d25c692
429323f
 
 
 
 
 
d25c692
429323f
 
 
 
 
 
d25c692
0b2f44d
 
 
 
429323f
 
0b2f44d
 
 
 
d03b8f4
 
 
0b2f44d
 
 
d25c692
429323f
 
 
 
0b2f44d
 
 
 
 
 
 
6e1bb63
0b2f44d
 
 
d25c692
 
3ab1010
0b2f44d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d25c692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b2f44d
d25c692
0b2f44d
 
 
d25c692
0b2f44d
d25c692
 
 
0b2f44d
d25c692
0b2f44d
d25c692
0b2f44d
d25c692
0b2f44d
d25c692
 
0b2f44d
d25c692
 
 
 
 
 
 
 
 
 
 
 
0b2f44d
 
 
 
d25c692
 
 
 
 
0b2f44d
d25c692
0b2f44d
d25c692
0b2f44d
d25c692
 
0b2f44d
d25c692
 
 
0b2f44d
 
d25c692
0b2f44d
d25c692
0b2f44d
 
 
 
 
d25c692
 
 
 
 
 
 
 
 
 
0b2f44d
 
 
 
 
 
d25c692
 
 
 
 
 
0b2f44d
d25c692
 
0b2f44d
 
 
 
 
 
 
 
 
 
 
d25c692
 
 
0b2f44d
d25c692
 
0b2f44d
 
 
 
 
d25c692
 
 
 
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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
"""Hugging Face / Gradio entry point for the Induced-Moment Exchange Explorer."""

from __future__ import annotations

import hashlib
import inspect
import os
import sys
import tempfile
from pathlib import Path

import numpy as np

# ZeroGPU Spaces provides this package at runtime. Keep local CPU-only
# development import-safe when the package is not installed.
try:
    import spaces
except ImportError:  # pragma: no cover - depends on the hosting runtime
    class _LocalSpaces:
        @staticmethod
        def GPU(*args, **kwargs):
            def decorate(function):
                return function

            return decorate

    spaces = _LocalSpaces()


# Hugging Face ZeroGPU requires at least one GPU-decorated function at startup.
# The explorer itself is CPU-only; this probe is deliberately not connected to
# any UI event and is never called during normal use.
@spaces.GPU(duration=1)
def _zerogpu_probe():
    return True


# The Space runs ``python app.py`` directly from a source-layout repository.
SRC = Path(__file__).resolve().parent / "src"
if str(SRC) not in sys.path:
    sys.path.insert(0, str(SRC))

# Gradio imports a plotting component during startup.  Keep Matplotlib's
# cache in a writable ephemeral location on minimal Space containers.
MPL_CACHE = Path(tempfile.gettempdir()) / "induced_exchange_mpl"
MPL_CACHE.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MPL_CACHE))

APP_CSS = """
:root { --imx-ink: #183b56; --imx-teal: #18a999; --imx-coral: #e07a5f; --imx-paper: #f7fafc; --imx-page: #102a43; --imx-panel: #183b56; --imx-text-muted: #c7d6df; color-scheme: dark; }
html, body, .gradio-container { background: var(--imx-page) !important; color: var(--imx-paper) !important; }
.gradio-container {
  --body-background-fill: var(--imx-page) !important;
  --background-fill-primary: var(--imx-panel) !important;
  --background-fill-secondary: var(--imx-page) !important;
  --body-text-color: var(--imx-paper) !important;
  --body-text-color-subdued: var(--imx-text-muted) !important;
  --block-background-fill: var(--imx-panel) !important;
  --block-info-text-color: var(--imx-text-muted) !important;
  --block-label-background-fill: var(--imx-teal) !important;
  --block-label-text-color: var(--imx-page) !important;
  --block-title-text-color: var(--imx-page) !important;
  --checkbox-label-text-color: var(--imx-paper) !important;
  --table-text-color: var(--imx-paper) !important;
  --table-even-background-fill: #214b63 !important;
  --table-odd-background-fill: var(--imx-panel) !important;
  --button-secondary-background-fill: #214b63 !important;
  --button-secondary-text-color: var(--imx-paper) !important;
  --input-background-fill: var(--imx-paper) !important;
  --input-background-fill-hover: #ffffff !important;
  --input-background-fill-focus: #ffffff !important;
  --input-text-color: #000000 !important;
  --input-placeholder-color: #597080 !important;
}
.gradio-container .gradio-dropdown input,
.gradio-container .gradio-dropdown .wrap,
.gradio-container .gradio-dropdown .options,
.gradio-container .gradio-dropdown .options *,
.gradio-container .gradio-dropdown [role="listbox"],
.gradio-container .gradio-dropdown [role="listbox"] * {
  color: #000000 !important;
}
/* DropdownOptions is rendered as a global fixed-position menu in Gradio. */
ul.options,
ul.options *,
[role="option"],
[role="option"] * {
  color: #000000 !important;
}
input[role="listbox"] {
  color: #000000 !important;
  -webkit-text-fill-color: #000000 !important;
}
.imx-light-field input[type="number"],
.imx-light-field .gradio-dropdown input,
.imx-light-field input[role="listbox"] {
  color: #000000 !important;
  -webkit-text-fill-color: #000000 !important;
}
ul.options,
ul.options .item,
ul.options [role="option"] {
  background-color: #ffffff !important;
}
ul.options .item:hover,
ul.options .item.active,
ul.options [role="option"]:hover,
ul.options [role="option"][aria-selected="true"] {
  background-color: #f0f0f0 !important;
}
.gradio-container .prose,
.gradio-container .prose p,
.gradio-container .prose li,
.gradio-container .prose h1,
.gradio-container .prose h2,
.gradio-container .prose h3,
.gradio-container .prose h4,
.gradio-container .prose blockquote,
.gradio-container .label-wrap label,
.gradio-container .block-info,
.gradio-container .info,
.gradio-container .description,
.gradio-container .tab-nav button { color: var(--imx-paper) !important; }
.gradio-container .imx-plot-heading { color: var(--imx-teal) !important; }
.gradio-container .imx-file-upload .wrap { color: var(--imx-paper) !important; }
.gradio-container .imx-file-upload .wrap .or { color: var(--imx-text-muted) !important; }
.gradio-container .imx-file-upload .wrap .icon-wrap { color: var(--imx-text-muted) !important; }
.imx-shell { max-width: 1380px; margin: 0 auto; }
.imx-hero { background: linear-gradient(125deg, #183b56 0%, #236477 58%, #18a999 100%); color: white; border-radius: 20px; padding: 30px 34px; margin-bottom: 18px; box-shadow: 0 14px 32px rgba(24,59,86,.16); }
.imx-hero h1 { margin: 0 0 8px; font-size: 2.2rem; letter-spacing: -.03em; }
.imx-hero p { margin: 0; max-width: 860px; color: #e7f4f2; font-size: 1.05rem; }
.imx-kicker { text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; font-weight: 700; color: #a9ebe0; }
.imx-note { border-left: 4px solid var(--imx-coral); background: #fff7f3; padding: 12px 15px; border-radius: 8px; color: #5b3b35; }
.imx-card { border: 1px solid #dfe8ed; background: rgba(255,255,255,.72); border-radius: 14px; padding: 6px; }
.imx-muted { color: #597080; }
.imx-plot-heading { color: var(--imx-teal); font-size: .95rem; font-weight: 700; margin: 4px 0 6px; padding: 0 6px; }
.imx-plot-heading p { margin: 0; }
footer { display: none !important; }
"""


def _configure_launch_options(demo, gr):
    """Apply theme/CSS across Gradio versions around the 5-to-6 transition."""

    theme = gr.themes.Soft(primary_hue="teal", secondary_hue="slate")
    parameters = inspect.signature(demo.launch).parameters
    options = {}
    if "theme" in parameters:
        options["theme"] = theme
    else:
        # Gradio 5.50 still consumes these values from the Blocks instance,
        # but warns when they are supplied to Blocks(...).  Configure the
        # already-built instance to avoid both the warning and a launch
        # TypeError on versions that have not moved the arguments yet.
        demo.theme = theme
        demo.theme_css = theme._get_theme_css()
        demo.stylesheets = theme._stylesheets
        demo.theme_hash = hashlib.sha256(demo.theme_css.encode("utf-8")).hexdigest()
    if "css" in parameters:
        options["css"] = APP_CSS
    else:
        demo.css = APP_CSS
    return options


def build_demo():
    """Build the UI lazily so the scientific package remains Gradio-free."""

    try:
        import gradio as gr
    except ImportError as exc:  # pragma: no cover - exercised on bare library installs
        raise RuntimeError("The Space UI needs Gradio. Install the dependencies from requirements.txt.") from exc

    from induced_exchange.space import (
        UploadMappingError,
        AnalysisSession,
        _figure_dressed,
        _figure_dressed_realspace,
        _figure_dressed_realspace_delta,
        _figure_dressed_realspace_relative,
        _figure_exchange,
        _figure_magnons,
        _figure_realspace,
        _figure_response,
        analyse_model,
        classification_from_induced_sites,
        classification_rows,
        cleanup_analysis_artifacts,
        default_induced_sites,
        default_stiffness_q_max,
        dressed_shell_rows,
        export_files,
        inference_rows,
        inspect_upload_mapping,
        load_uploaded_set,
        magnon_markdown,
        model_summary,
        ordering_markdown,
        response_markdown,
        shell_rows,
    )
    from induced_exchange.io_uppasd import InputFormatError, LoadedUppASD

    examples_root = Path(__file__).resolve().parent / "examples"
    example_choices = ["Upload my files", "Simple analytic FM", "Induced-moment toy", "FePt-style UppASD", "UppASD symmetry-reduced Jij"]
    example_paths = {
        "Simple analytic FM": examples_root / "simple_fm" / "inpsd.dat",
        "Induced-moment toy": examples_root / "induced_toy" / "inpsd.dat",
        "FePt-style UppASD": examples_root / "fept_style" / "inpsd.dat",
        "UppASD symmetry-reduced Jij": examples_root / "uppasd_style" / "inpsd.dat",
    }

    def plot_heading(text):
        """Render plot context outside the Matplotlib canvas."""

        return gr.Markdown(f"<div class='imx-plot-heading'>{text}</div>")

    with gr.Blocks(title="Induced-Moment Exchange Explorer") as demo:
        session_state = gr.State(None)
        gr.HTML("<div class='imx-hero'><div class='imx-kicker'>Scientific exchange analysis · CPU-friendly</div><h1>Induced-Moment Exchange Explorer</h1><p>From UppASD-style Jij input to reciprocal exchange, induced response, downfolded interactions, and FM-compatible magnon diagnostics.</p></div>", padding=False)

        with gr.Tabs():
            with gr.Tab("1 · Input"):
                gr.Markdown("### Bring an UppASD input set into the explorer\nUpload `inpsd.dat` and its referenced files. The `inpsd.dat` file is required because it supplies the explicit cell vectors used for reciprocal-space analysis; individual `posfile`, `momfile`, and `jfile` uploads alone are not loaded and no identity cell is assumed. If `inpsd.dat` contains `alat` (in metres), the loader applies it automatically to the cell, positions, and exchange displacements. References are matched by basename because browser uploads do not preserve arbitrary relative paths; manual assignment is available below.")
                with gr.Row():
                    example = gr.Dropdown(example_choices, value="Upload my files", label="Starting point")
                    energy_unit = gr.Dropdown(["mRy", "meV", "eV", "unspecified"], value="mRy", label="Energy unit (default: mRy; confirm input)")
                    symmetry_mode = gr.Radio(
                        [("Complete jfile (use as supplied)", "complete"), ("Symmetry-reduced jfile (expand with spglib)", "reduced")],
                        value="complete",
                        label="Jij symmetry",
                        info="Complete accepts a full UppASD neighbour setup. Reduced generates symmetry-related neighbours; conflicting Jij values are reported, never averaged.",
                    )
                with gr.Row():
                    inpsd_file = gr.File(label="inpsd.dat", file_count="single", type="filepath", elem_classes=["imx-file-upload"])
                    supporting_files = gr.File(label="posfile / momfile / jfile (max 32 files / 100 MiB)", file_count="multiple", type="filepath", elem_classes=["imx-file-upload"])
                with gr.Row():
                    refresh_mapping = gr.Button("Inspect file mapping", variant="secondary")
                    load_button = gr.Button("Load input set", variant="primary")
                mapping_table = gr.Dataframe(headers=["reference", "uploaded basename", "status"], datatype=["str", "str", "str"], row_count=1, label="Referenced-file mapping", interactive=False)
                with gr.Row():
                    manual_pos = gr.Dropdown([], label="Manual posfile assignment", allow_custom_value=False, elem_classes=["imx-light-field"])
                    manual_mom = gr.Dropdown([], label="Manual momfile assignment", allow_custom_value=False, elem_classes=["imx-light-field"])
                    manual_exchange = gr.Dropdown([], label="Manual exchange assignment", allow_custom_value=False, elem_classes=["imx-light-field"])
                load_status = gr.Markdown()
                parsed_summary = gr.Markdown()
                classification = gr.Dataframe(headers=["site", "atom_type", "moment (mu_B)", "role"], datatype=["number", "str", "number", "str"], type="array", row_count=1, label="Role preview (default: m < 0.5 μB is induced)", interactive=False)
                induced_site_toggle = gr.CheckboxGroup(choices=[], value=[], label="Induced sites (toggle)", info="Select induced sites explicitly. By default, sites with reference moment m < 0.5 μB are selected; all other sites are robust.")
                with gr.Row():
                    mesh_size = gr.Slider(4, 16, value=8, step=1, label="Seekpath points per segment", info="The exchange, response, dressed-exchange, and magnon plots use this high-symmetry path. The same value controls the auxiliary 3-D mesh used only for J_eff(r).", elem_classes=["imx-light-field"])
                    analyze_button = gr.Button("Run analysis", variant="primary")

            with gr.Tab("2 · Raw exchange"):
                gr.Markdown("### Raw rigid-site exchange\nAll supplied sites and literal UppASD jfile Jij rows are retained. The ordering diagnostic is the largest eigenvalue of J(q) under the UppASD ordered-pair convention `H = −Σᵢ≠ⱼ Jij eᵢ·eⱼ`. The scan is restricted to the seekpath high-symmetry path (or the explicitly labelled fallback path if seekpath cannot classify the structure); it is not a global 3-D ordering search. The distance plot separates `J_MM` (robust–robust, circles), `J_Mm` (robust–induced, squares), and `J_mm` (induced–induced, triangles).")
                raw_ordering = gr.Markdown("Load an input set and run analysis.")
                with gr.Row():
                    with gr.Column():
                        plot_heading("Raw Jij by subspace · distance")
                        distance_plot = gr.Plot(label="Raw Jij by subspace · distance", show_label=False)
                    with gr.Column():
                        plot_heading("J(q) eigenvalue scan · high-symmetry path")
                        raw_plot = gr.Plot(label="J(q) eigenvalue scan · high-symmetry path", show_label=False)
                shell_table = gr.Dataframe(headers=["shell", "radius", "count", "mean Jij", "min Jij", "max Jij"], row_count=1, label="Radial exchange shells", interactive=False)

            with gr.Tab("3 · Induced response"):
                gr.Markdown("### Induced response\nClassify sites explicitly in the Input tab. The induced response is algebraic and instantaneous; induced sites are not independent LLG/LSWT degrees of freedom. The response panel uses a coherent unit-amplitude robust spin spiral along the same seekpath high-symmetry path.")
                with gr.Row():
                    response_mode = gr.Radio([("J-weighted approximation", "j_weighted"), ("Historical unweighted", "historical")], value="j_weighted", label="Response model")
                    x_override = gr.Textbox(label="X override", placeholder="blank = infer · e.g. 0.12 or 2:0.12, 3:0.08", info="J-weighted mode: X has inverse-energy units and p_ind = X K e, where p_ind = m_ind/|m_ind⁰|.")
                    include_induced = gr.Checkbox(value=True, label="Include induced–induced propagation")
                response_recompute = gr.Button("Recompute response and dressing", variant="primary")
                response_info = gr.Markdown()
                with gr.Row():
                    with gr.Column():
                        plot_heading("p_ind(q) / p_ind(Γ) · coherent spiral")
                        response_plot = gr.Plot(label="p_ind(q) / p_ind(Γ) · coherent spiral", show_label=False)
                    inference_table = gr.Dataframe(headers=["site", "m_ref (mu_B)", "source field (energy)", "X (1/energy)", "warnings"], row_count=1, label="X inference and source-field normalization", interactive=False)

            with gr.Tab("4 · Dressed exchange"):
                gr.Markdown("### Robust-space exchange after induced-moment elimination\nThe induced contribution is a model-dependent correction using the selected response approximation. `J_eff(q)` is the Fourier-space matrix in the robust basis; the q-plot shows its leading eigenvalue. For one robust site (such as Fe with Pt treated as induced), this matrix is a scalar. The first real-space plot uses shell means to compare the direct `J_RR(r)`, cross-block `K_RI(r)`, Mryasov/downfolded `J_Mryasov(r)`, and Polesya-like induced `J_Polesya(r)` channels. With the same static response model, the last two are numerically equivalent and are shown with separate labels. The induced correction `ΔJ_induced(r)` and its relative value `ΔJ_induced(r) / J_RR(r)` are plotted separately below. Real-space x-axes use the implicit UppASD lattice parameter convention (`alat = 1`), while all curves are reconstructed from a separate complete q mesh; moment normalization remains separate in the magnon calculation. The exported `dressed_jfile` is directly compatible with UppASD's ordered-pair convention.")
                dressed_info = gr.Markdown()
                with gr.Row():
                    with gr.Column():
                        plot_heading("Robust-space J_eff(q) · high-symmetry path")
                        dressed_plot = gr.Plot(label="Robust-space J_eff(q) · high-symmetry path", show_label=False)
                    with gr.Column():
                        plot_heading("Real-space exchange channels · shell means")
                        dressed_realspace_plot = gr.Plot(label="Real-space exchange channels · shell means", show_label=False)
                with gr.Row():
                    with gr.Column():
                        plot_heading("Induced correction ΔJ_induced(r) · shell means")
                        dressed_delta_realspace_plot = gr.Plot(label="Induced correction ΔJ_induced(r) · shell means", show_label=False)
                    with gr.Column():
                        plot_heading("Relative induced correction ΔJ_induced/J_MM · shell means")
                        dressed_relative_realspace_plot = gr.Plot(label="Relative induced correction ΔJ_induced/J_MM · shell means", show_label=False)
                dressed_table = gr.Dataframe(headers=["shell", "radius", "count", "mean J_MM(r)", "mean ΔJ_induced(r)", "mean J_eff(r)", "max |Im J_eff|", "mean J_eff(r)/m_R"], row_count=1, label="Auxiliary-mesh reconstructed robust-space exchange shells", interactive=False)

            with gr.Tab("5 · Magnons"):
                gr.Markdown("### FM-compatible spectra\nIf Gamma is not locally stable, the plot is explicitly signed harmonic data, not a stable magnon spectrum.")
                magnon_info = gr.Markdown()
                plot_heading("Magnon bands")
                magnon_plot = gr.Plot(label="Magnon bands", show_label=False)

            with gr.Tab("6 · Compare datasets"):
                gr.Markdown("### Compare two compatible Jij datasets\nUseful for LKAG vs frozen-magnon comparisons. Basis cell/positions must match; exchange values are allowed to differ.")
                with gr.Row():
                    b_inpsd = gr.File(label="Dataset B · inpsd.dat", file_count="single", type="filepath", elem_classes=["imx-file-upload"])
                    b_files = gr.File(label="Dataset B · referenced files", file_count="multiple", type="filepath", elem_classes=["imx-file-upload"])
                    external_response_file = gr.File(label="Optional external induced response (qx qy qz m_ind or path_coordinate m_ind)", file_count="single", type="filepath", elem_classes=["imx-file-upload"])
                    compare_observable = gr.Dropdown(["raw", "dressed", "magnons"], value="raw", label="Compare observable")
                    compare_button = gr.Button("Compare A vs B", variant="primary")
                compare_status = gr.Markdown()
                compare_table = gr.Dataframe(headers=["check / diagnostic", "value"], row_count=1, label="Compatibility and diagnostics", interactive=False)
                plot_heading("Raw exchange comparison")
                compare_plot = gr.Plot(label="Raw exchange comparison", show_label=False)
                plot_heading("Optional induced-response comparison")
                compare_response_plot = gr.Plot(label="Optional induced-response comparison", show_label=False)

            with gr.Tab("7 · Methods / limitations"):
                gr.Markdown("""
                ### Methods in one page

                **Raw rigid-site model.** Every supplied magnetic site is treated as an ordinary rigid moment. This is diagnostic when some sites are physically induced.

                **Polesya-like induced moments.** Induced moments are eliminated as algebraic variables. The historical mode uses an unweighted local neighbour sum; the selected J-weighted mode evaluates the labelled **J-weighted induced-response approximation**.

                **Mryasov-like downfolding.** The induced subspace is eliminated variationally from one quadratic energy functional, yielding a robust-space `J_eff(q)`. No induced magnon branches are added.

                **Reciprocal sampling.** The exchange, response, dressed-exchange, and magnon plots share a seekpath high-symmetry path. The dressed real-space channel, absolute-correction, and relative-correction plots are reconstructed from a separate complete auxiliary mesh and are not inferred from that line.

                **Important limitation.** Conventional input Jij are not, by themselves, an exact first-principles susceptibility or induction kernel. Results depend on the explicit classification, X values, response mode, and conditioning of `I − X K_II`.

                The conceptual foundations are discussed in Mryasov *et al.* on induced moments and two-ion anisotropy in FePt, and Polesya *et al.* on induced moments in FePd/CoPt. See the repository README for the scope and citations. Unsupported cases are flagged rather than silently converted: noncollinear order, AF/ferrimagnetic LSWT, DMI, exchange tensors, SOC, anisotropy, and frequency-dependent susceptibility.
                """)

        gr.Markdown("### Downloads\nEvery successful analysis writes canonical model JSON, validation diagnostics, raw/dressed J(q), response data, dressed Jij where resolvable, and magnon bands.")
        downloads = gr.File(label="Analysis files", file_count="multiple", interactive=False)

        def do_mapping(inpsd, files):
            try:
                mapping = inspect_upload_mapping(inpsd, files)
                choices = list(mapping.choices)
                updates = [gr.Dropdown(choices=choices, value=mapping.references.get(key)) for key in ("posfile", "momfile", "exchange")]
                return mapping.rows(), *updates
            except Exception as exc:
                return [["error", str(exc), "unavailable"]], gr.Dropdown(choices=[]), gr.Dropdown(choices=[]), gr.Dropdown(choices=[])

        def _induced_site_update(model):
            default_sites = default_induced_sites(model)
            choices = [
                (f"site {site.index} · type {site.atom_type} · m={site.moment:.6g} μB", site.index)
                for site in model.sites
                if site.moment is not None
            ]
            return gr.CheckboxGroup(choices=choices, value=list(default_sites))

        def _role_preview(induced_selection, current):
            if current is None:
                return []
            loaded = current.loaded if isinstance(current, AnalysisSession) else current
            if not isinstance(loaded, LoadedUppASD):
                return []
            classification_obj = classification_from_induced_sites(induced_selection, loaded.model)
            return classification_rows(loaded.model, classification_obj.site_role)

        def do_load(current, inpsd, files, selected_example, energy, symmetry_mode_flag, manual_pos_name, manual_mom_name, manual_exchange_name):
            old_session = current if isinstance(current, AnalysisSession) else None
            try:
                expand_symmetry = symmetry_mode_flag == "reduced"
                if selected_example in example_paths:
                    example_dir = example_paths[selected_example].parent
                    example_files = [example_dir / name for name in ("posfile", "momfile", "jfile")]
                    loaded, mapping = load_uploaded_set(example_paths[selected_example], example_files, energy_unit=energy, expand_symmetry=expand_symmetry)
                else:
                    loaded, mapping = load_uploaded_set(inpsd, files, manual_mapping={"posfile": manual_pos_name or "", "momfile": manual_mom_name or "", "exchange": manual_exchange_name or ""}, energy_unit=energy, expand_symmetry=expand_symmetry)
                cleanup_analysis_artifacts(old_session)
                return loaded, f"✅ Loaded `{mapping.inpsd}` with {len(loaded.model.sites)} basis sites and {len(loaded.model.exchange_bonds)} exchange rows.", model_summary(loaded), classification_rows(loaded.model), _induced_site_update(loaded.model), mapping.rows()
            except UploadMappingError as exc:
                return None, f"⚠️ **Could not load input:** {exc}", "", [], gr.CheckboxGroup(choices=[], value=[]), exc.mapping.rows()
            except (FileNotFoundError, InputFormatError, ValueError) as exc:
                return None, f"⚠️ **Could not load input:** {exc}", "", [], gr.CheckboxGroup(choices=[], value=[]), []

        def do_analyse(current, induced_selection, mesh, mode, x_value, include):
            if current is None:
                return (None, "⚠️ Load an input set first.", "", [], None, None, "", [], None, "", None, None, None, None, [], "", None, [])
            old_session = current if isinstance(current, AnalysisSession) else None
            try:
                # The load callback stores LoadedUppASD in gr.State; after the
                # first analysis the same state contains AnalysisSession.
                # Normalize both states before reading the model.  In
                # particular, LoadedUppASD delegates unknown attributes to its
                # MagneticCrystal, so current.loaded.model gives a misleading
                # ``MagneticCrystal has no attribute loaded`` error.
                loaded = current.loaded if isinstance(current, AnalysisSession) else current
                if not isinstance(loaded, LoadedUppASD):
                    raise ValueError("analysis state is neither a loaded UppASD input nor an analysis session")
                classification_obj = classification_from_induced_sites(induced_selection, loaded.model)
                analysis = analyse_model(loaded, classification_obj, mesh_size=int(mesh), mode=mode, x=x_value, include_induced_induced=include)
                cleanup_analysis_artifacts(old_session)
                dressed_rows = []
                if analysis.downfolding is not None:
                    dressed_rows = dressed_shell_rows(analysis)
                delta_max = 0.0 if analysis.downfolding is None else float(np.max(np.abs(analysis.downfolding.delta_induced), initial=0.0))
                path_source = "unknown" if analysis.path is None else analysis.path.path.source
                realspace_note = "controlled auxiliary complete-mesh J_eff(r) is available" if analysis.dressed_real_space is not None else "controlled auxiliary J_eff(r) reconstruction is unavailable"
                rescale_note = "For one positive-moment robust site, the real-space panel also shows J_eff(r)/m_R, the q-dependent exchange scale entering the moment-normalized FM dynamical matrix." if len(analysis.robust_sites) == 1 else "The robust-space result remains matrix-valued for multiple robust sites; no scalar rescaling is implied."
                dressed_text = ordering_markdown("Robust-only", analysis.robust_ordering) + "  \n" + ordering_markdown("Dressed", analysis.dressed_ordering) + f"  \n**Induced correction:** max |ΔJ(q)| = `{delta_max:.6g}`  \n**q sampling:** seekpath high-symmetry path (`{path_source}`)  \n**Real-space output:** {realspace_note}; it is not obtained by inverse-transforming the 1-D path.  \n{rescale_note}"
                return (analysis, "✅ Analysis complete.", ordering_markdown("Raw", analysis.raw_ordering), shell_rows(analysis.loaded.model), _figure_exchange(analysis), _figure_realspace(analysis), response_markdown(analysis), inference_rows(analysis.inference), _figure_response(analysis), dressed_text, _figure_dressed(analysis), _figure_dressed_realspace(analysis), _figure_dressed_realspace_delta(analysis), _figure_dressed_realspace_relative(analysis), dressed_rows, magnon_markdown(analysis), _figure_magnons(analysis), export_files(analysis))
            except Exception as exc:
                return (current, f"⚠️ Analysis failed: {exc}", "", [], None, None, f"⚠️ {exc}", [], None, "", None, None, None, None, [], f"⚠️ {exc}", None, [])

        def do_compare(current, b_input, b_supporting, external_response, observable, energy, symmetry_mode_flag, induced_selection, mesh, mode, x_value, include):
            if current is None:
                return "⚠️ Load and analyze Dataset A first.", [], None, None
            b_analysis = None
            try:
                b_loaded, _ = load_uploaded_set(b_input, b_supporting, energy_unit=energy, expand_symmetry=symmetry_mode_flag == "reduced")
                classification_obj = classification_from_induced_sites(induced_selection, current.loaded.model)
                b_classification = classification_from_induced_sites(induced_selection, b_loaded.model)
                b_analysis = analyse_model(b_loaded, b_classification, mesh_size=int(mesh), mode=mode, x=x_value, include_induced_induced=include)
                from induced_exchange import ExchangeDataset, compare_exchange_datasets
                a_dataset = ExchangeDataset(current.loaded.model, label="Dataset A", robust_sites=classification_obj.robust_sites, induced_sites=classification_obj.induced_sites, x=_parse_x_for_compare(x_value), mode=mode)
                b_dataset = ExchangeDataset(b_analysis.loaded.model, label="Dataset B", robust_sites=b_analysis.robust_sites, induced_sites=b_analysis.induced_sites, x=_parse_x_for_compare(x_value), mode=mode)
                comparison = compare_exchange_datasets(a_dataset, b_dataset, current.q_fractional, include_magnons=True, stiffness_q_max=default_stiffness_q_max(current.loaded.model), external_response=external_response)
                rows_out = [["compatible", comparison.compatibility.compatible], *[["warning", item] for item in comparison.compatibility.warnings], *[["diagnostic", item] for item in comparison.diagnostics]]
                for analysis in (comparison.dataset_a, comparison.dataset_b):
                    for name, stiffness in (('raw', analysis.raw_stiffness), ('robust-only', analysis.robust_stiffness), ('dressed', analysis.dressed_stiffness)):
                        if stiffness is not None:
                            rows_out.append([f"{analysis.dataset.label} {name} stiffness", stiffness.coefficient if stiffness.coefficient is not None else "unavailable"])
                response_plot = None
                if comparison.induced_response is not None:
                    induced = comparison.induced_response
                    if induced.external is not None:
                        rows_out.append(["external induced response source", induced.external.source or "uploaded data"])
                        for label, metrics in (("Dataset A", induced.metrics_a), ("Dataset B", induced.metrics_b)):
                            if metrics is not None:
                                rows_out.append([f"{label} external response relative RMSE", metrics.relative_rmse if metrics.relative_rmse is not None else "unavailable"])
                        from induced_exchange import plot_induced_response_comparison
                        response_plot = plot_induced_response_comparison(comparison)
                import matplotlib.pyplot as plt
                fig, ax = plt.subplots(figsize=(8.4, 4.1))
                from induced_exchange import plot_comparison
                plot_comparison(comparison, observable=observable, ax=ax)
                ax.set_title(f"Dataset A vs B · {observable}")
                fig.tight_layout()
                if response_plot is not None:
                    response_plot = response_plot.figure
                    plt.close(response_plot)
                plt.close(fig)
                cleanup_analysis_artifacts(b_analysis)
                return f"✅ Compared `{comparison.dataset_a.dataset.label}` and `{comparison.dataset_b.dataset.label}`.", rows_out, fig, response_plot
            except Exception as exc:
                cleanup_analysis_artifacts(b_analysis)
                return f"⚠️ Comparison unavailable: {exc}", [], None, None

        def _parse_x_for_compare(value):
            if value is None or not str(value).strip():
                return None
            text = str(value).strip()
            if ":" in text or "=" in text:
                return {int(k.strip()): float(v) for k, v in (token.replace("=", ":", 1).split(":", 1) for token in text.replace(";", ",").split(",") if token.strip())}
            vals = [float(token.strip()) for token in text.replace(";", ",").split(",") if token.strip()]
            return vals[0] if len(vals) == 1 else vals

        refresh_mapping.click(do_mapping, [inpsd_file, supporting_files], [mapping_table, manual_pos, manual_mom, manual_exchange])
        load_button.click(do_load, [session_state, inpsd_file, supporting_files, example, energy_unit, symmetry_mode, manual_pos, manual_mom, manual_exchange], [session_state, load_status, parsed_summary, classification, induced_site_toggle, mapping_table])
        induced_site_toggle.change(_role_preview, [induced_site_toggle, session_state], classification)
        outputs = [session_state, load_status, raw_ordering, shell_table, raw_plot, distance_plot, response_info, inference_table, response_plot, dressed_info, dressed_plot, dressed_realspace_plot, dressed_delta_realspace_plot, dressed_relative_realspace_plot, dressed_table, magnon_info, magnon_plot, downloads]
        for button in (analyze_button, response_recompute):
            button.click(do_analyse, [session_state, induced_site_toggle, mesh_size, response_mode, x_override, include_induced], outputs)
        compare_button.click(do_compare, [session_state, b_inpsd, b_files, external_response_file, compare_observable, energy_unit, symmetry_mode, induced_site_toggle, mesh_size, response_mode, x_override, include_induced], [compare_status, compare_table, compare_plot, compare_response_plot])

    return demo


if __name__ == "__main__":
    import gradio as gr

    demo = build_demo()
    demo.launch(**_configure_launch_options(demo, gr))