Coverage for /builds/ase/ase/ase/io/utils.py: 89.09%

330 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2from itertools import islice 

3from typing import IO 

4 

5import numpy as np 

6 

7from ase.data import atomic_numbers, covalent_radii 

8from ase.data.colors import jmol_colors as default_colors 

9from ase.io.formats import string2index 

10from ase.utils import irotate, rotate 

11 

12 

13def normalize(a): 

14 return np.array(a) / np.linalg.norm(a) 

15 

16 

17def complete_camera_vectors(look=None, up=None, right=None): 

18 """Creates the camera (or look) basis vectors from user input and 

19 will autocomplete missing vector or non-orthogonal vectors using dot 

20 products. The look direction will be maintained, up direction has higher 

21 priority than right direction""" 

22 

23 # ensure good input 

24 if look is not None: 

25 assert len(look) == 3 

26 l = np.array(look) 

27 

28 if up is not None: 

29 assert len(up) == 3 

30 u = np.array(up) 

31 

32 if right is not None: 

33 assert len(right) == 3 

34 r = np.array(right) 

35 

36 if look is not None and up is not None: 

37 r = normalize(np.cross(l, u)) 

38 u = normalize(np.cross(r, l)) # ensures complete perpendicularity 

39 l = normalize(np.cross(u, r)) 

40 elif look is not None and right is not None: 

41 u = normalize(np.cross(r, l)) 

42 r = normalize(np.cross(l, u)) # ensures complete perpendicularity 

43 l = normalize(np.cross(u, r)) 

44 elif up is not None and right is not None: 

45 l = normalize(np.cross(u, r)) 

46 r = normalize(np.cross(l, u)) # ensures complete perpendicularity 

47 u = normalize(np.cross(r, u)) 

48 else: 

49 raise ValueError('''At least two camera vectors of <look>, <up>, 

50 or <right> must be specified''') 

51 return l, u, r 

52 

53 

54def get_cell_vertex_points(cell, disp=(0.0, 0.0, 0.0)): 

55 """Returns 8x3 list of the cell vertex coordinates""" 

56 cell_vertices = np.empty((2, 2, 2, 3)) 

57 displacement = np.array(disp) 

58 for c1 in range(2): 

59 for c2 in range(2): 

60 for c3 in range(2): 

61 cell_vertices[c1, c2, c3] = [c1, c2, c3] @ cell + displacement 

62 cell_vertices.shape = (8, 3) 

63 return cell_vertices 

64 

65 

66def update_line_order_for_atoms(L, T, D, atoms, radii): 

67 # why/how does this happen before the camera rotation??? 

68 R = atoms.get_positions() 

69 r2 = radii**2 

70 for n in range(len(L)): 

71 d = D[T[n]] 

72 if ((((R - L[n] - d)**2).sum(1) < r2) & 

73 (((R - L[n] + d)**2).sum(1) < r2)).any(): 

74 T[n] = -1 

75 return T 

76 

77 

78def combine_bboxes(bbox_a, bbox_b): 

79 """Combines bboxes using their extrema""" 

80 bbox_low = np.minimum(bbox_a[0], bbox_b[0]) 

81 bbox_high = np.maximum(bbox_a[1], bbox_b[1]) 

82 return np.array([bbox_low, bbox_high]) 

83 

84 

85def has_cell(atoms): 

86 return atoms.cell.rank > 0 

87 

88 

89HIDE = 0 

90SHOW_CELL = 1 

91SHOW_CELL_AND_FIT_TO_ALL = 2 

92SHOW_CELL_AND_FIT_TO_CELL = 3 

93 

94 

95class PlottingVariables: 

96 # removed writer - self 

97 def __init__(self, atoms, rotation='', show_unit_cell=2, 

98 radii=None, bbox=None, colors=None, scale=20, 

99 maxwidth=500, extra_offset=(0., 0.), 

100 auto_bbox_size=1.05, 

101 auto_image_plane_z='front_all', 

102 ): 

103 

104 assert show_unit_cell in (0, 1, 2, 3) 

