Coverage for src/pygnd/core.py: 92%

385 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 23:45 +0000

1import os 

2import warnings 

3from pathlib import Path 

4from collections.abc import Iterable 

5 

6import numpy as np 

7from tqdm import tqdm 

8from scipy import optimize 

9from joblib import Parallel, delayed 

10 

11from pygnd import io, rotations, quaternions 

12from pygnd.utils import tqdm_joblib 

13 

14 

15warnings.filterwarnings("ignore", category=UserWarning) 

16 

17_PRECISION = np.float32 

18 

19 

20def _resolve_n_cpus(n_cpus: int) -> int: 

21 """Resolve a joblib-style n_cpus value (e.g. -1 for all cores, -2 for all but 

22 one) to a positive core count, for use in chunk-size arithmetic.""" 

23 cpu_count = os.cpu_count() or 1 

24 if n_cpus < 0: 

25 return max(1, cpu_count + 1 + n_cpus) 

26 return max(1, n_cpus) 

27 

28 

29def get_linear_operator(cs: int, slip_systems: str = "all") -> tuple[np.ndarray, np.ndarray]: 

30 """Pre-calculate the A matrix for the given crystal structure and desired slip systems. 

31 

32 Args: 

33 cs (int): The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

34 slip_systems (str, optional): The slip systems to be used. Defaults to 'all'. 

35 (FCC) - unused, always 'all' 

36 (BCC) - 'screw+110', 'screw+112', 'screw+123', 'screw+110+112', 'screw+110+123', 'screw+112+123', 'all' 

37 (HCP) - 'basal', 'prismatic', 'pyramidal', 'basal+prismatic', 'basal+pyramidal', 'prismatic+pyramidal', 'all' 

38 

39 Returns: 

40 A (np.ndarray): The A matrix for the given crystal structure and slip systems. Shape (9, n_slip_systems) 

41 B (np.ndarray): The B matrix (psuedo-inverse of A) for the given crystal structure. Shape (n_slip_systems, 9) 

42 """ 

43 # Check the input values 

44 if type(cs) != int: 

45 raise ValueError("Crystal structure must be an integer value.") 

46 if cs not in [1, 2, 3]: 

47 raise ValueError("Crystal structure must be 1, 2, or 3.") 

48 if type(slip_systems) != str: 

49 raise ValueError("Slip systems must be a string.") 

50 slip_systems = slip_systems.lower().strip() 

51 if slip_systems not in [ 

52 "all", 

53 "screw+110", 

54 "screw+112", 

55 "screw+123", 

56 "screw+110+112", 

57 "screw+110+123", 

58 "screw+112+123", 

59 "basal", 

60 "prismatic", 

61 "pyramidal", 

62 "basal+prismatic", 

63 "basal+pyramidal", 

64 "prismatic+pyramidal", 

65 ]: 

66 raise ValueError( 

67 "Slip systems must be 'all', 'screw+110', 'screw+112', 'screw+123', 'screw+110+112', 'basal', 'basal+prismatic', depending on the crystam structure." 

68 ) 

69 

70 # Create the A matrix for the given crystal structure 

71 if cs == 1: 

72 a = np.sqrt(3).astype(_PRECISION) / 9 

73 c = np.sqrt(3).astype(_PRECISION) / 84 

74 d = 1 / 18 

75 f = 3 / 14 

76 

77 # See Arsenlis & Parks 1999 

78 B = np.array( 

79 [ 

80 [a, 7 * c, -13 * c, -7 * c, -a, 13 * c, c, -c, 0], 

81 [-a, 13 * c, -7 * c, -c, 0, c, 7 * c, -13 * c, a], 

82 [0, c, -c, -13 * c, a, 7 * c, 13 * c, -7 * c, -a], 

83 [a, -7 * c, 13 * c, 7 * c, -a, 13 * c, -c, -c, 0], 

84 [-a, -13 * c, 7 * c, c, 0, c, -7 * c, -13 * c, a], 

85 [0, -c, c, 13 * c, a, 7 * c, -13 * c, -7 * c, -a], 

86 [a, -7 * c, -13 * c, 7 * c, -a, -13 * c, c, c, 0], 

87 [-a, -13 * c, -7 * c, c, 0, -c, 7 * c, 13 * c, a], 

88 [0, -c, -c, 13 * c, a, -7 * c, 13 * c, 7 * c, -a], 

89 [a, 7 * c, 13 * c, -7 * c, -a, -13 * c, -c, c, 0], 

90 [-a, 13 * c, 7 * c, -c, 0, -c, -7 * c, 13 * c, -a], 

91 [0, c, c, -13 * c, a, -7 * c, -13 * c, 7 * c, -a], 

92 [5 * d, f, 0, f, 5 * d, 0, 0, 0, -d], 

93 [5 * d, 0, f, 0, -d, 0, f, 0, 5 * d], 

94 [-d, 0, 0, 0, 5 * d, f, 0, f, 5 * d], 

95 [5 * d, -f, 0, -f, 5 * d, 0, 0, 0, -d], 

96 [5 * d, 0, -f, 0, -d, 0, -f, 0, 5 * d], 

97 [-d, 0, 0, 0, 5 * d, -f, 0, -f, 5 * d], 

98 ] 

99 ).astype(_PRECISION) 

100 

101 # FCC 

102 A = pseudo_inverse(B) 

103 

104 elif cs == 2: 

105 # BCC 

106 A = generate_BCC_A_matrix() 

107 if slip_systems == "screw+110": 

108 A = A[:, :16] 

109 elif slip_systems == "screw+112": 

110 A = np.hstack((A[:, :4], A[:, 16:28])) 

111 elif slip_systems == "screw+123": 

112 A = np.hstack((A[:, :4], A[:, 28:])) 

113 elif slip_systems == "screw+110+112": 

114 A = A[:, :28] 

115 elif slip_systems == "screw+110+123": 

116 A = np.hstack((A[:, :16], A[:, 28:])) 

117 elif slip_systems == "screw+112+123": 

118 A = np.hstack((A[:, :4], A[:, 16:])) 

119 elif slip_systems == "all": 119 ↛ 121line 119 didn't jump to line 121 because the condition on line 119 was always true

120 pass 

121 B = pseudo_inverse(A) 

122 

123 elif cs == 3: 123 ↛ 147line 123 didn't jump to line 147 because the condition on line 123 was always true

124 # HCP 

125 A = generate_HCP_A_matrix() 

126 if slip_systems == "basal": 

127 A = A[:, :6] # 3 edge basal and 3 screw basal slip systems 

128 elif slip_systems == "prismatic": 

129 A = A[:, 6:9] # 3 edge prismatic slip systems 

130 elif slip_systems == "pyramidal": 

131 A = A[:, 9:] # 12 edge pyramidal and 12 screw pyramidal slip systems 

132 elif slip_systems == "basal+prismatic": 

133 A = A[:, :9] 

134 elif slip_systems == "basal+pyramidal": 

135 A = np.hstack((A[:, :6], A[:, 9:])) 

136 elif slip_systems == "prismatic+pyramidal": 

137 A = A[:, 6:] 

