packing_simulation.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import os
  2. os.environ["OPENBLAS_NUM_THREADS"] = "1" # mitigates the clash between numpy and multiprocessing parallelization
  3. import numpy as np
  4. import ctypes
  5. import multiprocessing
  6. from functools import partial
  7. import time
  8. from scipy.optimize import minimize as minim, OptimizeResult
  9. from dataclasses import dataclass, field
  10. from abc import ABC, abstractmethod
  11. def ephi(phi):
  12. u = np.zeros((len(phi), 3))
  13. u[:, 0] = -np.sin(phi)
  14. u[:, 1] = np.cos(phi)
  15. return u
  16. def etheta(phi, theta):
  17. u = np.zeros((len(phi), 3))
  18. u[:, 0] = np.cos(phi) * np.cos(theta)
  19. u[:, 1] = np.sin(phi) * np.cos(theta)
  20. u[:, 2] = -np.sin(theta)
  21. return u
  22. def sph_to_cart(sph):
  23. """Transformation form spherical coordinates phi-theta on a sphere to cartesian coordinates."""
  24. cart = np.zeros((len(sph), 3))
  25. cart[:, 0] = np.cos(sph[:, 0]) * np.sin(sph[:, 1])
  26. cart[:, 1] = np.sin(sph[:, 0]) * np.sin(sph[:, 1])
  27. cart[:, 2] = np.cos(sph[:, 1])
  28. return cart
  29. def vector_orientations(sph, xi):
  30. """
  31. Calculates vector_orientations of ellipse orientations given their positions in spherical coordinates
  32. and orientation angles.
  33. """
  34. cosxi = np.cos(xi)[:, np.newaxis]
  35. sinxi = np.sin(xi)[:, np.newaxis]
  36. basis_theta = etheta(sph[:, 0], sph[:, 1])
  37. basis_phi = ephi(sph[:, 0])
  38. return cosxi * basis_theta + sinxi * basis_phi
  39. clib = ctypes.cdll.LoadLibrary('./overlap_algorithm.so')
  40. clib.contact_mono_pack_multi.argtypes = [
  41. ctypes.c_int,
  42. ctypes.c_double,
  43. ctypes.c_double,
  44. np.ctypeslib.ndpointer(dtype=np.float64, ndim=2, flags='C_CONTIGUOUS'),
  45. np.ctypeslib.ndpointer(dtype=np.float64, ndim=2, flags='C_CONTIGUOUS'),
  46. ctypes.c_double
  47. ]
  48. clib.contact_mono_pack_multi.restype = ctypes.c_double
  49. clib.contact_bidisp_pack_multi.argtypes = [
  50. ctypes.c_int,
  51. np.ctypeslib.ndpointer(dtype=np.float64, ndim=1, flags='C_CONTIGUOUS'),
  52. np.ctypeslib.ndpointer(dtype=np.float64, ndim=1, flags='C_CONTIGUOUS'),
  53. np.ctypeslib.ndpointer(dtype=np.float64, ndim=2, flags='C_CONTIGUOUS'),
  54. np.ctypeslib.ndpointer(dtype=np.float64, ndim=2, flags='C_CONTIGUOUS'),
  55. ctypes.c_double,
  56. ctypes.c_double
  57. ]
  58. clib.contact_bidisp_pack_multi.restype = ctypes.c_double
  59. def monodisp_config_energy(inv2a, inv2b, coords, orients, dist_lim) -> float:
  60. if inv2a >= 1:
  61. n = len(coords)
  62. return clib.contact_mono_pack_multi(n, inv2a, inv2b, coords, orients, dist_lim)
  63. raise ValueError("Semi-major axis should be smaller that pi/2, otherwise the ellipse becomes inverted.")
  64. def bidisp_config_energy(inv2a_array, inv2b_array, coords, orients, dist_lim, omega) -> float:
  65. if inv2a_array[0] >= 1:
  66. n = len(coords)
  67. return clib.contact_bidisp_pack_multi(n, inv2a_array, inv2b_array, coords, orients, dist_lim, omega)
  68. raise ValueError("Semi-major axis should be smaller that pi/2, otherwise the ellipse becomes inverted.")
  69. class EllipseMinim(ABC):
  70. """Base class fir optimization of ellipse configurations on a sphere."""
  71. @abstractmethod
  72. def change_params(self, new_alpha: float) -> None:
  73. pass
  74. @abstractmethod
  75. def evaluate_energy(self) -> float:
  76. pass
  77. @abstractmethod
  78. def export_config(self, folder: str, idx: int) -> None:
  79. pass
  80. @abstractmethod
  81. def minim(self, eps, gtol) -> OptimizeResult:
  82. """
  83. Method for finding optimal configuration with BFGS energy minimization method.
  84. :param eps: Absolute step size used for numerical approximation of the jacobian in the BFGS method.
  85. :param gtol: BFGS minimization is terminated after all gradient components are smaller than gtol (inf norm)
  86. """
  87. pass
  88. class EllipseMinimMonodisp(EllipseMinim):
  89. def __init__(self, n, alpha, epsilon, initial: np.ndarray = None):
  90. """
  91. :param n: number of spherical ellipses
  92. :param alpha: ellipse size (geodesic semi-major axis)
  93. :param epsilon: ellipse aspect ratio
  94. """
  95. self.n = n
  96. self.alpha = alpha
  97. self.beta = alpha / epsilon
  98. self.epsilon = epsilon
  99. self.config_xi = initial
  100. if self.config_xi is None:
  101. np.random.seed()
  102. self.config_xi = np.array([2 * np.pi * np.random.random(self.n),
  103. np.arccos(2 * np.random.random(self.n) - 1),
  104. 2 * np.pi * np.random.random(self.n)]).flatten()
  105. self.inv2a = 1 / np.sin(self.alpha) ** 2
  106. self.inv2b = 1 / np.sin(self.beta) ** 2
  107. self.dist_lim = np.cos(2 * self.alpha)
  108. def change_params(self, new_alpha):
  109. self.alpha = new_alpha
  110. self.beta = new_alpha / self.epsilon
  111. self.inv2a = 1 / np.sin(self.alpha) ** 2
  112. self.inv2b = 1 / np.sin(self.beta) ** 2
  113. self.dist_lim = np.cos(2 * self.alpha)
  114. def evaluate_energy(self):
  115. sph = self.config_xi[:2 * self.n].reshape((2, self.n)).T
  116. positions = sph_to_cart(sph)
  117. orientations = vector_orientations(sph, self.config_xi[2 * self.n:])
  118. return monodisp_config_energy(self.inv2a, self.inv2b, positions, orientations, self.dist_lim)
  119. def export_config(self, folder: str, idx: int):
  120. sph = self.config_xi[:2 * self.n].reshape((2, self.n)).T
  121. positions = sph_to_cart(sph)
  122. orientations = vector_orientations(sph, self.config_xi[2 * self.n:])
  123. np.savetxt(folder + f'/coord{idx}.dat', positions)
  124. np.savetxt(folder + f'/orient{idx}.dat', orientations)
  125. def minim(self, eps=1.4901161193847656e-08, gtol=1e-5):
  126. def energy(xi):
  127. sph = xi[:2 * self.n].reshape((2, self.n)).T
  128. positions = sph_to_cart(sph)
  129. orientations = vector_orientations(sph, xi[2 * self.n:])
  130. return monodisp_config_energy(self.inv2a, self.inv2b, positions, orientations, self.dist_lim)
  131. result = minim(energy, self.config_xi, method='BFGS', options={'gtol': gtol, 'eps': eps})
  132. self.config_xi = result.x
  133. return result
  134. class EllipseMinimBidisp(EllipseMinim):
  135. def __init__(self, n, alpha1, alpha2, epsilon, num_frac=0.5, initial=None):
  136. """
  137. :param n: number of spherical ellipses
  138. :param alpha1: geodesic semi-major axis for the 1st size of ellipses
  139. :param alpha2: geodesic semi-major axis for the 2nd size of ellipses
  140. :param epsilon: ellipse aspect ratio
  141. :param num_frac: number fraction of larger ellipses
  142. """
  143. alpha1, alpha2 = max(alpha1, alpha2), min(alpha1, alpha2)
  144. if epsilon < 1:
  145. epsilon = 1 / epsilon
  146. self.n = n
  147. self.alpha1 = alpha1
  148. self.alpha2 = alpha2
  149. self.beta1 = alpha1 / epsilon
  150. self.beta2 = alpha2 / epsilon
  151. self.num_frac = num_frac
  152. self.size_ratio = alpha1 / alpha2
  153. self.epsilon = epsilon
  154. self.config_xi = initial
  155. if self.config_xi is None:
  156. np.random.seed()
  157. self.config_xi = np.array([2 * np.pi * np.random.random(self.n),
  158. np.arccos(2 * np.random.random(self.n) - 1),
  159. 2 * np.pi * np.random.random(self.n)]).flatten()
  160. self.num_large = int(n * num_frac)
  161. self.inv2a = np.zeros(self.n)
  162. self.inv2b = np.zeros(self.n)
  163. self.inv2a[:self.num_large] = 1 / np.sin(self.alpha1) ** 2
  164. self.inv2a[self.num_large:] = 1 / np.sin(self.alpha2) ** 2
  165. self.inv2b[:self.num_large] = 1 / np.sin(self.beta1) ** 2
  166. self.inv2b[self.num_large:] = 1 / np.sin(self.beta2) ** 2
  167. self.dist_lim = np.cos(2 * self.alpha1)
  168. self.omega = 1 / np.sin(self.alpha1) ** 2
  169. def change_params(self, new_alpha1):
  170. self.alpha1 = new_alpha1
  171. self.alpha2 = new_alpha1 / self.size_ratio
  172. self.beta1 = self.alpha1 / self.epsilon
  173. self.beta2 = self.alpha2 / self.epsilon
  174. self.inv2a[:self.num_large] = 1 / np.sin(self.alpha1) ** 2
  175. self.inv2a[self.num_large:] = 1 / np.sin(self.alpha2) ** 2
  176. self.inv2b[:self.num_large] = 1 / np.sin(self.beta1) ** 2
  177. self.inv2b[self.num_large:] = 1 / np.sin(self.beta2) ** 2
  178. self.dist_lim = np.cos(2 * self.alpha1)
  179. def evaluate_energy(self):
  180. sph = self.config_xi[:2 * self.n].reshape((2, self.n)).T
  181. positions = sph_to_cart(sph)
  182. orientations = vector_orientations(sph, self.config_xi[2 * self.n:])
  183. return bidisp_config_energy(self.inv2a, self.inv2b, positions, orientations, self.dist_lim, self.omega)
  184. def export_config(self, folder: str, idx: int):
  185. sph = self.config_xi[:2 * self.n].reshape((2, self.n)).T
  186. positions = sph_to_cart(sph)
  187. orientations = vector_orientations(sph, self.config_xi[2 * self.n:])
  188. np.savetxt(folder + f'/coord{idx}.dat', positions)
  189. np.savetxt(folder + f'/orient{idx}.dat', orientations)
  190. def minim(self, eps=1.4901161193847656e-08, gtol=1e-5):
  191. def energy_eig(xi):
  192. sph = xi[:2 * self.n].reshape((2, self.n)).T
  193. positions = sph_to_cart(sph)
  194. orientations = vector_orientations(sph, xi[2 * self.n:])
  195. return bidisp_config_energy(self.inv2a, self.inv2b, positions, orientations, self.dist_lim, self.omega)
  196. result = minim(energy_eig, self.config_xi, method='BFGS', options={'gtol': gtol, 'eps': eps})
  197. self.config_xi = result.x
  198. return result
  199. def ellipse_minim_factory(n, alpha1, alpha2, epsilon, num_frac=0.5) -> EllipseMinim:
  200. if alpha1 == alpha2:
  201. return EllipseMinimMonodisp(n, alpha1, epsilon)
  202. return EllipseMinimBidisp(n, alpha1, alpha2, epsilon, num_frac=num_frac)
  203. class PackingSimulation:
  204. def __init__(self, n: int, epsilon: float, data_folder: str = None, size_ratio: float = 1., num_frac: float = 0.5):
  205. self.n = n
  206. self.epsilon = epsilon
  207. self.num_frac = num_frac
  208. self.size_ratio = size_ratio
  209. if self.size_ratio < 1:
  210. self.size_ratio = 1 / self.size_ratio
  211. self.data_folder = data_folder
  212. if self.data_folder is None:
  213. self.data_folder = os.getcwd()
  214. if size_ratio == 1:
  215. self.folder = self.data_folder + f'/packing_results/monodisp/n{n}ratio{int(1000 * epsilon)}'
  216. else:
  217. self.folder = self.data_folder + \
  218. f'/packing_results/bidisp{int(100 * size_ratio)}frac{int(100 * num_frac)}/n{n}ratio{int(1000 * epsilon)}'
  219. try:
  220. existing = np.loadtxt(self.folder + '/ellipse_sizes.dat')
  221. self.existing_idx = len(existing)
  222. except OSError:
  223. self.existing_idx = 0
  224. except TypeError:
  225. self.existing_idx = 1
  226. def run(self, num_configs, tol=1e-8, n_jobs=8):
  227. os.makedirs(self.folder, exist_ok=True)
  228. if self.existing_idx != 0:
  229. print(f'{self.existing_idx} configurations for given parameters already exist.')
  230. threads = multiprocessing.Pool(n_jobs)
  231. ellipse_sizes = threads.map(partial(ellipse_packing, n=self.n, epsilon=self.epsilon, tol=tol,
  232. size_ratio=self.size_ratio, num_frac=self.num_frac,
  233. folder=self.folder, existing_idx=self.existing_idx),
  234. range(num_configs))
  235. threads.close()
  236. with open(self.folder + '/ellipse_sizes.dat', 'ab') as f:
  237. np.savetxt(f, ellipse_sizes)
  238. self.existing_idx += num_configs
  239. @dataclass
  240. class PackingSimData:
  241. minim_iter: list = field(init=False, default_factory=list)
  242. minim_nfev: list = field(init=False, default_factory=list)
  243. ellipse_scaling: list = field(init=False, default_factory=list)
  244. scaling_step: list = field(init=False, default_factory=list)
  245. energy: list = field(init=False, default_factory=list)
  246. def append_all(self, minim_result: OptimizeResult, ellipse_scaling: float, scaling_step: float):
  247. self.minim_iter.append(minim_result.nit)
  248. self.minim_nfev.append(minim_result.nfev)
  249. self.ellipse_scaling.append(ellipse_scaling)
  250. self.scaling_step.append(scaling_step)
  251. self.energy.append(minim_result.fun)
  252. def export(self, folder: str, idx: int):
  253. minim_iter = np.array(self.minim_iter)
  254. minim_nfev = np.array(self.minim_nfev)
  255. ellipse_scaling = np.array(self.ellipse_scaling)
  256. scaling_step = np.array(self.scaling_step)
  257. energy = np.array(self.energy)
  258. np.savetxt(folder + f'/iter_data{idx}.dat',
  259. np.vstack((ellipse_scaling, scaling_step, minim_iter, minim_nfev, energy)).T)
  260. def ellipse_packing(idx, epsilon: float, n: int, tol: float, size_ratio: float,
  261. num_frac: float, folder: str, existing_idx: int = 0):
  262. scaling = np.sqrt(2 * epsilon / n) # we start with scaling for packing fraction cca phi<0.5
  263. scaling_step0 = scaling / 100
  264. scaling_step = scaling_step0
  265. inflate_deflate = 1
  266. counter = 0
  267. n_iter = 0
  268. # default values
  269. eps = 1.4901161193847656e-08
  270. gtol = 1e-5
  271. packing_sim_data = PackingSimData()
  272. # random initialization of ellipse positions and orientations
  273. ellipse_minim = ellipse_minim_factory(n, scaling, scaling / size_ratio, epsilon, num_frac=num_frac)
  274. minim_result = ellipse_minim.minim(eps=eps, gtol=gtol)
  275. packing_sim_data.append_all(minim_result, scaling, scaling_step)
  276. if minim_result.fun > 1e-4:
  277. raise ValueError("Check initialization (packing fraction), the first minimization should get rid of overlaps.")
  278. # first: lowering of eps and gtol as long as minimization does not reach the desired accuracy
  279. while True:
  280. if minim_result.fun < 1e-12 or eps < 2 * 1e-10:
  281. break
  282. eps = eps / 2
  283. print(f'Lowering eps at first minim for idx {idx}, now {eps}')
  284. minim_result = ellipse_minim.minim(eps=eps, gtol=gtol)
  285. packing_sim_data.append_all(minim_result, scaling, scaling_step)
  286. # lowering the desired accuracy of gradient calculation if lowering eps does not help
  287. delta_en = packing_sim_data.energy[-2] - packing_sim_data.energy[-1]
  288. if delta_en < packing_sim_data.energy[-2] / 10 and gtol > 2 * 1e-7:
  289. gtol = gtol / np.sqrt(10)
  290. print(f'Lowering gtol at first minim for idx {idx}, now {gtol}')
  291. while True:
  292. if n_iter == 300:
  293. print('Exceeded max number of scaling iterations (n_iter_max=300).')
  294. break
  295. energy = ellipse_minim.evaluate_energy()
  296. if tol < energy < 2 * tol:
  297. break
  298. elif energy < tol and inflate_deflate == -1:
  299. scaling_step = scaling_step / 2
  300. inflate_deflate = 1 # ellipse size is increased
  301. counter = 0
  302. elif energy > 2 * tol and inflate_deflate == 1:
  303. scaling_step = scaling_step / 2
  304. inflate_deflate = -1 # ellipse size is decreased
  305. counter = 0
  306. # if there is no change in scaling step for 8 minimizations, we increase it
  307. elif counter == 8 and scaling_step < scaling_step0:
  308. scaling_step = 2 * scaling_step
  309. counter = 0
  310. # additional adjustment of eps and gtol:
  311. # we mandate strict energy accuracy during the first 10 iterations (there should still be no overlaps)
  312. if n_iter <= 10:
  313. if energy > 2 * 1e-12 and eps > 2 * 1e-10:
  314. eps = eps / 2
  315. print(f'Lowering eps at {n_iter + 1}. minim for idx {idx}, now {eps}')
  316. minim_result = ellipse_minim.minim(eps=eps, gtol=gtol)
  317. packing_sim_data.append_all(minim_result, scaling, scaling_step)
  318. delta_en = packing_sim_data.energy[-2] - packing_sim_data.energy[-1]
  319. if delta_en < packing_sim_data.energy[-2] / 10 and gtol > 2 * 1e-7:
  320. gtol = gtol / np.sqrt(10)
  321. print(f'Lowering gtol at {n_iter + 1}. minim minim for idx {idx}, now {gtol}')
  322. if energy > tol:
  323. raise ValueError('Check initialization, there should still be no overlaps for the first 10 iterations.')
  324. # increase (or decrease) the size of ellipses
  325. scaling += inflate_deflate * scaling_step
  326. ellipse_minim.change_params(scaling)
  327. minim_result = ellipse_minim.minim(eps=eps, gtol=gtol)
  328. packing_sim_data.append_all(minim_result, scaling, scaling_step)
  329. counter += 1
  330. n_iter += 1
  331. # save packing configuration and simulation data
  332. ellipse_minim.export_config(folder, idx=idx + existing_idx)
  333. packing_sim_data.export(folder, idx=idx + existing_idx)
  334. return scaling