Coverage for tbkit/lattice.py: 100%

227 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 11:04 +0000

1from __future__ import annotations 

2 

3from typing import Sequence 

4 

5import numpy as np 

6from numpy.typing import NDArray 

7import matplotlib.pyplot as plt 

8from matplotlib.figure import Figure 

9from scipy.spatial import cKDTree 

10import tbkit.error_handling as error_handling 

11 

12 

13PI = np.pi 

14COOR_DTYPE = [('x', 'f8'), ('y', 'f8'), ('tag', 'U1')] 

15#: Site dtype of a lattice in 3D space (*unit_cell* and *prim_vec* given as 3-tuples). 

16COOR_DTYPE_3D = [('x', 'f8'), ('y', 'f8'), ('z', 'f8'), ('tag', 'U1')] 

17 

18 

19################################# 

20# CLASS LATTICE 

21################################# 

22 

23 

24class Lattice(): 

25 r''' 

26 Build up 1D, 2D or 3D lattice. 

27 Lattice is defined by the discrete operation: 

28 

29 .. math:: 

30 

31 \mathbf{R} = n_1\mathbf{a}_1 + n_2\mathbf{a}_2 + n_3\mathbf{a}_3 

32 

33 where :math:`\mathbf{a}_1`, :math:`\mathbf{a}_2`, :math:`\mathbf{a}_3` are the 

34 primitive vectors and :math:`n_1`, :math:`n_2`, :math:`n_3` are the number of 

35 unit cells along them. 

36 

37 Parameters 

38 ---------- 

39 unit_cell : List of dictionaries 

40 One dictionary per site within the unit cell. Each dictionary has two keys: 

41 

42 * 'tag', one-character string. Label of the associated sublattice. 

43 * 'r0', Tuple. Position: :math:`(x, y)`, or :math:`(x, y, z)` for a 

44 lattice in 3D space. 

45 prim_vec : List of tuples 

46 Define the primitive vectors. List of one/two/three tuples for 1D/2D/3D 

47 lattices, each the cartesian coordinates of a primitive vector: 2-tuples 

48 in the plane, or 3-tuples in 3D space (then *r0* must be 3-tuples too, 

49 and *coor* gains a 'z' field, see :data:`COOR_DTYPE_3D`). 

50 

51 Notes 

52 ----- 

53 In 3D space, the geometry methods acting in the plane (*rotation*, 

54 *ellipse_in*, *boundary_line*, ...) act on :math:`(x, y)` only (rotation 

55 about the z axis, cylinders, vertical planes), and *plot* draws the 

56 projection on the :math:`(x, y)` plane. 

57 

58 Examples 

59 -------- 

60 Example usage:: 

61 

62 # Line-Centered Square lattice 

63 unit_cell = [{'tag': 'a', 'r0': (0., 0.)}, {'tag': 'a', 'r0': (0., 1.)}] 

64 prim_vec = [(0, 2), (2, 0)] 

65 lat = lattice(unit_cell=unit_cell, prim_vec=prim_vec) 

66 ''' 

67 

68 def __init__(self, unit_cell: list[dict], prim_vec: Sequence[tuple[float, ...]]) -> None: 

69 error_handling.unit_cell(unit_cell) 

70 error_handling.prim_vec(prim_vec) 

71 error_handling.space_dim(unit_cell, prim_vec) 

72 self.unit_cell = unit_cell 

73 self.prim_vec = prim_vec 

74 self.tags = np.unique(np.array([dic['tag'] for dic in self.unit_cell])) 

75 self.space_dim = len(prim_vec[0]) # 2, or 3 for a lattice in 3D space 

76 self.dtype = COOR_DTYPE if self.space_dim == 2 else COOR_DTYPE_3D 

77 self.n1, self.n2, self.n3 = 0, 0, 0 

78 self.coor = np.array([], dtype=self.dtype) 

79 self.sites = 0 

80 

81 def get_lattice(self, n1: int, n2: int = 1, n3: int = 1) -> None: 