138 elif slip_systems == "all": 138 ↛ 140line 138 didn't jump to line 140 because the condition on line 138 was always true

139 pass 

140 B = pseudo_inverse(A) 

141 

142 # Add this diagnostic 

143 # print(f"Condition number of A: {np.linalg.cond(A)}") 

144 # print(f"Rank of A: {np.linalg.matrix_rank(A)}") 

145 # print(f"||B @ A - I||_F: {np.linalg.norm(B @ A - np.eye(A.shape[1]), 'fro')}") 

146 # print(f"||A @ B - I||_F: {np.linalg.norm(A @ B - np.eye(A.shape[0]), 'fro')}") 

147 return (A, B) 

148 

149 

150def generate_BCC_A_matrix() -> np.ndarray: 

151 """Generate the A matrix for BCC crystal structure.""" 

152 # Burgers vectors and slip plane normals for BCC 

153 b_n = np.array( 

154 [ 

155 [[1, 1, -1], [1, 1, -1]], # <111> screw 

156 [[1, -1, -1], [1, -1, -1]], 

157 [[1, -1, 1], [1, -1, 1]], 

158 [[1, 1, 1], [1, 1, 1]], 

159 [[1, 1, -1], [0, 1, 1]], # {110}<111> edge 

160 [[1, 1, -1], [1, 0, 1]], 

161 [[1, 1, -1], [1, -1, 0]], 

162 [[1, -1, -1], [0, 1, -1]], 

163 [[1, -1, -1], [1, 0, 1]], 

164 [[1, -1, -1], [1, 1, 0]], 

165 [[1, -1, 1], [0, 1, 1]], 

166 [[1, -1, 1], [1, 0, -1]], 

167 [[1, -1, 1], [1, 1, 0]], 

168 [[1, 1, 1], [0, 1, -1]], 

169 [[1, 1, 1], [1, 0, -1]], 

170 [[1, 1, 1], [1, -1, 0]], 

171 [[-1, -1, 1], [-2, 1, -1]], # {112}<111> edge 

172 [[-1, -1, 1], [1, -2, -1]], 

173 [[-1, -1, 1], [1, 1, 2]], 

174 [[-1, 1, 1], [-2, -1, -1]], 

175 [[-1, 1, 1], [1, 2, -1]], 

176 [[-1, 1, 1], [1, -1, 2]], 

177 [[1, -1, 1], [2, 1, -1]], 

178 [[1, -1, 1], [-1, -2, -1]], 

179 [[1, -1, 1], [-1, 1, 2]], 

180 [[1, 1, 1], [2, -1, -1]], 

181 [[1, 1, 1], [-1, 2, -1]], 

182 [[1, 1, 1], [-1, -1, 2]], 

183 [[1, 1, -1], [1, 2, 3]], # {123}<111> edge 

184 [[1, 1, -1], [-1, 3, 2]], 

185 [[1, 1, -1], [2, 1, 3]], 

186 [[1, 1, -1], [-2, 3, 1]], 

187 [[1, 1, -1], [3, -1, 2]], 

188 [[1, 1, -1], [3, -2, 1]], 

189 [[1, -1, -1], [-1, 2, -3]], 

190 [[1, -1, -1], [1, 3, -2]], 

191 [[1, -1, -1], [2, -1, 3]], 

192 [[1, -1, -1], [2, 3, -1]], 

193 [[1, -1, -1], [3, 1, 2]], 

194 [[1, -1, -1], [3, 2, 1]], 

195 [[1, -1, 1], [1, -2, -3]], 

196 [[1, -1, 1], [1, 3, 2]], 

197 [[1, -1, 1], [2, -1, -3]], 

198 [[1, -1, 1], [2, 3, 1]], 

199 [[1, -1, 1], [3, 1, -2]], 

200 [[1, -1, 1], [3, 2, -1]], 

201 [[1, 1, 1], [1, 2, -3]], 

202 [[1, 1, 1], [1, -3, 2]], 

203 [[1, 1, 1], [2, 1, -3]], 

204 [[1, 1, 1], [2, -3, 1]], 

205 [[1, 1, 1], [-3, 1, 2]], 

206 [[1, 1, 1], [-3, 2, 1]], 

207 ] 

208 ).astype(float) 

209 burgers = b_n[:, 0] / np.sqrt(3) 

210 normals = b_n[:, 1] / np.linalg.norm(b_n[:, 1], axis=1)[:, None] 

211 

212 # Get the sense vectors 

213 t = np.cross(normals, burgers) 

214 

215 # Fix the screw dislocations (sense vectors are the burgers vectors) 

216 t[:4] = burgers[:4] 

217 

218 # Calculate the outer product of the two vectors 

219 outer = np.einsum("...i,...j->...ij", burgers, t) 

220 

221 # Convert to the (n_slip_systems, 9) matrix 

222 A_bcc = outer.reshape(-1, 9).T 

223 

224 return A_bcc 

225 

226 

227def generate_HCP_A_matrix() -> np.ndarray: 

228 """Generate the A matrix for HCP crystal structure.""" 

229 # Relevant Direcitons in [uvtw] notation 

230 b_n_uvtw = np.array( 

231 [ 

232 [[1, 1, -2, 0], [0, 0, 0, 1]], # Basal 

233 [[1, -2, 1, 0], [0, 0, 0, 1]], 

234 [[-2, 1, 1, 0], [0, 0, 0, 1]], 

235 [[2, -1, -1, 0], [0, 1, -1, 0]], # Prismatic 

236 [[-1, 2, -1, 0], [1, 0, -1, 0]], 

237 [[1, 1, -2, 0], [1, -1, 0, 0]], 

238 [[-1, -1, 2, 3], [1, 0, -1, 1]], # Pyramidal 

239 [[-2, 1, 1, 3], [1, 0, -1, 1]], 

240 [[1, 1, -2, 3], [0, -1, 1, 1]], 

241 [[-1, 2, -1, 3], [0, -1, 1, 1]], 

242 [[2, -1, -1, 3], [-1, 1, 0, 1]], 

243 [[1, -2, 1, 3], [-1, 1, 0, 1]], 

244 [[2, -1, -1, 3], [-1, 0, 1, 1]], 

245 [[1, 1, -2, 3], [-1, 0, 1, 1]], 

246 [[-1, -1, 2, 3], [0, 1, -1, 1]], 

247 [[1, -2, 1, 3], [0, 1, -1, 1]], 

248 [[-2, 1, 1, 3], [1, -1, 0, 1]], 

249 [[-1, 2, -1, 3], [1, -1, 0, 1]], 

250 ] 

251 ).astype(float) 

252 

253 # Convert to uvw 

254 u = b_n_uvtw[:, 0, 0] 

255 v = b_n_uvtw[:, 0, 1] 

256 t = b_n_uvtw[:, 0, 2] 

257 w = b_n_uvtw[:, 0, 3] 

258 burgers = np.array([u - t, v - t, w]).T 

259 burgers /= np.linalg.norm(burgers, axis=1)[:, None] 

260 

261 u = b_n_uvtw[:, 1, 0] 

262 v = b_n_uvtw[:, 1, 1] 

