Skills › Scientific Agent Skills › pymatgen › symmetry_sensitivity_report.py
symmetry_sensitivity_report.py
212 lines · skills/pymatgen/scripts/symmetry_sensitivity_report.py at 5f2bc03
1#!/usr/bin/env python3
2"""Report space-group sensitivity across explicit symmetry tolerances."""
4from __future__ import annotations
6import argparse
7import math
8from typing import Any
10from _common import (
11 CliError,
12 DEFAULT_MAX_INPUT_BYTES,
13 DEFAULT_MAX_OUTPUT_BYTES,
14 DEFAULT_MAX_SITES,
15 checked_output_file,
16 emit_json,
17 load_structure,
18 positive_int,
19 write_json_new,
20)
23def parse_tolerances(value: str, label: str) -> list[float]:
24 """Parse a unique comma-separated finite positive float list."""
25 pieces = [piece.strip() for piece in value.split(",") if piece.strip()]
26 if not pieces:
27 raise CliError(f"{label} must contain at least one value")
28 result: list[float] = []
29 for piece in pieces:
30 try:
31 number = float(piece)
32 except ValueError as exc:
33 raise CliError(f"{label} contains a non-number: {piece!r}") from exc
34 if not math.isfinite(number) or number <= 0:
35 raise CliError(f"{label} values must be finite and positive")
36 if number not in result:
37 result.append(number)
38 if len(result) > 10:
39 raise CliError(f"{label} may contain at most 10 unique values")
40 return result
43def analyze_grid(
44 structure: Any,
45 *,
46 symprec_values: list[float],
47 angle_values: list[float],
48) -> dict[str, Any]:
49 """Evaluate a bounded Cartesian product of symmetry tolerances."""
50 combinations = len(symprec_values) * len(angle_values)
51 if combinations > 25:
52 raise CliError("symmetry tolerance grid may contain at most 25 combinations")
53 from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
55 rows: list[dict[str, Any]] = []
56 assignments: set[tuple[str, int]] = set()
57 failures = 0
58 for symprec in symprec_values:
59 for angle in angle_values:
60 try:
61 analyzer = SpacegroupAnalyzer(
62 structure,
63 symprec=symprec,
64 angle_tolerance=angle,
65 )
66 symbol = analyzer.get_space_group_symbol()
67 number = int(analyzer.get_space_group_number())
68 assignments.add((symbol, number))
69 rows.append(
70 {
71 "symprec_angstrom": symprec,
72 "angle_tolerance_degrees": angle,
73 "space_group_symbol": symbol,
74 "space_group_number": number,
75 "crystal_system": str(analyzer.get_crystal_system()),
76 "point_group_symbol": analyzer.get_point_group_symbol(),
77 "symmetry_operations": len(
78 analyzer.get_symmetry_operations()
79 ),
80 "equivalent_site_groups": len(
81 analyzer.get_symmetrized_structure().equivalent_indices
82 ),
83 }
84 )
85 except (RuntimeError, TypeError, ValueError) as exc:
86 failures += 1
87 rows.append(
88 {
89 "symprec_angstrom": symprec,
90 "angle_tolerance_degrees": angle,
91 "error": f"{type(exc).__name__}: {exc}"[:500],
92 }
93 )
94 return {
95 "backend": "spglib through pymatgen",
96 "grid": rows,
97 "combinations": combinations,
98 "failures": failures,
99 "distinct_assignments": [
100 {"space_group_symbol": symbol, "space_group_number": number}
101 for symbol, number in sorted(assignments, key=lambda item: item[1])
102 ],
103 "tolerance_sensitive": len(assignments) > 1 or failures > 0,
104 "interpretation": (
105 "A tolerance-sensitive assignment must be reported with its exact "
106 "symprec and angle_tolerance; it is not a unique structure invariant."
107 ),
108 }
111def build_parser() -> argparse.ArgumentParser:
112 parser = argparse.ArgumentParser(
113 description=(
114 "Compare space-group assignments across a bounded tolerance grid. "
115 "The structure is not standardized or written."
116 )
117 )
118 parser.add_argument("structure_file")
119 parser.add_argument("--structure-index", type=int, default=0)
120 parser.add_argument(
121 "--symprec",
122 default="0.001,0.01,0.1",
123 help="Comma-separated distance tolerances in angstrom",
124 )
125 parser.add_argument(
126 "--angle-tolerance",
127 default="1,5",
128 help="Comma-separated angle tolerances in degrees",
129 )
130 parser.add_argument(
131 "--allow-disordered",
132 action="store_true",
133 help="Acknowledge that symmetry assignment for disorder may be misleading",
134 )
135 parser.add_argument("--output", help="New JSON output; default stdout")
136 parser.add_argument(
137 "--max-input-bytes",
138 type=positive_int,
139 default=DEFAULT_MAX_INPUT_BYTES,
140 )
141 parser.add_argument("--max-sites", type=positive_int, default=DEFAULT_MAX_SITES)
142 parser.add_argument(
143 "--max-output-bytes",
144 type=positive_int,
145 default=DEFAULT_MAX_OUTPUT_BYTES,
146 )
147 return parser
150def main() -> int:
151 args = build_parser().parse_args()
152 try:
153 if args.structure_index < 0:
154 raise CliError("--structure-index must be non-negative")
155 symprec_values = parse_tolerances(args.symprec, "--symprec")
156 angle_values = parse_tolerances(
157 args.angle_tolerance, "--angle-tolerance"
158 )
159 structure, input_path, parse_report = load_structure(
160 args.structure_file,
161 structure_index=args.structure_index,
162 max_bytes=args.max_input_bytes,
163 max_sites=args.max_sites,
164 )
165 if not structure.is_ordered and not args.allow_disordered:
166 raise CliError(
167 "disordered structure requires --allow-disordered after reviewing "
168 "occupancies and the chosen symmetry model"
169 )
170 report = {
171 "ok": True,
172 "analysis": "symmetry_tolerance_sensitivity",
173 "input": parse_report,
174 "structure": {
175 "formula": structure.composition.reduced_formula,
176 "sites": len(structure),
177 "ordered": bool(structure.is_ordered),
178 "periodic_boundary_conditions": [
179 bool(value) for value in structure.lattice.pbc
180 ],
181 },
182 "symmetry": analyze_grid(
183 structure,
184 symprec_values=symprec_values,
185 angle_values=angle_values,
186 ),
187 "disorder_acknowledged": bool(args.allow_disordered),
188 "structure_modified": False,
189 }
190 if args.output:
191 output = checked_output_file(args.output, input_paths=(input_path,))
192 write_json_new(output, report, max_bytes=args.max_output_bytes)
193 emit_json(
194 {
195 "ok": True,
196 "output": output.name,
197 "overwrote_existing": False,
198 "tolerance_sensitive": report["symmetry"][
199 "tolerance_sensitive"
200 ],
201 }
202 )
203 else:
204 emit_json(report)
205 return 0
206 except (CliError, ImportError, OSError, RuntimeError, TypeError, ValueError) as exc:
207 emit_json({"ok": False, "error": f"{type(exc).__name__}: {exc}"[:1000]})
208 return 2
211if __name__ == "__main__":
212 raise SystemExit(main())