105 """Handles camera/paper space transformations used for rendering, 2D 

106 plots, ...and a few legacy features. after camera rotations, the image 

107 plane is set to the front of structure. 

108 

109 atoms: Atoms object 

110 The Atoms object to render/plot. 

111 

112 rotation: string or 3x3 matrix 

113 Controls camera rotation. Can be a string with euler angles in 

114 degrees like '45x, 90y, 0z' or a rotation matrix. 

115 (defaults to '0x, 0y, 0z') 

116 

117 show_unit_cell: int 0, 1, 2, or 3 

118 0 cell is not shown, 1 cell is shown, 2 cell is shown and bounding 

119 box is computed to fit atoms and cell, 3 bounding box is fixed to 

120 cell only. (default 2) 

121 

122 radii: list of floats 

123 a list of atomic radii for the atoms. (default None) 

124 

125 bbox: list of four floats 

126 Allows explicit control of the image plane bounding box in the form 

127 (xlo, ylo, xhi, yhi) where x and y are the horizontal and vertical 

128 axes of the image plane. The units are in atomic coordinates without 

129 the paperspace scale factor. (defaults to None the automatic 

130 bounding box is used) 

131 

132 colors : a list of RGB color triples 

133 a list of the RGB color triples for each atom. (default None, uses 

134 Jmol colors) 

135 

136 scale: float 

137 The ratio between the image plane units and atomic units, e.g. 

138 Angstroms per cm. (default 20.0) 

139 

140 maxwidth: float 

141 Limits the width of the image plane. (why?) Uses paperspace units. 

142 (default 500) 

143 

144 extra_offset: (float, float) 

145 Translates the image center in the image plane by (x,y) where x and 

146 y are the horizontal and vertical shift distances, respectively. 

147 (default (0.0, 0.0)) should only be used for small tweaks to the 

148 automatically fit image plane 

149 

150 auto_bbox_size: float 

151 Controls the padding given to the bounding box in the image plane. 

152 With auto_bbox_size=1.0 the structure touches the edges of the 

153 image. auto_bbox_size>1.0 gives whitespace padding. (default 1.05) 

154 

155 auto_image_plane_z: string ('front_all', 'front_auto', 'legacy') 

156 After a camera rotation, controls where to put camera image plane 

157 relative to the atoms and cell. 'front_all' puts everything in front 

158 of the camera. 'front_auto' sets the image plane location to 

159 respect the show_unit_cell option so that the atoms or cell can be 

160 ignored when setting the image plane. 'legacy' leaves the image 

161 plane passing through the origin for backwards compatibility. 

162 (default: 'front_all') 

163 """ 

164 

165 self.show_unit_cell = show_unit_cell 

166 self.numbers = atoms.get_atomic_numbers() 

167 self.maxwidth = maxwidth 

168 self.atoms = atoms 

169 # not used in PlottingVariables, keeping for legacy 

170 self.natoms = len(atoms) 

171 

172 self.auto_bbox_size = auto_bbox_size 

173 self.auto_image_plane_z = auto_image_plane_z 

174 self.offset = np.zeros(3) 

175 self.extra_offset = np.array(extra_offset) 

176 

177 self.constraints = atoms.constraints 

178 # extension for partial occupancies 

179 self.frac_occ = False 

180 self.tags = None 

181 self.occs = None 

182 

183 if 'occupancy' in atoms.info: 

184 self.occs = atoms.info['occupancy'] 

185 self.tags = atoms.get_tags() 

186 self.frac_occ = True 

187 

188 # colors 

189 self.colors = colors 

190 if colors is None: 

191 ncolors = len(default_colors) 

192 self.colors = default_colors[self.numbers.clip(max=ncolors - 1)] 

193 

194 # radius 

195 if radii is None: 

196 radii = covalent_radii[self.numbers] 

197 elif isinstance(radii, float): 

198 radii = covalent_radii[self.numbers] * radii 

199 else: 

200 radii = np.array(radii) 

201 

202 self.radii = radii # radius in Angstroms 

203 self.scale = scale # Angstroms per cm 

204 

205 self.set_rotation(rotation) 

206 self.update_image_plane_offset_and_size_from_structure(bbox=bbox) 

207 

208 def to_dict(self): 

209 out = { 

210 'bbox': self.get_bbox(), 

211 'rotation': self.rotation, 

212 'scale': self.scale, 

213 'colors': self.colors} 

214 return out 

215 

216 @property 

217 def d(self): 

218 # XXX hopefully this can be deprecated someday. 

219 """Returns paperspace diameters for scale and radii lists""" 

220 return 2 * self.scale * self.radii 

221 

222 def set_rotation(self, rotation): 

223 if rotation is not None: 

