pyNanoMatBuilder 0.10.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. pyNanoMatBuilder/.ipynb_checkpoints/TEM_creator-checkpoint.py +1361 -0
  2. pyNanoMatBuilder/.ipynb_checkpoints/__init__-checkpoint.py +32 -0
  3. pyNanoMatBuilder/.ipynb_checkpoints/archimedeanNPs-checkpoint.py +1301 -0
  4. pyNanoMatBuilder/.ipynb_checkpoints/catalanNPs-checkpoint.py +776 -0
  5. pyNanoMatBuilder/.ipynb_checkpoints/crystalNPs-checkpoint.py +1263 -0
  6. pyNanoMatBuilder/.ipynb_checkpoints/data-checkpoint.py +204 -0
  7. pyNanoMatBuilder/.ipynb_checkpoints/johnsonNPs-checkpoint.py +876 -0
  8. pyNanoMatBuilder/.ipynb_checkpoints/make_files_remastered-checkpoint.py +2204 -0
  9. pyNanoMatBuilder/.ipynb_checkpoints/otherNPs-checkpoint.py +258 -0
  10. pyNanoMatBuilder/.ipynb_checkpoints/platonicNPs-checkpoint.py +2250 -0
  11. pyNanoMatBuilder/.ipynb_checkpoints/pyNMBcore-checkpoint.py +93 -0
  12. pyNanoMatBuilder/.ipynb_checkpoints/utils-checkpoint.py +3573 -0
  13. pyNanoMatBuilder/.ipynb_checkpoints/visualID-checkpoint.py +196 -0
  14. pyNanoMatBuilder/README +1 -0
  15. pyNanoMatBuilder/__init__.py +32 -0
  16. pyNanoMatBuilder/archimedeanNPs.py +1225 -0
  17. pyNanoMatBuilder/catalanNPs.py +733 -0
  18. pyNanoMatBuilder/crystalNPs.py +1216 -0
  19. pyNanoMatBuilder/data.py +205 -0
  20. pyNanoMatBuilder/johnsonNPs.py +835 -0
  21. pyNanoMatBuilder/otherNPs.py +257 -0
  22. pyNanoMatBuilder/platonicNPs.py +2255 -0
  23. pyNanoMatBuilder/pyNMBcore.py +98 -0
  24. pyNanoMatBuilder/resources/.ipynb_checkpoints/tools4pyPC-checkpoint.py +44 -0
  25. pyNanoMatBuilder/resources/.ipynb_checkpoints/visualID-checkpoint.py +101 -0
  26. pyNanoMatBuilder/resources/.ipynb_checkpoints/visualID_Eng-checkpoint.py +140 -0
  27. pyNanoMatBuilder/resources/__init__.py +0 -0
  28. pyNanoMatBuilder/resources/cif_database/.ipynb_checkpoints/cod1000041-NaCl-checkpoint.cif +259 -0
  29. pyNanoMatBuilder/resources/cif_database/CsPbBr3_cubic_231023.cif +112 -0
  30. pyNanoMatBuilder/resources/cif_database/CsPbBr3_ortho_14608.cif +93 -0
  31. pyNanoMatBuilder/resources/cif_database/__init__.py +0 -0
  32. pyNanoMatBuilder/resources/cif_database/amorphousC/__init__.py +0 -0
  33. pyNanoMatBuilder/resources/cif_database/amorphousC/aC_relax_10x10.xyz.gz +0 -0
  34. pyNanoMatBuilder/resources/cif_database/amorphousC/aC_relax_5x5.xyz.gz +0 -0
  35. pyNanoMatBuilder/resources/cif_database/cod1000041-NaCl.cif +259 -0
  36. pyNanoMatBuilder/resources/cif_database/cod1539039-Fe_beta.cif +79 -0
  37. pyNanoMatBuilder/resources/cif_database/cod1539039-Mn_beta.cif +79 -0
  38. pyNanoMatBuilder/resources/cif_database/cod5000217-Fe_bcc.cif +168 -0
  39. pyNanoMatBuilder/resources/cif_database/cod9008459-Ag_fcc.cif +252 -0
  40. pyNanoMatBuilder/resources/cif_database/cod9008463-Au_fcc.cif +259 -0
  41. pyNanoMatBuilder/resources/cif_database/cod9008466-Co_fcc.cif +254 -0
  42. pyNanoMatBuilder/resources/cif_database/cod9008492-Co_hcp.cif +86 -0
  43. pyNanoMatBuilder/resources/cif_database/cod9008513-Ru_hcp.cif +84 -0
  44. pyNanoMatBuilder/resources/cif_database/cod9011068-Mn_alpha.cif +118 -0
  45. pyNanoMatBuilder/resources/cif_database/cod9012884-Co_epsilon.cif +82 -0
  46. pyNanoMatBuilder/resources/cif_database/cod9012957-Pt_fcc.cif +263 -0
  47. pyNanoMatBuilder/resources/cif_database/cod9015662-TiO2-rutile.cif +65 -0
  48. pyNanoMatBuilder/resources/cif_database/cod9015929-TiO2-anatase.cif +90 -0
  49. pyNanoMatBuilder/resources/css/.directory +4 -0
  50. pyNanoMatBuilder/resources/css/BrainHalfHalf-120x139.base64 +1 -0
  51. pyNanoMatBuilder/resources/css/BrainHalfHalf-120x139.png +0 -0
  52. pyNanoMatBuilder/resources/css/BrainHalfHalf.base64 +8231 -0
  53. pyNanoMatBuilder/resources/css/BrainHalfHalf.png +0 -0
  54. pyNanoMatBuilder/resources/css/BrainHalfHalf.svg +289 -0
  55. pyNanoMatBuilder/resources/css/visualID.css +274 -0
  56. pyNanoMatBuilder/resources/figs/.directory +6 -0
  57. pyNanoMatBuilder/resources/figs/.ipynb_checkpoints/bccrdd-C-checkpoint.png +0 -0
  58. pyNanoMatBuilder/resources/figs/InoD-C.png +0 -0
  59. pyNanoMatBuilder/resources/figs/InoD.base64 +1 -0
  60. pyNanoMatBuilder/resources/figs/InoD.png +0 -0
  61. pyNanoMatBuilder/resources/figs/InoD.xyz +310 -0
  62. pyNanoMatBuilder/resources/figs/MarksD-C.png +0 -0
  63. pyNanoMatBuilder/resources/figs/MarksD.base64 +1 -0
  64. pyNanoMatBuilder/resources/figs/MarksD.png +0 -0
  65. pyNanoMatBuilder/resources/figs/MarksD.xyz +51 -0
  66. pyNanoMatBuilder/resources/figs/OhWS-C.png +0 -0
  67. pyNanoMatBuilder/resources/figs/OhWS.png +0 -0
  68. pyNanoMatBuilder/resources/figs/OhWS.script +1 -0
  69. pyNanoMatBuilder/resources/figs/OhWS.xyz +87 -0
  70. pyNanoMatBuilder/resources/figs/WS-C.png +0 -0
  71. pyNanoMatBuilder/resources/figs/WS.png +0 -0
  72. pyNanoMatBuilder/resources/figs/WS.script +1 -0
  73. pyNanoMatBuilder/resources/figs/WS.xyz +607 -0
  74. pyNanoMatBuilder/resources/figs/__init__.py +0 -0
  75. pyNanoMatBuilder/resources/figs/at.xyz +3 -0
  76. pyNanoMatBuilder/resources/figs/bccrDDWS-C.png +0 -0
  77. pyNanoMatBuilder/resources/figs/bccrDDWS.png +0 -0
  78. pyNanoMatBuilder/resources/figs/bccrDDWS.script +1 -0
  79. pyNanoMatBuilder/resources/figs/bccrDDWS.xyz +67 -0
  80. pyNanoMatBuilder/resources/figs/bccrdd-C.png +0 -0
  81. pyNanoMatBuilder/resources/figs/bccrdd.base64 +1 -0
  82. pyNanoMatBuilder/resources/figs/bccrdd.png +0 -0
  83. pyNanoMatBuilder/resources/figs/bccrdd.xyz +16 -0
  84. pyNanoMatBuilder/resources/figs/bccrddWS.png +0 -0
  85. pyNanoMatBuilder/resources/figs/bccrddWS.script +1 -0
  86. pyNanoMatBuilder/resources/figs/bccrddWS.xyz +67 -0
  87. pyNanoMatBuilder/resources/figs/cube-C.png +0 -0
  88. pyNanoMatBuilder/resources/figs/cube.base64 +1 -0
  89. pyNanoMatBuilder/resources/figs/cube.png +0 -0
  90. pyNanoMatBuilder/resources/figs/cube.xyz +11 -0
  91. pyNanoMatBuilder/resources/figs/cubeWS-C.png +0 -0
  92. pyNanoMatBuilder/resources/figs/cubeWS.png +0 -0
  93. pyNanoMatBuilder/resources/figs/cubeWS.script +1 -0
  94. pyNanoMatBuilder/resources/figs/cubeWS.xyz +367 -0
  95. pyNanoMatBuilder/resources/figs/cubo-C.png +0 -0
  96. pyNanoMatBuilder/resources/figs/cubo.base64 +1 -0
  97. pyNanoMatBuilder/resources/figs/cubo.png +0 -0
  98. pyNanoMatBuilder/resources/figs/cubo.xyz +14 -0
  99. pyNanoMatBuilder/resources/figs/cuboWS-C.png +0 -0
  100. pyNanoMatBuilder/resources/figs/cuboWS.png +0 -0
  101. pyNanoMatBuilder/resources/figs/cuboWS.script +1 -0
  102. pyNanoMatBuilder/resources/figs/cuboWS.xyz +311 -0
  103. pyNanoMatBuilder/resources/figs/dicoTdWS-C.png +0 -0
  104. pyNanoMatBuilder/resources/figs/dicoTdWS.png +0 -0
  105. pyNanoMatBuilder/resources/figs/dicoTdWS.script +1 -0
  106. pyNanoMatBuilder/resources/figs/dicoTdWS.xyz +4749 -0
  107. pyNanoMatBuilder/resources/figs/ellipsoid-C.png +0 -0
  108. pyNanoMatBuilder/resources/figs/ellipsoid.base64 +1 -0
  109. pyNanoMatBuilder/resources/figs/ellipsoid.png +0 -0
  110. pyNanoMatBuilder/resources/figs/fccOh-C.png +0 -0
  111. pyNanoMatBuilder/resources/figs/fccOh.base64 +1 -0
  112. pyNanoMatBuilder/resources/figs/fccOh.png +0 -0
  113. pyNanoMatBuilder/resources/figs/fccOh.xyz +8 -0
  114. pyNanoMatBuilder/resources/figs/fccTd-C.png +0 -0
  115. pyNanoMatBuilder/resources/figs/fccTd.base64 +1 -0
  116. pyNanoMatBuilder/resources/figs/fccTd.png +0 -0
  117. pyNanoMatBuilder/resources/figs/fccTd.xyz +6 -0
  118. pyNanoMatBuilder/resources/figs/fccdrdd.png +0 -0
  119. pyNanoMatBuilder/resources/figs/fccdrdd.xyz +16 -0
  120. pyNanoMatBuilder/resources/figs/fccrdd-C.png +0 -0
  121. pyNanoMatBuilder/resources/figs/fccrdd.base64 +1 -0
  122. pyNanoMatBuilder/resources/figs/fccrdd.png +0 -0
  123. pyNanoMatBuilder/resources/figs/fccrdd.xyz +16 -0
  124. pyNanoMatBuilder/resources/figs/hcpsph1WS-C.png +0 -0
  125. pyNanoMatBuilder/resources/figs/hcpsph1WS.png +0 -0
  126. pyNanoMatBuilder/resources/figs/hcpsph1WS.script +1 -0
  127. pyNanoMatBuilder/resources/figs/hcpsph1WS.xyz +410 -0
  128. pyNanoMatBuilder/resources/figs/hcpsph2WS-C.png +0 -0
  129. pyNanoMatBuilder/resources/figs/hcpsph2WS.png +0 -0
  130. pyNanoMatBuilder/resources/figs/hcpsph2WS.script +1 -0
  131. pyNanoMatBuilder/resources/figs/hcpsph2WS.xyz +344 -0
  132. pyNanoMatBuilder/resources/figs/ico-C.png +0 -0
  133. pyNanoMatBuilder/resources/figs/ico.base64 +1 -0
  134. pyNanoMatBuilder/resources/figs/ico.png +0 -0
  135. pyNanoMatBuilder/resources/figs/ico.xyz +14 -0
  136. pyNanoMatBuilder/resources/figs/pbpy-C.png +0 -0
  137. pyNanoMatBuilder/resources/figs/pbpy.base64 +1 -0
  138. pyNanoMatBuilder/resources/figs/pbpy.png +0 -0
  139. pyNanoMatBuilder/resources/figs/pbpy.xyz +9 -0
  140. pyNanoMatBuilder/resources/figs/pnmbAvailableStructures.png +0 -0
  141. pyNanoMatBuilder/resources/figs/pnmbAvailableStructures.svg +1037 -0
  142. pyNanoMatBuilder/resources/figs/rDD-C.png +0 -0
  143. pyNanoMatBuilder/resources/figs/rDD.base64 +1 -0
  144. pyNanoMatBuilder/resources/figs/rDD.png +0 -0
  145. pyNanoMatBuilder/resources/figs/rDD.xyz +22 -0
  146. pyNanoMatBuilder/resources/figs/rhcuboWS-C.png +0 -0
  147. pyNanoMatBuilder/resources/figs/rhcuboWS.png +0 -0
  148. pyNanoMatBuilder/resources/figs/rhcuboWS.script +1 -0
  149. pyNanoMatBuilder/resources/figs/rhcuboWS.xyz +333 -0
  150. pyNanoMatBuilder/resources/figs/script-facettes-345PtLight.spt +77 -0
  151. pyNanoMatBuilder/resources/figs/sphere-C.png +0 -0
  152. pyNanoMatBuilder/resources/figs/sphere.base64 +1 -0
  153. pyNanoMatBuilder/resources/figs/sphere.png +0 -0
  154. pyNanoMatBuilder/resources/figs/tbp-C.png +0 -0
  155. pyNanoMatBuilder/resources/figs/tbp.base64 +1 -0
  156. pyNanoMatBuilder/resources/figs/tbp.png +0 -0
  157. pyNanoMatBuilder/resources/figs/tbp.xyz +22 -0
  158. pyNanoMatBuilder/resources/figs/tpt-C.png +0 -0
  159. pyNanoMatBuilder/resources/figs/tpt.base64 +1 -0
  160. pyNanoMatBuilder/resources/figs/tpt.png +0 -0
  161. pyNanoMatBuilder/resources/figs/tpt.xyz +150 -0
  162. pyNanoMatBuilder/resources/figs/trOh-C.png +0 -0
  163. pyNanoMatBuilder/resources/figs/trOh.base64 +1 -0
  164. pyNanoMatBuilder/resources/figs/trOh.png +0 -0
  165. pyNanoMatBuilder/resources/figs/trOh.xyz +40 -0
  166. pyNanoMatBuilder/resources/figs/trOhWS-C.png +0 -0
  167. pyNanoMatBuilder/resources/figs/trOhWS.png +0 -0
  168. pyNanoMatBuilder/resources/figs/trOhWS.script +1 -0
  169. pyNanoMatBuilder/resources/figs/trOhWS.xyz +57 -0
  170. pyNanoMatBuilder/resources/figs/trTd-C.png +0 -0
  171. pyNanoMatBuilder/resources/figs/trTd.base64 +1 -0
  172. pyNanoMatBuilder/resources/figs/trTd.png +0 -0
  173. pyNanoMatBuilder/resources/figs/trTd.xyz +14 -0
  174. pyNanoMatBuilder/resources/figs/trbccrDDWS-C.png +0 -0
  175. pyNanoMatBuilder/resources/figs/trbccrDDWS.png +0 -0
  176. pyNanoMatBuilder/resources/figs/trbccrDDWS.script +1 -0
  177. pyNanoMatBuilder/resources/figs/trbccrDDWS.xyz +371 -0
  178. pyNanoMatBuilder/resources/figs/trbccrddWS.png +0 -0
  179. pyNanoMatBuilder/resources/figs/trbccrddWS.script +1 -0
  180. pyNanoMatBuilder/resources/figs/trbccrddWS.xyz +371 -0
  181. pyNanoMatBuilder/resources/figs/trcubeWS-C.png +0 -0
  182. pyNanoMatBuilder/resources/figs/trcubeWS.png +0 -0
  183. pyNanoMatBuilder/resources/figs/trcubeWS.script +1 -0
  184. pyNanoMatBuilder/resources/figs/trcubeWS.xyz +359 -0
  185. pyNanoMatBuilder/resources/figs/ttrbccrDDWS-C.png +0 -0
  186. pyNanoMatBuilder/resources/figs/ttrbccrDDWS.png +0 -0
  187. pyNanoMatBuilder/resources/figs/ttrbccrDDWS.script +1 -0
  188. pyNanoMatBuilder/resources/figs/ttrbccrDDWS.xyz +61 -0
  189. pyNanoMatBuilder/resources/figs/ttrbccrddWS.png +0 -0
  190. pyNanoMatBuilder/resources/figs/ttrbccrddWS.script +1 -0
  191. pyNanoMatBuilder/resources/figs/ttrbccrddWS.xyz +61 -0
  192. pyNanoMatBuilder/resources/figs/underConstruction-C.png +0 -0
  193. pyNanoMatBuilder/resources/figs/underConstruction.png +0 -0
  194. pyNanoMatBuilder/resources/figs/underConstruction.svg +259 -0
  195. pyNanoMatBuilder/resources/svg/Logo-Universite-Toulouse-n-2023.png +0 -0
  196. pyNanoMatBuilder/resources/svg/Logo-institutionnel-couleur-Uonly.svg +64 -0
  197. pyNanoMatBuilder/resources/svg/Python_logo_and_wordmark.svg.png +0 -0
  198. pyNanoMatBuilder/resources/svg/__init__.py +0 -0
  199. pyNanoMatBuilder/resources/svg/bccrdd.spt +288 -0
  200. pyNanoMatBuilder/resources/svg/bccrdd.xyz +17 -0
  201. pyNanoMatBuilder/resources/svg/logo-C.png +0 -0
  202. pyNanoMatBuilder/resources/svg/logo.png +0 -0
  203. pyNanoMatBuilder/resources/svg/logo2-C.png +0 -0
  204. pyNanoMatBuilder/resources/svg/logo2.png +0 -0
  205. pyNanoMatBuilder/resources/svg/logo3-C.png +0 -0
  206. pyNanoMatBuilder/resources/svg/logo3.png +0 -0
  207. pyNanoMatBuilder/resources/svg/logoEnd.svg +94 -0
  208. pyNanoMatBuilder/resources/svg/logo_lpcno_300_dpi_notexttransparent.png +0 -0
  209. pyNanoMatBuilder/resources/svg/pyNanoMatBuilder_banner.png +0 -0
  210. pyNanoMatBuilder/resources/svg/pyNanoMatBuilder_banner.svg +162 -0
  211. pyNanoMatBuilder/resources/svg/pyNanoMatBuilder_logo.png +0 -0
  212. pyNanoMatBuilder/resources/svg/pyNanoMatBuilder_logo.svg +123 -0
  213. pyNanoMatBuilder/utils/.ipynb_checkpoints/__init__-checkpoint.py +12 -0
  214. pyNanoMatBuilder/utils/.ipynb_checkpoints/core-checkpoint.py +750 -0
  215. pyNanoMatBuilder/utils/.ipynb_checkpoints/crystals-checkpoint.py +375 -0
  216. pyNanoMatBuilder/utils/.ipynb_checkpoints/energy-checkpoint.py +215 -0
  217. pyNanoMatBuilder/utils/.ipynb_checkpoints/external_pgm-checkpoint.py +156 -0
  218. pyNanoMatBuilder/utils/.ipynb_checkpoints/geometry-checkpoint.py +1340 -0
  219. pyNanoMatBuilder/utils/.ipynb_checkpoints/io-checkpoint.py +587 -0
  220. pyNanoMatBuilder/utils/.ipynb_checkpoints/polydispersity-checkpoint.py +837 -0
  221. pyNanoMatBuilder/utils/.ipynb_checkpoints/prop-checkpoint.py +864 -0
  222. pyNanoMatBuilder/utils/.ipynb_checkpoints/symmetry-checkpoint.py +135 -0
  223. pyNanoMatBuilder/utils/__init__.py +12 -0
  224. pyNanoMatBuilder/utils/core.py +751 -0
  225. pyNanoMatBuilder/utils/crystals.py +375 -0
  226. pyNanoMatBuilder/utils/energy.py +215 -0
  227. pyNanoMatBuilder/utils/external_pgm.py +156 -0
  228. pyNanoMatBuilder/utils/geometry.py +1340 -0
  229. pyNanoMatBuilder/utils/io.py +587 -0
  230. pyNanoMatBuilder/utils/polydispersity.py +837 -0
  231. pyNanoMatBuilder/utils/prop.py +864 -0
  232. pyNanoMatBuilder/utils/symmetry.py +135 -0
  233. pyNanoMatBuilder/utils/utils.py.org +3573 -0
  234. pyNanoMatBuilder/utils/utils.py.tmp +75 -0
  235. pyNanoMatBuilder/visualID.py +123 -0
  236. pynanomatbuilder-0.10.3.dist-info/METADATA +118 -0
  237. pynanomatbuilder-0.10.3.dist-info/RECORD +240 -0
  238. pynanomatbuilder-0.10.3.dist-info/WHEEL +5 -0
  239. pynanomatbuilder-0.10.3.dist-info/licenses/LICENSE +674 -0
  240. pynanomatbuilder-0.10.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1361 @@
