Coverage for wulffpack/core/base_particle.py: 96%

306 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-02 08:51 +0000

1from typing import Dict, List, Tuple 

2import numpy as np 

3from ase import Atoms 

4from .facet import Facet 

5from .form import Form 

6from scipy.spatial import ConvexHull 

7from .geometry import is_array_in_arrays 

8 

9 

10class BaseParticle(): 

11 """ 

12 Base class for Wulff constructions, useful for systems of 

13 (almost) any symmetry. 

14 

15 The Wulff construction calculates the vertices of the dual, 

16 calculates the convex hull of those vertices using scipy's 

17 ConvexHull, and then converts the dual back to the Wulff 

18 shape. 

19 

20 Parameters 

21 ---------- 

22 forms 

23 List of forms belonging to the system at hand 

24 standardized_structure 

25 The structural unit implicitly carrying information about 

26 basis vectors etc. 

27 natoms 

28 Used as a target number of atoms if an atomistic structure 

29 is requested. Together with `standardized_structure`, 

30 `natoms` also defines the volume of the particle. 

31 ngrains 

32 If only a part of a complete particle is to be constructed, 

33 for example a fifth of a decahedron, this should be reflected 

34 by this parameter. 

35 volume_scale 

36 Decahedral and icosahedral particle need to be strained to 

37 fill space. With this parameter, the volume is scaled such 

38 that the volume of the particle is correct *after* this 

39 straining. 

40 tolerance 

41 Numerical tolerance 

42 """ 

43 

44 def __init__(self, forms: List[Form], 

45 standardized_structure: Atoms, 

46 natoms: int, 

47 ngrains: int = 1, 

48 volume_scale: int = 1.0, 

49 tol: float = 1e-5): 

50 self._natoms = natoms 

51 self._standardized_structure = standardized_structure 

52 self._forms = forms 

53 self.tol = tol 

54 

55 # Calculate convex hull of dual vertices 

56 duals = [] 

57 facets = [facet for facet in self._yield_facets()] 

58 for facet in self._yield_facets(): 

59 facet_points = facet.normal * facet.energy 

60 duals.append(facet_points / np.dot(facet_points, facet_points)) 

61 hull = ConvexHull(duals) 

62 

63 # Calculate vertices of dual again (i.e., back to Wulff shape) 

64 for i, j, k in hull.simplices: 

65 normal = np.cross(duals[j] - duals[i], duals[k] - duals[i]) 

66 if np.linalg.norm(normal) < 1e-12: 66 ↛ 67line 66 didn't jump to line 67, because the condition on line 66 was never true

67 continue # Rare instances of duals on a line 

68 normal *= facets[i].energy / np.dot(normal, facets[i].normal) 

69 for facet_i in (i, j, k): 

70 facets[facet_i].add_vertex(normal.copy()) 

71 

72 # Remove vertices that are on the line between two other vertices 

73 # (relevant in the Winterbottom case and some corner cases) 

74 for facet in self._yield_facets(): 

75 facet.remove_redundant_vertices() 

76 

77 # Delete facets with less than three vertices 

78 to_pop = [] 

79 for i, form in enumerate(self.forms): 

80 if len(form.facets[0].vertices) < 3: 

81 to_pop.append(i) 

82 for i in reversed(to_pop): 

83 self.forms.pop(i) 

84 

85 # Scale everyting such that the volume matches the specified one 

86 target_volume = self.natoms * standardized_structure.get_volume() / \ 

87 len(standardized_structure) 

88 scale_factor = (target_volume / (ngrains * self.volume * volume_scale)) ** (1 / 3) 

89 self._scale_size(scale_factor) 

90 

91 def _scale_size(self, scale_factor: float): 

92 """ 

93 Scale the size of the particle. 

94 

95 Parameters 

96 ---------- 

97 scale_factor 

98 Factor that all coordinates will be scaled with. 

99 """ 

100 for facet in self._yield_facets(): 

101 for i, _ in enumerate(facet.vertices): 

102 facet.vertices[i] *= scale_factor 

103 for i, _ in enumerate(facet.original_vertices): 