82 r''' 

83 Get the lattice positions, sorted by (y, x) -- by (z, y, x) in 3D space. 

84 

85 Parameters 

86 ---------- 

87 n1 : Positive Integer 

88 Number of unit cells along :math:`\mathbf{a}_1`. 

89 n2 : Positive Integer, default 1 

90 Number of unit cells along :math:`\mathbf{a}_2`. 

91 n3 : Positive Integer, default 1 

92 Number of unit cells along :math:`\mathbf{a}_3` (3D lattices only). 

93 

94 Examples 

95 -------- 

96 Example usage:: 

97 

98 # Line-Centered Square lattice 

99 unit_cell = [{'tag': 'a', 'r0': (0., 0.)}, {'tag': 'a', 'r0': (0., 1.)}] 

100 prim_vec = [(0, 2), (2, 0)] 

101 lat = lattice(unit_cell=unit_cell, prim_vec=prim_vec) 

102 lat.get_lattice(n1=4, n2=5) 

103 ''' 

104 error_handling.get_lattice(self.prim_vec, n1, n2, n3) 

105 sites_uc = len(self.unit_cell) 

106 sites_tag = n1*n2*n3 

107 self.sites = sites_uc * sites_tag 

108 self.coor = np.empty(self.sites, dtype=self.dtype) 

109 self.n1, self.n2, self.n3 = n1, n2, n3 

110 # translations R = i1 a1 + i2 a2 (+ i3 a3), i1 running fastest 

111 axes = [f for f in ('x', 'y', 'z')[:self.space_dim]] 

112 trans = np.zeros((sites_tag, self.space_dim)) 

113 counts = (n1, n2, n3) 

114 index = np.indices(counts[:max(len(self.prim_vec), 1)][::-1]).reshape( 

115 max(len(self.prim_vec), 1), -1)[::-1] 

116 for n, a in zip(index, self.prim_vec): 

117 trans += n[:, None] * np.array(a, dtype='f8')[None, :] 

118 for i, dic in enumerate(self.unit_cell): 

119 for c, f in enumerate(axes): 

120 self.coor[f][i*sites_tag: (i+1)*sites_tag] = trans[:, c] + dic['r0'][c] 

121 self.coor['tag'][i*sites_tag: (i+1)*sites_tag] = dic['tag'] 

122 self.coor = np.sort(self.coor, order=self.sort_order()) 

123 

124 def sort_order(self) -> tuple[str, ...]: 

125 ''' 

126 Private method. Fields the sites are sorted by: ('y', 'x'), or 

127 ('z', 'y', 'x') in 3D space. 

128 ''' 

129 return ('y', 'x') if self.space_dim == 2 else ('z', 'y', 'x') 

130 

131 def distances(self) -> tuple[NDArray, ...]: 

132 ''' 

133 Private method. Pairwise coordinate differences, *d[i, j]* from site 

134 *i* to site *j*: (dx, dy), or (dx, dy, dz) in 3D space. 

135 ''' 

136 return tuple(self.coor[f] - self.coor[f].reshape(self.sites, 1) 

137 for f in ('x', 'y', 'z')[:self.space_dim]) 

138 

139 def add_sites(self, coor: NDArray) -> None: 

140 ''' 

141 Add sites. 

142 

143 Parameters 

144 ---------- 

145 coor 

146 Structured array with keys: {'x', 'y', 'tag'} 

147 ({'x', 'y', 'z', 'tag'} for a lattice in 3D space). 

148 

149 Examples 

150 -------- 

151 Example usage:: 

152 

153 # Square lattice 

154 unit_cell = [{'tag': 'a', 'r0': (0., 0.)}] 

155 prim_vec = [(0, 1), (1, 0)] 

156 lat = lattice(unit_cell=unit_cell, prim_vec=prim_vec) 

157 lat.get_lattice(n1=2, n2=2) 

158 coor = np.array([(-1., -1, 'b'), (-2., -2, 'c')], 

159 dtype=[('x', 'f8'), ('y', 'f8'), ('tag', 'U1')]) 

160 lat.add_sites(coor) 

161 ''' 

