Coverage for /builds/debichem-team/python-ase/ase/calculators/kim/neighborlist.py: 52.38%

189 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-03-06 04:00 +0000

1from collections import defaultdict 

2 

3import numpy as np 

4 

5from ase import Atom 

6from ase.neighborlist import neighbor_list 

7 

8from .kimpy_wrappers import c_double, c_int, check_call_wrapper, kimpy 

9 

10 

11class NeighborList: 

12 

13 kimpy_arrays = { 

14 "num_particles": c_int, 

15 "coords": c_double, 

16 "particle_contributing": c_int, 

17 "species_code": c_int, 

18 "cutoffs": c_double, 

19 "padding_image_of": c_int, 

20 "need_neigh": c_int, 

21 } 

22 

23 def __setattr__(self, name, value): 

24 """ 

25 Override assignment to any of the attributes listed in 

26 kimpy_arrays to automatically cast the object to a numpy array. 

27 This is done to avoid a ton of explicit numpy.array() calls (and 

28 the possibility that we forget to do the cast). It is important 

29 to use np.asarray() here instead of np.array() because using the 

30 latter will mean that incrementation (+=) will create a new 

31 object that the reference is bound to, which becomes a problem 

32 if update_compute_args isn't called to reregister the 

33 corresponding address with the KIM API. 

34 """ 

35 if name in self.kimpy_arrays and value is not None: 

36 value = np.asarray(value, dtype=self.kimpy_arrays[name]) 

37 self.__dict__[name] = value 

38 

39 def __init__( 

40 self, 

41 neigh_skin_ratio, 

42 model_influence_dist, 

43 model_cutoffs, 

44 padding_not_require_neigh, 

45 debug, 

46 ): 

47 

48 self.set_neigh_parameters( 

49 neigh_skin_ratio, 

50 model_influence_dist, 

51 model_cutoffs, 

52 padding_not_require_neigh, 

53 ) 

54 

55 self.debug = debug 

56 

57 if self.debug: 

58 print() 

59 print(f"Calculator skin: {self.skin}") 

60 print(f"Model influence distance: {model_influence_dist}") 

61 print( 

62 "Calculator influence distance (including skin distance): {}" 

63 "".format(self.influence_dist) 

64 ) 

65 print(f"Number of cutoffs: {model_cutoffs.size}") 

66 print(f"Model cutoffs: {model_cutoffs}") 

67 print( 

68 "Calculator cutoffs (including skin distance): {}" 

69 "".format(self.cutoffs) 

70 ) 

71 print( 

72 "Model needs neighbors of padding atoms: {}" 

73 "".format(self.padding_need_neigh) 

74 ) 

75 print() 

76 

77 # Attributes to be set by subclasses 

78 self.neigh = None 

79 self.num_contributing_particles = None 

80 self.padding_image_of = None 

81 self.num_particles = None 

82 self.coords = None 

83 self.particle_contributing = None 

84 self.species_code = None 

85 self.need_neigh = None 

86 self.last_update_positions = None 

87 

88 def set_neigh_parameters( 

89 self, 

90 neigh_skin_ratio, 

91 model_influence_dist, 

92 model_cutoffs, 

93 padding_not_require_neigh, 

94 ): 

95 self.skin = neigh_skin_ratio * model_influence_dist 

96 self.influence_dist = model_influence_dist + self.skin 

97 self.cutoffs = model_cutoffs + self.skin 

98 self.padding_need_neigh = not padding_not_require_neigh.all() 

99 

100 def update_kim_coords(self, atoms): 

101 """Update atomic positions in self.coords, which is where the KIM 

102 API will look to find them in order to pass them to the model. 

103 """ 

104 if self.padding_image_of.size != 0: 

105 disp_contrib = atoms.positions - self.coords[: len(atoms)] 

106 disp_pad = disp_contrib[self.padding_image_of] 

107 self.coords += np.concatenate((disp_contrib, disp_pad)) 

108 else: 

109 np.copyto(self.coords, atoms.positions) 

110 

111 if self.debug: 

112 print("Debug: called update_kim_coords") 

113 print() 

114 

115 def need_neigh_update(self, atoms, system_changes): 

116 need_neigh_update = True 

117 if len(system_changes) == 1 and "positions" in system_changes: 