263 t = b_n_uvtw[:, 1, 2] 

264 w = b_n_uvtw[:, 1, 3] 

265 normals = np.array([u - t, v - t, w]).T 

266 normals /= np.linalg.norm(normals, axis=1)[:, None] 

267 

268 # Get the sense vectors 

269 t = np.cross(normals, burgers) 

270 

271 # Put in the screw dislocations 

272 burgers = np.vstack((burgers[:3], burgers)) # 3 screw basal dislocations 

273 t = np.vstack((burgers[:3], t)) 

274 burgers = np.vstack((burgers, burgers[-12:])) # 12 screw pyramidal dislocations 

275 t = np.vstack((t, burgers[-12:])) 

276 

277 # Calculate the outer product of the two vectors 

278 outer = np.einsum("...i,...j->...ij", burgers, t) 

279 outer = outer.reshape(-1, 9).T 

280 outer = outer / np.linalg.norm(outer, axis=0) 

281 

282 return outer 

283 

284 

285def pseudo_inverse(A: np.ndarray) -> np.ndarray: 

286 """Calculate the B matrix (psuedo-inverse of A) for the given A matrix. 

287 

288 Args: 

289 A (np.ndarray): The A matrix. Shape (9, n_slip_systems) 

290 

291 Returns: 

292 np.ndarray: The B matrix. Shape (n_slip_systems, 9) 

293 """ 

294 # return A.T.dot(np.linalg.inv(A.dot(A.T))).astype(PRECISION) 

295 return np.linalg.pinv(A).astype(_PRECISION) 

296 

297 

298def get_completeness(grain_ids: np.ndarray) -> np.ndarray: 

299 """ 

300 Vectorized version of neighborhood analysis for 3D EBSD dataset. 

301 

302 Args: 

303 grain_ids (np.ndarray): The grain ID map. Shape (n_x, n_y, n_z) 

304 

305 Returns: 

306 np.ndarray: The completeness array. Shape (n_x, n_y, n_z, 3) 

307 """ 

308 shape = grain_ids.shape 

309 

310 # Initialize output array 

311 completeness = np.zeros((*shape, 3), dtype=np.int8) 

312 

313 # Create masks for valid grain IDs 

314 valid_grains = grain_ids != 0 

315 

316 # Compute transitions (now using broadcasting) 

317 x_trans = np.ones(shape, dtype=bool) 

318 y_trans = np.ones(shape, dtype=bool) 

319 z_trans = np.ones(shape, dtype=bool) 

320 

321 if shape[0] > 1: 

322 x_trans = np.pad(grain_ids[:-1, ...] != grain_ids[1:, ...], ((0, 1), (0, 0), (0, 0))) 

323 if shape[1] > 1: 323 ↛ 325line 323 didn't jump to line 325 because the condition on line 323 was always true

324 y_trans = np.pad(grain_ids[:, :-1, :] != grain_ids[:, 1:, :], ((0, 0), (0, 1), (0, 0))) 

325 if shape[2] > 1: 325 ↛ 329line 325 didn't jump to line 329 because the condition on line 325 was always true

326 z_trans = np.pad(grain_ids[..., :-1] != grain_ids[..., 1:], ((0, 0), (0, 0), (0, 1))) 

327 

328 # Interior points 

329 interior_mask = np.zeros_like(grain_ids, dtype=bool) 

330 interior_mask[1:-1, :, :] = True 

331 

332 # X-direction vectorized analysis 

333 if shape[0] > 1: 

334 completeness[0, :, :, 0] = np.where( 

335 x_trans[0, :, :], 0, 1 

336 ) # Forward differences for first slice 

337 completeness[-1, :, :, 0] = np.where( 

338 x_trans[-2, :, :], 0, 2 

339 ) # Backward differences for last slice 

340 completeness[1:-1, :, :, 0] = np.select( # Central differences 

341 [ 

342 (x_trans[:-2, :, :] & x_trans[1:-1, :, :]), 

343 x_trans[:-2, :, :], 

344 x_trans[1:-1, :, :], 

345 ], 

346 [0, 1, 2], 

347 default=3, 

348 ) 

349 

350 # Y-direction vectorized analysis 

351 if shape[1] > 1: 351 ↛ 365line 351 didn't jump to line 365 because the condition on line 351 was always true

352 completeness[:, 0, :, 1] = np.where(y_trans[:, 0, :], 0, 1) 

353 completeness[:, -1, :, 1] = np.where(y_trans[:, -2, :], 0, 2) 

354 completeness[:, 1:-1, :, 1] = np.select( 

355 [ 

356 (y_trans[:, :-2, :] & y_trans[:, 1:-1, :]), 

357 y_trans[:, :-2, :], 

358 y_trans[:, 1:-1, :], 

359 ], 

360 [0, 1, 2], 

361 default=3, 

362 ) 

363 

364 # Z-direction (similar logic) 

365 if shape[2] > 1: 365 ↛ 379line 365 didn't jump to line 379 because the condition on line 365 was always true

366 completeness[:, :, 0, 2] = np.where(z_trans[:, :, 0], 0, 1) 

367 completeness[:, :, -1, 2] = np.where(z_trans[:, :, -2], 0, 2) 

368 completeness[:, :, 1:-1, 2] = np.select( 

369 [ 

370 (z_trans[:, :, :-2] & z_trans[:, :, 1:-1]), 

371 z_trans[:, :, :-2], 

372 z_trans[:, :, 1:-1], 

373 ], 

374 [0, 1, 2], 

375 default=3, 

376 ) 

377 

378 # Zero out values where grain_id is 0 

379 completeness[~valid_grains] = 0 

380 

381 return completeness 

382 

383 

384def get_neighbors(completeness: np.ndarray) -> np.ndarray: 

385 """Convert the completeness array into a list of neighbor indices.""" 

386 # Get the shape of the completeness array 

387 shape = completeness.shape[:-1] 

388 

389 # Create coordinate shifts for the pairs and the scale for the finite difference calculation 

390 shifts0 = np.zeros((3,) + shape + (3,), dtype=np.int32) 

391 shifts1 = np.zeros((3,) + shape + (3,), dtype=np.int32) 

392 scale = np.zeros(shape + (3,), dtype=_PRECISION) 

393 

394 # Only central and backward differences will have a shift in the first point 

395 shifts0[0][(completeness[..., 0] == 2) | (completeness[..., 0] == 3)] = [-1, 0, 0] 

396 shifts0[1][(completeness[..., 1] == 2) | (completeness[..., 1] == 3)] = [0, -1, 0] 

397 shifts0[2][(completeness[..., 2] == 2) | (completeness[..., 2] == 3)] = [0, 0, -1] 

398 

399 # Only central and forward differences will have a shift in the second point 

400 shifts1[0][(completeness[..., 0] == 1) | (completeness[..., 0] == 3)] = [1, 0, 0] 

401 shifts1[1][(completeness[..., 1] == 1) | (completeness[..., 1] == 3)] = [0, 1, 0] 

402 shifts1[2][(completeness[..., 2] == 1) | (completeness[..., 2] == 3)] = [0, 0, 1] 

403 

404 # Create coordinate array 