224 if isinstance(rotation, str): 

225 rotation = rotate(rotation) 

226 self.rotation = rotation 

227 self.update_patch_and_line_vars() 

228 

229 def update_image_plane_offset_and_size_from_structure(self, bbox=None): 

230 """Updates image size to fit structure according to show_unit_cell 

231 if bbox=None. Otherwise, sets the image size from bbox. bbox is in the 

232 image plane. Note that bbox format is (xlo, ylo, xhi, yhi) for 

233 compatibility reasons the internal functions use (2,3)""" 

234 

235 # zero out the offset so it's not involved in the 

236 # to_image_plane_positions() calculations which are used to calcucate 

237 # the offset 

238 self.offset = np.zeros(3) 

239 

240 # computing the bboxes in self.atoms here makes it easier to follow the 

241 # various options selection/choices later 

242 bbox_atoms = self.get_bbox_from_atoms(self.atoms, self.d / 2) 

243 if has_cell(self.atoms): 

244 cell = self.atoms.get_cell() 

245 disp = self.atoms.get_celldisp().flatten() 

246 bbox_cell = self.get_bbox_from_cell(cell, disp) 

247 bbox_combined = combine_bboxes(bbox_atoms, bbox_cell) 

248 else: 

249 bbox_combined = bbox_atoms 

250 

251 # bbox_auto is the bbox that matches the show_unit_cell option 

252 if has_cell(self.atoms) and self.show_unit_cell in ( 

253 SHOW_CELL_AND_FIT_TO_ALL, SHOW_CELL_AND_FIT_TO_CELL): 

254 

255 if self.show_unit_cell == SHOW_CELL_AND_FIT_TO_ALL: 

256 bbox_auto = bbox_combined 

257 else: 

258 bbox_auto = bbox_cell 

259 else: 

260 bbox_auto = bbox_atoms 

261 

262 # 

263 if bbox is None: 

264 middle = (bbox_auto[0] + bbox_auto[1]) / 2 

265 im_size = self.auto_bbox_size * (bbox_auto[1] - bbox_auto[0]) 

266 # should auto_bbox_size pad the z_heght via offset? 

267 

268 if im_size[0] > self.maxwidth: 

269 rescale_factor = self.maxwidth / im_size[0] 

270 im_size *= rescale_factor 

271 self.scale *= rescale_factor 

272 offset = middle - im_size / 2 

273 else: 

274 width = (bbox[2] - bbox[0]) * self.scale 

275 height = (bbox[3] - bbox[1]) * self.scale 

276 

277 im_size = np.array([width, height, 0]) 

278 offset = np.array([bbox[0], bbox[1], 0]) * self.scale 

279 

280 # this section shifts the image plane up and down parallel to the look 

281 # direction to match the legacy option, or to force it allways touch the 

282 # front most objects regardless of the show_unit_cell setting 

283 if self.auto_image_plane_z == 'front_all': 

284 offset[2] = bbox_combined[1, 2] # highest z in image orientation 

285 elif self.auto_image_plane_z == 'legacy': 

286 offset[2] = 0 

287 elif self.auto_image_plane_z == 'front_auto': 

288 offset[2] = bbox_auto[1, 2] 

289 else: 

290 raise ValueError( 

291 f'bad image plane setting {self.auto_image_plane_z!r}') 

292 

293 # since we are moving the origin in the image plane (camera coordinates) 

294 self.offset += offset 

295 

296 # Previously, the picture size changed with extra_offset, This is very 

297 # counter intuitive and seems like a bug. Leaving it commented out in 

298 # case someone relying on this likely bug needs to revert it. 

299 self.w = im_size[0] # + self.extra_offset[0] 

300 self.h = im_size[1] # + self.extra_offset[1] 

301 

302 # allows extra_offset to be 2D or 3D 

303 for i in range(len(self.extra_offset)): 

304 self.offset[i] -= self.extra_offset[i] 

305 

306 # we have to update the arcane stuff after every camera update. 

307 self.update_patch_and_line_vars() 

308 

309 def center_camera_on_position(self, pos, scaled_position=False): 

310 if scaled_position: 

311 pos = pos @ self.atoms.cell 

312 im_pos = self.to_image_plane_positions(pos) 

313 cam_pos = self.to_image_plane_positions(self.get_image_plane_center()) 

314 in_plane_shift = im_pos - cam_pos 