118 # Only position changes 

119 if self.last_update_positions is not None: 

120 a = self.last_update_positions 

121 b = atoms.positions 

122 if a.shape == b.shape: 

123 delta = np.linalg.norm(a - b, axis=1) 

124 # Indices of the two largest elements 

125 try: 

126 ind = np.argpartition(delta, -2)[-2:] 

127 

128 if sum(delta[ind]) <= self.skin: 

129 need_neigh_update = False 

130 except ValueError as error: 

131 # if there is only a single atom that gets displaced 

132 # np.argpartition(delta, -2) will fail with a 

133 # ValueError, a single atom has no neighbors to update 

134 if atoms.positions.shape[0] != 1: 

135 raise error 

136 need_neigh_update = False 

137 

138 return need_neigh_update 

139 

140 

141class ASENeighborList(NeighborList): 

142 def __init__( 

143 self, 

144 compute_args, 

145 neigh_skin_ratio, 

146 model_influence_dist, 

147 model_cutoffs, 

148 padding_not_require_neigh, 

149 debug, 

150 ): 

151 super().__init__( 

152 neigh_skin_ratio, 

153 model_influence_dist, 

154 model_cutoffs, 

155 padding_not_require_neigh, 

156 debug, 

157 ) 

158 

159 self.neigh = {} 

160 compute_args.set_callback( 

161 kimpy.compute_callback_name.GetNeighborList, self.get_neigh, 

162 self.neigh 

163 ) 

164 

165 @staticmethod 

166 def get_neigh(data, cutoffs, neighbor_list_index, particle_number): 

167 """Retrieves the neighbors of each atom using ASE's native neighbor 

168 list library 

169 """ 

170 # We can only return neighbors of particles that were stored 

171 number_of_particles = data["num_particles"] 

172 if particle_number >= number_of_particles or particle_number < 0: 

173 return (np.array([]), 1) 

174 

175 neighbors = data["neighbors"][neighbor_list_index][particle_number] 

176 return (neighbors, 0) 

177 

178 def build(self, orig_atoms): 

179 """Build the ASE neighbor list and return an Atoms object with 

180 all of the neighbors added. First a neighbor list is created 

181 from ase.neighbor_list, having only information about the 

182 neighbors of the original atoms. If neighbors of padding atoms 

183 are required, they are calculated using information from the 

184 first neighbor list. 

185 """ 

186 syms = orig_atoms.get_chemical_symbols() 

187 orig_num_atoms = len(orig_atoms) 

188 orig_pos = orig_atoms.get_positions() 

189 

190 # New atoms object that will contain the contributing atoms plus 

191 # the padding atoms 

192 new_atoms = orig_atoms.copy() 

193 

194 neigh_list = defaultdict(list) 

195 neigh_dists = defaultdict(list) 

196 

197 # Information for padding atoms 

198 padding_image_of = [] 

199 padding_shifts = [] 

200 

201 # Ask ASE to build the neighbor list using the influence distance. 

202 # This is not a neighbor list for each atom, but rather a listing 

203 # of all neighbor pairs that exist. Atoms with no neighbors will 

204 # not show up. 

205 ( 

206 neigh_indices_i, 

207 neigh_indices_j, 

208 relative_pos, 

209 neigh_cell_offsets, 

210 dists, 

211 ) = neighbor_list("ijDSd", orig_atoms, self.influence_dist) 

212 

213 # Loop over all neighbor pairs. Because this loop will generally 

214 # include image atoms (for periodic systems), we keep track of 

215 # which atoms/images we've accounted for in the `used` dictionary. 

216 used = {} 

217 for neigh_i, neigh_j, rel_pos, offset, dist in zip( 

218 neigh_indices_i, neigh_indices_j, 

219 relative_pos, neigh_cell_offsets, dists 

220 ): 

221 # Get neighbor position of neighbor 

222 # (mapped back into unit cell, so this may overlap with other atoms) 

223 wrapped_pos = orig_pos[neigh_i] + rel_pos 

224 

225 shift = tuple(offset) 

226 uniq_index = (neigh_j,) + shift 

227 if shift == (0, 0, 0): 

228 # This atom is in the unit cell, i.e. it is contributing 

229 neigh_list[neigh_i].append(neigh_j) 