405 coords = np.indices(shape).transpose(1, 2, 3, 0) # (x, y, z, ndim) 

406 coords0 = np.stack([coords + shift for shift in shifts0], axis=-2) 

407 coords1 = np.stack([coords + shift for shift in shifts1], axis=-2) 

408 

409 # Handle forward and backward differences (scale is 1) 

410 scale[..., 0][(completeness[..., 0] == 1) | (completeness[..., 0] == 2)] = 1 

411 scale[..., 1][(completeness[..., 1] == 1) | (completeness[..., 1] == 2)] = 1 

412 scale[..., 2][(completeness[..., 2] == 1) | (completeness[..., 2] == 2)] = 1 

413 

414 # Handle central differences (scale is 2) 

415 scale[..., 0][(completeness[..., 0] == 3)] = 2 

416 scale[..., 1][(completeness[..., 1] == 3)] = 2 

417 scale[..., 2][(completeness[..., 2] == 3)] = 2 

418 

419 # Make sure everywhere that has a 0 completeness has a 0 scale 

420 scale[completeness == 0] = 0 

421 

422 # coords dimensions 

423 # 0: x index in volume 

424 # 1: y index in volume 

425 # 2: z index in volume 

426 # 3: the axis of the difference (0: x, 1: y, 2: z) 

427 # 4: the coordinate of the voxel (0: x, 1: y, 2: z) 

428 # so coords0[4, 5, 6, 0, 1] is the y-coordinate of the first voxel in the finite difference pair along the x-direction for the voxel at (4, 5, 6) 

429 # and coords1[4, 5, 6, 0, 1] is the y-coordinate of the second voxel in the pair 

430 

431 return (coords0, coords1, scale) 

432 

433 

434def get_finite_difference_coordinates( 

435 grain_ids: np.ndarray, 

436) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

437 """Calculate the coordinates for finite difference pairs in a 3D EBSD dataset. 

438 

439 Args: 

440 grain_ids: 3D numpy array containing grain IDs 

441 

442 Returns: 

443 tuple of three 3D arrays containing the coordinates of the first voxel, the coordinates of the second voxel, and the scale factor 

444 """ 

445 # Get the completeness array 

446 completeness = get_completeness(grain_ids) 

447 

448 # Get the neighbors 

449 coords0, coords1, scale = get_neighbors(completeness) 

450 

451 return (coords0, coords1, scale) 

452 

453 

454def get_orientation_gradients( 

455 quats: np.ndarray, 

456 pts0: np.ndarray, 

457 pts1: np.ndarray, 

458 distances: np.ndarray, 

459 cs: int, 

460 n_cpus: int = 1, 

461 chunk_size: int = None, 

462 progress_bar: bool = False, 

463) -> np.ndarray: 

464 """Calculate the orientation gradients for a 3D EBSD dataset. 

465 This is essentially the rotation vectors corresponding to the disorientation between neighboring voxels, 

466 divided by the spacing along each dimension. The result is a 3x3 matrix for each voxel. 

467 This function will call the private function _get_orientation_gradients to do the actual calculations. 

468 Supports parallel processing. 

469 

470 Args: 

471 quats: 3D numpy array containing quaternions, (X, Y, Z, 4) 

472 pts0: 3D numpy array containing the coordinates of the first voxel in the finite difference pairs, (X, Y, Z, 3) 

473 pts1: 3D numpy array containing the coordinates of the second voxel in the finite difference pairs, (X, Y, Z, 3) 

474 distances: 3D numpy array containing the distances between the finite difference pairs, (X, Y, Z, 3) 

475 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

476 n_cpus: The number of CPUs to use for parallel processing. If None, all available CPUs minus one will be used. 

477 

478 Returns: 

479 3D numpy array containing the orientation gradients, (X, Y, Z, 3, 3) 

480 This is essentially 3 rotation vectors corresponding to the disorientation between neighboring voxels, 

481 each divided by the distance between the finite difference pair. The 3x3 matrix for each point is the rotation vector for each axis. 

482 """ 

483 # Get the shape 

484 out_shape = quats.shape[:-1] 

485 

486 # Reshape the data to be 1D 

487 quats = quats.reshape(-1, 4).astype(_PRECISION) 

488 N = quats.shape[0] 

489 pts0 = pts0.reshape(-1, 3, 3) 

490 pts1 = pts1.reshape(-1, 3, 3) 

491 distances = distances.reshape(-1, 3) 

492 

493 # Convert points to raveled indices 

494 pts0 = np.stack( 

495 [ 

496 np.ravel_multi_index(pts0[:, 0].T, out_shape), 

497 np.ravel_multi_index(pts0[:, 1].T, out_shape), 

498 np.ravel_multi_index(pts0[:, 2].T, out_shape), 

499 ], 

500 axis=-1, 

501 ) 

502 pts1 = np.stack( 

503 [ 

504 np.ravel_multi_index(pts1[:, 0].T, out_shape), 

505 np.ravel_multi_index(pts1[:, 1].T, out_shape), 

506 np.ravel_multi_index(pts1[:, 2].T, out_shape), 

507 ], 

508 axis=-1, 

509 ) 

510 

511 # Get quaternion pairs 

512 q0 = np.stack( 

513 [quats[pts0[:, 0]], quats[pts0[:, 1]], quats[pts0[:, 2]]], 

514 axis=1, 

515 dtype=_PRECISION, 

516 ) # (n_pairs, 3, 4) 

517 q1 = np.stack( 

518 [quats[pts1[:, 0]], quats[pts1[:, 1]], quats[pts1[:, 2]]], 

519 axis=1, 

520 dtype=_PRECISION, 

521 ) # (n_pairs, 3, 4) 

522 del quats, pts0, pts1 # Free memory 

523 

524 # Get laue_id 

525 laue_id = 11 if cs == 1 or cs == 2 else 9 

526 

527 if n_cpus == 1: 

528 quats_disorientation = quaternions.qu_disorientation(q0, q1, laue_id, laue_id).transpose( 

529 1, 0, 2 

530 ) 

531 else: 

532 # Setup chunk size 

533 if chunk_size is None: 

534 resolved_cpus = _resolve_n_cpus(n_cpus) 

