Source code for mag4.cli.extract_moments

"""CLI: extract converged magnetic moments from a tree of WIEN2k ``.scf`` files.

Layer: **WIEN2k-specific**.

Beyond tabulating the per-site moments, it **checks that every configuration
reached its intended magnetic state** — a config whose moment collapsed to ~0
(non-magnetic solution) or drifted far from the generated ``|MAGMOM| = 2·S``
has an unreliable total energy and would poison a J / Jring extraction.
"""

from __future__ import annotations

import argparse
import os
import re
import sys

from mag4.analysis.wien2k import (
    build_table,
    find_cases,
    find_report,
    parse_report_target_moment,
)


def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mag4-moments",
        description="Tabulate WIEN2k per-site magnetic moments across "
                    "configurations and verify each reached its target state.")
    p.add_argument("atoms", nargs="*",
                   help="Atom-index range 'FIRST LAST' (e.g. 33 47), "
                        "optionally followed by explicit config directories. "
                        "Omit to be prompted for the range.")
    p.add_argument("--code", choices=["wien2k", "vasp"], default="wien2k",
                   help="DFT code to read moments from: 'wien2k' → per-sphere "
                        ":MMI: in <dir>/<name>.scf (default); 'vasp' → per-ion "
                        "'magnetization (x)' block in <dir>/OUTCAR "
                        "(needs LORBIT=10/11).")
    p.add_argument("--scffile", default=None, metavar="NAME",
                   help="WIEN2k only.  Basename (with or without '.scf') of the "
                        "SCF file inside each config directory — use when runs "
                        "are saved side by side as e.g. pbe.scf / r2scan.scf. "
                        "Default: <dirname>/<dirname>.scf.")
    p.add_argument("--moment", type=float, default=None, metavar="MU",
                   help="Expected per-site |moment| in μ_B (= the generated "
                        "|MAGMOM| = 2·S).  Each config is flagged if any site "
                        "is off this by more than --tol.")
    p.add_argument("--spin", type=float, default=None, metavar="S",
                   help="Spin S; sets the expected |moment| to 2·S when "
                        "--moment is not given.")
    p.add_argument("--tol", type=float, default=0.5, metavar="MU",
                   help="Tolerance (μ_B) for the target check (default 0.5).")
    return p


[docs] def main(argv=None) -> int: args = _build_parser().parse_args(argv) # Positional parsing: leading integers are the atom range; the rest are # explicit config directories (backward compatible with the old CLI). atom_first, atom_last, explicit_dirs, num_count = None, None, [], 0 for a in args.atoms: if num_count < 2 and re.match(r"^\d+$", a): if num_count == 0: atom_first = int(a) else: atom_last = int(a) num_count += 1 else: explicit_dirs.append(a) if atom_first is None: atom_first = int(input(" First atom index (e.g. 9): ").strip()) if atom_last is None: atom_last = int(input(" Last atom index (e.g. 16): ").strip()) atom_indices = list(range(atom_first, atom_last + 1)) base_dir = "." # Target per-site |moment| = |MAGMOM| = 2·S. Priority: --moment, then # --spin, then the generated value recorded in the report (four_state_report # .dat carries "MAGMOM (S = …, |MAGMOM| = 2·S = …)"). target = args.moment if target is None and args.spin is not None: target = 2.0 * args.spin if target is None: rpt = find_report(base_dir) if rpt is not None: target = parse_report_target_moment(rpt) if target is not None: print(f" Target |μ| = {target:g} μ_B " f"(2·S, read from {os.path.basename(rpt)})") if target is None: print(" Note: no target moment (pass --moment/--spin, or run in the " "compound dir with its report) — checking for missing data only.") if explicit_dirs: cases = explicit_dirs else: cases = find_cases(base_dir, scf_basename=args.scffile, code=args.code) if not cases: where = ("an 'OUTCAR'" if args.code == "vasp" else f"a '{args.scffile}.scf'" if args.scffile else "any '.scf'") print(f"Error: no subdirectory under '{os.path.abspath(base_dir)}' " f"contains {where} file.") if args.code == "wien2k" and args.scffile: print(" Check the --scffile basename, or drop it to auto-scan " "for <dirname>.scf.") return 1 print(f"\n Auto-detected {len(cases)} configuration(s):") for c in cases: print(f" {os.path.basename(c)}") compound = os.path.basename(os.path.abspath(base_dir)) output_file = f"{compound}_moments.txt" lines, summary = build_table(cases, atom_indices, scf_basename=args.scffile, target=target, tol=args.tol, code=args.code) with open(output_file, "w", encoding="utf-8") as fout: for line in lines: print(line) fout.write(line + "\n") print(f"\n Results saved to: {os.path.abspath(output_file)}") # Exit code reflects the target check so scripts/pipelines can gate on it. if summary["n_data"] == 0: print(" ✗ No moments were read — every SCF file was missing " "(see warnings).") return 2 if summary["failures"]: return 3 return 0
if __name__ == "__main__": # pragma: no cover sys.exit(main())