Coverage for tbkit/lattice.py: 100%

227 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 06:13 +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 :param unit_cell: List of dictionaries. 

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

39 

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

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

42 lattice in 3D space. 

43 :param prim_vec: List of tuples. 

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

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

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

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

48 

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

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

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

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

53 

54 Example usage:: 

55 

56 # Line-Centered Square lattice 

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

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

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

60 ''' 

61 

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

63 error_handling.unit_cell(unit_cell) 

64 error_handling.prim_vec(prim_vec) 

65 error_handling.space_dim(unit_cell, prim_vec) 

66 self.unit_cell = unit_cell 

67 self.prim_vec = prim_vec 

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

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

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

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

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

73 self.sites = 0 

74 

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

76 r''' 

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

78 

79 :param n1: Positive Integer. 

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

81 :param n2: Positive Integer. Default value 1. 

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

83 :param n3: Positive Integer. Default value 1. 

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

85 

86 Example usage:: 

87 

88 # Line-Centered Square lattice 

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

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

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

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

93 ''' 

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

95 sites_uc = len(self.unit_cell) 

96 sites_tag = n1*n2*n3 

97 self.sites = sites_uc * sites_tag 

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

99 self.n1, self.n2, self.n3 = n1, n2, n3 

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

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

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

103 counts = (n1, n2, n3) 

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

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

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

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

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

109 for c, f in enumerate(axes): 

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

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

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

113 

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

115 ''' 

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

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

118 ''' 

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

120 

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

122 ''' 

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

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

125 ''' 

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

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

128 

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

130 ''' 

131 Add sites. 

132 

133 :param coor: Structured array with keys: {'x', 'y', 'tag'} 

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

135 

136 Example usage:: 

137 

138 # Square lattice 

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

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

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

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

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

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

145 lat.add_sites(coor) 

146 ''' 

147 error_handling.coor(coor, self.dtype) 

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

149 self.sites += len(coor) 

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

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

152 

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

154 ''' 

155 Remove sites defined by their indices 

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

157 to get access to the site indices). 

158 

159 :param index: List. Site indices to be removed. 

160 

161 Example usage:: 

162 

163 # Square lattice 

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

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

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

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

168 lat.remove_sites([0, 2]) 

169 ''' 

170 error_handling.empty_coor(self.coor) 

171 error_handling.remove_sites(index, self.sites) 

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

173 mask[index] = False 

174 self.coor = self.coor[mask] 

175 self.sites = self.coor.size 

176 

177 def remove_dangling(self) -> None: 

178 ''' 

179 Remove dangling sites 

180 (sites connected with just another site). 

181 ''' 

182 error_handling.empty_coor(self.coor) 

183 while True: 

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

185 tree = cKDTree(coords) 

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

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

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

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

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

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

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

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

194 self.sites -= len(dang) 

195 if dang == []: 

196 break 

197 

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

199 ''' 

200 Shift the x coordinates. 

201 

202 :param shift: Real number. Shift value. 

203 ''' 

204 error_handling.empty_coor(self.coor) 

205 error_handling.real_number(shift, 'shift') 

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

207 

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

209 ''' 

210 Shift the y coordinates. 

211 

212 :param shift: Real number. Shift value. 

213 ''' 

214 error_handling.empty_coor(self.coor) 

215 error_handling.real_number(shift, 'shift') 

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

217 

218 def change_sign_x(self) -> None: 

219 ''' 

220 Change x coordinates sign. 

221 ''' 

222 error_handling.empty_coor(self.coor) 

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

224 

225 def change_sign_y(self) -> None: 

226 ''' 

227 Change y coordinates sign. 

228 ''' 

229 error_handling.empty_coor(self.coor) 

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

231 

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

233 ''' 

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

235 

236 :param shift: Real number. Shift value. 

237 ''' 

238 error_handling.empty_coor(self.coor) 

239 error_handling.space_3d(self.space_dim) 

240 error_handling.real_number(shift, 'shift') 

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

242 

243 def change_sign_z(self) -> None: 

244 ''' 

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

246 ''' 

247 error_handling.empty_coor(self.coor) 

248 error_handling.space_3d(self.space_dim) 

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

250 

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

252 r''' 

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

254 

255 :param z_min: Real number. 

256 :param z_max: Real number, larger than *z_min*. 

257 ''' 

258 error_handling.empty_coor(self.coor) 

259 error_handling.space_3d(self.space_dim) 

260 error_handling.real_number(z_min, 'z_min') 

261 error_handling.real_number(z_max, 'z_max') 

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

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

264 self.sites = len(self.coor) 

265 

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

267 r''' 

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

269 

270 :param cx: Real number. :math:`c_x` value. 

271 :param cy: Real number. :math:`c_y` value. 

272 :param co: Real number. :math:`c_0` value. 

273 ''' 

274 error_handling.empty_coor(self.coor) 

275 error_handling.real_number(cx, 'cx') 

276 error_handling.real_number(cy, 'cy') 

277 error_handling.real_number(co, 'co') 

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

279 self.sites = len(self.coor) 

280 

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

282 r''' 

283 Select sites according to 

284 

285 .. math:: 

286 

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

288 

289 :param rx: Positive Real number. Radius along :math:`x`. 