230 neigh_dists[neigh_i].append(dist) 

231 if uniq_index not in used: 

232 used[uniq_index] = neigh_j 

233 else: 

234 # This atom is not in the unit cell, i.e. it is padding 

235 if uniq_index not in used: 

236 # Add the neighbor as a padding atom 

237 used[uniq_index] = len(new_atoms) 

238 new_atoms.append(Atom(syms[neigh_j], position=wrapped_pos)) 

239 padding_image_of.append(neigh_j) 

240 padding_shifts.append(offset) 

241 neigh_list[neigh_i].append(used[uniq_index]) 

242 neigh_dists[neigh_i].append(dist) 

243 neighbor_list_size = orig_num_atoms 

244 

245 # Add neighbors of padding atoms if the potential requires them 

246 if self.padding_need_neigh: 

247 neighbor_list_size = len(new_atoms) 

248 inv_used = {v: k for k, v in used.items()} 

249 # Loop over all the neighbors (k) and the image of that neighbor 

250 # in the cell (neigh) 

251 for k, neigh in enumerate(padding_image_of): 

252 # Shift from original atom in cell to neighbor 

253 shift = padding_shifts[k] 

254 for orig_neigh, orig_dist in zip( 

255 neigh_list[neigh], neigh_dists[neigh]): 

256 # Get the shift of the neighbor of the original atom 

257 orig_shift = inv_used[orig_neigh][1:] 

258 

259 # Apply sum of original shift and current shift to 

260 # neighbors of original atom 

261 total_shift = orig_shift + shift 

262 

263 # Get the image in the cell of the original neighbor 

264 if orig_neigh <= orig_num_atoms - 1: 

265 orig_neigh_image = orig_neigh 

266 else: 

267 orig_neigh_image = padding_image_of[orig_neigh - 

268 orig_num_atoms] 

269 

270 # If the original image with the total shift has been 

271 # used before then it is also a neighbor of this atom 

272 uniq_index = (orig_neigh_image,) + tuple(total_shift) 

273 if uniq_index in used: 

274 neigh_list[k + orig_num_atoms].append(used[uniq_index]) 

275 neigh_dists[k + orig_num_atoms].append(orig_dist) 

276 

277 # If the model has multiple cutoffs, we need to return neighbor lists 

278 # corresponding to each of them 

279 neigh_lists = [] 

280 for cut in self.cutoffs: 

281 neigh_list = [ 

282 np.array(neigh_list[k], dtype=c_int)[neigh_dists[k] <= cut] 

283 for k in range(neighbor_list_size) 

284 ] 

285 neigh_lists.append(neigh_list) 

286 

287 self.padding_image_of = padding_image_of 

288 

289 self.neigh["neighbors"] = neigh_lists 

290 self.neigh["num_particles"] = neighbor_list_size 

291 

292 return new_atoms 

293 

294 def update(self, orig_atoms, species_map): 

295 """Create the neighbor list along with the other required 

296 parameters (which are stored as instance attributes). The 

297 required parameters are: 

298 

299 - num_particles 

300 - coords 

301 - particle_contributing 

302 - species_code 

303 

304 Note that the KIM API requires a neighbor list that has indices 

305 corresponding to each atom. 

306 """ 

307 

308 # Information of original atoms 

309 self.num_contributing_particles = len(orig_atoms) 

310 

311 new_atoms = self.build(orig_atoms) 

312 

313 # Save the number of atoms and all their neighbors and positions 

314 num_atoms = len(new_atoms) 

315 num_padding = num_atoms - self.num_contributing_particles 

316 self.num_particles = [num_atoms] 

317 self.coords = new_atoms.get_positions() 

318 

319 # Save which coordinates are from original atoms and which are from 

320 # neighbors using a mask 

321 indices_mask = [1] * self.num_contributing_particles + [0] * num_padding 

322 self.particle_contributing = indices_mask 

323 

324 # Species support and code 

325 try: 

326 self.species_code = [ 

327 species_map[s] for s in new_atoms.get_chemical_symbols() 

328 ] 

329 except KeyError as e: 

330 raise RuntimeError( 

331 "Species not supported by KIM model; {}".format( 

332 str(e))) 

333 

334 self.last_update_positions = orig_atoms.get_positions() 

