Skill Tracer
Skills › Scientific Agent Skills › pymatgen › structure_converter.py

structure_converter.py

226 lines · skills/pymatgen/scripts/structure_converter.py at 5f2bc03

1#!/usr/bin/env python3
2"""Convert one local periodic structure with explicit loss acknowledgement."""
3
4from __future__ import annotations
5
6import argparse
7import warnings
8
9from _common import (
10 CliError,
11 DEFAULT_MAX_INPUT_BYTES,
12 DEFAULT_MAX_OUTPUT_BYTES,
13 DEFAULT_MAX_SITES,
14 checked_output_file,
15 emit_json,
16 load_structure,
17 positive_int,
18 structure_oxidation_summary,
19 write_text_new,
23FORMATS = ("cif", "cssr", "json", "poscar", "xsf", "xyz")
24NONPERIODIC_TARGETS = {"xyz"}
25LIMITED_TARGETS = {"cssr", "poscar", "xsf", "xyz"}
28def conversion_risks(structure: object, output_format: str) -> list[str]:
29 """Describe representation that a target format may not preserve."""
30 risks: list[str] = []
31 oxidation = structure_oxidation_summary(structure)
32 site_properties = sorted(structure.site_properties)
33 if output_format != "json" and oxidation["decorated_species_components"]:
34 risks.append("oxidation-state decoration may not round-trip")
35 if output_format != "json" and site_properties:
36 risks.append(
37 "site properties may be omitted or represented format-specifically: "
38 + ", ".join(site_properties[:20])
39 )
40 if output_format in NONPERIODIC_TARGETS:
41 risks.append("target does not preserve lattice vectors or periodicity")
42 if output_format in LIMITED_TARGETS and not structure.is_ordered:
43 risks.append("target cannot faithfully represent partial occupancies/disorder")
44 return risks
47def render_structure(
48 structure: object,
49 *,
50 output_format: str,
51 coordinate_mode: str,
52) -> str:
53 """Render a structure without giving pymatgen an output path."""
54 with warnings.catch_warnings(record=True) as caught:
55 warnings.simplefilter("always")
56 if output_format == "poscar":
57 from pymatgen.io.vasp import Poscar
59 text = Poscar(structure).get_str(direct=coordinate_mode == "direct")
60 elif output_format == "xyz":
61 if coordinate_mode != "not-applicable":
62 raise CliError("--coordinate-mode is only meaningful for POSCAR output")
63 from pymatgen.io.xyz import XYZ
65 # Structure.to() does not register XYZ; this writer explicitly drops PBC.
66 text = str(XYZ(structure))
67 else:
68 if coordinate_mode != "not-applicable":
69 raise CliError(
70 "--coordinate-mode is only meaningful for POSCAR output"
71 )
72 text = structure.to(fmt=output_format)
73 if caught:
74 messages = "; ".join(str(item.message) for item in caught[:10])
75 raise CliError(f"writer emitted warnings; conversion stopped: {messages}")
76 return text
79def build_parser() -> argparse.ArgumentParser:
80 parser = argparse.ArgumentParser(
81 description=(
82 "Convert one bounded local periodic structure. The original and any "
83 "existing output are never overwritten."
84 )
85 )
86 parser.add_argument("input", help="Existing local structure file")
87 parser.add_argument("output", help="New output file")
88 parser.add_argument(
89 "--output-format",
90 required=True,
91 choices=FORMATS,
92 help="Explicit target format; filename inference is not used",
93 )
94 parser.add_argument(
95 "--coordinate-mode",
96 choices=("direct", "cartesian", "not-applicable"),
97 default="not-applicable",
98 help="POSCAR coordinate mode; use not-applicable for other formats",
99 )
100 parser.add_argument(
101 "--structure-index",
102 type=int,
103 default=0,
104 help="Zero-based structure index for a multi-block CIF (default: 0)",
105 )
106 parser.add_argument(
107 "--allow-lossy",
108 action="store_true",
109 help="Acknowledge every representation risk listed in the report",
110 )
111 parser.add_argument(
112 "--acknowledge-parser-warnings",
113 action="store_true",
114 help="Continue only after reviewing warnings emitted while parsing",
115 )
116 parser.add_argument(
117 "--max-input-bytes",
118 type=positive_int,
119 default=DEFAULT_MAX_INPUT_BYTES,
120 )
121 parser.add_argument(
122 "--max-output-bytes",
123 type=positive_int,
124 default=DEFAULT_MAX_OUTPUT_BYTES,
125 )
126 parser.add_argument(
127 "--max-sites",
128 type=positive_int,
129 default=DEFAULT_MAX_SITES,
130 )
131 return parser
134def main() -> int:
135 args = build_parser().parse_args()
136 try:
137 if args.structure_index < 0:
138 raise CliError("--structure-index must be non-negative")
139 if args.output_format == "poscar" and args.coordinate_mode == "not-applicable":
140 raise CliError("POSCAR output requires --coordinate-mode direct|cartesian")
141 structure, input_path, parse_report = load_structure(
142 args.input,
143 structure_index=args.structure_index,
144 max_bytes=args.max_input_bytes,
145 max_sites=args.max_sites,
146 )
147 output_path = checked_output_file(
148 args.output,
149 input_paths=(input_path,),
150 )
151 parser_warnings = [
152 *parse_report["python_warnings"],
153 *parse_report["parser_warnings"],
154 ]
155 if parser_warnings and not args.acknowledge_parser_warnings:
156 raise CliError(
157 "parser warnings require --acknowledge-parser-warnings after review: "
158 + "; ".join(parser_warnings[:10])
159 )
160 risks = conversion_risks(structure, args.output_format)
161 if risks and not args.allow_lossy:
162 raise CliError(
163 "conversion may be lossy; review the I/O plan and rerun with "
164 "--allow-lossy: "
165 + "; ".join(risks)
166 )
167 if not structure.is_ordered and args.output_format in LIMITED_TARGETS:
168 raise CliError(
169 f"{args.output_format} cannot faithfully encode this disordered "
170 "structure; choose JSON or CIF"
171 )
172 text = render_structure(
173 structure,
174 output_format=args.output_format,
175 coordinate_mode=args.coordinate_mode,
176 )
177 write_text_new(
178 output_path,
179 text,
180 max_bytes=args.max_output_bytes,
181 )
182 parse_report["warnings_acknowledged"] = bool(
183 args.acknowledge_parser_warnings
184 )
185 emit_json(
186 {
187 "ok": True,
188 "action": "structure_conversion",
189 "input": parse_report,
190 "output": {
191 "name": output_path.name,
192 "format": args.output_format,
193 "coordinate_mode": args.coordinate_mode,
194 "bytes": output_path.stat().st_size,
195 "created": True,
196 "overwrote_existing": False,
197 },
198 "structure": {
199 "formula": structure.composition.reduced_formula,
200 "sites": len(structure),
201 "ordered": structure.is_ordered,
202 "periodic_boundary_conditions": list(structure.lattice.pbc),
203 "oxidation_states": structure_oxidation_summary(structure),
204 },
205 "representation_risks": risks,
206 "losses_acknowledged": bool(args.allow_lossy),
207 "scientific_equivalence_verified": False,
208 }
209 )
210 return 0
211 except (CliError, ImportError, OSError, RuntimeError, TypeError, ValueError) as exc:
212 emit_json(
213 {
214 "ok": False,
215 "error": str(exc),
216 "output_created": False,
217 "hint": (
218 "Install the pinned snapshot with uv if pymatgen is missing."
219 ),
220 }
221 )
222 return 2
225if __name__ == "__main__":
226 raise SystemExit(main())