Coverage for src/pygnd/cli.py: 100%
94 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 23:45 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 23:45 +0000
1"""Command line interface for running GND calculations from a terminal or an
2HPC batch script, without needing to write a Python driver script.
3"""
5import argparse
6import sys
8import numpy as np
10from pygnd import __version__, io
11from pygnd.core import calculate_and_save_ang, calculate_and_save_dream3d
14_SLIP_SYSTEMS_HELP = (
15 "Slip systems to use. 'all' works for every crystal structure. BCC also "
16 "accepts: screw+110, screw+112, screw+123, screw+110+112, screw+110+123, "
17 "screw+112+123. HCP also accepts: basal, prismatic, pyramidal, "
18 "basal+prismatic, basal+pyramidal, prismatic+pyramidal. (default: %(default)s)"
19)
21_LOGO = """
39 --------- -----+ ###### ##########
40 --------------- -----++ ###### ###############
41 -------------- -----+++ ###### #################
42 ### -------- --- -----++++ ###### ###### +##########
43############ #### #### ------- --------- -----++-+- ###### ###### #######
44##### #### #### #### ------- --------- -----+---+++##### ###### +######
45#### #### ### ### ------- --------- -------+++++##### ###### #######
46#### #### #### #### -------- ------ ------ -++++##### ########+########
47#### #### ####### ---------------- ------ +++###### ################
48############ ##### --------------- ------ +++##### ###############
49#### ##### ### -------- ------ ++##### #########
50#### ####
51#### ######
52#### #####
70"""
74def _parse_burgers(value: str):
75 """Parse a --burgers argument into a float, or a tuple of two floats.
77 Args:
78 value: a single number, or two comma-separated numbers
79 (`basal/prismatic,pyramidal`) for mixed HCP slip systems.
81 Returns:
82 A `float`, or a `tuple[float, float]` for the two-value form.
83 """
84 parts = [p for p in value.split(",") if p.strip()]
85 try:
86 floats = [float(p) for p in parts]
87 except ValueError as exc:
88 raise argparse.ArgumentTypeError(f"could not parse burgers vector {value!r}") from exc
89 if len(floats) == 1:
90 return floats[0]
91 if len(floats) == 2:
92 return tuple(floats)
93 raise argparse.ArgumentTypeError(
94 "--burgers must be a single value, or two comma-separated values "
95 "(basal/prismatic,pyramidal) for mixed HCP slip systems"
96 )
99def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
100 """Add the calculation arguments shared by the dream3d and ang subcommands."""
101 parser.add_argument(
102 "--dry-run",
103 action="store_true",
104 help="Only load and validate the input file (report array shapes and "
105 "exit) without running the GND calculation. --cs and --burgers are "
106 "not required in this mode.",
107 )
108 parser.add_argument(
109 "--cs",
110 type=int,
111 default=None,
112 choices=(1, 2, 3),
113 help="Crystal structure: 1 for FCC, 2 for BCC, 3 for HCP. "
114 "Required unless --dry-run is set.",
115 )
116 parser.add_argument(
117 "--burgers",
118 type=_parse_burgers,
119 default=None,
120 metavar="VALUE[,VALUE]",
121 help="Burgers vector magnitude in meters. For HCP with mixed "
122 "basal/prismatic + pyramidal slip systems, pass two comma-separated "
123 "values, e.g. 2.48e-10,2.5e-10. Required unless --dry-run is set.",
124 )
125 parser.add_argument("--slip-systems", default="all", help=_SLIP_SYSTEMS_HELP)
126 parser.add_argument(
127 "--minimization",
128 nargs="+",
129 default=["l2"],
130 choices=("l1", "l2"),
131 help="Minimization scheme(s) to use. Pass both with --minimization l1 l2. "
132 "(default: %(default)s)",
133 )
134 parser.add_argument(
135 "--n-cpus",
136 type=int,
137 default=-1,
138 help="Number of CPUs to use for parallel processing during L1 "
139 "minimization; -1 uses all available cores. Not used for L2. (default: %(default)s)",
140 )
141 parser.add_argument(
142 "--chunk-size",
143 type=int,
144 default=1000,
145 help="Number of voxels to process per chunk during parallel L1 "
146 "minimization. (default: %(default)s)",
147 )
148 parser.add_argument(
149 "--progress-bar",
150 action="store_true",
151 help="Display a progress bar during L1 minimization.",
152 )
155def _build_parser() -> argparse.ArgumentParser:
156 """Build the argparse parser for the `pygnd_calculate` console script."""
157 parser = argparse.ArgumentParser(
158 prog="pygnd_calculate",
159 description="Calculate geometrically necessary dislocation (GND) "
160 "densities from an EBSD dataset and save the results.",
161 )
162 parser.add_argument("--version", action="version", version=f"pygnd {__version__}")
163 subparsers = parser.add_subparsers(dest="command", required=True)
165 dream3d = subparsers.add_parser(
166 "dream3d",
167 help="Calculate from a DREAM3D file and save the results back into it.",
168 )
169 dream3d.add_argument("dream3d_path", help="Path to the DREAM3D file.")
170 dream3d.add_argument(
171 "--ids-name", required=True, help="Name of the grain ID data array in the DREAM3D file."
172 )
173 dream3d.add_argument(
174 "--euler-name",
175 required=True,
176 help="Name of the Euler angles data array in the DREAM3D file.",
177 )
178 dream3d.add_argument(
179 "--spacing-units",
180 default="um",
181 help="Units of the voxel spacing stored in the DREAM3D file. (default: %(default)s)",
182 )
183 _add_common_arguments(dream3d)
185 ang = subparsers.add_parser(
186 "ang", help="Calculate from an .ang file and save the results as .npy files."
187 )
188 ang.add_argument("ang_path", help="Path to the .ang file.")
189 ang.add_argument(
190 "--grain-ids-path",
191 default=None,
192 help="Path to a grain-ID file generated by OIM Analysis. If omitted, "
193 "the entire dataset is treated as a single grain.",
194 )
195 _add_common_arguments(ang)
197 return parser
200def _dry_run(args: argparse.Namespace) -> int:
201 """Load the input file and report array shapes without running the GND
202 calculation. Useful as a fast pre-flight check before queuing a large HPC
203 job, to catch a bad file path or dataset name early.
205 Args:
206 args: parsed command line arguments.
208 Returns:
209 Process exit code: `0` if the file loaded successfully, `1` otherwise.
210 """
211 try:
212 if args.command == "dream3d":
213 euler, ids, spacing = io.read_dream3d(
214 args.dream3d_path, args.ids_name, args.euler_name, args.spacing_units
215 )
216 else:
217 euler, ids, spacing = io.read_ang(args.ang_path, args.grain_ids_path)
218 except (FileNotFoundError, KeyError, ValueError) as exc:
219 print(f"Error: {exc}", file=sys.stderr)
220 return 1
222 print("Dry run: file loaded successfully, no calculation performed.")
223 print("Euler angles shape:", euler.shape)
224 print("Grain IDs shape:", ids.shape)
225 print("Voxel spacing (m):", spacing)
226 print("Unique grain IDs:", np.unique(ids).size)
227 return 0
230def main(argv: list[str] | None = None) -> int:
231 """Entry point for the `pygnd_calculate` console script.
233 Args:
234 argv: command line arguments, or `None` to use `sys.argv`.
236 Returns:
237 Process exit code: `0` on success, `1` on failure.
238 """
239 parser = _build_parser()
240 args = parser.parse_args(argv)
242 if args.dry_run:
243 return _dry_run(args)
245 if args.cs is None or args.burgers is None:
246 parser.error("--cs and --burgers are required unless --dry-run is set")
248 minimization = (
249 args.minimization[0] if len(args.minimization) == 1 else tuple(args.minimization)
250 )
252 try:
253 if args.command == "dream3d":
254 success = calculate_and_save_dream3d(
255 args.dream3d_path,
256 args.ids_name,
257 args.euler_name,
258 args.cs,
259 args.burgers,
260 spacing_units=args.spacing_units,
261 minimization=minimization,
262 n_cpus=args.n_cpus,
263 slip_systems=args.slip_systems,
264 progress_bar=args.progress_bar,
265 chunk_size=args.chunk_size,
266 )
267 else:
268 success = calculate_and_save_ang(
269 args.ang_path,
270 args.cs,
271 args.burgers,
272 grain_ids_path=args.grain_ids_path,
273 minimization=minimization,
274 n_cpus=args.n_cpus,
275 slip_systems=args.slip_systems,
276 progress_bar=args.progress_bar,
277 chunk_size=args.chunk_size,
278 )
279 except (FileNotFoundError, KeyError, ValueError) as exc:
280 print(f"Error: {exc}", file=sys.stderr)
281 return 1
283 return 0 if success else 1
286_ENTRY_POINTS = [
287 ("pygnd", "Show this summary (version, logo, available entry points)."),
288 (
289 "pygnd_calculate",
290 "Run GND calculations from the command line. Subcommands: dream3d, ang.",
291 ),
292 ("pygnd_gui", "Launch the desktop GUI."),
293]
296def _build_info_parser() -> argparse.ArgumentParser:
297 """Build the argparse parser for the `pygnd` console script."""
298 parser = argparse.ArgumentParser(
299 prog="pygnd",
300 description="Show the PyGND version and available command line entry points.",
301 )
302 parser.add_argument("--version", action="version", version=f"pygnd {__version__}")
303 return parser
306def info(argv: list[str] | None = None) -> int:
307 """Entry point for the `pygnd` console script: prints the logo, the
308 installed version, and a summary of the other command line entry points
309 this package provides.
311 Args:
312 argv: command line arguments, or `None` to use `sys.argv`.
314 Returns:
315 Process exit code: always `0`.
316 """
317 _build_info_parser().parse_args(argv)
319 logo_lines = _LOGO.splitlines()
320 while logo_lines and not logo_lines[0].strip():
321 logo_lines.pop(0)
322 while logo_lines and not logo_lines[-1].strip():
323 logo_lines.pop()
324 print("\n".join(logo_lines))
325 print()
326 print(f"pygnd {__version__}")
327 print()
328 print("Available command line entry points:")
329 for name, description in _ENTRY_POINTS:
330 print(f" {name:<16} {description}")
331 print()
332 print("Run any entry point with --help for its full list of options.")
333 return 0
336if __name__ == "__main__":
337 sys.exit(main())