162 error_handling.coor(coor, self.dtype) 

163 self.coor = np.concatenate([self.coor, coor]) 

164 self.sites += len(coor) 

165 self.tags = np.unique(np.concatenate([self.tags, coor['tag']])) 

166 self.coor = np.sort(self.coor, order=self.sort_order()) 

167 

168 def remove_sites(self, index: list[int]) -> None: 

169 ''' 

170 Remove sites defined by their indices 

171 (use method lattice.plot(plt_index=True) 

172 to get access to the site indices). 

173 

174 Parameters 

175 ---------- 

176 index : List 

177 Site indices to be removed. 

178 

179 Examples 

180 -------- 

181 Example usage:: 

182 

183 # Square lattice 

184 unit_cell = [{'tag': 'a', 'r0': (0., 0.)}] 

185 prim_vec = [(0, 1), (1, 0)] 

186 lat = lattice(unit_cell=unit_cell, prim_vec=prim_vec) 

187 lat.get_lattice(n1=2, n2=2) 

188 lat.remove_sites([0, 2]) 

189 ''' 

190 error_handling.empty_coor(self.coor) 

191 error_handling.remove_sites(index, self.sites) 

192 mask = np.ones(self.sites, bool) 

193 mask[index] = False 

194 self.coor = self.coor[mask] 

195 self.sites = self.coor.size 

196 

197 def remove_dangling(self) -> None: 

198 ''' 

199 Remove dangling sites 

200 (sites connected with just another site). 

201 ''' 

202 error_handling.empty_coor(self.coor) 

203 while True: 

204 coords = np.stack([self.coor[f] for f in ('x', 'y', 'z')[:self.space_dim]], axis=1) 

205 tree = cKDTree(coords) 

206 near = tree.query(coords, k=min(self.sites, 8))[0][:, 1:] 

207 len_hop = np.min(near[near > 0]) # the shortest distance between sites 

208 pairs = tree.query_pairs(len_hop * (1. + 1e-5) + 1e-8, output_type='ndarray') 

209 dis = np.linalg.norm(coords[pairs[:, 1]] - coords[pairs[:, 0]], axis=1) 

210 pairs = pairs[np.isclose(dis, len_hop)] 

211 degree = np.bincount(pairs.ravel(), minlength=self.sites) 

212 dang = list(np.flatnonzero(degree == 1)) 

213 self.coor = np.delete(self.coor, dang, axis=0) 

214 self.sites -= len(dang) 

215 if dang == []: 

216 break 

217 

218 def shift_x(self, shift: float) -> None: 

219 ''' 

220 Shift the x coordinates. 

221 

222 Parameters 

223 ---------- 

224 shift : Real number 

225 Shift value. 

226 ''' 

227 error_handling.empty_coor(self.coor) 

228 error_handling.real_number(shift, 'shift') 

229 self.coor['x'] += shift 

230 

231 def shift_y(self, shift: float) -> None: 

232 ''' 

233 Shift the y coordinates. 

234 

235 Parameters 

236 ---------- 

237 shift : Real number 

238 Shift value. 

239 ''' 

240 error_handling.empty_coor(self.coor) 

241 error_handling.real_number(shift, 'shift') 

242 self.coor['y'] += shift 

243 

244 def change_sign_x(self) -> None: 

245 ''' 

246 Change x coordinates sign. 

247 ''' 

248 error_handling.empty_coor(self.coor) 

249 self.coor['x'] *= -1 

250 

251 def change_sign_y(self) -> None: 

252 ''' 

253 Change y coordinates sign. 

254 ''' 

255 error_handling.empty_coor(self.coor) 

256 self.coor['y'] *= -1 

257 

258 def shift_z(self, shift: float) -> None: 

