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

composition_structure_validator.py

301 lines · skills/pymatgen/scripts/composition_structure_validator.py at 5f2bc03

1#!/usr/bin/env python3
2"""Validate a composition or local periodic structure without modifying it."""
3
4from __future__ import annotations
5
6import argparse
7import math
8from typing import Any
9
10from _common import (
11 ABSOLUTE_MAX_PAIRWISE_SITES,
12 CliError,
13 DEFAULT_MAX_INPUT_BYTES,
14 DEFAULT_MAX_OUTPUT_BYTES,
15 DEFAULT_MAX_SITES,
16 checked_output_file,
17 emit_json,
18 load_structure,
19 positive_int,
20 structure_oxidation_summary,
21 write_json_new,
25def validate_composition(
26 formula: str,
27 *,
28 guess_oxidation_states: bool,
29) -> dict[str, Any]:
30 """Parse a strict formula and optionally run a bounded oxidation-state guess."""
31 if len(formula) > 500:
32 raise CliError("formula exceeds 500 characters")
33 from pymatgen.core import Composition
35 try:
36 composition = Composition(formula, strict=True)
37 except (TypeError, ValueError) as exc:
38 raise CliError(f"invalid strict composition: {exc}") from exc
39 errors: list[str] = []
40 warnings_list: list[str] = []
41 if composition.num_atoms <= 0:
42 errors.append("composition_has_no_positive_amount")
43 if any(float(amount) <= 0 for amount in composition.values()):
44 errors.append("non_positive_species_amount")
45 species_have_oxidation = [
46 hasattr(specie, "oxi_state") for specie in composition
47 ]
48 explicit_charge = (
49 sum(
50 float(amount) * float(specie.oxi_state)
51 for specie, amount in composition.items()
52 )
53 if all(species_have_oxidation)
54 else None
55 )
56 if not all(species_have_oxidation):
57 warnings_list.append("oxidation_states_not_fully_explicit")
58 guesses: list[dict[str, float]] | None = None
59 if guess_oxidation_states:
60 if len(composition.elements) > 6 or composition.num_atoms > 100:
61 raise CliError(
62 "oxidation-state guessing is limited to 6 elements and 100 atoms"
63 )
64 raw_guesses = composition.oxi_state_guesses()
65 guesses = [
66 {str(element): float(state) for element, state in guess.items()}
67 for guess in raw_guesses[:20]
68 ]
69 if len(raw_guesses) > 20:
70 warnings_list.append("oxidation_state_guesses_truncated_to_20")
71 return {
72 "ok": not errors,
73 "kind": "composition",
74 "input_formula": formula,
75 "formula": composition.formula,
76 "reduced_formula": composition.reduced_formula,
77 "hill_formula": composition.hill_formula,
78 "chemical_system": composition.chemical_system,
79 "num_atoms_in_formula": float(composition.num_atoms),
80 "mass_amu": float(composition.weight),
81 "amounts": {
82 str(specie): float(amount)
83 for specie, amount in sorted(
84 composition.items(), key=lambda item: str(item[0])
85 )
86 },
87 "formal_charge_from_explicit_oxidation_states": explicit_charge,
88 "oxidation_state_guesses": guesses,
89 "oxidation_state_guess_requested": guess_oxidation_states,
90 "errors": errors,
91 "warnings": warnings_list,
92 "chemical_validity_established": False,
93 }
96def minimum_distance(structure: Any, limit: int) -> tuple[float | None, list[int] | None]:
97 """Find a minimum pair distance when the quadratic calculation is bounded."""
98 if len(structure) < 2 or len(structure) > limit:
99 return None, None
100 best = math.inf
101 pair: list[int] | None = None
102 for first in range(len(structure)):
103 for second in range(first + 1, len(structure)):
104 distance = float(structure.get_distance(first, second))
105 if distance < best:
106 best = distance
107 pair = [first, second]
108 return (best if math.isfinite(best) else None), pair
111def validate_structure(structure: Any, args: argparse.Namespace) -> dict[str, Any]:
112 """Check representation invariants and common structural hazards."""
113 errors: list[str] = []
114 warnings_list: list[str] = []
115 lattice = structure.lattice
116 if not math.isfinite(float(structure.volume)) or structure.volume <= 0:
117 errors.append("non_positive_or_non_finite_lattice_volume")
118 if any(
119 not math.isfinite(float(value))
120 for row in lattice.matrix
121 for value in row
122 ):
123 errors.append("non_finite_lattice_component")
124 occupancy_issues: list[dict[str, Any]] = []
125 outside_unit_cell = 0
126 for index, site in enumerate(structure):
127 occupancy = sum(float(value) for value in site.species.values())
128 if occupancy <= 0 or occupancy > 1 + args.occupancy_tolerance:
129 occupancy_issues.append(
130 {"site_index": index, "occupancy_sum": occupancy}
131 )
132 if any(not math.isfinite(float(value)) for value in site.frac_coords):
133 errors.append(f"non_finite_fractional_coordinate_at_site_{index}")
134 if any(float(value) < 0 or float(value) >= 1 for value in site.frac_coords):
135 outside_unit_cell += 1
136 if occupancy_issues:
137 errors.append("site_occupancy_outside_allowed_range")
138 if not structure.is_ordered:
139 warnings_list.append(
140 "partial_occupancies_or_disorder_present; downstream methods may reject it"
141 )
142 if outside_unit_cell:
143 warnings_list.append(
144 f"{outside_unit_cell} sites have fractional coordinates outside [0, 1); "
145 "they may be periodic images, but coordinate convention must be explicit"
146 )
147 oxidation = structure_oxidation_summary(structure)
148 if not oxidation["all_decorated"]:
149 warnings_list.append("oxidation_states_are_not_fully_explicit")
150 distance, pair = minimum_distance(structure, args.max_distance_sites)
151 if distance is None and len(structure) > args.max_distance_sites:
152 warnings_list.append(
153 "minimum-distance check omitted because the quadratic site limit was exceeded"
154 )
155 elif distance is not None and distance < args.min_distance:
156 errors.append("sites_closer_than_minimum_distance")
157 return {
158 "ok": not errors,
159 "kind": "periodic_structure",
160 "units": {
161 "length": "angstrom",
162 "angle": "degree",
163 "volume": "angstrom^3",
164 "density": "g/cm^3",
165 },
166 "formula": structure.composition.reduced_formula,
167 "site_count": len(structure),
168 "ordered": bool(structure.is_ordered),
169 "periodic_boundary_conditions": [
170 bool(value) for value in structure.lattice.pbc
171 ],
172 "lattice": {
173 "matrix_rows_angstrom": [
174 [float(value) for value in row] for row in lattice.matrix
175 ],
176 "volume_angstrom_cubed": float(structure.volume),
177 "density_g_cm3": float(structure.density),
178 },
179 "coordinates_interpreted_as": "fractional",
180 "sites_outside_canonical_unit_cell": outside_unit_cell,
181 "occupancy_tolerance": args.occupancy_tolerance,
182 "occupancy_issues": occupancy_issues[:100],
183 "occupancy_issues_omitted": max(0, len(occupancy_issues) - 100),
184 "oxidation_states": oxidation,
185 "minimum_periodic_distance_angstrom": distance,
186 "minimum_distance_pair": pair,
187 "minimum_distance_includes_self_images": False,
188 "minimum_allowed_distance_angstrom": args.min_distance,
189 "errors": errors,
190 "warnings": warnings_list,
191 "scientific_validity_established": False,
192 }
195def build_parser() -> argparse.ArgumentParser:
196 parser = argparse.ArgumentParser(
197 description=(
198 "Validate a composition or bounded local periodic structure. "
199 "No files are modified and oxidation states are never guessed implicitly."
200 )
201 )
202 subparsers = parser.add_subparsers(dest="command", required=True)
203 composition = subparsers.add_parser("composition", help="Validate a formula")
204 composition.add_argument("formula")
205 composition.add_argument(
206 "--guess-oxidation-states",
207 action="store_true",
208 help="Explicitly run pymatgen's bounded oxidation-state guesser",
209 )
210 composition.add_argument("--output", help="New JSON output; default stdout")
212 structure = subparsers.add_parser(
213 "structure", help="Validate a local periodic structure"
214 )
215 structure.add_argument("structure_file")
216 structure.add_argument("--structure-index", type=int, default=0)
217 structure.add_argument("--output", help="New JSON output; default stdout")
218 structure.add_argument("--min-distance", type=float, default=0.5)
219 structure.add_argument("--occupancy-tolerance", type=float, default=1e-6)
220 structure.add_argument(
221 "--max-input-bytes",
222 type=positive_int,
223 default=DEFAULT_MAX_INPUT_BYTES,
224 )
225 structure.add_argument(
226 "--max-sites", type=positive_int, default=DEFAULT_MAX_SITES
227 )
228 structure.add_argument(
229 "--max-distance-sites", type=positive_int, default=500
230 )
231 for child in (composition, structure):
232 child.add_argument(
233 "--max-output-bytes",
234 type=positive_int,
235 default=DEFAULT_MAX_OUTPUT_BYTES,
236 )
237 return parser
240def main() -> int:
241 args = build_parser().parse_args()
242 try:
243 input_paths: tuple[Any, ...] = ()
244 if args.command == "composition":
245 report = validate_composition(
246 args.formula,
247 guess_oxidation_states=args.guess_oxidation_states,
248 )
249 else:
250 if args.structure_index < 0:
251 raise CliError("--structure-index must be non-negative")
252 if not math.isfinite(args.min_distance) or args.min_distance <= 0:
253 raise CliError("--min-distance must be finite and positive")
254 if (
255 not math.isfinite(args.occupancy_tolerance)
256 or args.occupancy_tolerance < 0
257 ):
258 raise CliError(
259 "--occupancy-tolerance must be finite and non-negative"
260 )
261 if args.max_distance_sites > ABSOLUTE_MAX_PAIRWISE_SITES:
262 raise CliError(
263 f"--max-distance-sites may not exceed "
264 f"{ABSOLUTE_MAX_PAIRWISE_SITES}"
265 )
266 structure, input_path, parse_report = load_structure(
267 args.structure_file,
268 structure_index=args.structure_index,
269 max_bytes=args.max_input_bytes,
270 max_sites=args.max_sites,
271 )
272 report = validate_structure(structure, args)
273 report["input"] = parse_report
274 input_paths = (input_path,)
275 if args.output:
276 output_path = checked_output_file(
277 args.output,
278 input_paths=input_paths,
279 )
280 write_json_new(
281 output_path,
282 report,
283 max_bytes=args.max_output_bytes,
284 )
285 emit_json(
286 {
287 "ok": report["ok"],
288 "output": output_path.name,
289 "overwrote_existing": False,
290 }
291 )
292 else:
293 emit_json(report)
294 return 0 if report["ok"] else 2
295 except (CliError, ImportError, OSError, RuntimeError, TypeError, ValueError) as exc:
296 emit_json({"ok": False, "error": f"{type(exc).__name__}: {exc}"[:1000]})
297 return 2
300if __name__ == "__main__":
301 raise SystemExit(main())