104 facet.original_vertices[i] *= scale_factor 

105 

106 @property 

107 def natoms(self) -> List[int]: 

108 """ 

109 The approximate number of atoms in the particle 

110 (implicitly defining the volume). 

111 """ 

112 return self._natoms 

113 

114 @natoms.setter 

115 def natoms(self, natoms: int): 

116 """ 

117 Change the approximate number of atoms and thus the volume. 

118 """ 

119 scale_factor = (natoms / self._natoms) ** (1 / 3.) 

120 self._scale_size(scale_factor) 

121 self._natoms = natoms 

122 

123 @property 

124 def forms(self) -> List[Form]: 

125 """List of inequivalent forms for the particle""" 

126 return self._forms 

127 

128 @property 

129 def standardized_structure(self) -> Atoms: 

130 """ 

131 The standardized atomic structure that defines the geometry 

132 and thus the meaning of the Miller indices. Also forms the building 

133 blocks when `particle.atoms` is called. 

134 """ 

135 return self._standardized_structure 

136 

137 @property 

138 def _twin_form(self) -> Form: 

139 """Returns the twin form if there is one, otherwise None.""" 

140 for form in self.forms: 

141 if form.parent_miller_indices == 'twin': 

142 return form 

143 return None 

144 

145 def get_continuous_color_scheme(self, 

146 base_colors: dict = None, 

147 normalize: bool = False) -> dict: 

148 """ 

149 Returns a dictionary with RGB colors for each form. 

150 The colors smoothly interpolate between three 

151 base colors, corresponding to (1, 1, 1), (1, 1, 0) and 

152 (1, 0, 0). Note that this is sensible primarily for 

153 cubic systems. 

154 

155 Parameters 

156 ---------- 

157 base_colors 

158 User chosen colors for one or several of (1, 1, 1), 

159 (1, 1, 0) and (1, 0, 0). To enforce, say, green 

160 (1, 1, 1), use ``base_colors={(1, 1, 1): 'g'}`` 

161 normalize 

162 If True, the norm of the RGB vectors will be 1. Note 

163 that this may affect the ``base_colors`` too. 

164 """ 

165 from matplotlib.colors import to_rgba 

166 

167 # Adapt base_colors dictionary 

168 new_base_colors = {} 

169 if base_colors: 

170 for key in base_colors.keys(): 

171 adapted_key = tuple(sorted(abs(i) for i in key)) 

172 new_base_colors[adapted_key] = base_colors[key] 

173 

174 # Make sure base_colors is complete 

175 default_colors = {(1, 1, 1): '#2980B9', 

176 (0, 1, 1): '#E92866', 

177 (0, 0, 1): '#FFE82C'} 

178 for key, color in default_colors.items(): 

179 if key not in new_base_colors: 

180 new_base_colors[key] = color 

181 A = [(1, 1, 1), (0, 1, 1), (0, 0, 1)] 

182 base_color_matrix = np.array([to_rgba(new_base_colors[f])[:3] for f in A]).T 

183 

184 # Normalize rows in A 

185 A = np.array([np.array(miller) / np.linalg.norm(miller) for miller in A]).T 

186 

187 # Calculate color for each form 

188 colors = {} 

189 for form in self.forms: 

190 if type(form.parent_miller_indices) is not tuple: 

191 continue 

192 miller = np.array(sorted(abs(i) for i in form.parent_miller_indices)) 

193 color = np.linalg.solve(A, miller) 

194 color /= np.linalg.norm(color) 

195 color = np.dot(base_color_matrix, color) 

196 if normalize: 

197 color /= np.linalg.norm(color) 

198 else: 

199 color = [min(1, c) for c in color] 

200 colors[form.parent_miller_indices] = color 

201 return colors 

202 

203 def make_plot(self, ax, alpha: float = 0.85, linewidth: float = 0.3, colors: dict = None): 