315 self.offset[0:2] += in_plane_shift[0:2] 

316 self.update_patch_and_line_vars() 

317 

318 def get_bbox(self): 

319 xlo = self.offset[0] 

320 ylo = self.offset[1] 

321 xhi = xlo + self.w 

322 yhi = ylo + self.h 

323 return np.array([xlo, ylo, xhi, yhi]) / self.scale 

324 

325 def set_rotation_from_camera_directions(self, 

326 look=None, up=None, right=None, 

327 scaled_position=False): 

328 

329 if scaled_position: 

330 if look is not None: 

331 look = look @ self.atoms.cell 

332 if right is not None: 

333 right = right @ self.atoms.cell 

334 if up is not None: 

335 up = up @ self.atoms.cell 

336 

337 look, up, right = complete_camera_vectors(look, up, right) 

338 

339 rotation = np.zeros((3, 3)) 

340 rotation[:, 0] = right 

341 rotation[:, 1] = up 

342 rotation[:, 2] = -look 

343 self.rotation = rotation 

344 self.update_patch_and_line_vars() 

345 

346 def get_rotation_angles(self): 

347 """Gets the rotation angles from the rotation matrix in the current 

348 PlottingVariables object""" 

349 return irotate(self.rotation) 

350 

351 def get_rotation_angles_string(self, digits=5): 

352 fmt = '%.{:d}f'.format(digits) 

353 angles = self.get_rotation_angles() 

354 outstring = (fmt + 'x, ' + fmt + 'y, ' + fmt + 'z') % (angles) 

355 return outstring 

356 

357 def update_patch_and_line_vars(self): 

358 """Updates all the line and path stuff that is still inobvious, this 

359 function should be deprecated if nobody can understand why it's features 

360 exist.""" 

361 cell = self.atoms.get_cell() 

362 disp = self.atoms.get_celldisp().flatten() 

363 positions = self.atoms.get_positions() 

364 

365 if self.show_unit_cell in ( 

366 SHOW_CELL, SHOW_CELL_AND_FIT_TO_ALL, SHOW_CELL_AND_FIT_TO_CELL): 

367 

368 L, T, D = cell_to_lines(self, cell) 

369 cell_verts_in_atom_coords = get_cell_vertex_points(cell, disp) 

370 cell_vertices = self.to_image_plane_positions( 

371 cell_verts_in_atom_coords) 

372 T = update_line_order_for_atoms(L, T, D, self.atoms, self.radii) 

373 # D are a positions in the image plane, 

374 # not sure why it's setup like this 

375 D = (self.to_image_plane_positions(D) + self.offset)[:, :2] 

376 positions = np.concatenate((positions, L), axis=0) 

377 else: 

378 L = np.empty((0, 3)) 

379 T = None 

380 D = None 

381 cell_vertices = None 

382 # just a rotations and scaling since offset is currently [0,0,0] 

383 positions = self.to_image_plane_positions(positions) 

384 self.positions = positions 

385 # list of 2D cell points in the imageplane without the offset 

386 self.D = D 

387 # integers, probably z-order for lines? 

388 self.T = T 

389 self.cell_vertices = cell_vertices 

390 

391 # no displacement since it's a vector 

392 cell_vec_im = self.scale * self.atoms.get_cell() @ self.rotation 

393 self.cell = cell_vec_im 

394 

395 def to_image_plane_positions(self, positions): 

396 """Converts atomic coordinates to image plane positions. The third 

397 coordinate is distance above/below the image plane""" 

398 im_positions = (positions @ self.rotation) * self.scale - self.offset 

399 return im_positions 

400 

401 def to_atom_positions(self, im_positions): 

402 """Converts image plane positions to atomic coordinates.""" 

403 positions = ((im_positions + self.offset) / 

404 self.scale) @ self.rotation.T 

405 return positions 

406 

407 def get_bbox_from_atoms(self, atoms, im_radii): 

408 """Uses supplied atoms and radii to compute the bounding box of the 

409 atoms in the image plane""" 

410 im_positions = self.to_image_plane_positions(atoms.get_positions()) 

411 im_low = (im_positions - im_radii[:, None]).min(0) 

412 im_high = (im_positions + im_radii[:, None]).max(0) 

413 return np.array([im_low, im_high]) 

414 

415 def get_bbox_from_cell(self, cell, disp=(0.0, 0.0, 0.0)): 