535 chunk_size = max(1, min(N // resolved_cpus, N // 100)) 

536 

537 # Split the data into chunks 

538 q0 = np.array_split(q0, max(1, q0.shape[0] // chunk_size)) 

539 q1 = np.array_split(q1, max(1, q1.shape[0] // chunk_size)) 

540 n_chunks = len(q0) 

541 chunks = zip(q0, q1) 

542 

543 # Run the calculations in parallel 

544 quats_disorientation = np.empty((N, 3, 4), dtype=_PRECISION) 

545 if progress_bar: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 with tqdm_joblib( 

547 tqdm(total=n_chunks, desc="Calculating orientation gradients") 

548 ) as progress_bar: 

549 out = Parallel(n_jobs=n_cpus, timeout=9999999)( 

550 delayed(quaternions.qu_disorientation)(q0, q1, laue_id, laue_id) 

551 for q0, q1 in chunks 

552 ) 

553 else: 

554 out = Parallel(n_jobs=n_cpus, timeout=9999999)( 

555 delayed(quaternions.qu_disorientation)(q0, q1, laue_id, laue_id) 

556 for q0, q1 in chunks 

557 ) 

558 del q0, q1 # Free memory 

559 

560 # Concatenate the results 

561 start_idx = 0 

562 for chunk in tqdm(out, desc="Unpacking orientation gradients"): 

563 end_idx = start_idx + chunk.shape[0] 

564 quats_disorientation[start_idx:end_idx] = chunk 

565 start_idx = end_idx 

566 del out, chunk 

567 quats_disorientation = quats_disorientation.transpose(1, 0, 2) 

568 

569 # Convert quaternions to rotation vectors 

570 rot_vectors = quaternions.qu_log(quats_disorientation) * 2 

571 del quats_disorientation # Free memory 

572 

573 # Get the misorientations from the rotation vectors 

574 misorientation = np.linalg.norm(rot_vectors, axis=-1) 

575 

576 # Get the orientation gradients 

577 with np.errstate(divide="ignore", invalid="ignore"): 

578 gradient_tensors = np.where( 

579 (misorientation == 0).reshape(3, -1, 1), 

580 0, 

581 rot_vectors / distances.T[..., None], 

582 ) 

583 

584 # Reshape the output 

585 gradient_tensors = gradient_tensors.transpose(1, 0, 2).reshape(out_shape + (3, 3)) 

586 misorientation = misorientation.T.reshape(out_shape + (3,)) 

587 return gradient_tensors, misorientation 

588 

589 

590def _minimize_l2(Lambda: np.ndarray, B: np.ndarray, chunk_size: int = None) -> np.ndarray: 

591 """Perform the minimization using the L2 norm. 

592 Negative densities indicate left handed dislocations, positive densities indicate right handed dislocations. 

593 

594 Args: 

595 Lambda: The Nye tensor components. Shape (n_voxels, 9) 

596 B: The B matrix. Shape (n_slip_systems, 9) 

597 chunk_size: The size of the chunks to process in parallel. If None, the entire array is processed at once. 

598 

599 Returns: 

600 np.ndarray: The dislocation density. Shape (n_slip_systems, n_voxels)""" 

601 if chunk_size is None: 601 ↛ 606line 601 didn't jump to line 606 because the condition on line 601 was always true

602 dd = B.dot(Lambda.T).reshape((-1,)) 

603 else: 

604 # Split Lambda into chunks, rebuilding the full (n_slip_systems, n_voxels) 

605 # matrix before the final flatten so the ordering matches the unchunked path 

606 chunks = np.array_split(Lambda, max(1, Lambda.shape[0] // chunk_size)) 

607 dd = np.hstack([B.dot(chunk.T) for chunk in chunks]).reshape((-1,)) 

608 return dd 

609 

610 

611def _minimize_l1(Lambda: np.ndarray, A: np.ndarray, tol: float = 1e-4) -> np.ndarray: 

612 """Perform L1 minimization using expanded basis (separate left/right handed dislocations). 

613 

614 Following Arsenlis & Parks 1999, the basis is expanded so that left-handed and 

615 right-handed dislocations each get their own index, all constrained to be non-negative. 

616 

617 Args: 

618 Lambda: The Nye tensor components. Shape (n_voxels, 9) 

619 A: The A matrix for compact basis. Shape (9, n_slip_systems) 

620 tol: Tolerance for constraint satisfaction 

621 

622 Returns: 

623 np.ndarray: The dislocation density in compact basis (allowing negative values). 

624 Shape (n_slip_systems, n_voxels) 

625 """ 

626 Lambda = Lambda.astype(np.float64) 

627 A = A.astype(np.float64) 

628 

629 n_constraints = A.shape[0] 

630 n_slip_systems = A.shape[1] 

631 N = Lambda.shape[0] 

632 

633 # Expand the basis: [A, -A] so that each column represents one sign 

634 # rho_expanded = [rho_plus, rho_minus] where both are ≥ 0 

635 # Then A @ (rho_plus - rho_minus) = Lambda 

636 A_expanded = np.hstack([A, -A]) # Shape (9, 2*n_slip_systems) 

637 n_variables = 2 * n_slip_systems 

638 

639 dd_compact = np.zeros((n_slip_systems, N), dtype=np.float64) 

640 bad_count = 0 

641 

642 for i in range(N): 

643 # Minimize sum of all densities (L1 norm of expanded representation) 

644 c = np.ones(n_variables, dtype=np.float64) 

645 

646 # Equality constraint: A_expanded @ rho_expanded = Lambda[i] 

647 A_eq = A_expanded 

648 b_eq = Lambda[i].reshape(-1).astype(np.float64) 

649 

650 # All variables non-negative (this is the key for L1) 

651 bounds = [(0.0, None)] * n_variables 

652 

653 # Solve 

654 result = optimize.linprog( 

655 c, 

656 A_eq=A_eq, 

657 b_eq=b_eq, 

658 bounds=bounds, 

659 method="highs", 

660 options={ 

661 "presolve": True, 

662 "disp": False, 

663 "dual_feasibility_tolerance": tol, 

664 "primal_feasibility_tolerance": tol, 

665 }, 

666 ) 

667 

668 if not result.success: 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 dd_compact[:, i] = 0 

670 bad_count += 1 

671 else: 

672 # Convert back to compact basis: rho = rho_plus - rho_minus 

673 rho_plus = result.x[:n_slip_systems] 

674 rho_minus = result.x[n_slip_systems:] 

675 dd_compact[:, i] = rho_plus - rho_minus 

676 

677 if bad_count > 0: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true

678 print(f"L1 minimization: {bad_count} failed optimizations out of {N}") 

679 return dd_compact 

680 

681 

682def minimize( 

683 alpha: np.ndarray, 

684 cs: int, 

685 A: np.ndarray, 

686 B: np.ndarray, 

687 burgers: np.ndarray, 

688 minimization="l2", 

689 n_cpus: int = 1, 

690 chunk_size: int = None, 

691 progress_bar: bool = False, 

692) -> np.ndarray: 

693 """Minimize the dislocation density using the given minimization scheme. 

694 The equation to be solved is A*rho = Lambda, where A is the A matrix, rho is the dislocation density, and Lambda is the Nye tensor. 

695 This is solved directly using L2 minimization with the pseudo-inverse of A. 

696 This can also be solved using L1 minimization, which is done in parallel for each point in the Nye tensor. 

697 

698 Args: 

699 alpha: The Nye tensor components. Shape (..., 3, 3) 

700 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

701 A: The A matrix. Shape (9, n_slip_systems) 

702 B: The B matrix, psuedo-inverse of A. Shape (n_slip_systems, 9) 

703 burgers: The burgers vector for the material. 

704 For HCP with pyramidal slip systems, this should be a tuple of the two burgers vectors. 

705 minimization: The minimization scheme to use. Either 'l2' or 'l1'. 

706 n_cpus: The number of CPUs to use for parallel processing during L1 minimization. 

707 If None, all available CPUs minus one will be used. Not used for L2 minimization. 

708 chunk_size: The size of the chunks to process in parallel during L1 minimization. 

709 Default chunk size is max(1, n_voxels / (4 * n_cpus)). 

710 progress_bar: Whether to display a progress bar during L1 minimization.""" 

711 # Equation to be solved -> A*rho[array form] = Lambda[Nye in array form] 

712 # Solve: A*rho = Lambd 

713 # Nye tensor must be converted into array form Lambda 

714 # Get shape 

715 shape = alpha.shape[:-2] 

716 Lambda = alpha.reshape(-1, 9) 

717 out_shape = (A.shape[1],) + shape 

718 

719 # DEBUG 

720 # Lambda_i = Lambda[0] 

721 # print(f"Debug: Lambda[0] = {Lambda_i}") 

722 # print(f"Debug: A shape = {A.shape}, B shape = {B.shape}") 

723 # print(f"A matrix:\n{A}") 

724 # print(f"B matrix:\n{B}") 

725 # print(f"Debug: A @ B (should be close to identity):\n{A @ B}") 

726 # print( 

727 # f"Debug: L1 minimization result for first point:\n{_minimize_l1(Lambda[0:1], A)}" 

728 # ) 

729 # print( 

730 # f"Debug: L2 minimization result for first point:\n{_minimize_l2(Lambda[0:1], B)}" 

731 # ) 

732 # exit() 

733 

734 if minimization == "l2": 

735 print("Performing L2 minimization...") 

736 dd = _minimize_l2(Lambda, B, chunk_size).reshape(out_shape) 

737 

738 elif minimization == "l1": 

739 # Setup chunk size 

740 if chunk_size is None: 

741 resolved_cpus = _resolve_n_cpus(n_cpus) 

742 chunk_size = max(1, Lambda.shape[0] // (resolved_cpus * 4)) 

743 

744 # Split into chunks 

745 chunks = np.array_split(Lambda, max(1, Lambda.shape[0] // chunk_size)) 

746 

747 # Add progress bar if desired 

748 if progress_bar: 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true

749 chunks = tqdm(chunks, desc="Minimizing (L1) chunks") 

750 

751 # Process chunks in parallel 

752 with Parallel(n_jobs=n_cpus, timeout=9999999) as parallel: 

753 chunk_results = parallel(delayed(_minimize_l1)(chunk, A) for chunk in chunks) 

754 

755 # Combine the results 

756 dd = np.hstack(chunk_results).reshape(out_shape) 

757 

758 else: 

759 raise ValueError("Minimization scheme not recognized. Please choose either 'l1' or 'l2'") 

760 

761 # Divide by Burgers vector correctly based on crystal structure and slip systems 

762 if cs == 1 or cs == 2: 

763 dd = dd / burgers 

764 else: 

765 burgers_basal_prismatic = None 

766 burgers_pyramidal = None 

767 if not isinstance(burgers, (tuple, list, np.ndarray)): 

768 burgers = (burgers,) 

769 if len(burgers) == 2: 

770 burgers_basal_prismatic = burgers[0] 

771 burgers_pyramidal = burgers[1] 

772 elif len(burgers) == 1 and dd.shape[0] <= 9: 

773 burgers_basal_prismatic = burgers[0] 

774 elif len(burgers) == 1 and dd.shape[0] == 24: 774 ↛ 777line 774 didn't jump to line 777 because the condition on line 774 was always true

775 burgers_pyramidal = burgers[0] 

776 else: 

777 raise ValueError( 

778 "For HCP, when mixing basal/prismatic and pyramidal slip systems, the Burgers vector must be a tuple of (basa/prismatic, pyramidal) Burgers vectors." 

779 ) 

780 

781 # Basal slip 

782 if dd.shape[0] == 6: 

783 dd = dd / burgers_basal_prismatic 

784 

785 # Prismatic slip 

786 elif dd.shape[0] == 3: 

787 dd = dd / burgers_basal_prismatic 

788 

789 # Pyramidal slip 

790 elif dd.shape[0] == 24: 

791 dd = dd / burgers_pyramidal 

792 

793 # Basal + Prismatic slip 

794 elif dd.shape[0] == 9: 

795 dd = dd / burgers_basal_prismatic 

796 

797 # Basal + Pyramidal slip 

798 elif dd.shape[0] == 30: 

799 dd[:6] = dd[:6] / burgers_basal_prismatic 

800 dd[6:] = dd[6:] / burgers_pyramidal 

801 

802 # Prismatic + Pyramidal slip 

803 elif dd.shape[0] == 27: 

804 dd[:3] = dd[:3] / burgers_basal_prismatic 

805 dd[3:] = dd[3:] / burgers_pyramidal 

806 

807 # All slip systems 

808 elif dd.shape[0] == 33: 808 ↛ 812line 808 didn't jump to line 812 because the condition on line 808 was always true

809 dd[:9] = dd[:9] / burgers_basal_prismatic 

810 dd[9:] = dd[9:] / burgers_pyramidal 

811 

812 return dd 

813 

814 

815def calculate( 

816 euler: np.ndarray, 

817 ids: np.ndarray, 

818 cs: int, 

819 slip_systems: str, 

820 burgers: tuple[float, tuple], 

821 spacing: tuple, 

822 minimization: tuple[str, tuple] = "l2", 

823 n_cpus: int = -1, 

824 progress_bar: bool = True, 

825 chunk_size: int = None, 

826) -> tuple[np.ndarray, np.ndarray]: 

827 """Calculate the GND density for a 2D or 3D EBSD dataset. 

828 

829 Args: 

830 euler: The Euler angles for the dataset. Shape (..., 3) 

831 ids: The grain IDs for the dataset. Shape (...) 

832 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

833 slip_systems (str, optional): The slip systems to be used. Defaults to 'all'. 

834 (FCC) - unused, always 'all' 

835 (BCC) - 'screw+110', 'screw+112', 'screw+123', 'screw+110+112', 'screw+110+123', 'screw+112+123', 'all' 

836 (HCP) - 'basal', 'prismatic', 'pyramidal', 'basal+prismatic', 'basal+pyramidal', 'prismatic+pyramidal', 'all' 

837 burgers: The burgers vector for the material in meters. 

838 For HCP with pyramidal slip systems, this should be a tuple of the two burgers vectors. 

839 spacing: The spacing between voxels in meters. Needs to be a tuple of the same length as the ids. 

840 minimization: The minimization scheme to use. Either 'l2' or 'l1' or a tuple containing both. 

841 n_cpus: The number of CPUs to use for parallel processing during L1 minimization. 

842 If None, all available CPUs minus one will be used. Not used for L2 minimization. 

843 progress_bar: Whether to display a progress bar during L1 minimization. 

844 

845 Returns: 

846 dd: The dislocation density. Shape (n_slip_systems, ...) 

847 mis: The misorientation. Shape (3, ...)""" 

848 

849 # Check inputs 

850 ndim = euler.ndim - 1 

851 if euler.shape[-1] != 3: 

852 raise ValueError("The Euler angles must have shape (..., 3)") 

853 if ids.shape != euler.shape[:-1]: 

854 raise ValueError("The grain IDs must have the same shape as the Euler angles") 

855 if cs not in (1, 2, 3): 

856 raise ValueError("The crystal structure must be 1 for FCC, 2 for BCC, or 3 for HCP") 

857 if len(spacing) != ndim: 

858 raise ValueError("The spacing must have the same number of dimensions as the Euler angles") 

859 

860 euler = euler.astype(_PRECISION) 

861 

862 # Handle minimization 

863 if isinstance(minimization, tuple): 

864 if len(minimization) > 2: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true

865 raise ValueError( 

866 "The minimization scheme must be either 'l1' or 'l2' or both, but cannot have more than two elements" 

867 ) 

868 minimization = tuple(m.lower() for m in minimization) 

869 elif isinstance(minimization, str): 

870 minimization = (minimization.lower(),) 

871 minimization = sorted(minimization) 

872 for m in minimization: 

873 if m not in ("l1", "l2"): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true

874 raise ValueError("The minimization scheme must be either 'l1' or 'l2'") 

875 

876 # Get the linear operator 

877 A, B = get_linear_operator(cs, slip_systems) 

878 

879 # Convert Euler angles to quaternions 

880 quats = rotations.eu2qu(euler) 

881 del euler # Free memory 

882 

883 # Get the finite difference coordinates 

884 print("Getting finite difference coordinates...") 

885 nbrs0, nbrs1, distances = get_finite_difference_coordinates(ids) 

886 distances *= spacing 

887 

888 # Get the orientation gradients 

889 dphi, mis = get_orientation_gradients( 

890 quats, 

891 nbrs0, 

892 nbrs1, 

893 distances, 

894 cs, 

895 n_cpus, 

896 progress_bar=progress_bar, 

897 chunk_size=chunk_size, 

898 ) 

899 del quats, nbrs0, nbrs1, distances # Free memory 

900 mis = np.rad2deg(mis) 

901 mis = mis.transpose(3, 0, 1, 2) # (..., 3) -> (3, ...) 

902 

903 # Calculate the alpha tensor 

904 trace = np.trace(dphi, axis1=3, axis2=4) 

905 alpha = dphi.transpose(0, 1, 2, 4, 3) - trace[..., None, None] * np.eye(3).reshape( 

906 1, 1, 1, 3, 3 

907 ) 

908 del dphi, trace # Free memory 

909 

910 # Minimize the dislocation density 

911 dd = {} 

912 for m in minimization: 

913 dd[m] = np.abs(minimize(alpha, cs, A, B, burgers, m, n_cpus, progress_bar=progress_bar)) 

914 

915 return dd, mis 

916 

917 

918def _run_calculation( 

919 euler: np.ndarray, 

920 ids: np.ndarray, 

921 spacing: tuple, 

922 cs: int, 

923 burgers: np.ndarray, 

924 minimization: str | Iterable[str], 

925 n_cpus: int, 

926 slip_systems: str, 

927 progress_bar: bool, 

928 chunk_size: int, 

929) -> tuple[dict[str, np.ndarray], np.ndarray] | None: 

930 """Shared calculation + results-summary step used by both 

931 calculate_and_save_dream3d and calculate_and_save_ang. 

932 

933 Args: 

934 euler: The Euler angles for the dataset. Shape (..., 3) 

935 ids: The grain IDs for the dataset. Shape (...) 

936 spacing: The spacing between voxels in meters. 

937 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

938 burgers: The burgers vector for the material in meters. 

939 minimization: The minimization scheme to use. Either 'l2' or 'l1' or both. 

940 n_cpus: The number of CPUs to use for parallel processing during L1 minimization. 

941 slip_systems: The slip systems to be used. 

942 progress_bar: Whether to display a progress bar during L1 minimization. 

943 chunk_size: The size of the chunks to process in parallel. 

944 

945 Returns: 

946 `(dd, mis)` on success, or `None` if the calculation itself raised. 

947 """ 

948 print("Euler angles shape:", euler.shape) 

949 print("Grain IDs shape:", ids.shape) 

950 print("Voxel spacing (m):", spacing) 

951 

952 try: 

953 dd, mis = calculate( 

954 euler, 

955 ids, 

956 cs, 

957 slip_systems, 

958 burgers, 

959 spacing, 

960 minimization, 

961 n_cpus, 

962 progress_bar, 

963 chunk_size, 

964 ) 

965 except Exception as e: 

966 print(f"Error during GND calculation: {e}") 

967 return None 

968 

969 print("\nResults summary:") 

970 print("----------------") 

971 for m in dd: 

972 print(f"- {m} GND max: {dd[m].max():.3e} m\u207b\u00b2") 

973 nonzero = dd[m][dd[m] > 0] 

974 if nonzero.size > 0: 

975 print(f"- {m} GND min (non-zero): {nonzero.min():.3e} m\u207b\u00b2") 

976 else: 

977 print(f"- {m} GND min (non-zero): none (all values are zero)") 

978 print(f"- FDM_avg max: {mis.mean(axis=0).max():.3f}\u00b0") 

979 print(f"- FDM_max max: {mis.max(axis=0).max():.3f}\u00b0") 

980 print("----------------") 

981 return dd, mis 

982 

983 

984def calculate_and_save_dream3d( 

985 dream3d_path: Path | str, 

986 ids_name: str, 

987 euler_name: str, 

988 cs: int, 

989 burgers: np.ndarray, 

990 spacing_units: str = "um", 

991 minimization: str | Iterable[str] = "l2", 

992 n_cpus: int = -1, 

993 slip_systems: str = "all", 

994 progress_bar: bool = False, 

995 chunk_size: int = 1000, 

996) -> bool: 

997 """Calculate the GND density for a DREAM3D EBSD dataset and save the 

998 results back into the same DREAM3D file. 

999 

1000 Args: 

1001 dream3d_path: Path to the DREAM3D file. 

1002 ids_name: Name of the grain ID data array in the DREAM3D file. 

1003 euler_name: Name of the Euler angles data array in the DREAM3D file. 

1004 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

1005 burgers: The burgers vector for the material in meters. For HCP with 

1006 pyramidal slip systems, this should be a tuple of the two burgers vectors. 

1007 spacing_units: Units of the spacing in the DREAM3D file. Options are 'nm', 

1008 'nanometer', 'nanometers', 'um', 'µm', 'micron', 'microns', 'micrometer', 

1009 'micrometers', 'mm', 'millimeter', 'millimeters', 'm', 'meter', 'meters'. 

1010 minimization: The minimization scheme to use. Either 'l2' or 'l1' or both. 

1011 n_cpus: The number of CPUs to use for parallel processing during L1 

1012 minimization. If -1, all available CPUs minus one will be used. Not 

1013 used for L2 minimization. 

1014 slip_systems: The slip systems to be used. Defaults to 'all'. 

1015 (FCC) - unused, always 'all'. 

1016 (BCC) - 'screw+110', 'screw+112', 'screw+123', 'screw+110+112', 

1017 'screw+110+123', 'screw+112+123', 'all'. 

1018 (HCP) - 'basal', 'prismatic', 'pyramidal', 'basal+prismatic', 

1019 'basal+pyramidal', 'prismatic+pyramidal', 'all'. 

1020 progress_bar: Whether to display a progress bar during L1 minimization. 

1021 chunk_size: The size of the chunks to process in parallel. 

1022 

1023 Returns: 

1024 True if the results were saved back into the DREAM3D file. False if the 

1025 calculation or save failed, in which case a `.npy` fallback is written 

1026 next to the DREAM3D file instead. 

1027 """ 

1028 folder = Path(dream3d_path).parent 

1029 euler, ids, spacing = io.read_dream3d(dream3d_path, ids_name, euler_name, spacing_units) 

1030 

1031 result = _run_calculation( 

1032 euler, ids, spacing, cs, burgers, minimization, n_cpus, slip_systems, progress_bar, chunk_size 

1033 ) 

1034 if result is None: 1034 ↛ 1035line 1034 didn't jump to line 1035 because the condition on line 1034 was never true

1035 return False 

1036 dd, mis = result 

1037 

1038 try: 

1039 saved = io.save_to_dream3d(dream3d_path, ids_name, dd, mis) 

1040 except Exception as e: 

1041 print(f"Error saving data to DREAM3D file: {e}") 

1042 saved = False 

1043 

1044 if not saved: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 io.save_npz(dd, mis, folder) 

1046 print("Failed to save results to DREAM3D file. See .npy files for raw data.") 

1047 return False 

1048 

1049 print(f"Results saved to DREAM3D file: {Path(dream3d_path).absolute()}") 

1050 return True 

1051 

1052 

1053def calculate_and_save_ang( 

1054 ang_path: Path | str, 

1055 cs: int, 

1056 burgers: np.ndarray, 

1057 grain_ids_path: Path | str | None = None, 

1058 minimization: str | Iterable[str] = "l2", 

1059 n_cpus: int = -1, 

1060 slip_systems: str = "all", 

1061 progress_bar: bool = False, 

1062 chunk_size: int = 1000, 

1063) -> bool: 

1064 """Calculate the GND density for an .ang EBSD dataset and save the results 

1065 as `.npy` files (and preview images) next to the .ang file. 

1066 

1067 Args: 

1068 ang_path: Path to the .ang file. 

1069 cs: The crystal structure of the material. 1 for FCC, 2 for BCC, 3 for HCP. 

1070 burgers: The burgers vector for the material in meters. For HCP with 

1071 pyramidal slip systems, this should be a tuple of the two burgers vectors. 

1072 grain_ids_path: Path to a grain-data file generated by OIM Analysis. If 

1073 not provided, the entire dataset is assumed to be a single grain. 

1074 minimization: The minimization scheme to use. Either 'l2' or 'l1' or both. 

1075 n_cpus: The number of CPUs to use for parallel processing during L1 

1076 minimization. If -1, all available CPUs minus one will be used. Not 

1077 used for L2 minimization. 

1078 slip_systems: The slip systems to be used. Defaults to 'all'. 

1079 (FCC) - unused, always 'all'. 

1080 (BCC) - 'screw+110', 'screw+112', 'screw+123', 'screw+110+112', 

1081 'screw+110+123', 'screw+112+123', 'all'. 

1082 (HCP) - 'basal', 'prismatic', 'pyramidal', 'basal+prismatic', 

1083 'basal+pyramidal', 'prismatic+pyramidal', 'all'. 

1084 progress_bar: Whether to display a progress bar during L1 minimization. 

1085 chunk_size: The size of the chunks to process in parallel. 

1086 

1087 Returns: 

1088 True if the calculation succeeded and results were saved, False otherwise. 

1089 """ 

1090 if grain_ids_path is None: 1090 ↛ 1096line 1090 didn't jump to line 1096 because the condition on line 1090 was always true

1091 warnings.warn( 

1092 "grain_ids_path not provided. Assuming the entire dataset is a single grain.", 

1093 Warning, 

1094 ) 

1095 

1096 folder = Path(ang_path).parent 

1097 euler, ids, spacing = io.read_ang(ang_path, grain_ids_path) 

1098 

1099 result = _run_calculation( 

1100 euler, ids, spacing, cs, burgers, minimization, n_cpus, slip_systems, progress_bar, chunk_size 

1101 ) 

1102 if result is None: 1102 ↛ 1103line 1102 didn't jump to line 1103 because the condition on line 1102 was never true

1103 return False 

1104 dd, mis = result 

1105 

1106 # Remove z dimension if 2D, order is (C, Z, Y, X), with C either being 3 (mis) or n_slip_systems (dd) 

1107 dd = {k: v[:, 0] for k, v in dd.items()} 

1108 mis = mis[:, 0] 

1109 io.save_npz(dd, mis, folder) 

1110 print("Calculation complete. Check .npy files for raw data.") 

1111 io.generate_images(gnd_data=dd, fdm_data=mis, folder=folder) 

1112 return True 

1113 

1114 

1115def calculate_and_save( 

1116 cs: int, 

1117 burgers: np.ndarray, 

1118 dream3d_path: Path | str | None = None, 

1119 ids_name: str | None = None, 

1120 euler_name: str | None = None, 

1121 ang_path: Path | str | None = None, 

1122 grain_ids_path: Path | str | None = None, 

1123 spacing_units: str = "um", 

1124 minimization: str | Iterable[str] = "l2", 

1125 n_cpus: int = -1, 

1126 slip_systems: str = "all", 

1127 progress_bar: bool = False, 

1128 chunk_size: int = 1000, 

1129) -> bool: 

1130 """Deprecated: use `calculate_and_save_dream3d` or `calculate_and_save_ang` instead. 

1131 

1132 This combined-argument entry point is kept only for backwards compatibility 

1133 and dispatches to one of the two functions above based on whether 

1134 `dream3d_path` or `ang_path` is provided. 

1135 """ 

1136 warnings.warn( 

1137 "calculate_and_save() is deprecated; use calculate_and_save_dream3d() " 

1138 "or calculate_and_save_ang() instead.", 

1139 DeprecationWarning, 

1140 stacklevel=2, 

1141 ) 

1142 if dream3d_path is not None and ang_path is not None: 

1143 raise ValueError("Provide either dream3d_path or ang_path, not both.") 

1144 if dream3d_path is not None: 

1145 if ids_name is None or euler_name is None: 

1146 raise ValueError( 

1147 "If dream3d_path is provided, ids_name and euler_name must also be provided." 

1148 ) 

1149 return calculate_and_save_dream3d( 

1150 dream3d_path, 

1151 ids_name, 

1152 euler_name, 

1153 cs, 

1154 burgers, 

1155 spacing_units, 

1156 minimization, 

1157 n_cpus, 

1158 slip_systems, 

1159 progress_bar, 

1160 chunk_size, 

1161 ) 

1162 if ang_path is not None: 

1163 return calculate_and_save_ang( 

1164 ang_path, 

1165 cs, 

1166 burgers, 

1167 grain_ids_path, 

1168 minimization, 

1169 n_cpus, 

1170 slip_systems, 

1171 progress_bar, 

1172 chunk_size, 

1173 ) 

1174 raise ValueError("Either dream3d_path or ang_path must be provided.")