204 """ 

205 Plot a particle in an axis object. This function can be used to 

206 make customized plots of particles. 

207 

208 Parameters 

209 ---------- 

210 ax : matplotlib Axes3DSubplot 

211 An axis object with 3d projection 

212 alpha 

213 Opacity of the faces 

214 linewidth 

215 Thickness of lines between faces 

216 colors 

217 Allows custom colors for facets of all or a subset of forms, 

218 example `{(1, 1, 1): '#FF0000'}` 

219 

220 Example 

221 ------- 

222 In the following example, three different particles are 

223 plotted in the same figure:: 

224 

225 >>> from wulffpack import SingleCrystal, Decahedron, Icosahedron 

226 >>> import matplotlib.pyplot as plt 

227 >>> from mpl_toolkits.mplot3d import Axes3D 

228 >>> 

229 >>> surface_energies = {(1, 1, 1): 1.0, 

230 ... (1, 0, 0): 1.1, 

231 ... (1, 1, 0): 1.15, 

232 ... (3, 2, 1): 1.15} 

233 >>> twin_energy = 0.05 

234 >>> 

235 >>> fig = plt.figure(figsize=(3*4.0, 4.0)) 

236 >>> ax = fig.add_subplot(131, projection='3d') 

237 >>> particle = SingleCrystal(surface_energies) 

238 >>> particle.make_plot(ax) 

239 >>> 

240 >>> ax = fig.add_subplot(132, projection='3d') 

241 >>> particle = Decahedron(surface_energies, 

242 ... twin_energy=0.05) 

243 >>> particle.make_plot(ax) 

244 >>> 

245 >>> ax = fig.add_subplot(133, projection='3d') 

246 >>> particle = Icosahedron(surface_energies, 

247 ... twin_energy=0.05) 

248 >>> particle.make_plot(ax) 

249 >>> 

250 >>> plt.subplots_adjust(top=1, bottom=0, left=0, 

251 ... right=1, wspace=0, hspace=0) 

252 >>> plt.savefig('particles.png') 

253 

254 """ 

255 from mpl_toolkits.mplot3d.art3d import (Poly3DCollection, 

256 Line3DCollection) 

257 

258 # Standardize color dict 

259 if colors is None: 

260 colors = {} 

261 

262 default_colors = ['#D62728', 

263 '#E377C2', 

264 '#8C564B', 

265 '#7F7F7F', 

266 '#9467BD', 

267 '#BCBD22', 

268 '#17BECF', 

269 '#AEC7E8', 

270 '#FFBB78', 

271 '#98DF8A', 

272 '#FF9896', 

273 '#C5B0D5', 

274 '#C49C94', 

275 '#F7B6D2', 

276 '#C7C7C7', 

277 '#DBDB8D', 

278 '#9EDAE5'] 

279 

280 mins = [1e9, 1e9, 1e9] 

281 maxs = [-1e9, -1e9, -1e9] 

282 poly3d = [] 

283 

284 used_forms = [] 

285 color_counter = 0 

286 for form in self.forms: 

287 if form.parent_miller_indices == 'twin': 

288 continue 

289 

290 # Determine color 

291 if form.parent_miller_indices in colors: 

292 color = colors[form.parent_miller_indices] 

293 elif form.parent_miller_indices == (1, 1, 1): 

294 color = '#4f81f1' 

295 colors[form.parent_miller_indices] = color 

296 elif tuple(sorted(form.parent_miller_indices)) == (0, 0, 1): 296 ↛ 299line 296 didn't jump to line 299, because the condition on line 296 was never false

297 color = '#f8c73b' 

298 colors[form.parent_miller_indices] = color 

299 elif tuple(sorted(form.parent_miller_indices)) == (0, 1, 1): 

300 color = '#2CA02C' 

301 colors[form.parent_miller_indices] = color 

302 else: 

303 color = default_colors[color_counter % len(default_colors)] 

304 colors[form.parent_miller_indices] = color 

305 color_counter += 1 

306 

307 # Save the used forms to be able to make a legend 

308 # (the colors dict may contain forms that are not present 

309 # if it was supplied by the user) 

310 if form.parent_miller_indices not in used_forms: 310 ↛ 314line 310 didn't jump to line 314, because the condition on line 310 was never false

311 used_forms.append(form.parent_miller_indices) 

312 

313 # Plot all facets in the form 

314 for facet in form.facets: 