335 

336 if self.debug: 

337 print("Debug: called update_ase_neigh") 

338 print() 

339 

340 

341class KimpyNeighborList(NeighborList): 

342 def __init__( 

343 self, 

344 compute_args, 

345 neigh_skin_ratio, 

346 model_influence_dist, 

347 model_cutoffs, 

348 padding_not_require_neigh, 

349 debug, 

350 ): 

351 super().__init__( 

352 neigh_skin_ratio, 

353 model_influence_dist, 

354 model_cutoffs, 

355 padding_not_require_neigh, 

356 debug, 

357 ) 

358 

359 self.neigh = kimpy.neighlist.NeighList() 

360 

361 compute_args.set_callback_pointer( 

362 kimpy.compute_callback_name.GetNeighborList, 

363 kimpy.neighlist.get_neigh_kim(), 

364 self.neigh, 

365 ) 

366 

367 @check_call_wrapper 

368 def build(self): 

369 return self.neigh.build( 

370 self.coords, self.influence_dist, self.cutoffs, self.need_neigh 

371 ) 

372 

373 @check_call_wrapper 

374 def create_paddings( 

375 self, cell, pbc, contributing_coords, contributing_species_code 

376 ): 

377 # Cast things passed through kimpy to numpy arrays 

378 cell = np.asarray(cell, dtype=c_double) 

379 pbc = np.asarray(pbc, dtype=c_int) 

380 contributing_coords = np.asarray(contributing_coords, dtype=c_double) 

381 

382 return kimpy.neighlist.create_paddings( 

383 self.influence_dist, 

384 cell, 

385 pbc, 

386 contributing_coords, 

387 contributing_species_code, 

388 ) 

389 

390 def update(self, atoms, species_map): 

391 """Create the neighbor list along with the other required 

392 parameters (which are stored as instance attributes). The 

393 required parameters are: 

394 

395 - num_particles 

396 - coords 

397 - particle_contributing 

398 - species_code 

399 

400 Note that the KIM API requires a neighbor list that has indices 

401 corresponding to each atom. 

402 """ 

403 

404 # Get info from Atoms object 

405 cell = np.asarray(atoms.get_cell(), dtype=c_double) 

406 pbc = np.asarray(atoms.get_pbc(), dtype=c_int) 

407 contributing_coords = np.asarray(atoms.get_positions(), dtype=c_double) 

408 self.num_contributing_particles = atoms.get_global_number_of_atoms() 

409 num_contributing = self.num_contributing_particles 

410 

411 # Species support and code 

412 try: 

413 contributing_species_code = np.array( 

414 [species_map[s] for s in atoms.get_chemical_symbols()], 

415 dtype=c_int 

416 ) 

417 except KeyError as e: 

418 raise RuntimeError( 

419 "Species not supported by KIM model; {}".format( 

420 str(e))) 

421 

422 if pbc.any(): # Need padding atoms 

423 # Create padding atoms 

424 

425 ( 

426 padding_coords, 

427 padding_species_code, 

428 self.padding_image_of, 

429 ) = self.create_paddings( 

430 cell, pbc, contributing_coords, contributing_species_code 

431 ) 

432 num_padding = padding_species_code.size 

433 

434 self.num_particles = [num_contributing + num_padding] 

435 self.coords = np.concatenate((contributing_coords, padding_coords)) 

436 self.species_code = np.concatenate( 

437 (contributing_species_code, padding_species_code) 

438 ) 

439 self.particle_contributing = [1] * \ 

440 num_contributing + [0] * num_padding 

441 self.need_neigh = [1] * self.num_particles[0] 

442 if not self.padding_need_neigh: 

443 self.need_neigh[num_contributing:] = 0 

444 

445 else: # Do not need padding atoms 

446 self.padding_image_of = [] 

447 self.num_particles = [num_contributing] 

448 self.coords = contributing_coords 

449 self.species_code = contributing_species_code 

450 self.particle_contributing = [1] * num_contributing 

451 self.need_neigh = self.particle_contributing 

452 

453 # Create neighborlist 

454 self.build() 

455 

456 self.last_update_positions = atoms.get_positions() 

457 

458 if self.debug: 

459 print("Debug: called update_kimpy_neigh") 

460 print()