259 ''' 

260 Shift the z coordinates (3D lattices only). 

261 

262 Parameters 

263 ---------- 

264 shift : Real number 

265 Shift value. 

266 ''' 

267 error_handling.empty_coor(self.coor) 

268 error_handling.space_3d(self.space_dim) 

269 error_handling.real_number(shift, 'shift') 

270 self.coor['z'] += shift 

271 

272 def change_sign_z(self) -> None: 

273 ''' 

274 Change z coordinates sign (3D lattices only). 

275 ''' 

276 error_handling.empty_coor(self.coor) 

277 error_handling.space_3d(self.space_dim) 

278 self.coor['z'] *= -1 

279 

280 def slab(self, z_min: float, z_max: float) -> None: 

281 r''' 

282 Keep only the sites with :math:`z_{min} < z < z_{max}` (3D lattices only). 

283 

284 Parameters 

285 ---------- 

286 z_min : Real number 

287 z_max : Real number, larger than *z_min* 

288 ''' 

289 error_handling.empty_coor(self.coor) 

290 error_handling.space_3d(self.space_dim) 

291 error_handling.real_number(z_min, 'z_min') 

292 error_handling.real_number(z_max, 'z_max') 

293 error_handling.smaller(z_min, 'z_min', z_max, 'z_max') 

294 self.coor = self.coor[(self.coor['z'] > z_min) & (self.coor['z'] < z_max)] 

295 self.sites = len(self.coor) 

296 

297 def boundary_line(self, cx: float, cy: float, co: float) -> None: 

298 r''' 

299 Keep only the sites with :math:`c_yy+c_xx > c_0`. 

300 

301 Parameters 

302 ---------- 

303 cx : Real number 

304 :math:`c_x` value. 

305 cy : Real number 

306 :math:`c_y` value. 

307 co : Real number 

308 :math:`c_0` value. 

309 ''' 

310 error_handling.empty_coor(self.coor) 

311 error_handling.real_number(cx, 'cx') 

312 error_handling.real_number(cy, 'cy') 

313 error_handling.real_number(co, 'co') 

314 self.coor = self.coor[cy * self.coor['y'] + cx * self.coor['x'] > co] 

315 self.sites = len(self.coor) 

316 

317 def ellipse_in(self, rx: float, ry: float, x0: float, y0: float) -> None: 

318 r''' 

319 Select sites according to 

320 

321 .. math:: 

322 

323 (x-x_0)^2/r_x^2+(y-y_0)^2/r_y^2 < 1\, . 

324 

325 Parameters 

326 ---------- 

327 rx : Positive Real number 

328 Radius along :math:`x`. 

329 ry : Positive Real number 

330 Radius along :math:`y`. 

331 x0 : Real number 

332 :math:`x` center. 

333 y0 : Real number 

334 :math:`y` center. 

335 ''' 

336 error_handling.empty_coor(self.coor) 

337 error_handling.positive_real(rx, 'rx') 

338 error_handling.positive_real(ry, 'ry') 

339 error_handling.real_number(x0, 'x0') 

340 error_handling.real_number(y0, 'y0') 

341 self.coor = self.coor[(self.coor['x'] -x0) ** 2 / rx ** 2 + \ 

342 (self.coor['y'] -y0) ** 2 / ry ** 2 < 1.] 

343 self.sites = len(self.coor) 

344 

345 def ellipse_out(self, rx: float, ry: float, x0: float, y0: float) -> None: 

346 r''' 

347 Select sites according to 

348 

349 .. math:: 

350 

351 (x-x_0)^2/r_x^2+(y-y_0)^2/r_y^2 > 1\, . 

352 

353 Parameters 

354 ---------- 

355 rx : Positive Real number 

356 Radius along :math:`x`. 

357 ry : Positive Real number 

358 Radius along :math:`y`. 

359 x0 : Real number 

360 :math:`x` center. 

361 y0 : Real number 

362 :math:`y` center. 

363 ''' 

364 error_handling.empty_coor(self.coor) 