315 poly3d = [facet.ordered_vertices] 

316 

317 # Facets 

318 collection = Poly3DCollection(poly3d, alpha=alpha) 

319 collection.set_facecolor(color) 

320 ax.add_collection3d(collection) 

321 

322 # Lines 

323 ax.add_collection3d(Line3DCollection(poly3d, colors='k', linewidths=linewidth)) 

324 

325 # Find proper ranges for the axes 

326 for vertex in facet.ordered_vertices: 

327 for i, v in enumerate(vertex): 

328 if v > maxs[i]: 

329 maxs[i] = v 

330 if v < mins[i]: 

331 mins[i] = v 

332 

333 # Plot fake lines to make a legend 

334 for form in used_forms: 

335 if isinstance(form, tuple): 

336 label = ('{{' + '{}' * len(form) + '}}').format(*form) 

337 else: 

338 label = form 

339 ax.plot([0, 0], [0, 0], color=colors[form], linewidth=8.0, label=label) 

340 

341 # Fiddle with the layout 

342 extent = max(maxs) - min(mins) 

343 bounds = [min(mins) + 0.15 * extent, max(maxs) - 0.15 * extent] 

344 ax.set_xlim(*bounds) 

345 ax.set_ylim(*bounds) 

346 ax.set_zlim(*bounds) 

347 ax.set_xticks([]) 

348 ax.set_yticks([]) 

349 ax.set_zticks([]) 

350 ax.axis('off') 

351 try: 

352 ax.set_box_aspect((1, 1, 1)) 

353 except AttributeError: 

354 # set_box_aspect only available in recent versions of matplotlib 

355 pass 

356 

357 def view(self, alpha: float = 0.85, linewidth: float = 0.3, colors: dict = None, 

358 legend: bool = True, save_as: str = None): 

359 """ 

360 Use matplotlib to view a rendition of the particle. 

361 

362 Parameters 

363 ---------- 

364 alpha 

365 Opacity of the faces 

366 linewidth 

367 Thickness of lines between faces 

368 colors 

369 Allows custom colors for facets of all or a subset of forms, 

370 example `{(1, 1, 1): '#FF0000'}` 

371 legend 

372 Whether or not to show a legend with facet-color definitions 

373 save_as 

374 Filename to save figure as. If None, show the particle 

375 with the GUI instead. 

376 """ 

377 import matplotlib.pyplot as plt 

378 from mpl_toolkits.mplot3d import Axes3D # NOQA 

379 fig = plt.figure(figsize=(6, 6)) 

380 ax = fig.add_subplot(111, projection='3d') 

381 self.make_plot(ax, alpha=alpha, linewidth=linewidth, colors=colors) 

382 plt.subplots_adjust(left=0, bottom=0, right=1, top=1) 

383 if legend: 383 ↛ 385line 383 didn't jump to line 385, because the condition on line 383 was never false

384 fig.legend(frameon=False) 

385 plt.axis('off') 

386 if save_as: 386 ↛ 387line 386 didn't jump to line 387, because the condition on line 386 was never true

387 plt.savefig(save_as, transparent=True) 

388 else: 

389 plt.show() 

390 

391 def write(self, filename: str): 

392 """ 

393 Write particle to file. The file format is derived from the 

394 filename. Currently supported fileformats are: 

395 

396 * Wavefront .obj 

397 

398 Parameters 

399 --------- 

400 filename 

401 Filename of file to write to 

402 """ 

403 supported_file_formats = ['obj'] 

404 fileformat = filename.split('.')[-1] 

405 if fileformat not in supported_file_formats: 

406 raise ValueError('File format {} not supported, '.format(fileformat) + 

407 'supported formats are: ' + ' '.join(supported_file_formats)) 

408 with open(filename, 'w') as f: 

409 if fileformat == 'obj': 409 ↛ exitline 409 didn't return from function 'write', because the condition on line 409 was never false

410 f.write('# Vertices\n') 

411 for form in self.forms: 

412 for facet in form.facets: 

413 for vertex in facet.ordered_vertices[:-1]: 

414 f.write('v {:15.8f} {:15.8f} {:15.8f}\n'.format(*vertex)) 