1
+ # External dependencies
2
+
3
+ import sys
4
+ import os
5
+ import gzip
6
+ from pathlib import Path
7
+ import re
8
+ import importlib
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ import abtem
14
+
15
+ from ase import io
16
+ from ase.atoms import Atoms
17
+ from ase.geometry import cellpar_to_cell
18
+ from ase.spacegroup import get_spacegroup
19
+ from ase.io import read
20
+ from ase.io import write
21
+ from ase.visualize import view
22
+
23
+ import matplotlib.pyplot as plt
24
+ from sklearn.decomposition import PCA
25
+ import math
26
+
27
+ # Internal Relative Imports
28
+ from .visualID import fg, hl, bg
29
+ from . import visualID as vID
30
+
31
+ from . import crystalNPs as cyNP
32
+ from . import platonicNPs as pNP
33
+ from . import archimedeanNPs as aNP
34
+ from . import catalanNPs as cNP
35
+ from . import johnsonNPs as jNP
36
+ from . import otherNPs as oNP
37
+ from . import utils as pyNMBu
38
+ from . import data
39
+
40
+ class CreateHRTEMStructure:
41
+ """
42
+ A class for generating HRTEM images from CIF compounds. First the class creates XYZ files, viewable on JMOL,
43
+ containing the coordinates of a nanoparticule laying on a transmission electronic miscroscope (TEM) grid of carbon.
44
+ This class allows to create images for different NPs (various compounds, shapes, sizes) laying on their surfaces or edges
45
+ with different angles rotations, meaning it is possible to explore different orientation of the NP on the grid.
46
+ Then the class uses AbTEM to generate their HRTEM images. Many parameters are explored, see the HRTEM_parameter.ipynb notebook
47
+ for explanations.
48
+
49
+ Additional Notes:
50
+ - The supported nanoparticle shapes include: Wulff constructions: cube, octahedron, cuboctahedron, dodecahedron,
51
+ spheroids, and their truncated versions
52
+ - The NPs are laying on flat areas of the carbon substrate.
53
+ - The name of the output XYZ files is : {element}_{structure}_{form}_{counter}.xyz
54
+ - The name of the output PNG files is : {element}_{structure}_{form}_{xyz_counter}_{microscope_counter}.png
55
+ - Orientation (surface/edge), angles and size metadata are stored in CSV files.
56
+ - The NPs are created using the crystalNPs class.
57
+ - Files are created for each NP surface plane and each carbon grid flat surface.
58
+ - A distance tolerance between the NP and the grid can be choosen by the user.
59
+
60
+ """
61
+
62
+ def __init__(self, path, xyz_gz_file, cif_data, wulff_shapes,
63
+ nRot, sizes, min_size : float=0, max_size: float=50, tolerance: int=3, noOutput:bool = True):
64
+
65
+ """
66
+ Initialize the class with CIF data, Wulff shapes information and size,
67
+ and the tolerance distance between the NP and the carbon grid.
68
+
69
+ Args:
70
+ path (str): Path that will contain the output XYZ files.
71
+ xyz_gz_file (str): Path to the gzipped (.gz) XYZ file containing
72
+ atomic coordinates of the carbon substrate.
73
+ cif_data (pd.DataFrame): The CIF data of the compounds.
74
+ wulff_shapes (pd.DataFrame): The Wulff shapes and their informations.
75
+ nRot (int): Number of rotations of the NP laying on its surface
76
+ along z (angle = 360/nRot).
77
+ sizes (array_like): Array of the sizes of the nanoparticles.
78
+ min_size (float, optional): Minimal size for the NPs, equals to the
79
+ diameter of the circumscribed sphere. Defaults to 0 nm.
80
+ max_size (float, optional): Maximal size for the NPs, equals to the
81
+ diameter of the circumscribed sphere. Defaults to 50 nm.
82
+ tolerance (float, optional): Tolerance distance between the NP and
83
+ the carbon grid. Be careful: if too small, chemical bonds
84
+ appear in the interface. Defaults to 2.0.
85
+ noOutput (bool): If False, prints details of the generated files.
86
+
87
+ Note:
88
+ This class utilizes the internal method
89
+ :meth:`self.create_NP_TEMimages` using the provided parameters
90
+ (tolerance, noOutput, min_size, max_size, nRot, path, xyz_gz_file)
91
+ to process the nanoparticle images.
92
+ """
93
+ self.path = path
94
+ self.xyz_gz_file = Path(xyz_gz_file)
95
+ self.cif_data = cif_data # DataFrame containing CIF data
96
+ self.wulff_shapes = wulff_shapes # DataFrame of Wulff forms
97
+ self.loaded_cifs = {} # stock loaded cif files
98
+ self.nRot = nRot
99
+ self.sizes= [[k] for k in sizes] #nested list of the sizes
100
+ self.tolerance= tolerance
101
+ self._xyz_counter = 0
102
+ self._xyz_metadata = []
103
+
104
+ substrate_name = str(xyz_gz_file)
105
+ if substrate_name.endswith('aC_relax_10x10.xyz.gz'):
106
+ self.substrate_size = [10, 10, 10]
107
+ elif substrate_name.endswith('aC_relax_5x5.xyz.gz'):
108
+ self.substrate_size = [5, 5, 5]
109
+
110
+ self.create_NP_TEMimages(tolerance, noOutput,min_size, max_size, nRot,path, xyz_gz_file)
111
+
112
+
113
+ def find_surface_atoms(self,xyz_gz_file, grid_size=2, z_tolerance=6):
114
+ """
115
+ Identify surface atoms from a compressed XYZ file using a grid-based method.
116
+
117
+ Args:
118
+ xyz_gz_file (str): Path to the gzipped (.gz) XYZ file containing
119
+ atomic coordinates of the carbon substrate.
120
+ grid_size (float, optional): Size of the xy-grid cells in Angstroms,
121
+ used to discretize the xy-plane. Smaller grid size results in
122
+ finer surface resolution, larger grid size is coarser.
123
+ Defaults to 2.
124
+ z_tolerance (float, optional): Maximum allowed vertical distance from
125
+ global maximum z to consider an atom as a surface atom.
126
+ Defaults to 6.
127
+
128
+ Returns:
129
+ None: Generates two files in a subdirectory named 'output_xyz':
130
+ * **<original_filename>_surface.xyz**: XYZ file copy where surface
131
+ carbon atoms are replaced by oxygen atoms ('O') for
132
+ visualization purposes.
133
+ * **<original_filename>_surface_atoms.txt**: Text file listing
134
+ indices (1-based) of atoms identified as surface atoms.
135
+
136
+ Note:
137
+ If some xy-grid cells do not contain any atoms near the surface
138
+ (for instance due to vertical gaps or columns), the algorithm might
139
+ select atoms located deep within the material. The `z_tolerance`
140
+ parameter ensures that only atoms close enough to the global maximum
141
+ z-value are considered, preventing deep internal atoms from being
142
+ mistakenly identified as surface atoms.
143
+
144
+ Procedure:
145
+ 1. Reads atomic coordinates from the gzipped XYZ file.
146
+ 2. Defines a regular xy-grid over the atomic coordinates.
147
+ 3. Identifies the topmost atom (maximum z-coordinate) in each grid cell,
148
+ only if close enough to the global maximum z.
149
+ 4. Marks these atoms as surface atoms and replaces their type with 'O'.
150
+ 5. Saves the modified XYZ file and a text file listing surface
151
+ atom indices.
152
+
153
+ Example:
154
+ >>> find_surface_atoms('file.xyz.gz', grid_size=2.0, z_tolerance=5.0)
155
+ """
156
+
157
+ with gzip.open(xyz_gz_file, 'rt', encoding='utf-8' ) as f:
158
+ lines = f.readlines()
159
+
160
+ num_atoms = int(lines[0].strip())
161
+ header = lines[:2]
162
+ atom_lines = lines[2:2 + num_atoms]
163
+ coords = []
164
+ for line in atom_lines:
165
+ parts = line.split()
166
+ atom_type = parts[0]
167
+ x, y, z = map(float, parts[1:4])
168
+ coords.append((atom_type, x, y, z))
169
+
170
+ coords_array = np.array(coords, dtype=object)
171
+ xy_values = coords_array[:, 1:3].astype(float)
172
+ z_values = coords_array[:, 3].astype(float)
173
+ z_global_max = z_values.max()
174
+ x_min, y_min = xy_values.min(axis=0)
175
+ x_max, y_max = xy_values.max(axis=0)
176
+ x_bins = np.arange(x_min, x_max + grid_size, grid_size)
177
+ y_bins = np.arange(y_min, y_max + grid_size, grid_size)
178
+
179
+ surface_indices = []
180
+ for i in range(len(x_bins)-1):
181
+ for j in range(len(y_bins)-1):
182
+ in_cell = np.where((xy_values[:, 0] >= x_bins[i]) & (xy_values[:, 0] < x_bins[i+1]) &
183
+ (xy_values[:, 1] >= y_bins[j]) & (xy_values[:, 1] < y_bins[j+1]))[0]
184
+ if len(in_cell) > 0:
185
+ z_local_max_idx = in_cell[np.argmax(z_values[in_cell])]
186
+ if z_values[z_local_max_idx] >= z_global_max - z_tolerance:
187
+ surface_indices.append(z_local_max_idx)
188
+
189
+ surface_indices = np.unique(surface_indices)
190
+ modified_coords = coords.copy()
191
+ for idx in surface_indices:
192
+ atom_type, x, y, z = modified_coords[idx]
193
+ modified_coords[idx] = ('O', x, y, z)
194
+
195
+ return surface_indices
196
+
197
+
198
+ def flat_areas_atoms(self,xyz_gz_file, surface_indices, diameter_nm=2, min_cluster_span=20, max_z_variation=5, anisotropy_threshold= 1.1, overlap_tolerance=0.2):
199
+ """
200
+ Identify candidate grafting sites for a nanoparticle of given diameter
201
+ using a sliding grid over the xy-plane.
202
+
203
+ Each candidate cluster must satisfy the following criteria:
204
+ * Contain at least two atoms.
205
+ * Cover a minimum lateral spatial extent (min_cluster_span).
206
+ * Remain sufficiently flat (within max_z_variation in the z direction).
207
+ * Be reasonably isotropic in xy (to avoid long narrow stripes).
208
+
209
+ Args:
210
+ xyz_gz_file (str): Path to the gzipped (.gz) XYZ file containing
211
+ atomic coordinates of the carbon substrate.
212
+ surface_indices (list of int): List of surface atom indices (0-based)
213
+ to consider for clustering.
214
+ diameter_nm (float): Diameter of the nanoparticle (in nanometers),
215
+ used to define the size of sliding xy grid cells (1 nm = 10 Å).
216
+ min_cluster_span (float): Minimum lateral spatial extent (in Å)
217
+ required between atoms in a cluster (in xy-plane). Prevents
218
+ selecting groups that are too small to accommodate the NP laterally.
219
+ max_z_variation (float): Maximum allowed difference in z-coordinates
220
+ (in Å) within a cluster. Ensures that the candidate site is
221
+ sufficiently flat.
222
+ anisotropy_threshold (float): Maximum allowed ratio between std(x)
223
+ and std(y) (or vice versa) in the cluster. Rejects elongated,
224
+ anisotropic clusters.
225
+
226
+ Returns:
227
+ list: A list containing the coordinates (np.ndarray) of the
228
+ accepted substrate flat areas.
229
+ """
230
+ import numpy as np
231
+ from pathlib import Path
232
+ import gzip
233
+ import random
234
+ from scipy.spatial.distance import pdist
235
+ from matplotlib import colormaps
236
+
237
+ log_lines = []
238
+ with gzip.open(xyz_gz_file, 'rt', encoding='utf-8') as f:
239
+ lines = f.readlines()
240
+
241
+ num_atoms = int(lines[0].strip())
242
+ atom_lines = lines[2:2 + num_atoms]
243
+
244
+ coords = []
245
+ for line in atom_lines:
246
+ parts = line.split()
247
+ x, y, z = map(float, parts[1:4])
248
+ coords.append((x, y, z))
249
+
250
+ xyz = np.array(coords)
251
+ surface_xyz = xyz[surface_indices]
252
+
253
+ # Define sliding grid parameters
254
+ diameter_angstrom = diameter_nm * 10
255
+
256
+ x_min, y_min = surface_xyz[:, :2].min(axis=0)
257
+ x_max, y_max = surface_xyz[:, :2].max(axis=0)
258
+
259
+ accepted_clusters = []
260
+ cluster_dict = {}
261
+ cluster_id = 0
262
+ cluster_rejected_dict = {}
263
+ cluster_rejected_id = 0
264
+
265
+ x_starts = np.arange(x_min, x_max, 5.0)
266
+ y_starts = np.arange(y_min, y_max, 10.0)
267
+ accepted_cluster_coords=[]
268
+
269
+
270
+ for i, x0 in enumerate(x_starts):
271
+ for j, y0 in enumerate(y_starts):
272
+ local_indices = [idx for idx, (x, y, _) in enumerate(surface_xyz)
273
+ if x0 <= x < x0 + diameter_angstrom and y0 <= y < y0 + diameter_angstrom]
274
+ msg = f"🔵🔵🔵 Grid cell ({i},{j}) at ({x0:.1f}, {y0:.1f}) → {len(local_indices)} atoms"
275
+ # print(msg)
276
+ log_lines.append(msg)
277
+
278
+ if len(local_indices) >= 2:
279
+ coords_patch = surface_xyz[local_indices]
280
+ xy_patch = coords_patch[:, :2]
281
+ max_xy_dist = np.max(pdist(xy_patch)) if len(xy_patch) >= 2 else 0
282
+ std_x, std_y = np.std(xy_patch[:, 0]), np.std(xy_patch[:, 1])
283
+ anisotropy = max(std_x, std_y) / max(1e-6, min(std_x, std_y))
284
+ x_values= coords_patch[:, 0]
285
+ y_values= coords_patch[:, 1]
286
+ z_values = coords_patch[:, 2]
287
+ z_range = z_values.max() - z_values.min()
288
+
289
+ reasons = []
290
+ ok = []
291
+ if max_xy_dist < min_cluster_span:
292
+ reasons.append(f"too compact (xy span = {max_xy_dist:.2f} Å < {min_cluster_span} Å)")
293
+ else:
294
+ ok.append(f"compact (xy span = {max_xy_dist:.2f} Å > {min_cluster_span} Å)")
295
+ if z_range > max_z_variation:
296
+ reasons.append(f"not flat (z_range = {z_range:.2f} Å > {max_z_variation} Å)")
297
+ else:
298
+ ok.append(f"flat (z_range = {z_range:.2f} Å < {max_z_variation} Å)")
299
+ if anisotropy > anisotropy_threshold:
300
+ reasons.append(f"too elongated (anisotropy = {anisotropy:.2f} > {anisotropy_threshold})")
301
+ else:
302
+ ok.append(f"regular shape (anisotropy = {anisotropy:.2f} < {anisotropy_threshold})")
303
+
304
+
305
+ # NEW
306
+
307
+ # Check overlap
308
+ atom_ids = [surface_indices[i] + 1 for i in local_indices]
309
+ overlap_found = False
310
+ for existing in accepted_clusters:
311
+ common = set(atom_ids) & set(existing)
312
+ if len(common) / max(len(atom_ids), len(existing)) > overlap_tolerance:
313
+ reasons.append(f"overlap > {int(overlap_tolerance*100)}% with existing cluster")
314
+ overlap_found = True
315
+ break
316
+
317
+
318
+
319
+ if not reasons:
320
+ cluster_dict[cluster_id] = [surface_indices[i] + 1 for i in local_indices]
321
+ atom_ids = cluster_dict[cluster_id]
322
+ accepted_clusters.append(atom_ids)
323
+ select_line = f"select all; color atoms grey; select {', '.join(f'@{idx}' for idx in atom_ids)}; color atoms green"
324
+ z_mean = z_values.mean()
325
+ msg = f" ✅ Cluster {cluster_id:2d} ✓ n = {len(local_indices):2d}, z mean = {z_mean:.2f}, min = {z_values.min():.2f}, max = {z_values.max():.2f}, max_xy_dist={max_xy_dist:.2f} [{min_cluster_span} Å], z_range={z_range:.2f} [{max_z_variation} Å], anisotropy={anisotropy:.2f}"
326
+ # print(msg)
327
+ log_lines.append(msg)
328
+ log_lines.append(select_line)
329
+ log_lines.append("") # blank line between clusters
330
+ cluster_id += 1
331
+ accepted_cluster_coords.append(coords_patch) # NEW
332
+ else:
333
+ cluster_rejected_dict[cluster_rejected_id] = [surface_indices[i] + 1 for i in local_indices]
334
+ atom_ids = cluster_rejected_dict[cluster_rejected_id]
335
+ select_line = f"select all; color atoms grey; select {', '.join(f'@{idx}' for idx in atom_ids)}; color atoms red"
336
+ out_str = "; ".join(reasons)
337
+ ok_str = "; ".join(ok)
338
+ out_str = f"{out_str}; ✅ FULFILLED CRITERIA = {ok_str}"
339
+ msg = f" ❌ Rejected: {out_str}"
340
+ # print(msg)
341
+ log_lines.append(msg)
342
+ log_lines.append(select_line)
343
+ log_lines.append("") # blank line between clusters
344
+ cluster_rejected_id += 1
345
+ return accepted_cluster_coords
346
+
347
+
348
+ def place_NPsurface_on_grid(self, path, xyz_gz_file, surface_indices,tolerance,instanceWulff,surfaces_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter):
349
+
350
+ """
351
+ Create XYZ files of NPs laying on one of their surfaces on a carbon grid.
352
+
353
+ This function generates the interface between the nanoparticle and the carbon
354
+ substrate, positioning the nanoparticle so it lays flat on its surface facet.
355
+
356
+ Args:
357
+ xyz_gz_file (str): Path to the gzipped (.gz) XYZ file containing
358
+ atomic coordinates of the carbon substrate.
359
+ tolerance (float, optional): Tolerance distance between the NP and
360
+ the carbon grid. Be careful: if too small, artificial chemical
361
+ bonds may appear at the interface.
362
+ instanceWulff (object): Instance of the crystalNPs class used to
363
+ create the Wulff nanoparticle.
364
+ element (str): Compound elements, as defined in
365
+ :meth:`create_NP_TEMimages`.
366
+ structure (str): Lattice type, as defined in
367
+ :meth:`create_NP_TEMimages`.
368
+ form (str): Wulff form/shape, as defined in
369
+ :meth:`create_NP_TEMimages`.
370
+ nRot (int): Number of rotations of the NP along the z-axis
371
+ (rotation angle = 360/nRot).
372
+ number (int): Index for the nanoparticle size, as defined in
373
+ :meth:`create_NP_TEMimages`.
374
+
375
+ Returns:
376
+ None: Generates XYZ files for each compound and size, representing
377
+ the nanoparticle laying on the carbon grid.
378
+ """
379
+
380
+ # 1. The NP surface
381
+ # Place the NP on its surface
382
+ plane = instanceWulff.trPlanes[0]
383
+ # if noOutput == False :
384
+ # print(f'Surface plane of the NP used = {plane}.')
385
+ normal_plane= np.array(plane[:3]) # Normal of the NP surface plane
386
+
387
+ # 2. The carbon surface
388
+ # Find the plane that fits the best the cluster points (flat area of the carbon substrate surface where the NP will be on)
389
+ accepted_cluster_coords= self.flat_areas_atoms(xyz_gz_file, surface_indices)
390
+
391
+ # Select the flat-area cluster depending on the substrate file
392
+ substrate_name = str(xyz_gz_file)
393
+ if substrate_name.endswith('aC_relax_10x10.xyz.gz'):
394
+ cluster = accepted_cluster_coords[10]
395
+ elif substrate_name.endswith('aC_relax_5x5.xyz.gz'):
396
+ cluster = accepted_cluster_coords[2]
397
+ else:
398
+ cluster = accepted_cluster_coords[0] # default: first flat area
399
+
400
+ carbon_plane_positions = cluster # Positions of the atoms of the flat carbon surface
401
+ pca = PCA(n_components=3)
402
+ pca.fit(carbon_plane_positions)
403
+ normal_carbon = pca.components_[-1] # Normal vector but we don't know the sense (positive or negative)
404
+ if normal_carbon[2] < 0: # positive z
405
+ normal_carbon = -normal_carbon
406
+ normal_carbon_unit = normal_carbon / np.linalg.norm(normal_carbon)
407
+ center_carbon_plane = carbon_plane_positions.mean(axis=0)
408
+
409
+ # 3. Place the NPs close to the substrate
410
+ # Compute the carbon surface distance from the origin
411
+ dist_carbon = -np.dot(normal_carbon, center_carbon_plane) # -d
412
+ # Make the NP surface plane parallel to the carbon surface
413
+ rotated_positions = pyNMBu.rotateMoltoAlignItWithAxis(instanceWulff.NP.positions,axis=normal_plane,targetAxis=normal_carbon)
414
+ rotated_positions_on_carbon_unit = np.dot(rotated_positions - center_carbon_plane, normal_carbon_unit) # Project the NP atoms positions on the carbon surface normal vector : gives height from the carbon surface
415
+
416
+ min_point_proj=np.min(rotated_positions_on_carbon_unit) # Find the closest NP atom from the carbon surface
417
+
418
+ # 3.1 Move the np towards the carbon surface horizontally
419
+ center_np = rotated_positions.mean(axis=0)
420
+ # On veut projeter center_np sur le plan défini par center_carbon_plane et normal_carbon_unit
421
+ vec_to_plane = center_np - center_carbon_plane
422
+ dist_to_plane = np.dot(vec_to_plane, normal_carbon_unit)
423
+ proj_center_np_on_plane = center_np - dist_to_plane * normal_carbon_unit
424
+ # Décalage latéral à appliquer
425
+ lateral_shift = center_carbon_plane - proj_center_np_on_plane
426
+ # print('Horizontal translation vector = ',lateral_shift)
427
+ rotated_and_shifted_positions = rotated_positions + lateral_shift
428
+
429
+ # 3.2 Move the np towards the carbon surface (1 Angs above) vertically
430
+ # Translation_vector is for translating the NP vertically only
431
+ translation_vector = (-min_point_proj + tolerance) * normal_carbon_unit
432
+ translated_positions = rotated_and_shifted_positions + translation_vector
433
+ center_of_rotation = translated_positions.mean(axis=0)
434
+
435
+ # 3.3 Change the orientation of the NP along the plane xy
436
+ for i in np.random.randint(0, 360, nRot) :
437
+ angle = i
438
+ # translated_positions = pyNMBu.rotationMolAroundAxis(translated_positions, angle, normal_carbon_unit)
439
+ new_positions = pyNMBu.rotation_around_axis_through_point(translated_positions,angle_deg=angle,axis=normal_carbon,center=center_of_rotation)
440
+
441
+ # 4. Create the files for each NPs
442
+ self._xyz_counter += 1
443
+ xyz_filename = f"{path}/{element}_{structure}_{form}_{self._xyz_counter:06d}.xyz"
444
+ self._xyz_metadata.append({
445
+ "xyz_file": f"{element}_{structure}_{form}_{self._xyz_counter:06d}.xyz",
446
+ "orientation": "surface",
447
+ "angle_xy": int(angle),
448
+ "angle_tilt": 0,
449
+ "circumsphere_diameter_nm": round(circumsphere_diameter, 4),
450
+ "substrate_size_nm": self.substrate_size[0]
451
+ })
452
+ # Read the carbone files
453
+ carbon_lines = []
454
+ with gzip.open(xyz_gz_file, 'rt', encoding='utf-8') as f0:
455
+ i = 0
456
+ for line in f0:
457
+ i += 1
458
+ if i >= 3: # on saute les 2 premières lignes (nombre d'atomes et commentaire)
459
+ carbon_lines.append(line.strip())
460
+ n_carbon = len(carbon_lines)
461
+ n_au = len(new_positions)
462
+ total_atoms = n_carbon + n_au
463
+ with open(xyz_filename, "w") as f:
464
+ f.write(f"{total_atoms}\n")
465
+ f.write("Carbon substrate + translated nanoparticle\n")
466
+
467
+ # Écriture des atomes carbone
468
+ for line in carbon_lines:
469
+ f.write(f"{line}\n")
470
+
471
+ # Écriture des atomes or (NP)
472
+ for pos in new_positions:
473
+ f.write(f"{element} {pos[0]:.6f} {pos[1]:.6f} {pos[2]:.6f}\n")
474
+ if noOutput == False :
475
+ print(f" \033[34m File written : {xyz_filename} \033[0m")
476
+ print('')
477
+
478
+ def place_NPedge_on_grid(self, path,xyz_gz_file,surface_indices,tolerance,instanceWulff,surfaces_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter):
479
+
480
+ """
481
+ Create XYZ files of nanoparticles laying on one of their edges on a carbon grid.
482
+
483
+ The alignment process follows three main steps:
484
+ 1. The target edge and its two adjacent planes of the NP are computed.
485
+ 2. The NP edge is oriented to be parallel to the carbon flat surface.
486
+ 3. The bisector of the two adjacent planes is aligned with the normal of the carbon flat surface.
487
+
488
+ This configuration ensures a perfectly centered NP laying on its edge,
489
+ allowing for balanced tilting on both sides during TEM simulations.
490
+
491
+ Args:
492
+ xyz_gz_file (str): Path to the gzipped (.gz) XYZ file containing
493
+ atomic coordinates of the carbon substrate.
494
+ tolerance (float, optional): Tolerance distance between the NP and
495
+ the carbon grid. Be careful: if too small, artificial chemical
496
+ bonds may appear at the interface.
497
+ instanceWulff (object): Instance of the crystalNPs class used to
498
+ create the Wulff nanoparticle.
499
+ surfaces_indices (list of int): Indices of the atoms belonging to
500
+ the identified flat carbon surface.
501
+ element (str): Compound elements, as defined in
502
+ :meth:`create_NP_TEMimages`.
503
+ structure (str): Lattice type, as defined in
504
+ :meth:`create_NP_TEMimages`.
505
+ form (str): Wulff form/shape, as defined in
506
+ :meth:`create_NP_TEMimages`.
507
+ nRot (int): Number of rotations of the NP (incremental
508
+ angle = 360/nRot).
509
+ number (int): Index for the nanoparticle size, as defined in
510
+ :meth:`create_NP_TEMimages`.
511
+
512
+ Returns:
513
+ None: Generates XYZ files for each compound and size, representing
514
+ the nanoparticle laying on one of its edges on the carbon grid.
515
+
516
+ """
517
+
518
+ # 1. Find the NP edge and the two adjacent planes.
519
+ from collections import defaultdict
520
+ edge_count = defaultdict(int)
521
+
522
+ # Using simplices of the Hull algorithm
523
+ for triangle in instanceWulff.simplices:
524
+ for i in range(3):
525
+ a = triangle[i]
526
+ b = triangle[(i + 1) % 3]
527
+ edge = tuple(sorted((a, b)))
528
+ edge_count[edge] += 1
529
+ # Extract the edge
530
+ true_edges = [edge for edge, count in edge_count.items() if count == 2]
531
+
532
+ for edge in true_edges:
533
+ a, b = edge
534
+ atom_a = instanceWulff.NP.positions[a]
535
+ atom_b = instanceWulff.NP.positions[b]
536
+ edge = pyNMBu.vector(instanceWulff.NP.positions,a,b)
537
+ adjacent_planes = []
538
+
539
+ for planes in instanceWulff.trPlanes:
540
+ a1, b1, c1, d1 = planes
541
+ if (abs(a1 * atom_a[0] + b1 * atom_a[1] + c1 * atom_a[2] + d1) < 1e-2 and
542
+ abs(a1 * atom_b[0] + b1 * atom_b[1] + c1 * atom_b[2] + d1) < 1e-2):
543
+ adjacent_planes.append(planes)
544
+
545
+ if len(adjacent_planes) >= 2:
546
+ p1, p2 = adjacent_planes
547
+ n1 = np.array(p1[:3]) / np.linalg.norm(p1[:3])
548
+ n2 = np.array(p2[:3]) / np.linalg.norm(p2[:3])
549
+ bisector = n1 + n2
550
+ if np.linalg.norm(bisector) > 1e-6:
551
+ break # good edge found
552
+
553
+
554
+ # print('adjacent_planes',adjacent_planes)
555
+ if len(adjacent_planes) >= 2 :
556
+ # if noOutput == False :
557
+ # print('Two adjacent planes found.')
558
+
559
+ # Normals of the two planes in order to compute the bisector
560
+ normal_p1 = np.array(p1[:3])
561
+ normal_p2 = np.array(p2[:3])
562
+ normal_p1_unit = normal_p1 / np.linalg.norm(normal_p1)
563
+ normal_p2_unit = normal_p2 / np.linalg.norm(normal_p2)
564
+
565
+ # Compute the bisector of the two planes
566
+ bisector = normal_p1_unit + normal_p2_unit
567
+ if np.linalg.norm(bisector) < 1e-6:
568
+ if not noOutput :
569
+ print("Bisector not well defined, normals of the planes opposite.")
570
+ bisector_unit = bisector / np.linalg.norm(bisector)
571
+
572
+ # Carbon surface : compute the normal of the surface and a vector contained on the surface (using PCA)
573
+ accepted_cluster_coords = self.flat_areas_atoms(xyz_gz_file, surface_indices)
574
+
575
+ # Select the flat-area cluster depending on the substrate file
576
+ substrate_name = str(xyz_gz_file)
577
+ if substrate_name.endswith('aC_relax_10x10.xyz.gz'):
578
+ cluster = accepted_cluster_coords[10]
579
+ elif substrate_name.endswith('aC_relax_5x5.xyz.gz'):
580
+ cluster = accepted_cluster_coords[2]
581
+
582
+ else:
583
+ cluster = accepted_cluster_coords[0] # default: first flat area
584
+
585
+ carbon_plane_positions = cluster
586
+ pca = PCA(n_components=3)
587
+ pca.fit(carbon_plane_positions)
588
+ normal_carbon = pca.components_[-1]
589
+ plane_x = pca.components_[0] # vector in the plane (xy) = carbon plane
590
+
591
+ # Watchout the sign (z)
592
+ if normal_carbon[2] < 0:
593
+ normal_carbon = -normal_carbon
594
+ normal_carbon_unit = normal_carbon / np.linalg.norm(normal_carbon)
595
+ center_carbon_plane = carbon_plane_positions.mean(axis=0)
596
+
597
+ # Unique rotation to : 1) make the edge of the NP parallel to the carbon surface, and 2) align the bisector with the carbon normal
598
+ # Local coordinate system
599
+ edge_unit = edge / np.linalg.norm(edge)
600
+ z_NP = bisector_unit
601
+ x_NP = edge_unit
602
+ y_NP = np.cross(z_NP, x_NP)
603
+ y_NP /= np.linalg.norm(y_NP)
604
+ x_NP = np.cross(y_NP, z_NP)
605
+ R_NP = np.stack([x_NP, y_NP, z_NP], axis=1)
606
+
607
+ # Target coordinate system
608
+ z_target = normal_carbon_unit
609
+ x_target = plane_x / np.linalg.norm(plane_x)
610
+ y_target = np.cross(z_target, x_target)
611
+ y_target /= np.linalg.norm(y_target)
612
+ x_target = np.cross(y_target, z_target)
613
+ R_target = np.stack([x_target, y_target, z_target], axis=1)
614
+
615
+ # Global rotation
616
+ center_of_rotation = instanceWulff.NP.positions.mean(axis=0)
617
+ R = R_target @ R_NP.T
618
+ rotated_positions = (R @ (instanceWulff.NP.positions - center_of_rotation).T).T + center_of_rotation
619
+
620
+ # Lateral translation
621
+ center_np = rotated_positions.mean(axis=0)
622
+ vec_to_plane = center_np - center_carbon_plane
623
+ dist_to_plane = np.dot(vec_to_plane, normal_carbon_unit)
624
+ proj_center_np_on_plane = center_np - dist_to_plane * normal_carbon_unit
625
+ lateral_shift = center_carbon_plane - proj_center_np_on_plane
626
+ rotated_and_shifted_positions = rotated_positions + lateral_shift
627
+
628
+ # Vertical translation
629
+ rotated_positions_on_carbon_unit = np.dot(rotated_and_shifted_positions - center_carbon_plane, normal_carbon_unit)
630
+ min_point_proj = np.min(rotated_positions_on_carbon_unit)
631
+ translation_vector = (-min_point_proj + tolerance) * normal_carbon_unit
632
+ translated_positions = rotated_and_shifted_positions + translation_vector
633
+ new_center = translated_positions.mean(axis=0)
634
+
635
+ # Angle between the 2 planes
636
+ dot_product = np.dot(normal_p1_unit, normal_p2_unit)
637
+ angle_n1_n2 = np.arccos(dot_product)
638
+ angle_n1_n2 = np.degrees(angle_n1_n2)
639
+ # if not noOutput :
640
+ # print(f"Angle entre les 2 plans = {angle_n1_n2:.2f}°")
641
+ # Max angle between the planes and the (xy) plane
642
+ angle_max = (180 - angle_n1_n2 ) / 2
643
+ # if not noOutput :
644
+ # print(f"Angle max entre les plans et le plan (xy) = {angle_max:.2f}°")
645
+ from scipy.spatial.transform import Rotation as R
646
+ rotation_axis = edge_unit
647
+
648
+ # Tilt the NP along z
649
+ for angle in np.random.randint(-angle_max, angle_max, nRot) :
650
+ tilt_rotation = R.from_rotvec(angle * rotation_axis)
651
+ tilt_center = new_center
652
+ positions_tilted = tilt_rotation.apply(translated_positions - tilt_center) + tilt_center
653
+ center_of_rotation = positions_tilted.mean(axis=0)
654
+
655
+ # Change the orientation of the NP along the plane xy
656
+ for i in np.random.randint(0, 360, nRot) :
657
+ angle2 = i
658
+ # translated_positions = pyNMBu.rotationMolAroundAxis(translated_positions, angle, normal_carbon_unit)
659
+ new_positions = pyNMBu.rotation_around_axis_through_point(positions_tilted,angle_deg = angle2,axis = normal_carbon,center = center_of_rotation)
660
+
661
+ # Write and save the XYZ file
662
+ self._xyz_counter += 1
663
+ xyz_filename = f"{path}/{element}_{structure}_{form}_{self._xyz_counter:06d}.xyz"
664
+ self._xyz_metadata.append({
665
+ "xyz_file": f"{element}_{structure}_{form}_{self._xyz_counter:06d}.xyz",
666
+ "orientation": "edge",
667
+ "angle_xy": int(angle2),
668
+ "angle_tilt": int(angle),
669
+ "circumsphere_diameter_nm": round(circumsphere_diameter, 4),
670
+ "substrate_size_nm": self.substrate_size[0]
671
+ })
672
+ carbon_lines = []
673
+ with gzip.open(xyz_gz_file, 'rt', encoding='utf-8' ) as f0:
674
+ i = 0
675
+ for line in f0:
676
+ i += 1
677
+ if i >= 3:
678
+ carbon_lines.append(line.strip())
679
+
680
+ n_carbon = len(carbon_lines)
681
+ n_au = len(new_positions)
682
+ total_atoms = n_carbon + n_au
683
+
684
+ with open(xyz_filename, "w") as f:
685
+ f.write(f"{total_atoms}\n")
686
+ f.write("Carbon substrate + translated nanoparticle\n")
687
+ for line in carbon_lines:
688
+ f.write(f"{line}\n")
689
+ for pos in new_positions:
690
+ f.write(f"{element} {pos[0]:.6f} {pos[1]:.6f} {pos[2]:.6f}\n")
691
+ if not noOutput :
692
+ print(f"\033[32m File written : {xyz_filename} \033[0m")
693
+ print('')
694
+
695
+
696
+ def create_NP_TEMimages(self, tolerance, noOutput,min_size,max_size, nRot, path, xyz_gz_file):
697
+ """
698
+ Generate Wulff shapes and their files for a single specific CIF coumpound.
699
+
700
+ Args:
701
+ cif file (file): singular cif file
702
+ noOutput (bool): if bool=False : details of the files
703
+ min_size (float, optional) : Mainimal size for the NPs, equals to the diameter of the circumscribed sphere, equals 0 nm by default.
704
+ max_size (float, optional) : Maximal size for the NPs, equals to the diameter of the circumscribed sphere, equals 50 nm by default.
705
+ path (str) : path that will contain the created xyz/CIF files
706
+ substrate_size (list) : size of the substrate in each dimension
707
+ """
708
+ surface_indices = self.find_surface_atoms(xyz_gz_file, grid_size=2, z_tolerance=6)
709
+ for cif_name, cif_file in self.cif_data['cif file'].items():
710
+
711
+ # Extract the structure name for the name of the files, for example 'Rutile' or 'Anatase' or 'Alpha'
712
+ self.cif_name=cif_name
713
+ if len(self.cif_name.split())==2 : # For the name of the files, for example 'Rutile' or 'Anatase'
714
+ structure=self.cif_name.split()[1]
715
+ else :
716
+ structure=None
717
+ if not noOutput :
718
+ print(f'\n\033[1m {bg.LIGHTBLUEB} {cif_name.center(50)}\033[0m\n')
719
+ cif_info = pyNMBu.load_cif(self,cif_file,noOutput)
720
+ crystal_system_name = self.ucBL.__class__.__name__
721
+ direction=[0,0,1]
722
+ d_hkl=pyNMBu.interPlanarSpacing(direction,self.ucUnitcell,crystal_system_name)*0.1 #nm
723
+ if not noOutput:
724
+ print(f'\033[1m d_hkl={d_hkl} nm \033[0m')
725
+
726
+ if len(cif_name.split()) >= 2 : # to exclude TiO2 and NaCl
727
+ for form, row in self.wulff_shapes.iterrows():
728
+ lattices = [l.strip() for l in row['Bravais lattice'].split(',')]
729
+ if self.crystal_type in lattices: # Verify if the lattice of the compound matches the lattice of the wulff form
730
+ index=0
731
+ if not noOutput :
732
+ print(f"\n {bg.LIGHTGREENB} {self.crystal_type} corresponds to the lattices {lattices} of the Wulff {form} form. \033[0m \n")
733
+ number=0
734
+ # Create instances for each form and size
735
+ for i in self.sizes :
736
+ # number+=1
737
+ size= [i[0]*d_hkl]
738
+ index += 1
739
+ TestNP = cyNP.Crystal(
740
+ crystal=f'{cif_name}',
741
+ userDefCif=cif_info['cif_path'],
742
+ shape=f"Wulff: {form}",
743
+ sizesWulff= size,
744
+ threshold=0.001,
745
+ thresholdCoreSurface=2,
746
+ postAnalyzis=True,
747
+ jmolCrystalShape=True,
748
+ noOutput=True,
749
+ aseView=False,
750
+ skipSymmetryAnalyzis=True)
751
+
752
+ circumsphere_diameter=TestNP.radiusCircumscribedSphere*2*0.1 # Setting a maximal size of NPs : circumscribed sphere diameter
753
+ if min_size<=circumsphere_diameter<max_size :
754
+
755
+ number+=1
756
+ if not noOutput :
757
+ print(f'\033[1m Generating size is {size[0]:.4f} nm and is equal to dhkl multiplied by {i}.\033[0m ')
758
+ print(f'\033[1m Circumscribed sphere diameter ={circumsphere_diameter:.3f} nm and is in the interval [{min_size},{max_size}].\033[0m')
759
+ # Names of the files
760
+ element = self.cif_name.split()[0]
761
+ structure=self.crystal_type
762
+ if not noOutput :
763
+ print('')
764
+ print(f' Generate NPs laying on one of their surfaces.')
765
+ self.place_NPsurface_on_grid(path,xyz_gz_file, surface_indices,tolerance, TestNP, surface_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter)
766
+ if not noOutput :
767
+ print(f' Generate NPs laying on one of their edges.')
768
+ self.place_NPedge_on_grid(path, xyz_gz_file, surface_indices,tolerance, TestNP, surface_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter)
769
+
770
+ else :
771
+ # if min_size>=circumsphere_diameter :
772
+ # if not noOutput :
773
+ # print(f'\033[1m The circumscribed sphere diameter of the NP ={circumsphere_diameter} nm is smaller than the minimal size : {min_size}nm chosen. \033[0m')
774
+
775
+ if circumsphere_diameter>max_size :
776
+ print(f'\033[1m Stopping now: the circumscribed sphere diameter of the NP ={circumsphere_diameter:.3f} nm is not in the interval [{min_size},{max_size}] nm chosen anymore.\033[0m')
777
+ # if not noOutput :
778
+ # print(f'\033[1m The circumscribed sphere diameter of the NP ={circumsphere_diameter} nm is greater than the maximal size : {max_size}nm chosen. \033[0m')
779
+ break
780
+
781
+
782
+ # Add regIco
783
+ form = 'regico'
784
+ element = self.cif_name.split()[0]
785
+
786
+
787
+ if self.crystal_type=='fcc' :
788
+ dist= pyNMBu.FindInterAtomicDist(self)# Extract the interatomic distance
789
+ if not noOutput :
790
+ print(f"{bg.LIGHTGREENB}Addinng icosahedron with crystal type {self.crystal_type} and interatomic distance = {dist:.4f} nm.")
791
+
792
+ for i in np.arange(1,1000) :
793
+ index += 1
794
+ if not noOutput :
795
+ print(f'{bg.LIGHTBLUEB} Number of bonds is {i}')
796
+ TestNP2 =pNP.regIco(
797
+ element=element,
798
+ Rnn=dist,
799
+ nShell=i,
800
+ shape='regico',
801
+ postAnalyzis=True,
802
+ aseView=False,
803
+ thresholdCoreSurface=1,
804
+ skipSymmetryAnalyzis=True,
805
+ noOutput= True
806
+ )
807
+
808
+ circumsphere_diameter=TestNP2.radiusCircumscribedSphere()*2*0.1 # Setting a maximal size of NPs : circumscribed sphere diameter
809
+
810
+ if min_size<=circumsphere_diameter<max_size :
811
+ number+=1
812
+ if not noOutput :
813
+ print(f'\033[1m Generating size is {size[0]:.4f} nm.\033[0m ')
814
+ print(f'\033[1m Circumscribed sphere diameter ={circumsphere_diameter:.3f} nm and is in the interval [{min_size},{max_size}].\033[0m')
815
+ # Names of the files
816
+ element = self.cif_name.split()[0]
817
+ structure=self.crystal_type
818
+ if not noOutput :
819
+ print('')
820
+ print(f' Generate NPs laying on one of their surfaces.')
821
+ self.place_NPsurface_on_grid(path,xyz_gz_file, surface_indices,tolerance, TestNP2, surface_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter)
822
+ if not noOutput :
823
+ print(f' Generate NPs laying on one of their edges.')
824
+ self.place_NPedge_on_grid(path, xyz_gz_file, surface_indices,tolerance, TestNP2, surface_indices, element, structure, form, nRot, number, noOutput, circumsphere_diameter)
825
+
826
+ else :
827
+ # if min_size>=circumsphere_diameter :
828
+ # if not noOutput :
829
+ # print(f'\033[1m The circumscribed sphere diameter of the NP ={circumsphere_diameter} nm is smaller than the minimal size : {min_size}nm chosen. \033[0m')
830
+ if circumsphere_diameter>max_size :
831
+ print(f'\033[1m Stopping now: the circumscribed sphere diameter of the NP ={circumsphere_diameter:.3f} nm is not in the interval [{min_size:.4f},{max_size:.4f}] nm chosen anymore.\033[0m')
832
+ # if not noOutput :
833
+ # print(f'\033[1m The circumscribed sphere diameter of the NP ={circumsphere_diameter} nm is greater than the maximal size : {max_size}nm chosen. \033[0m')
834
+ break
835
+
836
+ elif self.crystal_type=='bcc': # do not make an icosahedron
837
+ # dist= pyNMBu.FindInterAtomicDist(self) # Extract the interatomic distance
838
+ if not noOutput :
839
+ print(f'{bg.LIGHTREDB} No icosahedron for bcc lattice. ')
840
+
841
+ elif self.crystal_type=='hcp': # do not make an icosahedron
842
+ dist= pyNMBu.FindInterAtomicDist(self) # Extract the interatomic distance
843
+ if not noOutput :
844
+ print(f'{bg.LIGHTREDB} No icosahedron for hcp lattice.')
845
+ else :
846
+ dist=None
847
+ if not noOutput :
848
+ print(f'{bg.LIGHTREDB} No interatomic distance found.')
849
+
850
+ # Save XYZ metadata CSV
851
+ if self._xyz_metadata:
852
+ df_xyz_meta = pd.DataFrame(self._xyz_metadata)
853
+ df_xyz_meta.to_csv(f"{path}/xyz_metadata.csv", index=False)
854
+ if not noOutput:
855
+ print(f"[CSV] Saved XYZ metadata: {path}/xyz_metadata.csv")
856
+
857
+
858
+ ############################################## Class for HRTEM simulations
859
+
860
+
861
+ class CreateHRTEMImage:
862
+ """
863
+ Simulate High-Resolution Transmission Electron Microscopy (HRTEM) images
864
+ from XYZ atomic structure files using the abTEM multislice framework.
865
+
866
+ This class reads XYZ files produced by ``CreateHRTEMStructure`` (nanoparticle
867
+ on an amorphous-carbon substrate) and generates realistic HRTEM images by
868
+ chaining the following physical steps:
869
+
870
+ **Simulation pipeline (per XYZ file)**
871
+
872
+ 1. ``place_atoms_grid`` – Crop the carbon substrate to a thin slab.
873
+ 2. ``calculate_potentials`` – Build frozen-phonon potentials (Debye-Waller).
874
+ 3. ``create_wave_function`` – Create an incident plane wave.
875
+ 4. ``perform_multislice`` – Propagate the wave through the potential slices.
876
+ 5. ``calculate_ctf`` – Apply the CTF (Cs, defocus, astigmatism C12/phi12,
877
+ temporal coherence via focal_spread).
878
+ 6. ``apply_astigmatism`` – Copy CTF for incoherent imaging.
879
+ 7. ``apply_partial_coherence`` – Compute intensity from the ensemble of frozen-phonon
880
+ exit waves convolved with the CTF.
881
+ 8. ``apply_poisson_noise`` – Add shot noise at a given electron dose.
882
+ 9. ``calculate_mtf`` – Apply the detector Modulation Transfer Function.
883
+ 10. ``generate_and_save_image`` – Save the PNG image and a per-image CSV metadata file.
884
+ 11. ``generate_mask_image`` – (optional) Save a binary segmentation mask of the NP.
885
+
886
+ **Aberration notation** follows the Krivanek convention used by abTEM:
887
+
888
+ ============ ============================================ ==========
889
+ Symbol Physical meaning Unit
890
+ ============ ============================================ ==========
891
+ Cs (C30) 3rd-order spherical aberration Angstrom
892
+ C10 Defocus (set to Scherzer by default) Angstrom
893
+ C12 / phi12 2-fold astigmatism amplitude / azimuth Angstrom / rad
894
+ focal_spread Temporal-coherence envelope = Cc * dE / E Angstrom
895
+ ============ ============================================ ==========
896
+
897
+ **Outputs**
898
+
899
+ For each input ``{name}.xyz`` the class produces:
900
+
901
+ - ``{name}_{index}.png`` – HRTEM image (grayscale, 512x512 px).
902
+ - ``{name}_{index}_metadata.csv`` – All simulation parameters + NP metadata.
903
+ - ``{name}_{index}_mask.png`` – Binary mask (if ``masking_images=True``).
904
+
905
+ Parameters
906
+ ----------
907
+ path_input : str
908
+ Directory containing the input ``.xyz`` files and optionally
909
+ ``xyz_metadata.csv`` (produced by ``CreateHRTEMStructure``).
910
+ path_output : str
911
+ Directory where output PNG and CSV files are saved.
912
+ sampling : float, default 0.05
913
+ Grid sampling for each dimension in Ångstrom per grid point.
914
+ Impact on the time computation.
915
+ masking_images : bool, default True
916
+ If True, generate a binary segmentation mask for each image.
917
+ phonon_config : int, default 8
918
+ The number of configurations around the equilibrium position.
919
+ sigmas : float, default 0.1
920
+ The standard deviation of the displacements in Angstrom for frozen phonons.
921
+ Cs_value : float, default -80 (i.e. -8e-6 * 1e10 Angstrom)
922
+ Spherical aberration coefficient C30 in Angstrom.
923
+ Negative for aberration-corrected microscopes.
924
+ C12 : float, default 0
925
+ Two-fold astigmatism amplitude in Angstrom.
926
+ phi12 : float, default 0
927
+ Azimuthal angle of the two-fold astigmatism in radians.
928
+ Cc_value : float, default 1e7 (i.e. 1.0e-3 * 1e10 Angstrom = 1 mm)
929
+ Chromatic aberration coefficient in Angstrom.
930
+ semiangle_cutoff_value : int, default 45
931
+ Objective aperture semiangle cutoff in mrad.
932
+ energy_spread : float, default 0.35
933
+ Energy spread dE of the electron source in eV.
934
+ Combined with Cc and E to compute the focal spread:
935
+ ``focal_spread = Cc_value * energy_spread / energy``.
936
+ c1 : float, default -0.6
937
+ MTF parameter – asymptotic contrast floor.
938
+ c2 : float, default 0.1
939
+ MTF parameter – half-power spatial frequency scaling.
940
+ c3 : float, default 1.0
941
+ MTF parameter – roll-off exponent.
942
+ dose_poisson_noise : float, default 1e4
943
+ Electron dose for Poisson shot noise in e-/Angstrom^2.
944
+ slice_thickness : float, default 1
945
+ Thickness of each potential slice for the multislice algorithm
946
+ in Angstrom.
947
+ Smaller values are more accurate but slower.
948
+ noOutput : bool, default True
949
+ Print diagnostic information (defocus, focal_spread, etc.).
950
+ energy : float, default 200e3
951
+ Electron beam energy in eV (200 keV by default).
952
+ substrate_size : list of float, default [10, 10, 10]
953
+ Simulation super-cell size [x, y, z] in nanometers.
954
+ device : str, default 'gpu'
955
+ Computation device: ``'gpu'`` (cupy/CUDA) or ``'cpu'``.
956
+ microscope_step : int, default 1
957
+ Index of the current microscope parameter combination.
958
+ Passed from the outer parameter sweep loop and used in
959
+ output filenames: ``{xyz_name}_{microscope_step:07d}.png``.
960
+
961
+ Attributes
962
+ ----------
963
+ focal_spread : float
964
+ Temporal-coherence envelope width computed as
965
+ ``Cc_value * energy_spread / energy`` (Angstrom).
966
+ defocus : float
967
+ Scherzer defocus C10 in Angstrom (set after ``calculate_ctf``).
968
+
969
+ Examples
970
+ --------
971
+ >>> CreateHRTEMImage(
972
+ ... path_input="output_xyz/",
973
+ ... path_output="output_hrtem/",
974
+ ... Cs_value=-8e-6 * 1e10,
975
+ ... energy=200e3,
976
+ ... device="gpu",
977
+ ... )
978
+ """
979
+
980
+ def __init__(self, path_input: str, path_output: str, sampling: float = 0.05, masking_images: bool = True,
981
+ phonon_config: int = 8, Cs_value: float = -8e-6 * 1e10, C12: float = 0,
982
+ phi12: float = 0, Cc_value: float = 1.0e-3 * 1e10,
983
+ semiangle_cutoff_value: int = 45, energy_spread: float = 0.35,
984
+ c1 : float = -0.6, c2 : float = 0.1, c3 : float= 1.0,
985
+ dose_poisson_noise: float = 1e4,
986
+ sigmas: float = 0.1, slice_thickness: float = 1,
987
+ noOutput: bool = True, energy: float = 200e3,
988
+ device: str = 'gpu', microscope_step: int = 1):
989
+
990
+ self.path_input = path_input
991
+ self.path_output = path_output
992
+ abtem.config.set({"device": device, "fft": "fftw"})
993
+ self.masking_images = masking_images
994
+ self.sampling = sampling
995
+ self.phonon_config = phonon_config
996
+ self.Cs_value = Cs_value
997
+ self.C12 = C12 # 2-fold astigmatism amplitude (Å)
998
+ self.phi12 = phi12 # 2-fold astigmatism angle (rad)
999
+ self.Cc_value = Cc_value # chromatic aberration coefficient (Å)
1000
+ self.semiangle_cutoff_value = semiangle_cutoff_value
1001
+ self.energy_spread = energy_spread # energy spread dE (eV)
1002
+ # focal_spread = Cc * dE / E (temporal coherence envelope)
1003
+ self.focal_spread = Cc_value * energy_spread / energy
1004
+ self.noOutput = noOutput
1005
+ self.energy = energy
1006
+ self.c1 = c1
1007
+ self.c2 = c2
1008
+ self.c3 = c3
1009
+ self.dose_poisson_noise = dose_poisson_noise # e⁻/Ų
1010
+ self.sigmas = sigmas # Debye-Waller rms displacement (Å)
1011
+ self.slice_thickness = slice_thickness # multislice slice thickness (Å)
1012
+ self.device = device
1013
+ self.microscope_step = microscope_step
1014
+ self.cluster = None
1015
+ self.atoms = None
1016
+ self.frozen_phonons = None
1017
+ self.potential = None
1018
+ self.wave = None
1019
+ self.exit_wave = None
1020
+ self.ctf = None
1021
+ self.incoherent_ctf = None
1022
+ self.measurement_ensemble = None
1023
+
1024
+ self.hrtem_image()
1025
+
1026
+
1027
+ def hrtem_image(self):
1028
+ """Main method to generate the HRTEM image."""
1029
+
1030
+ input_files=Path(self.path_input)
1031
+
1032
+ # Load XYZ metadata (orientation, angles, size) if available
1033
+ xyz_meta_path = input_files / "xyz_metadata.csv"
1034
+ xyz_meta_lookup = {}
1035
+ if xyz_meta_path.exists():
1036
+ df_xyz_meta = pd.read_csv(xyz_meta_path)
1037
+ xyz_meta_lookup = {row["xyz_file"]: row.to_dict() for _, row in df_xyz_meta.iterrows()}
1038
+
1039
+
1040
+ for f in sorted(input_files.glob("*.xyz")): # loop on all the output XYZ files
1041
+ xyz_meta = xyz_meta_lookup.get(f.name, {})
1042
+ self.substrate_size = xyz_meta.get("substrate_size_nm")
1043
+ self.atoms = read(f) # from XYZ files to ASE objects for AbTEM
1044
+ self.place_atoms_grid()
1045
+ self.calculate_potentials()
1046
+ self.create_wave_function()
1047
+ self.perform_multislice()
1048
+ self.calculate_ctf()
1049
+ self.apply_astigmatism()
1050
+ self.apply_partial_coherence()
1051
+ self.apply_poisson_noise()
1052
+ self.calculate_mtf(f, xyz_meta)
1053
+ if self.masking_images:
1054
+ self.generate_mask_image(f)
1055
+
1056
+
1057
+ def place_atoms_grid(self):
1058
+ """
1059
+ Function that only takes 2nm of the carbon substrate.
1060
+ """
1061
+ # Define the z-limits for the substrate slice (z is perpendicular to the substrate plane)
1062
+ z_min = 30
1063
+ z_max = 100
1064
+ substrate_size = self.substrate_size * 10 # Convert nm to Å
1065
+ atoms = self.atoms[[z_min <= atom.position[2] <= z_max for atom in self.atoms]]
1066
+ atoms.set_cell([substrate_size, substrate_size, substrate_size]) # Convert nm to Å
1067
+ atoms.center(axis=2, vacuum=2)
1068
+ self.atoms = atoms
1069
+
1070
+
1071
+ def display_cluster_views(self):
1072
+ """Display the cluster from different views."""
1073
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
1074
+ abtem.show_atoms(self.cluster, plane="xy", ax=ax1, title="Beam view")
1075
+ abtem.show_atoms(self.cluster, plane="yz", ax=ax2, title="Side view")
1076
+
1077
+
1078
+ def display_combined_views(self):
1079
+ """Display the combined views of the substrate and cluster."""
1080
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
1081
+ abtem.show_atoms(self.atoms, plane="xy", ax=ax1, title="Beam view")
1082
+ abtem.show_atoms(self.atoms, plane="xz", ax=ax2, title="Side view")
1083
+
1084
+
1085
+ def calculate_potentials(self):
1086
+ """Calculate the potentials for the system."""
1087
+ self.frozen_phonons = abtem.FrozenPhonons(self.atoms, self.phonon_config, sigmas=self.sigmas)
1088
+ self.potential = abtem.Potential(
1089
+ self.frozen_phonons,
1090
+ sampling= self.sampling,
1091
+ projection="infinite",
1092
+ slice_thickness=self.slice_thickness,
1093
+ )
1094
+
1095
+
1096
+ def create_wave_function(self):
1097
+ """Create the wave function for the simulation."""
1098
+ self.wave = abtem.PlaneWave(energy=self.energy)
1099
+
1100
+
1101
+ def perform_multislice(self):
1102
+ """Perform the multislice calculation."""
1103
+ self.exit_wave = self.wave.multislice(self.potential)
1104
+ self.exit_wave.compute()
1105
+
1106
+
1107
+ def calculate_ctf(self):
1108
+ """Calculate the contrast transfer function (CTF) with astigmatism and focal spread."""
1109
+ self.ctf = abtem.CTF(
1110
+ Cs=self.Cs_value,
1111
+ energy=self.wave.energy,
1112
+ defocus="scherzer",
1113
+ semiangle_cutoff=self.semiangle_cutoff_value,
1114
+ C12=self.C12,
1115
+ phi12=self.phi12,
1116
+ focal_spread=self.focal_spread,
1117
+ )
1118
+ if self.noOutput == False :
1119
+ print(f"defocus = {self.ctf.defocus:.2f} Å")
1120
+ self.defocus = self.ctf.defocus # C10 in Å
1121
+ if self.C12 != 0:
1122
+ print(f"C12 (astigmatism) = {self.C12:.2f} Å, phi12 = {self.phi12:.4f} rad")
1123
+ if self.focal_spread != 0:
1124
+ print(f"focal_spread = {self.focal_spread:.2f} Å (Cc={self.Cc_value:.2e} Å, dE={self.energy_spread} eV)")
1125
+
1126
+
1127
+ def apply_astigmatism(self):
1128
+ """Copy CTF for incoherent imaging (astigmatism already set in calculate_ctf)."""
1129
+ self.incoherent_ctf = self.ctf.copy()
1130
+
1131
+
1132
+ def apply_partial_coherence(self):
1133
+ """Apply partial coherence to the exit wave."""
1134
+ self.measurement_ensemble = self.exit_wave.apply_ctf(self.incoherent_ctf).intensity()
1135
+
1136
+
1137
+ def apply_poisson_noise(self):
1138
+ measurement = self.measurement_ensemble.mean(0)
1139
+ self.noisy_measurement = measurement.poisson_noise(dose_per_area=self.dose_poisson_noise)
1140
+
1141
+
1142
+ def calculate_mtf(self, f, xyz_meta=None):
1143
+ """
1144
+ The Modulation Transfer Function (MTF) is a measure of how well the contrast in an object is transferred to an image by a detector.*
1145
+ It characterizes the fidelity of the spatial frequency content of the object in the resulting image.
1146
+ """
1147
+ if xyz_meta is None:
1148
+ xyz_meta = {}
1149
+ from numpy.fft import fft2, ifft2, fftshift, ifftshift
1150
+
1151
+ # parameters
1152
+ pixel_size = self.sampling
1153
+ # Compute the spatial frequencies q
1154
+
1155
+ # cmb de angs sont couverts par un pixel
1156
+ q_N = 1 / (2 * pixel_size)
1157
+ # q_N = abtem.transfer.nyquist_sampling(self.semiangle_cutoff_value, self.energy)
1158
+ # print('q_N',q_N)
1159
+ # Compute the spatial frequencies q
1160
+ # noisy_measurement is already averaged over phonon configs (2D)
1161
+ mean_data = self.noisy_measurement.array
1162
+ # mean_data = np.mean(image_data, axis=0)
1163
+ # print('mean_data.shape',mean_data.shape)
1164
+
1165
+ ny, nx = mean_data.shape
1166
+ qx = np.fft.fftfreq(nx, d=pixel_size)
1167
+ qy = np.fft.fftfreq(ny, d=pixel_size)
1168
+ qx, qy = np.meshgrid(qx, qy)
1169
+ q = np.sqrt(qx**2 + qy**2)
1170
+
1171
+ # for c1 in self.c1 :
1172
+ # for c2 in self.c2:
1173
+ # for c3 in self.c3:
1174
+ # Compute the MTF
1175
+ if self.device == 'cpu' :
1176
+ mtf = (1 - self.c1) / (1 + (q / (2 * self.c2 * q_N))**self.c3) + self.c1
1177
+ # Apply MTF
1178
+ image_fft = fft2(mean_data) # fourier transform of the image: image in the frequency space (where low and high frequencies are separated)
1179
+ image_fft_filtered = image_fft * fftshift(mtf) # each spatial frequency is multiplied by its MFT value
1180
+ self.measurement_ensemble = np.real(ifft2(image_fft_filtered)) # back to the real space of the iamge
1181
+ self.generate_and_save_image(f, xyz_meta)
1182
+ if self.device == 'gpu' :
1183
+ import cupy as cp
1184
+ from cupy.fft import fftshift
1185
+ mtf =cp.asarray((1 - self.c1) / (1 + (q / (2 * self.c2 * q_N))**self.c3) + self.c1)
1186
+ # Apply MTF
1187
+ image_fft = fft2(mean_data) # fourier transform of the image: image in the frequency space (where low and high frequencies are separated)
1188
+ image_fft_filtered = image_fft * fftshift(mtf) # each spatial frequency is multiplied by its MFT value
1189
+ self.measurement_ensemble = np.real(ifft2(image_fft_filtered)) # back to the real space of the iamge
1190
+ self.generate_and_save_image(f, xyz_meta)
1191
+
1192
+ def generate_and_save_image(self, f, xyz_meta=None):
1193
+ """Generate and save the final image."""
1194
+ if xyz_meta is None:
1195
+ xyz_meta = {}
1196
+
1197
+ # 1. The PNG HRTEM image
1198
+
1199
+ self.final_filename = f'{self.path_output}/{f.stem}_{self.microscope_step:07d}'
1200
+ print(f"File is {self.final_filename}.png")
1201
+ plt.figure(figsize=(5.12, 5.12)) # Taille en pouces (ex: 6x6)
1202
+ plt.imshow(self.measurement_ensemble, cmap='gray', origin='lower')
1203
+ plt.axis('off') # Pas d’axes
1204
+ plt.tight_layout(pad=0) # Pas de bordures
1205
+ plt.savefig(f"{self.final_filename}.png", dpi=100, bbox_inches='tight', pad_inches=0)
1206
+ plt.close()
1207
+
1208
+ # 2. The CSV file of metadata
1209
+
1210
+ filename_csv = f'{self.final_filename}_metadata'
1211
+ path_csv = f"{filename_csv}.csv"
1212
+ # Parse element/structure/shape from simplified filename
1213
+ parts = f.stem.split('_')
1214
+ # Flatten metadata for CSV
1215
+ metadata_flat = {
1216
+ "id": f'{f.stem}_{self.microscope_step:07d}',
1217
+ "element": parts[0] if len(parts) > 0 else "",
1218
+ "crystal_structure": parts[1] if len(parts) > 1 else "",
1219
+ "shape": parts[2] if len(parts) > 2 else "",
1220
+ "orientation": xyz_meta.get("orientation", ""),
1221
+ "angle_xy_deg": xyz_meta.get("angle_xy", ""),
1222
+ "angle_tilt_deg": xyz_meta.get("angle_tilt", ""),
1223
+ "circumsphere_diameter_nm": xyz_meta.get("circumsphere_diameter_nm", ""),
1224
+ "sampling_A-1": self.sampling,
1225
+ "phonon_config": self.phonon_config,
1226
+ "Cs_value_A": self.Cs_value,
1227
+ "defocus_C10_A": self.defocus,
1228
+ "C12_A": self.C12,
1229
+ "phi12_rad": self.phi12,
1230
+ "Cc_value_A": self.Cc_value,
1231
+ "energy_spread_eV": self.energy_spread,
1232
+ "focal_spread_A": self.focal_spread,
1233
+ "semiangle_cutoff_value_mrad": self.semiangle_cutoff_value,
1234
+ "energy_eV": self.energy,
1235
+ "mtf_c1":self.c1,
1236
+ "mtf_c2":self.c2,
1237
+ "mtf_c3":self.c3,
1238
+ "dose_poisson_noise_e-A-2": self.dose_poisson_noise,
1239
+ "sigmas_A": self.sigmas,
1240
+ "slice_thickness_A": self.slice_thickness,
1241
+ "substrate_size_nm": self.substrate_size
1242
+ }
1243
+
1244
+ df = pd.DataFrame([metadata_flat])
1245
+ df.to_csv(path_csv, index=False)
1246
+ print(f"[CSV] Saved metadata: {path_csv}")
1247
+
1248
+ print('--------Finished---------------')
1249
+
1250
+
1251
+ def generate_mask_image(self, f):
1252
+ """
1253
+ Generate and save the masked HRTEM image for segmentation purposes.
1254
+ The background is set to zero and the nanoparticule to 1.
1255
+ A disk corresponding to the Van der Waals radius is drawn for each atom.
1256
+ """
1257
+ from ase.data import vdw_radii, covalent_radii
1258
+ from skimage.draw import disk
1259
+
1260
+ # 1. Dimensions of the HRTEM image just generated
1261
+ shape = self.measurement_ensemble.shape
1262
+ pixel_resolution = self.sampling # spatial resolution between pixels (angstroms per pixel)
1263
+
1264
+ # 2. Initialize the mask image with zeros (background)
1265
+ mask_image = np.zeros(shape, dtype=np.uint8)
1266
+
1267
+ # 3. Get the atoms of the nanoparticle (exclude Carbon)
1268
+ np_atoms = [atom for atom in self.atoms if atom.symbol != 'C']
1269
+
1270
+ # 4. Draw a disk for each atom on the mask
1271
+ for atom in np_atoms:
1272
+ # Atom coordinates in Angstroms
1273
+ x, y = atom.position[0], atom.position[1]
1274
+
1275
+ # Convert to pixel coordinates
1276
+ # The image origin (0,0) is top-left, but atom coordinates can be anywhere.
1277
+ # We need to align them with the image grid.
1278
+ px, py = int(y / pixel_resolution), int(x / pixel_resolution)
1279
+
1280
+ # Get Van der Waals radius in Angstroms and convert to pixels
1281
+ radius_ang = vdw_radii[atom.number]
1282
+ if np.isnan(radius_ang): # sometimes not defined
1283
+ radius_ang = covalent_radii[atom.number] # Fallback to covalent radius
1284
+
1285
+ if np.isnan(radius_ang): # sometimes not defined
1286
+ radius_ang = 1.5 # Default value if both are undefined
1287
+
1288
+ radius_px = int(radius_ang / pixel_resolution)
1289
+
1290
+ # Draw a disk for the atom on the mask, checking boundaries
1291
+ rr, cc = disk((py, px), radius_px, shape=shape)
1292
+ mask_image[rr, cc] = 1
1293
+
1294
+ # 5. Save the mask image
1295
+ base_filename = self.final_filename
1296
+ mask_filename = f"{base_filename}_mask.png"
1297
+
1298
+ # Use origin='lower' to match the orientation of the HRTEM image
1299
+
1300
+ plt.figure(figsize=(5.12, 5.12)) # Taille en pouces (ex: 6x6)
1301
+ plt.imshow(mask_image, cmap='gray', origin='lower')
1302
+ plt.axis('off') # Pas d’axes
1303
+ plt.tight_layout(pad=0) # Pas de bordures
1304
+ plt.savefig(mask_filename, dpi=100, bbox_inches='tight', pad_inches=0)
1305
+ plt.close()
1306
+
1307
+ # plt.imsave(mask_filename, mask_image, cmap='gray', origin='lower')
1308
+ print(f"Mask image saved as {mask_filename}")
1309
+
1310
+
1311
+
1312
+
1313
+ def create_csv(tem_image_paths, output_csv, noOutput=True):
1314
+ """
1315
+ Create a single CSV file containing the metadata of all generated PNG files.
1316
+
1317
+ This utility is particularly useful for Machine Learning applications,
1318
+ providing a structured dataset that maps image files to their
1319
+ corresponding physical and structural parameters.
1320
+
1321
+ Args:
1322
+ tem_image_paths (str): The directory path containing the PNG
1323
+ image files to be indexed.
1324
+ output_csv (str): The filename or full path for the resulting
1325
+ CSV metadata file.
1326
+
1327
+ Returns:
1328
+ None: Generates a CSV file at the specified location containing
1329
+ the metadata for all indexed TEM images.
1330
+ """
1331
+ import csv
1332
+
1333
+ # create a datadrame concatenating all the metadata CSV files
1334
+ all_metadata = []
1335
+ for img_path in Path((tem_image_paths)).iterdir():
1336
+ if img_path.is_file() and img_path.suffix == ".png":
1337
+ metadata_csv = img_path.with_name(img_path.stem + "_metadata.csv")
1338
+ if metadata_csv.exists():
1339
+ df_meta = pd.read_csv(metadata_csv)
1340
+ all_metadata.append(df_meta)
1341
+ else:
1342
+ if not noOutput:
1343
+ print(f"Metadata CSV not found for {img_path}")
1344
+
1345
+ if all_metadata:
1346
+ combined_df = pd.concat(all_metadata, ignore_index=True)
1347
+ combined_df.to_csv(output_csv, index=False)
1348
+ if not noOutput:
1349
+ print(f" CSV containing the metadata created : {output_csv}")
1350
+ else:
1351
+ if not noOutput:
1352
+ print(f"No metadata files found in {tem_image_paths}")
1353
+
1354
+ # save the csv
1355
+ if all_metadata:
1356
+ combined_df.to_csv(output_csv, index=False)
1357
+
1358
+ return combined_df
1359
+
1360
+
1361
+