"""Command-line interface for the FourJ package.""" from __future__ import annotations import argparse import sys from pathlib import Path from .workflow import FrozenMagnonWorkflow, WorkflowConfig def structure_metadata(workflow: FrozenMagnonWorkflow) -> list[tuple[str, str]]: """Return best-effort structure labels for CLI output.""" rows: list[tuple[str, str]] = [] if workflow.structure is None: return rows try: from .visualization import SeekPath _points, _segments, bravais = SeekPath(workflow.structure, workflow.config.symprec).get() rows.append(("Bravais", bravais)) except Exception: pass try: import spglib # type: ignore dataset = spglib.get_symmetry_dataset(workflow.structure.spglib_cell, symprec=workflow.config.symprec) if dataset is not None: number = getattr(dataset, "number", None) if not isinstance(dataset, dict) else dataset.get("number") international = getattr(dataset, "international", None) if not isinstance(dataset, dict) else dataset.get("international") hall = getattr(dataset, "hall", None) if not isinstance(dataset, dict) else dataset.get("hall") if international and number: rows.append(("Space group", f"{international} ({number})")) elif international: rows.append(("Space group", str(international))) if hall: rows.append(("Hall", str(hall))) except Exception: pass return rows def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="FourJ E(q) -> J(q) -> J_ij transform for Elk spin spirals.") parser.add_argument("--energy", default="energy_vs_q.dat", type=Path, help="Input E(q) table") parser.add_argument("--elk", default="elk.tmp", type=Path, help="Elk input/tmp file with scale, avec, atoms") parser.add_argument("--vectors", type=Path, default=None, help="Optional jfile-like R-vector list; columns 3-5 are integer direct-lattice R. Defaults to ./jfile if present; otherwise infer from q mesh unless --rmax is set.") parser.add_argument("--rmax", type=float, default=None, help="Generate all integer direct-lattice R vectors up to this distance in Angstrom when no vector file is given") parser.add_argument("--theta", type=float, default=90.0, help="Cone angle in degrees") parser.add_argument("--symmetry", choices=("none", "time-reversal", "lattice", "spglib"), default="time-reversal", help="q-space symmetry averaging/completion mode") parser.add_argument("--e0", choices=("q0", "min"), default="q0", help="Reference energy for E(q)-E0") parser.add_argument("--output-prefix", default="fourj", type=Path) parser.add_argument("--tol", type=float, default=1e-8) parser.add_argument("--round-decimals", type=int, default=10) parser.add_argument("--symprec", type=float, default=1e-5, help="spglib symmetry tolerance") parser.add_argument("--no-realspace-pair-symmetry", action="store_true", help="Do not enforce J(R)=conj[J(-R)]") parser.add_argument("--gui", action="store_true", help="Launch the Plotly dashboard preloaded with these CLI input paths and settings.") parser.add_argument("--gui-host", default="127.0.0.1", help="Host for --gui dashboard server") parser.add_argument("--gui-port", default=8050, type=int, help="Port for --gui dashboard server") parser.add_argument("--gui-debug", action="store_true", help="Run --gui dashboard in Dash debug mode") parser.add_argument("--plot-path", action="store_true", help="Plot an E(q)-derived spectrum along Seekpath symmetry segments.") parser.add_argument("--plot-kind", choices=("magnon", "energy"), default="magnon") parser.add_argument("--plot-tol", type=float, default=1e-7) parser.add_argument("--plot-min-points", type=int, default=2) parser.add_argument("--plot-lswt", action="store_true", help="Overlay J(0)-J(q) reconstructed from extracted J_ij.") parser.add_argument("--lswt-moment", type=float, default=None, help="If set, plot 4[J(0)-J(q)]/M in meV.") parser.add_argument("--lswt-dense-path", action="store_true", help="Evaluate LSWT/exchange overlay on a dense Seekpath line.") parser.add_argument("--lswt-path-points", type=int, default=202) parser.add_argument("--fit-lsq", action="store_true", help="Fit J(0)-J(q) to symmetry-grouped real-space exchange shells.") parser.add_argument("--fit-num-shells", type=int, default=None, help="Use only this many shortest shells, e.g. 2 for NN+NNN.") parser.add_argument("--fit-rmax", type=float, default=None) parser.add_argument("--fit-distance-tol", type=float, default=1e-6) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) if args.gui: from .dashboard import PathAnalysisConfig, create_app app = create_app( PathAnalysisConfig( elk_path=args.elk, energy_path=args.energy, vectors_path=args.vectors, theta=args.theta, symmetry=args.symmetry, e0=args.e0, rmax=args.rmax, fit_lsq=args.fit_lsq, fit_shells=args.fit_num_shells, moment=args.lswt_moment, dense_points=args.lswt_path_points, ) ) app.run(host=args.gui_host, port=args.gui_port, debug=args.gui_debug) return 0 workflow = FrozenMagnonWorkflow( WorkflowConfig( energy_path=args.energy, elk_path=args.elk, vectors_path=args.vectors, rmax=args.rmax, theta=args.theta, symmetry=args.symmetry, e0=args.e0, output_prefix=args.output_prefix, tol=args.tol, round_decimals=args.round_decimals, symprec=args.symprec, realspace_pair_symmetry=not args.no_realspace_pair_symmetry, ) ) result = workflow.run_transform() jij_path, dist_path, jq_path = workflow.write_transform_outputs() print(f"Read {len(workflow.energy_table.q)} raw q points from {args.energy}") print(f"Using {len(result.q)} symmetry-averaged q samples; symmetry mode: {workflow.symmetry_used}") print(f"Read/generated {len(result.vectors)} R vectors") print(f"R vectors are integer direct-lattice coordinates; source: {workflow.vector_source}") for label, value in structure_metadata(workflow): print(f"{label}: {value}") print(f"Wrote {jij_path}") print(f"Wrote {dist_path}") print(f"Wrote {jq_path}") print(f"Max |Im J_ij| after selected symmetrization: {workflow.max_imag_jij():.6e} mRy") if args.fit_lsq: lsq_result = workflow.fit_lsq(args.fit_num_shells, args.fit_rmax, args.fit_distance_tol) shell_path, fit_path = workflow.write_lsq_outputs() print(f"LSQ fit used {len(lsq_result.shells)} symmetry shells; rotation mode: {lsq_result.rotation_source}") print(f"LSQ RMSE: {lsq_result.rmse_mry:.6f} mRy; max |error|: {lsq_result.max_abs_error_mry:.6f} mRy") print(f"Wrote {shell_path}") print(f"Wrote {fit_path}") if args.plot_path: plot_path, data_path, skipped_segments = workflow.plot_seekpath( plot_kind=args.plot_kind, plot_lswt=args.plot_lswt, lswt_moment=args.lswt_moment, dense_path=args.lswt_dense_path, path_points=args.lswt_path_points, tol=args.plot_tol, min_points=args.plot_min_points, ) print(f"Wrote {plot_path}") print(f"Wrote {data_path}") if args.plot_lswt and args.lswt_dense_path: print(f"Wrote {args.output_prefix.with_suffix(f'.seekpath_{args.plot_kind}_lswt_dense.dat')}") if skipped_segments: print("Skipped Seekpath segments without enough existing q points: " + ", ".join(skipped_segments)) return 0 if __name__ == "__main__": raise SystemExit(main())