416 """Uses supplied cell to compute the bounding box of the cell in the 

417 image plane""" 

418 displacement = np.array(disp) 

419 cell_verts_in_atom_coords = get_cell_vertex_points(cell, displacement) 

420 cell_vertices = self.to_image_plane_positions(cell_verts_in_atom_coords) 

421 im_low = cell_vertices.min(0) 

422 im_high = cell_vertices.max(0) 

423 return np.array([im_low, im_high]) 

424 

425 def get_image_plane_center(self): 

426 return self.to_atom_positions(np.array([self.w / 2, self.h / 2, 0])) 

427 

428 def get_atom_direction(self, direction): 

429 c0 = self.to_atom_positions([0, 0, 0]) # self.get_image_plane_center() 

430 c1 = self.to_atom_positions(direction) 

431 atom_direction = c1 - c0 

432 return atom_direction / np.linalg.norm(atom_direction) 

433 

434 def get_camera_direction(self): 

435 """Returns vector pointing away from camera toward atoms/cell in atomic 

436 coordinates""" 

437 return self.get_atom_direction([0, 0, -1]) 

438 

439 def get_camera_up(self): 

440 """Returns the image plane up direction in atomic coordinates""" 

441 return self.get_atom_direction([0, 1, 0]) 

442 

443 def get_camera_right(self): 

444 """Returns the image plane right direction in atomic coordinates""" 

445 return self.get_atom_direction([1, 0, 0]) 

446 

447 

448def cell_to_lines(writer, cell): 

449 # XXX this needs to be updated for cell vectors that are zero. 

450 # Cannot read the code though! (What are T and D? nn?) 

451 nlines = 0 

452 nsegments = [] 

453 for c in range(3): 

454 d = np.sqrt((cell[c]**2).sum()) 

455 n = max(2, int(d / 0.3)) 

456 nsegments.append(n) 

457 nlines += 4 * n 

458 

459 positions = np.empty((nlines, 3)) 

460 T = np.empty(nlines, int) 

461 D = np.zeros((3, 3)) 

462 

463 n1 = 0 

464 for c in range(3): 

465 n = nsegments[c] 

466 dd = cell[c] / (4 * n - 2) 

467 D[c] = dd 

468 P = np.arange(1, 4 * n + 1, 4)[:, None] * dd 

469 T[n1:] = c 

470 for i, j in [(0, 0), (0, 1), (1, 0), (1, 1)]: 

471 n2 = n1 + n 

472 positions[n1:n2] = P + i * cell[c - 2] + j * cell[c - 1] 

473 n1 = n2 

474 

475 return positions, T, D 

476 

477 

478def make_patch_list(writer): 

479 from matplotlib.patches import Circle, PathPatch, Wedge 

480 from matplotlib.path import Path 

481 

482 indices = writer.positions[:, 2].argsort() 

483 patch_list = [] 

484 for a in indices: 

485 xy = writer.positions[a, :2] 

486 if a < writer.natoms: 

487 r = writer.d[a] / 2 

488 if writer.frac_occ: 

489 site_occ = writer.occs[str(writer.tags[a])] 

490 # first an empty circle if a site is not fully occupied 

491 if (np.sum([v for v in site_occ.values()])) < 1.0: 

492 # fill with white 

493 fill = '#ffffff' 

494 patch = Circle(xy, r, facecolor=fill, 

495 edgecolor='black') 

496 patch_list.append(patch) 

497 

498 start = 0 

499 # start with the dominant species 

500 for sym, occ in sorted(site_occ.items(), 

501 key=lambda x: x[1], 

502 reverse=True): 

503 if np.round(occ, decimals=4) == 1.0: 

504 patch = Circle(xy, r, facecolor=writer.colors[a], 

505 edgecolor='black') 

506 patch_list.append(patch) 

507 else: 

508 # jmol colors for the moment 

509 extent = 360. * occ 

510 patch = Wedge( 

511 xy, r, start, start + extent, 

512 facecolor=default_colors[atomic_numbers[sym]], 

513 edgecolor='black') 

514 patch_list.append(patch) 

515 start += extent 

516 

517 else: 

518 if ((xy[1] + r > 0) and (xy[1] - r < writer.h) and 

519 (xy[0] + r > 0) and (xy[0] - r < writer.w)): 

520 patch = Circle(xy, r, facecolor=writer.colors[a], 

521 edgecolor='black') 