415 

416 f.write('# Faces\n') 

417 vertex_counter = 1 

418 for form in self.forms: 

419 # Define a group to be able to color each form separately 

420 f.write('g {}\n'.format(form.parent_miller_indices)) 

421 for facet in form.facets: 

422 # Tie the face to the right vertices. Count starts from 1. 

423 s = ['{:5d}'.format(v + vertex_counter) for v in range(len(facet.vertices))] 

424 vertex_counter += len(facet.vertices) 

425 f.write('f ' + ' '.join(s) + '\n') 

426 

427 def _yield_facets(self, only_original_grains: bool = False) -> Facet: 

428 """Generate all facets that are included in the facets dictionary.""" 

429 for form in self.forms: 

430 for facet in form.facets: 

431 if only_original_grains and not facet.original_grain: 

432 continue 

433 yield facet 

434 

435 @property 

436 def volume(self) -> float: 

437 """Returns the volume of the particle""" 

438 volume = 0 

439 for form in self.forms: 

440 volume += form.volume 

441 return volume 

442 

443 @property 

444 def area(self) -> float: 

445 """ 

446 Returns total area of the surface of the particle (not including 

447 twin boundaries). 

448 """ 

449 area = 0 

450 for form in self.forms: 

451 if form.parent_miller_indices != 'twin': 

452 area += form.area 

453 return area 

454 

455 @property 

456 def edge_length(self) -> float: 

457 """Returns total edge length of the particle.""" 

458 edge_length = 0 

459 for form in self.forms: 

460 if form.parent_miller_indices != 'twin': 

461 edge_length += form.edge_length / 2 # Every edge comes twice 

462 return edge_length 

463 

464 @property 

465 def number_of_corners(self) -> float: 

466 """Returns the number of corners (vertices) on the particle.""" 

467 unique_vertices = [] 

468 for form in self.forms: 

469 if form.parent_miller_indices == 'twin': 

470 continue 

471 for facet in form.facets: 

472 for vertex in facet.vertices: 

473 if not is_array_in_arrays(vertex, unique_vertices): 

474 unique_vertices.append(vertex) 

475 return len(unique_vertices) 

476 

477 @property 

478 def surface_energy(self) -> float: 

479 """ 

480 The total surface energy of the particle (including twin boundaries). 

481 """ 

482 E = 0 

483 for form in self.forms: 

484 E += form.surface_energy 

485 return E 

486 

487 @property 

488 def facet_fractions(self) -> Dict[tuple, float]: 

489 """ 

490 Returns a dict specifying fraction of each form 

491 (not including twin boundaries). 

492 """ 

493 facet_fractions = {} 

494 total_area = 0 

495 for form in self.forms: 

496 if form.parent_miller_indices == 'twin': 

497 continue 

498 area = form.area 

499 total_area += area 

500 facet_fractions[form.parent_miller_indices] = facet_fractions.get( 

501 form.parent_miller_indices, 0) + area 

502 for form in facet_fractions: 

503 facet_fractions[form] = facet_fractions[form] / total_area 

504 return facet_fractions 

505 

506 @property 

507 def average_surface_energy(self) -> float: 

508 """ 

509 Average surface energy for the Wulff construction, i.e., 

510 a weighted average over all the facets, where the weights are 

511 the area fraction of each facet. 

512 """ 

513 fractions = self.facet_fractions 

514 weighted_average = 0 

515 used_miller_indices = [] 

516 for form in self.forms: 

517 if form.parent_miller_indices == 'twin' or \ 

518 form.parent_miller_indices in used_miller_indices: 

519 continue 

520 weighted_average += form.energy * fractions[form.parent_miller_indices] 

521 used_miller_indices.append(form.parent_miller_indices) 

522 return weighted_average 

523 

524 def _duplicate_particle(self, symmetries): 

525 """ 

526 Duplicate the particle by applying symmetry operations. 

527 

528 Useful for making an icosahedron from a single tetrahedron for example. 

529 

530 Parameters 

531 ---------- 

532 symmetries: list of NumPy arrays 

533 Each array a symmetry operation(operating on Cartesian coordinates) 

534 that should create a duplicate. 

535 """ 