365 error_handling.positive_real(rx, 'rx') 

366 error_handling.positive_real(ry, 'ry') 

367 error_handling.real_number(x0, 'x0') 

368 error_handling.real_number(y0, 'y0') 

369 self.coor = self.coor[(self.coor['x'] -x0) ** 2 / rx ** 2 + \ 

370 (self.coor['y'] -y0) ** 2 / ry ** 2 > 1.] 

371 self.sites = len(self.coor) 

372 

373 def center(self) -> None: 

374 ''' 

375 Fix the center of mass of the lattice at (0, 0) -- (0, 0, 0) in 3D space. 

376 ''' 

377 error_handling.empty_coor(self.coor) 

378 for f in ('x', 'y', 'z')[:self.space_dim]: 

379 self.coor[f] -= np.mean(self.coor[f]) 

380 

381 def rotation(self, theta: float) -> None: 

382 r''' 

383 Rotate the lattice structure about the origin (about the z axis, in 

384 3D space) by the angle :math:`\theta`. 

385 

386 Parameters 

387 ---------- 

388 theta 

389 Rotation angle in degrees. 

390 ''' 

391 error_handling.empty_coor(self.coor) 

392 error_handling.real_number(theta, 'theta') 

393 theta *= PI / 180 

394 x = self.coor['x'].copy() 

395 y = self.coor['y'].copy() 

396 self.coor['x'] = x * np.cos(theta) - y * np.sin(theta) 

397 self.coor['y'] = y * np.cos(theta) + x * np.sin(theta) 

398 

399 def clean_coor(self) -> None: 

400 ''' 

401 Keep only the sites with different coordinates. 

402 ''' 

403 error_handling.empty_coor(self.coor) 

404 fields = list(('x', 'y', 'z')[:self.space_dim]) 

405 coor = self.coor[fields].copy() 

406 for f in fields: 

407 coor[f] = self.coor[f].round(4) 

408 _, idx = np.unique(coor, return_index=True) 

409 self.coor = self.coor[idx] 

410 self.sites = len(self.coor) 

411 

412 def __add__(self, other: 'Lattice') -> 'Lattice': 

413 ''' 

414 Overloading operator +. 

415 ''' 

416 error_handling.lat(other) 

417 error_handling.empty_coor(self.coor) 

418 error_handling.empty_coor(other.coor) 

419 coor = np.concatenate([self.coor, other.coor]) 

420 tags = np.concatenate([self.tags, other.tags]) 

421 lat = lattice(unit_cell=self.unit_cell, prim_vec=self.prim_vec) 

422 lat.add_sites(coor) 

423 lat.sites = self.sites + other.sites 

424 lat.tags = np.unique(tags) 

425 return lat 

426 

427 def __iadd__(self, other: 'Lattice') -> 'Lattice': 

428 ''' 

429 Overloading operator +=. 

430 ''' 

431 error_handling.lat(other) 

432 error_handling.empty_coor(self.coor) 

433 error_handling.empty_coor(other.coor) 

434 self.coor = np.concatenate([self.coor, other.coor]) 

435 self.sites += other.sites 

436 self.tags = np.unique(np.concatenate([self.tags, other.tags])) 

437 return self 

438 

439 def __sub__(self, other: 'Lattice') -> 'Lattice': 

440 ''' 

441 Overloading operator -. 

442 

443 .. note:: 

444 

445 The tags are not considered in the lattice subtraction. 

446 ''' 

447 error_handling.lat(other) 

448 error_handling.empty_coor(self.coor) 

449 error_handling.empty_coor(other.coor) 

450 boo = self.overlaps(other) 

451 coor = self.coor[np.logical_not(boo)] 

452 lat = lattice(unit_cell=self.unit_cell, prim_vec=self.prim_vec) 

453 lat.add_sites(coor) 

454 lat.sites = len(lat.coor) 

455 lat.tags = self.tags 

456 return lat 

457 