522 patch_list.append(patch) 

523 else: 

524 a -= writer.natoms 

525 c = writer.T[a] 

526 if c != -1: 

527 hxy = writer.D[c] 

528 patch = PathPatch(Path((xy + hxy, xy - hxy))) 

529 patch_list.append(patch) 

530 return patch_list 

531 

532 

533class ImageChunk: 

534 """Base Class for a file chunk which contains enough information to 

535 reconstruct an atoms object.""" 

536 

537 def build(self, **kwargs): 

538 """Construct the atoms object from the stored information, 

539 and return it""" 

540 

541 

542class ImageIterator: 

543 """Iterate over chunks, to return the corresponding Atoms objects. 

544 Will only build the atoms objects which corresponds to the requested 

545 indices when called. 

546 Assumes ``ichunks`` is in iterator, which returns ``ImageChunk`` 

547 type objects. See extxyz.py:iread_xyz as an example. 

548 """ 

549 

550 def __init__(self, ichunks): 

551 self.ichunks = ichunks 

552 

553 def __call__(self, fd: IO, index=None, **kwargs): 

554 if isinstance(index, str): 

555 index = string2index(index) 

556 

557 if index is None or index == ':': 

558 index = slice(None, None, None) 

559 

560 if not isinstance(index, (slice, str)): 

561 index = slice(index, (index + 1) or None) 

562 

563 for chunk in self._getslice(fd, index): 

564 yield chunk.build(**kwargs) 

565 

566 def _getslice(self, fd: IO, indices: slice): 

567 try: 

568 iterator = islice(self.ichunks(fd), 

569 indices.start, indices.stop, 

570 indices.step) 

571 except ValueError: 

572 # Negative indices. Go through the whole thing to get the length, 

573 # which allows us to evaluate the slice, and then read it again 

574 if not hasattr(fd, 'seekable') or not fd.seekable(): 

575 raise ValueError('Negative indices only supported for ' 

576 'seekable streams') 

577 

578 startpos = fd.tell() 

579 nchunks = 0 

580 for _ in self.ichunks(fd): 

581 nchunks += 1 

582 fd.seek(startpos) 

583 indices_tuple = indices.indices(nchunks) 

584 iterator = islice(self.ichunks(fd), *indices_tuple) 

585 return iterator 

586 

587 

588def verify_cell_for_export(cell, check_orthorhombric=True): 

589 """Function to verify if the cell size is defined and if the cell is 

590 

591 Parameters: 

592 

593 cell: cell object 

594 cell to be checked. 

595 

596 check_orthorhombric: bool 

597 If True, check if the cell is orthorhombric, raise an ``ValueError`` if 

598 the cell is orthorhombric. If False, doesn't check if the cell is 

599 orthorhombric. 

600 

601 Raise a ``ValueError`` if the cell if not suitable for export to mustem xtl 

602 file or prismatic/computem xyz format: 

603 - if cell is not orthorhombic (only when check_orthorhombric=True) 

604 - if cell size is not defined 

605 """ 

606 

607 if check_orthorhombric and not cell.orthorhombic: 

608 raise ValueError('To export to this format, the cell needs to be ' 

609 'orthorhombic.') 

610 if cell.rank < 3: 

611 raise ValueError('To export to this format, the cell size needs ' 

612 'to be set: current cell is {}.'.format(cell)) 

613 

614 

615def verify_dictionary(atoms, dictionary, dictionary_name): 

616 """ 

617 Verify a dictionary have a key for each symbol present in the atoms object. 

618 

619 Parameters: 

620 

621 dictionary: dict 

622 Dictionary to be checked. 

623 

624 

625 dictionary_name: dict 

626 Name of the dictionary to be displayed in the error message. 

627 

628 cell: cell object 

629 cell to be checked. 

630 

631 

632 Raise a ``ValueError`` if the key doesn't match the atoms present in the 

633 cell. 

634 """ 

635 # Check if we have enough key 

636 for key in set(atoms.symbols): 

637 if key not in dictionary: 

638 raise ValueError('Missing the {} key in the `{}` dictionary.' 

639 ''.format(key, dictionary_name)) 

640 

641 

642def segment_list(data, segment_size): 

643 """Segments a list into sublists of a specified size.""" 

644 return [data[i:i + segment_size] for i in range(0, len(data), segment_size)]