290 :param ry: Positive Real number. Radius along :math:`y`. 

291 :param x0: Real number. :math:`x` center. 

292 :param y0: Real number. :math:`y` center. 

293 ''' 

294 error_handling.empty_coor(self.coor) 

295 error_handling.positive_real(rx, 'rx') 

296 error_handling.positive_real(ry, 'ry') 

297 error_handling.real_number(x0, 'x0') 

298 error_handling.real_number(y0, 'y0') 

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

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

301 self.sites = len(self.coor) 

302 

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

304 r''' 

305 Select sites according to 

306 

307 .. math:: 

308 

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

310 

311 

312 :param rx: Positive Real number. Radius along :math:`x`. 

313 :param ry: Positive Real number. Radius along :math:`y`. 

314 :param x0: Real number. :math:`x` center. 

315 :param y0: Real number. :math:`y` center. 

316 ''' 

317 error_handling.empty_coor(self.coor) 

318 error_handling.positive_real(rx, 'rx') 

319 error_handling.positive_real(ry, 'ry') 

320 error_handling.real_number(x0, 'x0') 

321 error_handling.real_number(y0, 'y0') 

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

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

324 self.sites = len(self.coor) 

325 

326 def center(self) -> None: 

327 ''' 

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

329 ''' 

330 error_handling.empty_coor(self.coor) 

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

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

333 

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

335 r''' 

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

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

338 

339 :param theta: Rotation angle in degrees. 

340 ''' 

341 error_handling.empty_coor(self.coor) 

342 error_handling.real_number(theta, 'theta') 

343 theta *= PI / 180 

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

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

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

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

348 

349 def clean_coor(self) -> None: 

350 ''' 

351 Keep only the sites with different coordinates. 

352 ''' 

353 error_handling.empty_coor(self.coor) 

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

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

356 for f in fields: 

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

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

359 self.coor = self.coor[idx] 

360 self.sites = len(self.coor) 

361 

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

363 ''' 

364 Overloading operator +. 

365 ''' 

366 error_handling.lat(other) 

367 error_handling.empty_coor(self.coor) 

368 error_handling.empty_coor(other.coor) 

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

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

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

372 lat.add_sites(coor) 

373 lat.sites = self.sites + other.sites 

374 lat.tags = np.unique(tags) 

375 return lat 

376 

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

378 ''' 

379 Overloading operator +=. 

380 ''' 

381 error_handling.lat(other) 

382 error_handling.empty_coor(self.coor) 

383 error_handling.empty_coor(other.coor) 

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

385 self.sites += other.sites 

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

387 return self 

388 

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

390 ''' 

391 Overloading operator -. 

392 

393 .. note:: 

394 

395 The tags are not considered in the lattice subtraction. 

396 ''' 

397 error_handling.lat(other) 

398 error_handling.empty_coor(self.coor) 

399 error_handling.empty_coor(other.coor) 

400 boo = self.overlaps(other) 

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

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

403 lat.add_sites(coor) 

404 lat.sites = len(lat.coor) 

405 lat.tags = self.tags 

406 return lat 

407 

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

409 ''' 

410 Overloading operator -=. 

411 

412 .. note:: 

413 

414 The tags are not considered in the lattice subtraction. 

415 ''' 

416 error_handling.lat(other) 

417 error_handling.empty_coor(self.coor) 

418 error_handling.empty_coor(other.coor) 

419 boo = self.overlaps(other) 

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

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

422 return self 

423 

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

425 ''' 

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

427 of *other*. 

428 ''' 

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

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

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

432 for c in other.coor: 

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

434 for f in fields: 

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

436 boo += match 

437 return boo 

438 

439 def plot( 

440 self, 

441 ms: float = 20, 

442 fs: float = 20, 

443 plt_index: bool = False, 

444 axis: bool = False, 

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

446 ) -> Figure: 

447 ''' 

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

449 in 3D space). 

450 

451 :param ms: Positive number. Default value 20. Markersize. 

452 :param fs: Positive number. Default value 20. Fontsize. 

453 :param plt_index: Boolean. Default value False. Plot site labels. 

454 :param axis: Boolean. Default value False. Plot axis. 

455 :param figsize: Tuple. Default value None. Figsize. 

456 

457 :returns: 

458 * **fig** -- Figure. 

459 ''' 

460 error_handling.empty_coor(self.coor) 

461 error_handling.positive_real(ms, 'ms') 

462 error_handling.positive_real(fs, 'fs') 

463 error_handling.boolean(plt_index, 'plt_index') 

464 error_handling.boolean(axis, 'axis') 

465 if figsize is None: 

466 figsize = (5, 5) 

467 error_handling.list_tuple_2elem(figsize, 'figsize') 

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

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

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

471 # plot sites 

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

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

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

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

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

477 ax.set_aspect('equal') 

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

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

480 if not axis: 

481 ax.axis('off') 

482 # plot indices 

483 if plt_index: 

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

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

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

487 textcoords='offset points', 

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

489 plt.draw() 

490 return fig 

491 

492 def show(self) -> None: 

493 """ 

494 Emulate Matplotlib method plt.show(). 

495 """ 

496 plt.show() 

497 

498 

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

500lattice = Lattice