Skills › Scientific Agent Skills › pymatgen › io_conversion_plan.py
io_conversion_plan.py
204 lines · skills/pymatgen/scripts/io_conversion_plan.py at 5f2bc03
1#!/usr/bin/env python3
2"""Create a dependency-free, non-executing structure conversion plan."""
4from __future__ import annotations
6import argparse
8from _common import (
9 CliError,
10 DEFAULT_MAX_OUTPUT_BYTES,
11 checked_output_file,
12 emit_json,
13 positive_int,
14 write_json_new,
15)
18FORMAT_CAPABILITIES = {
19 "cif": {
20 "periodic": True,
21 "disorder": True,
22 "oxidation_states": "format-dependent",
23 "site_properties": "limited",
24 "coordinate_modes": ["fractional"],
25 },
26 "cssr": {
27 "periodic": True,
28 "disorder": False,
29 "oxidation_states": False,
30 "site_properties": False,
31 "coordinate_modes": ["fractional"],
32 },
33 "json": {
34 "periodic": True,
35 "disorder": True,
36 "oxidation_states": True,
37 "site_properties": True,
38 "coordinate_modes": ["fractional", "cartesian-with-explicit-flag"],
39 },
40 "poscar": {
41 "periodic": True,
42 "disorder": False,
43 "oxidation_states": False,
44 "site_properties": "selective-dynamics/velocity-specific",
45 "coordinate_modes": ["direct", "cartesian"],
46 },
47 "xsf": {
48 "periodic": True,
49 "disorder": False,
50 "oxidation_states": False,
51 "site_properties": False,
52 "coordinate_modes": ["cartesian"],
53 },
54 "xyz": {
55 "periodic": False,
56 "disorder": False,
57 "oxidation_states": False,
58 "site_properties": False,
59 "coordinate_modes": ["cartesian"],
60 },
61}
64def build_plan(args: argparse.Namespace) -> dict:
65 """Build a conversion contract without opening any files."""
66 target = FORMAT_CAPABILITIES[args.output_format]
67 blockers: list[str] = []
68 risks: list[str] = []
69 if args.kind == "molecule":
70 blockers.append(
71 "the bundled structure_converter accepts periodic Structure objects only"
72 )
73 if args.periodic and not target["periodic"]:
74 risks.append("target drops lattice vectors and periodic boundary conditions")
75 if args.disordered and target["disorder"] is not True:
76 blockers.append("target cannot faithfully represent partial occupancies")
77 if args.oxidation_states and target["oxidation_states"] is not True:
78 risks.append("target may drop oxidation-state decoration")
79 if args.site_properties and target["site_properties"] is not True:
80 risks.append("target may drop arbitrary site properties")
81 if args.output_format == "poscar" and args.coordinate_mode == "not-applicable":
82 blockers.append("POSCAR requires direct or cartesian coordinate mode")
83 if args.output_format != "poscar" and args.coordinate_mode != "not-applicable":
84 blockers.append("coordinate-mode flag is only accepted for POSCAR output")
85 argv = [
86 "python",
87 "scripts/structure_converter.py",
88 args.input,
89 args.output,
90 "--output-format",
91 args.output_format,
92 "--coordinate-mode",
93 args.coordinate_mode,
94 ]
95 if risks:
96 argv.append("--allow-lossy")
97 return {
98 "ok": not blockers,
99 "action": "io_conversion_plan",
100 "executed": False,
101 "files_opened": False,
102 "network_accessed": False,
103 "source": {
104 "path_as_provided": args.input,
105 "format": args.input_format,
106 "kind": args.kind,
107 "periodic": args.periodic,
108 "has_disorder": args.disordered,
109 "has_oxidation_states": args.oxidation_states,
110 "has_site_properties": args.site_properties,
111 },
112 "target": {
113 "path_as_provided": args.output,
114 "format": args.output_format,
115 "coordinate_mode": args.coordinate_mode,
116 "capabilities": target,
117 },
118 "blockers": blockers,
119 "representation_risks": risks,
120 "reviewed_argv": argv if not blockers else None,
121 "requirements_before_execution": [
122 "Inspect parser warnings and all structures in multi-block CIF files.",
123 "Validate units, occupancies, oxidation states, lattice, and coordinate mode.",
124 "Use a new output path; never overwrite the source or an existing artifact.",
125 "Round-trip and scientifically compare the result before downstream use.",
126 ],
127 }
130def build_parser() -> argparse.ArgumentParser:
131 parser = argparse.ArgumentParser(
132 description=(
133 "Plan, but do not run, a local pymatgen conversion. No input file is "
134 "opened and no package import or network call occurs."
135 )
136 )
137 parser.add_argument("--input", required=True, help="Input path for disclosure")
138 parser.add_argument("--output", required=True, help="Intended new output path")
139 parser.add_argument(
140 "--input-format",
141 required=True,
142 choices=tuple(FORMAT_CAPABILITIES),
143 )
144 parser.add_argument(
145 "--output-format",
146 required=True,
147 choices=tuple(FORMAT_CAPABILITIES),
148 )
149 parser.add_argument("--kind", choices=("structure", "molecule"), default="structure")
150 periodicity = parser.add_mutually_exclusive_group()
151 periodicity.add_argument("--periodic", action="store_true", default=True)
152 periodicity.add_argument(
153 "--nonperiodic", action="store_false", dest="periodic"
154 )
155 parser.add_argument("--disordered", action="store_true")
156 parser.add_argument("--oxidation-states", action="store_true")
157 parser.add_argument("--site-properties", action="store_true")
158 parser.add_argument(
159 "--coordinate-mode",
160 choices=("direct", "cartesian", "not-applicable"),
161 default="not-applicable",
162 )
163 parser.add_argument("--plan-output", help="New JSON file for this plan")
164 parser.add_argument(
165 "--max-output-bytes",
166 type=positive_int,
167 default=DEFAULT_MAX_OUTPUT_BYTES,
168 )
169 return parser
172def main() -> int:
173 args = build_parser().parse_args()
174 try:
175 if "://" in args.input or "://" in args.output:
176 raise CliError("conversion paths must be local, not URLs")
177 plan = build_plan(args)
178 if args.plan_output:
179 output = checked_output_file(args.plan_output)
180 write_json_new(output, plan, max_bytes=args.max_output_bytes)
181 emit_json(
182 {
183 "ok": plan["ok"],
184 "plan_output": output.name,
185 "executed": False,
186 "overwrote_existing": False,
187 }
188 )
189 else:
190 emit_json(plan)
191 return 0 if plan["ok"] else 2
192 except (CliError, OSError, TypeError, ValueError) as exc:
193 emit_json(
194 {
195 "ok": False,
196 "error": f"{type(exc).__name__}: {exc}"[:1000],
197 "executed": False,
198 }
199 )
200 return 2
203if __name__ == "__main__":
204 raise SystemExit(main())