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

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""" 

4 

5import argparse 

6import sys 

7 

8import numpy as np 

9 

10from pygnd import __version__, io 

11from pygnd.core import calculate_and_save_ang, calculate_and_save_dream3d 

12 

13 

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) 

20 

21_LOGO = """ 

22  

23  

24  

25  

26  

27  

28  

29  

30  

31  

32  

33  

34  

35  

36  

37  

38  

39 --------- -----+ ###### ##########  

40 --------------- -----++ ###### ###############  

41 -------------- -----+++ ###### #################  

42 ### -------- --- -----++++ ###### ###### +########## 

43############ #### #### ------- --------- -----++-+- ###### ###### ####### 

44##### #### #### #### ------- --------- -----+---+++##### ###### +###### 

45#### #### ### ### ------- --------- -------+++++##### ###### ####### 

46#### #### #### #### -------- ------ ------ -++++##### ########+########  

47#### #### ####### ---------------- ------ +++###### ################  

48############ ##### --------------- ------ +++##### ###############  

49#### ##### ### -------- ------ ++##### #########  

50#### ####  

51#### ######  

52#### #####  

53  

54  

55  

56  

57  

58  

59  

60  

61  

62  

63  

64  

65  

66  

67  

68  

69  

70""" 

71 

72 

73 

74def _parse_burgers(value: str): 

75 """Parse a --burgers argument into a float, or a tuple of two floats. 

76 

77 Args: 

78 value: a single number, or two comma-separated numbers 

79 (`basal/prismatic,pyramidal`) for mixed HCP slip systems. 

80 

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 ) 

97 

98 

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 ) 

153 

154 

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) 

164 

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) 

184 

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) 

196 

197 return parser 

198 

199 

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. 

204 

205 Args: 

206 args: parsed command line arguments. 

207 

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 

221 

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 

228 

229 

230def main(argv: list[str] | None = None) -> int: 

231 """Entry point for the `pygnd_calculate` console script. 

232 

233 Args: 

234 argv: command line arguments, or `None` to use `sys.argv`. 

235 

236 Returns: 

237 Process exit code: `0` on success, `1` on failure. 

238 """ 

239 parser = _build_parser() 

240 args = parser.parse_args(argv) 

241 

242 if args.dry_run: 

243 return _dry_run(args) 

244 

245 if args.cs is None or args.burgers is None: 

246 parser.error("--cs and --burgers are required unless --dry-run is set") 

247 

248 minimization = ( 

249 args.minimization[0] if len(args.minimization) == 1 else tuple(args.minimization) 

250 ) 

251 

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 

282 

283 return 0 if success else 1 

284 

285 

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] 

294 

295 

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 

304 

305 

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. 

310 

311 Args: 

312 argv: command line arguments, or `None` to use `sys.argv`. 

313 

314 Returns: 

315 Process exit code: always `0`. 

316 """ 

317 _build_info_parser().parse_args(argv) 

318 

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 

334 

335 

336if __name__ == "__main__": 

337 sys.exit(main())