458 def __isub__(self, other: 'Lattice') -> 'Lattice': 

459 ''' 

460 Overloading operator -=. 

461 

462 .. note:: 

463 

464 The tags are not considered in the lattice subtraction. 

465 ''' 

466 error_handling.lat(other) 

467 error_handling.empty_coor(self.coor) 

468 error_handling.empty_coor(other.coor) 

469 boo = self.overlaps(other) 

470 self.coor = self.coor[np.logical_not(boo)] 

471 self.sites = int(np.sum(np.logical_not(boo))) 

472 return self 

473 

474 def overlaps(self, other: 'Lattice') -> NDArray[np.bool_]: 

475 ''' 

476 Private method. Mask of the sites of *self* that coincide with a site 

477 of *other*. 

478 ''' 

479 names = self.coor.dtype.names or () 

480 fields = [f for f in ('x', 'y', 'z') if f in names] 

481 boo = np.zeros(self.sites, bool) 

482 for c in other.coor: 

483 match = np.ones(self.sites, bool) 

484 for f in fields: 

485 match &= np.isclose(c[f] if f in (other.coor.dtype.names or ()) else 0., self.coor[f]) 

486 boo += match 

487 return boo 

488 

489 def plot( 

490 self, 

491 ms: float = 20, 

492 fs: float = 20, 

493 plt_index: bool = False, 

494 axis: bool = False, 

495 figsize: tuple[float, float] | None = None, 

496 ) -> Figure: 

497 ''' 

498 Plot the lattice in real space (its projection on the (x, y) plane, 

499 in 3D space). 

500 

501 Parameters 

502 ---------- 

503 ms : Positive number, default 20 

504 Markersize. 

505 fs : Positive number, default 20 

506 Fontsize. 

507 plt_index : Boolean, default False 

508 Plot site labels. 

509 axis : Boolean, default False 

510 Plot axis. 

511 figsize : Tuple, default None 

512 Figsize. 

513 

514 Returns 

515 ------- 

516 fig : Figure 

517 ''' 

518 error_handling.empty_coor(self.coor) 

519 error_handling.positive_real(ms, 'ms') 

520 error_handling.positive_real(fs, 'fs') 

521 error_handling.boolean(plt_index, 'plt_index') 

522 error_handling.boolean(axis, 'axis') 

523 if figsize is None: 

524 figsize = (5, 5) 

525 error_handling.list_tuple_2elem(figsize, 'figsize') 

526 error_handling.positive_real(figsize[0], 'figsize[0]') 

527 error_handling.positive_real(figsize[1], 'figsize[1]') 

528 fig, ax = plt.subplots(figsize=figsize) 

529 # plot sites 

530 colors = ['b', 'r', 'g', 'y', 'm', 'k'] 

531 for color, tag in zip(colors, self.tags): 

532 plt.plot(self.coor['x'][self.coor['tag'] == tag], 

533 self.coor['y'][self.coor['tag'] == tag], 

534 'o', color=color, ms=ms, markeredgecolor='none') 

535 ax.set_aspect('equal') 

536 ax.set_xlim(np.min(self.coor['x'])-1., np.max(self.coor['x'])+1.) 

537 ax.set_ylim(np.min(self.coor['y'])-1., np.max(self.coor['y'])+1.) 

538 if not axis: 

539 ax.axis('off') 

540 # plot indices 

541 if plt_index: 

542 indices = ['{}'.format(i) for i in range(self.sites)] 

543 for l, x, y in zip(indices, self.coor['x'], self.coor['y']): 

544 plt.annotate(l, xy=(x, y), xytext=(0, 0), 

545 textcoords='offset points', 

546 ha='right', va='bottom', size=fs) 

547 plt.draw() 

548 return fig 

549 

550 def show(self) -> None: 

551 """ 

552 Emulate Matplotlib method plt.show(). 

553 """ 

554 plt.show() 

555 

556 

557# Backward-compatible lowercase alias (pre-0.2 API). 

558lattice = Lattice