536 for form in self.forms: 

537 new_facets = [] 

538 for facet in form.facets: 

539 for R in symmetries: 

540 new_facet = Facet(normal=np.dot(R, facet.normal), 

541 energy=facet.energy, 

542 symmetry=None, 

543 original_grain=False) 

544 for vertex in facet.vertices: 

545 new_facet.add_vertex(np.dot(R, vertex)) 

546 

547 new_facets.append(new_facet) 

548 for facet in new_facets: 

549 form.facets.append(facet) 

550 

551 def translate_particle(self, translation: np.ndarray): 

552 """ 

553 Translate the particle. 

554 

555 Parameters 

556 ---------- 

557 translation: list of 3 floats 

558 Translation vector 

559 """ 

560 for facet in self._yield_facets(): 

561 for i, vertex in enumerate(facet.vertices): 

562 facet.vertices[i] = vertex + translation 

563 

564 def rotate_particle(self, rotation: np.ndarray): 

565 """ 

566 Rotate the particle. 

567 

568 Parameters 

569 ---------- 

570 rotation 

571 Rotation matrix 

572 """ 

573 if abs(abs(np.linalg.det(rotation)) - 1.0) > self.tol: 

574 raise ValueError('Provided matrix is not a rotation matrix ' 

575 '(its determinant does not equal 1 or -1).') 

576 for facet in self._yield_facets(): 

577 for i, vertex in enumerate(facet.vertices): 

578 facet.vertices[i] = np.dot(rotation, vertex) 

579 facet.normal = np.dot(rotation, facet.normal) 

580 facet.original_normal = np.dot(rotation, facet.original_normal) 

581 self.standardized_structure.cell = np.dot(rotation, self.standardized_structure.cell.T).T 

582 for atom in self.standardized_structure: 

583 atom.position = np.dot(rotation, atom.position) 

584 

585 def _get_atoms(self, center_shift: Tuple[float, float, float] = None) -> Atoms: 

586 """ 

587 Returns an ASE Atoms object the atoms of which are all 

588 within the facets. In polycrystalline particles, only 

589 one grain will be returned. 

590 

591 Parameters 

592 ---------- 

593 center_shift 

594 Shift of center in Cartesian coordinates. 

595 """ 

596 # Find max and min in x, y and z to bound the volume 

597 # where there may be atoms 

598 # (using scaled coordinates relative to the origin) 

599 mins = [1e9, 1e9, 1e9] 

600 maxs = [-1e9, -1e9, -1e9] 

601 for facet in self._yield_facets(only_original_grains=True): 

602 if facet.original_vertices: 

603 vertices = facet.original_vertices 

604 else: 

605 vertices = facet.vertices 

606 for vertex in vertices: 

607 scaled_vertex = np.linalg.solve(self.standardized_structure.cell.T, vertex) 

608 for i, v in enumerate(scaled_vertex): 

609 if v < mins[i]: 

610 mins[i] = v 

611 if v > maxs[i]: 

612 maxs[i] = v 

613 

614 # Prepare an unnecessarily large atoms object 

615 mins = [int(i) - 1 for i in mins] 

616 maxs = [int(np.ceil(i)) + 1 for i in maxs] 

617 repeat = [int(np.ceil(i)) - int(j) for i, j in zip(maxs, mins)] 

618 atoms = self.standardized_structure.repeat(repeat) 

619 atoms.translate(np.dot(self.standardized_structure.cell.T, mins)) 

620 

621 if center_shift: 

622 atoms.translate(center_shift) 

623 

624 # Loop over all scaled coordinates and check whether 

625 # they are within all facets 

626 to_delete = [] 

627 for atom in atoms: 

628 for facet in self._yield_facets(only_original_grains=True): 

629 if np.dot(atom.position, facet.original_normal) > \ 

630 facet.distance_from_origin + 1e-5: 

631 to_delete.append(atom.index) 

632 break 

633 del atoms[to_delete] 

634 atoms.set_pbc(False) 

635 atoms.set_cell([0., 0., 0.]) 

636 return atoms