Coverage for tbkit/exceptional.py: 100%
351 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 11:04 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 11:04 +0000
1r"""
2Band touchings of two-dimensional (non-Hermitian) Bloch bands:
3**diabolical points** (DPs), Hermitian degeneracies such as graphene's
4Dirac points, where two eigenvalues meet but their eigenvectors stay
5distinct; and **exceptional points** (EPs), where the eigenvalues *and*
6the eigenvectors coalesce, so that :math:`H(\mathbf{k})` stops being
7diagonalizable (a Jordan block).
9The tools below tell them apart and compute their invariants. Each takes a
10*model*: a 2D **KSpace** (the plane is the Brillouin zone, in the k
11coordinates of *get_ham*), or any callable ``model(p)`` returning a square
12matrix for a point ``p`` of a two-parameter plane.
14* **Eigenvalue vorticity** of a pair of bands around a closed loop
15 :math:`\Gamma` (Shen, Zhen and Fu, Phys. Rev. Lett. 120, 146402 (2018)),
17 .. math::
19 \nu_{mn}(\Gamma) = -\frac{1}{2\pi}\oint_\Gamma
20 \nabla_{\mathbf{k}}\arg\left[E_m(\mathbf{k}) - E_n(\mathbf{k})\right]\cdot d\mathbf{k}\, .
22 Band labels are not single-valued around an EP (the two eigenvalues swap
23 after one loop), so *vorticity* follows the eigenvalues by continuation
24 along the loop (*track_eigenvalues*), never by sorting. A second-order EP
25 has :math:`\nu = \pm1/2`, a DP :math:`\nu = 0`.
26* **Discriminant winding** (Yang, Schnyder, Hu and Chiu, Phys. Rev. Lett.
27 126, 086401 (2021)): the discriminant
28 :math:`\Delta(\mathbf{k}) = \prod_{m<n}(E_m - E_n)^2` is a polynomial in the
29 matrix elements, computed from the characteristic polynomial without any
30 eigenvalue, and its winding
31 :math:`W(\Gamma) = \frac{1}{2\pi}\oint_\Gamma \nabla_{\mathbf{k}}\arg\Delta\cdot d\mathbf{k}`
32 counts the EPs inside :math:`\Gamma` with their charges. With the two sign
33 conventions above, :math:`W = -2\sum_{m<n}\nu_{mn}`: :math:`W = -2\nu = \mp1`
34 for a second-order EP of a two-band model.
35* **EP finder** (*find_exceptional_points*): zeros of :math:`\Delta` with
36 :math:`W \neq 0` on a plaquette mesh of the Brillouin zone, refined by
37 Newton's method, with their charge and their order (the size of the Jordan
38 block, from the Petermann factors, which diverge at an EP). Since
39 :math:`\Delta` is periodic, the charges sum to zero over the zone: EPs come in
40 pairs of opposite charge (the doubling theorem). When a symmetry makes
41 :math:`\Delta` real, EPs form lines instead (exceptional rings, Zhen et al.,
42 Nature 525, 354 (2015)), which the finder also returns; *fermi_arcs* draws
43 the bulk Fermi arcs :math:`\mathrm{Re}(E_m - E_n) = 0` that join a pair of
44 EPs (Kozii and Fu, arXiv:1708.05841 (2017); Zhou et al., Science 359, 1009
45 (2018)).
46* **Encircling** (*encircle*): parallel transport of an eigenvector around
47 a loop. Around a DP one loop returns the state with a Berry phase
48 :math:`\pi`; around a second-order EP one loop swaps the two eigenstates,
49 two loops return the state with a sign flip, and four loops restore it
50 (Heiss, Phys. Rev. E 61, 929 (2000); Dembowski et al., Phys. Rev. Lett.
51 86, 787 (2001)).
52"""
53from __future__ import annotations
55from typing import Callable
57import numpy as np
58from numpy.typing import ArrayLike, NDArray
59import scipy.linalg as LA
60from scipy.optimize import linear_sum_assignment
62import tbkit.error_handling as error_handling
63from tbkit.kspace import KSpace
66PI = np.pi
67_HALVINGS = 30 # a continuation step may be halved this many times
68_ARG_STEP = PI / 4 # largest phase change of an eigenvalue difference per step
69_MESH_SHIFT = (0.3183098861837907, 0.2718281828459045) # keeps mesh edges off symmetry lines
72#################################
73# MODELS AND LOOPS
74#################################
77def _model(model) -> Callable[[NDArray[np.float64]], NDArray[np.complex128]]:
78 '''
79 Private function. The matrix-valued function p -> H(p) of a validated model.
80 '''
81 error_handling.ham_model(model, KSpace)
82 if isinstance(model, KSpace):
83 error_handling.no_overlap(model._overlap_hop)
84 return model.get_ham
86 def ham(p):
87 h = np.asarray(model(np.asarray(p, dtype='f8')))
88 error_handling.ham_matrix(h)
89 return h.astype('c16')
90 return ham
93def circle(center: ArrayLike, radius: float) -> Callable[[float], NDArray[np.float64]]:
94 r'''
95 Get a circular loop, run counterclockwise:
96 :math:`\mathbf{k}(s) = \mathbf{k}_0 + r(\cos 2\pi s, \sin 2\pi s)`,
97 :math:`s\in[0, 1]`.
99 Parameters
100 ----------
101 center : Tuple/list/ndarray of 2 real numbers
102 :math:`\mathbf{k}_0`.
103 radius : Positive real
104 :math:`r`.
106 Returns
107 -------
108 loop : Callable of s, returning a real ndarray of shape (2,)
109 '''
110 error_handling.k_vector(center, 'center', 2)
111 error_handling.positive_real(radius, 'radius')
112 k0 = np.asarray(center, dtype='f8')
114 def loop(s):
115 return k0 + radius * np.array([np.cos(2 * PI * s), np.sin(2 * PI * s)])
116 return loop
119def _loop(loop) -> Callable[[float], NDArray[np.float64]]:
120 '''
121 Private function. The callable s -> k(s), s in [0, 1], of a validated loop
122 (a callable, or the vertices of a polygon, each side covering an equal
123 range of s).
124 '''
125 error_handling.loop(loop)
126 if callable(loop):
127 def traced(s):
128 p = np.asarray(loop(float(s)), dtype='f8')
129 error_handling.loop_point(p)
130 return p
131 return traced
132 vert = np.asarray(loop, dtype='f8')
133 closed = np.vstack([vert, vert[:1]])
134 m = len(vert)
136 def path(s):
137 x = (s % 1.) * m
138 i = min(int(x), m - 1)
139 return closed[i] + (x - i) * (closed[i + 1] - closed[i])
140 return path
143def _closed(ham, path) -> int:
144 '''
145 Private function. Check that the loop is closed (H at its end equals H at
146 its start, which allows loops across the zone), and return norb.
147 '''
148 h0 = ham(path(0.))
149 error_handling.closed_loop(h0, ham(path(1.)))
150 return h0.shape[0]
153#################################
154# EIGENVALUE CONTINUATION
155#################################
158def _eig(h: NDArray[np.complex128], vectors: bool):
159 '''
160 Private function. Eigenvalues, right and left eigenvectors (as kets:
161 columns of vl with vl^H H = E vl^H) of the general eigenproblem.
162 '''
163 if vectors:
164 w, vl, vr = LA.eig(h, left=True, right=True)
165 return w, vr, vl
166 return LA.eigvals(h), None, None
169def _match(pred, w_old, w_new, vr_old, vr_new):
170 '''
171 Private function. Permutation of *w_new* continuing *w_old* (predicted
172 at *pred*), or None if the step is ambiguous: a matched eigenvalue must
173 be four times closer to its prediction than to any other eigenvalue,
174 no eigenvalue difference may turn by more than pi/4, and (with vectors)
175 each eigenvector must stay close to its predecessor. Eigenvalues equal to
176 rounding precision form a cluster and do not make the step ambiguous.
177 '''
178 _, perm = linear_sum_assignment(np.abs(pred[:, None] - w_new[None, :]))
179 w = w_new[perm]
180 deg = 1e-9 * max(1., float(np.max(np.abs(w))))
181 dist = np.abs(w[:, None] - w[None, :])
182 np.fill_diagonal(dist, np.inf)
183 single = dist.min(axis=1) >= deg # no partner equal to rounding precision
184 dist[dist < deg] = np.inf
185 if np.any(np.abs(w - pred) >= 0.25 * dist.min(axis=1)):
186 return None
187 d_old = w_old[:, None] - w_old[None, :]
188 d_new = w[:, None] - w[None, :]
189 keep = (np.abs(d_old) > deg) & (np.abs(d_new) > deg)
190 if np.any(np.abs(np.angle(d_new[keep] / d_old[keep])) > _ARG_STEP):
191 return None
192 if vr_old is not None:
193 # within a degenerate cluster the eigenvectors are arbitrary
194 overlap = np.abs(np.sum(vr_old.conj() * vr_new[:, perm], axis=0))
195 if np.any(overlap[single] < 0.9):
196 return None
197 return perm
200def _track(ham, path, s_end: float, nk: int, vectors: bool = False):
201 '''
202 Private function. Follow the eigenvalues of H(path(s)) by continuation
203 from s = 0 to *s_end* (loops are evaluated at s modulo 1): steps of at
204 most 1/nk, halved while a step is ambiguous (see *_match*), with a
205 linear prediction from the previous step. The labels are those of the
206 eigenvalues at s = 0 sorted by real part.
208 Returns s (n,), en (n, norb), and, with *vectors*, the right and left
209 eigenvectors (n, norb, norb), column m following label m.
210 '''
211 ds_max = 1. / nk
212 ds_min = ds_max / 2 ** _HALVINGS
213 w, vr, vl = _eig(ham(path(0.)), vectors)
214 order = np.argsort(w.real, kind='stable')
215 w = w[order]
216 if vectors:
217 vr, vl = vr[:, order], vl[:, order]
218 s_list, w_list, vr_list, vl_list = [0.], [w], [vr], [vl]
219 slope = np.zeros_like(w)
220 s, ds = 0., ds_max
221 while s < s_end:
222 last = ds >= s_end - s
223 s_new = s_end if last else s + ds
224 step = s_new - s
225 k = path(s_new % 1. if s_new != s_end or s_end % 1. else 1.)
226 w_new, vr_new, vl_new = _eig(ham(k), vectors)
227 perm = _match(w + slope * step, w, w_new, vr, vr_new)
228 if perm is None:
229 if step <= ds_min:
230 error_handling.tracking(k)
231 ds = step / 2
232 continue
233 slope = (w_new[perm] - w) / step
234 w, s = w_new[perm], s_new
235 if vectors:
236 vr, vl = vr_new[:, perm], vl_new[:, perm]
237 s_list.append(s)
238 w_list.append(w)
239 vr_list.append(vr)
240 vl_list.append(vl)
241 ds = min(2 * step, ds_max)
242 s_arr, en = np.array(s_list), np.array(w_list)
243 if vectors:
244 return s_arr, en, np.array(vr_list), np.array(vl_list)
245 return s_arr, en, None, None
248def track_eigenvalues(
249 model, loop, n_loops: int = 1, nk: int = 100,
250) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.complex128]]:
251 r'''
252 Follow the (complex) eigenvalues of :math:`H(\mathbf{k})` continuously
253 around a closed loop, *n_loops* times. Each step (at most 1/nk of the
254 loop) matches the new eigenvalues to a linear prediction from the
255 previous ones, and is halved while that matching is ambiguous; nothing
256 depends on how the eigensolver orders its output. Label :math:`m` is the
257 :math:`m`-th eigenvalue at the start of the loop, sorted by real part.
258 After one loop around a second-order exceptional point the labels come
259 back swapped: ``en[-1]`` is then a permutation of ``en[0]``.
261 Parameters
262 ----------
263 model
264 2D **KSpace**, or callable of a point of a plane returning
265 a square matrix.
266 loop
267 Closed loop: a callable of :math:`s\in[0, 1]` returning a
268 point (e.g. *circle*), or an array of polygon vertices, shape (m, 2).
269 For a **KSpace**, points are k in the coordinates of *get_ham*, and a
270 loop may close up to a reciprocal lattice vector.
271 n_loops : Positive integer, default 1
272 Number of turns.
273 nk : Positive integer, default 100
274 Steps per turn before
275 refinement.
277 Returns
278 -------
279 s : Real ndarray, shape (n,)
280 Loop parameter, from 0 to *n_loops*.
281 k : Real ndarray, shape (n, 2)
282 Points of the loop.
283 en : Complex ndarray, shape (n, norb)
284 Eigenvalues, en[:, m]
285 continuous in s.
286 '''
287 ham = _model(model)
288 path = _loop(loop)
289 error_handling.positive_int(n_loops, 'n_loops')
290 error_handling.positive_int(nk, 'nk')
291 _closed(ham, path)
292 s, en, _, _ = _track(ham, path, float(n_loops), nk)
293 return s, np.array([path(x % 1.) for x in s]), en
296def vorticity(model, loop, bands: tuple[int, int] = (0, 1), nk: int = 100) -> float:
297 r'''
298 Get the eigenvalue vorticity of a pair of bands around a closed loop
299 :math:`\Gamma` (Shen, Zhen and Fu, Phys. Rev. Lett. 120, 146402 (2018)),
301 .. math::
303 \nu_{mn}(\Gamma) = -\frac{1}{2\pi}\oint_\Gamma
304 \nabla_{\mathbf{k}}\arg\left[E_m(\mathbf{k}) - E_n(\mathbf{k})\right]
305 \cdot d\mathbf{k}\, ,
307 with the eigenvalues followed by continuation (see *track_eigenvalues*).
308 :math:`\nu_{mn} = \pm1/2` around a second-order exceptional point, where
309 :math:`E_m - E_n \propto \sqrt{\mathbf{k}-\mathbf{k}_{EP}}` changes sign
310 after one loop; :math:`\pm1/3` for each pair of a third-order one;
311 0 around a diabolical point, or a loop enclosing no degeneracy.
312 :math:`\nu_{mn} = \nu_{nm}`.
314 Parameters
315 ----------
316 model
317 See *track_eigenvalues*.
318 loop
319 See *track_eigenvalues*. Counterclockwise for the sign above.
320 bands : Pair of distinct integers
321 Default value (0, 1). Band labels
322 :math:`(m, n)` at the start of the loop, sorted by real part.
323 nk : Positive integer, default 100
324 See *track_eigenvalues*.
326 Returns
327 -------
328 nu : Real number, a multiple of 1/2 around second-order EPs
329 '''
330 ham = _model(model)
331 path = _loop(loop)
332 error_handling.positive_int(nk, 'nk')
333 norb = _closed(ham, path)
334 error_handling.band_pair(bands, norb)
335 s, en, _, _ = _track(ham, path, 1., nk)
336 diff = en[:, bands[0]] - en[:, bands[1]]
337 if np.any(diff == 0.):
338 error_handling.tracking(path(s[np.argmin(np.abs(diff))] % 1.))
339 return float(-np.sum(np.angle(diff[1:] / diff[:-1])) / (2 * PI))
342#################################
343# DISCRIMINANT
344#################################
347def _charpoly(h: NDArray[np.complex128]) -> NDArray[np.complex128]:
348 r'''
349 Private function. Coefficients, highest degree first, of the monic
350 characteristic polynomial :math:`p(z) = \det(z - H)`, from its values
351 at N+1 points of a circle of radius :math:`\|H\|_F` (a discrete Fourier
352 transform; no eigenvalues).
353 '''
354 n = h.shape[0]
355 r = LA.norm(h) or 1.
356 z = r * np.exp(2j * PI * np.arange(n + 1) / (n + 1))
357 vals = np.array([LA.det(zz * np.eye(n) - h) for zz in z])
358 coef = np.fft.fft(vals) / (n + 1) / r ** np.arange(n + 1)
359 coef[n] = 1.
360 return coef[::-1]
363def _discriminant(h: NDArray[np.complex128]) -> complex:
364 r'''
365 Private function. :math:`\Delta = (-1)^{N(N-1)/2}\,\mathrm{Res}(p, p')`
366 of the monic characteristic polynomial, from the determinant of the
367 Sylvester matrix, computed for :math:`H/\|H\|_F` and rescaled.
368 '''
369 n = h.shape[0]
370 if n < 2:
371 return 1. + 0j
372 r = LA.norm(h)
373 if r == 0.:
374 return 0j
375 c = _charpoly(h / r)
376 dc = c[:-1] * np.arange(n, 0, -1)
377 syl = np.zeros((2 * n - 1, 2 * n - 1), 'c16')
378 for i in range(n - 1):
379 syl[i, i:i + n + 1] = c
380 for i in range(n):
381 syl[n - 1 + i, i:i + n] = dc
382 return complex((-1) ** (n * (n - 1) // 2) * LA.det(syl) * r ** (n * (n - 1)))
385def discriminant(model, k: ArrayLike) -> complex:
386 r'''
387 Get the discriminant of :math:`H(\mathbf{k})`,
389 .. math::
391 \Delta(\mathbf{k}) = \prod_{m<n}\left[E_m(\mathbf{k}) - E_n(\mathbf{k})\right]^2
392 = (-1)^{N(N-1)/2}\,\mathrm{Res}(p, p')\, ,
394 computed as the resultant of the characteristic polynomial
395 :math:`p(E) = \det[E - H(\mathbf{k})]` and its derivative (the determinant
396 of their Sylvester matrix), without the eigenvalues. It is a polynomial
397 in the matrix elements, hence smooth and single-valued, and it vanishes
398 exactly where two eigenvalues meet. For a two-band model
399 :math:`H = d_0 + \mathbf{d}\cdot\boldsymbol\sigma`,
400 :math:`\Delta = 4\,\mathbf{d}\cdot\mathbf{d}`.
402 Parameters
403 ----------
404 model
405 See *track_eigenvalues*.
406 k : Tuple/list/ndarray of 2 real numbers
407 Point of the plane.
409 Returns
410 -------
411 delta : Complex number, in units of energy^(N(N-1)) (1 if N = 1)
412 '''
413 ham = _model(model)
414 error_handling.k_vector(k, 'k', 2)
415 return _discriminant(ham(np.asarray(k, dtype='f8')))
418def _arg_change(disc, path, s_a, s_b, d_a, d_b, depth=0) -> float:
419 '''
420 Private function. Change of arg(Delta) from path(s_a) to path(s_b),
421 bisecting until each piece turns by at most pi/4.
422 '''
423 if d_a == 0. or d_b == 0. or depth > 40:
424 error_handling.winding_path(path(s_b if d_b == 0. else s_a))
425 step = float(np.angle(d_b / d_a))
426 if abs(step) <= _ARG_STEP:
427 return step
428 s_m = (s_a + s_b) / 2
429 d_m = disc(path(s_m))
430 return (_arg_change(disc, path, s_a, s_m, d_a, d_m, depth + 1)
431 + _arg_change(disc, path, s_m, s_b, d_m, d_b, depth + 1))
434def _winding(ham, path, nk: int) -> float:
435 '''
436 Private function. Winding of the discriminant along path(s), s in [0, 1].
437 '''
438 def disc(p):
439 return _discriminant(ham(p))
440 s = np.linspace(0., 1., nk + 1)
441 d = [disc(path(x)) for x in s]
442 total = sum(_arg_change(disc, path, s[i], s[i + 1], d[i], d[i + 1]) for i in range(nk))
443 return total / (2 * PI)
446def discriminant_winding(model, loop, nk: int = 100) -> float:
447 r'''
448 Get the winding number of the discriminant (see *discriminant*) around a
449 closed loop :math:`\Gamma` (Yang, Schnyder, Hu and Chiu, Phys. Rev. Lett.
450 126, 086401 (2021)),
452 .. math::
454 W(\Gamma) = \frac{1}{2\pi}\oint_\Gamma \nabla_{\mathbf{k}}\arg\Delta(\mathbf{k})
455 \cdot d\mathbf{k} = -2\sum_{m<n}\nu_{mn}(\Gamma)\, ,
457 the total charge of the exceptional points inside :math:`\Gamma`
458 (:math:`\pm1` for a second-order EP, where :math:`\Delta` vanishes
459 linearly). It needs no eigenvalue tracking. A diabolical point has
460 :math:`W = 0`: :math:`\Delta \geq 0` for a Hermitian model. The relation to
461 the vorticities *vorticity* holds with the signs as written, the minus
462 sign coming from Shen, Zhen and Fu's definition of :math:`\nu`.
464 Parameters
465 ----------
466 model
467 See *track_eigenvalues*.
468 loop
469 See *track_eigenvalues*. Counterclockwise for the sign above.
470 nk : Positive integer, default 100
471 Points on the loop; a
472 step along which :math:`\arg\Delta` turns by more than :math:`\pi/4`
473 is bisected.
475 Returns
476 -------
477 W : Real number, close to an integer
478 '''
479 ham = _model(model)
480 path = _loop(loop)
481 error_handling.positive_int(nk, 'nk')
482 _closed(ham, path)
483 return float(_winding(ham, path, nk))
486#################################
487# EXCEPTIONAL POINTS
488#################################
491def _petermann(h: NDArray[np.complex128]):
492 '''
493 Private function. Eigenvalues (sorted by real part) and Petermann factors.
494 '''
495 w, vl, vr = LA.eig(h, left=True, right=True)
496 order = np.argsort(w.real, kind='stable')
497 w, vl, vr = w[order], vl[:, order], vr[:, order]
498 norms = np.sum(np.abs(vl) ** 2, axis=0) * np.sum(np.abs(vr) ** 2, axis=0)
499 pair = np.abs(np.sum(vl.conj() * vr, axis=0)) ** 2
500 return w, norms / np.maximum(pair, np.finfo(float).tiny)
503def petermann_factors(model, k: ArrayLike) -> tuple[NDArray[np.complex128], NDArray[np.float64]]:
504 r'''
505 Get the eigenvalues of :math:`H(\mathbf{k})` and their Petermann factors
507 .. math::
509 K_n = \frac{\langle L_n|L_n\rangle\langle R_n|R_n\rangle}{|\langle L_n|R_n\rangle|^2}
510 \geq 1\, ,
512 with :math:`|R_n\rangle`, :math:`|L_n\rangle` the right and left
513 eigenvectors: 1 for a normal (e.g. Hermitian) matrix, diverging at an
514 exceptional point, where :math:`\langle L_n|R_n\rangle \to 0`
515 (as :math:`|\mathbf{k}-\mathbf{k}_{EP}|^{-1}` near a second-order one).
517 Parameters
518 ----------
519 model
520 See *track_eigenvalues*.
521 k : Tuple/list/ndarray of 2 real numbers
523 Returns
524 -------
525 en : Complex ndarray, shape (norb,)
526 Eigenvalues, sorted by real part.
527 K : Real ndarray, shape (norb,)
528 Petermann factors.
529 '''
530 ham = _model(model)
531 error_handling.k_vector(k, 'k', 2)
532 return _petermann(ham(np.asarray(k, dtype='f8')))
535def _order(h: NDArray[np.complex128], k_min: float = 1e4) -> tuple[int, float, complex]:
536 '''
537 Private function. Order of the exceptional point at H: the number of
538 eigenvalues whose Petermann factor exceeds sqrt(max K) (the coalescing
539 ones, all of order max K), 1 if max K < k_min (no EP); with max K and
540 the mean coalescing eigenvalue.
541 '''
542 w, kp = _petermann(h)
543 k_max = float(np.max(kp))
544 if k_max < k_min:
545 return 1, k_max, complex(w[np.argmax(kp)])
546 big = kp > np.sqrt(k_max)
547 return int(np.sum(big)), k_max, complex(np.mean(w[big]))
550class ExceptionalPoints():
551 r'''
552 Exceptional points of a 2D model, see *find_exceptional_points*.
554 Attributes
555 ----------
556 k : Real ndarray, shape (n, 2)
557 Isolated exceptional points.
558 charge : Integer ndarray, shape (n,)
559 Discriminant winding around each
560 one (see *discriminant_winding*): :math:`\pm1` for a second-order EP.
561 order : Integer ndarray, shape (n,)
562 Number of coalescing eigenvalues
563 (the size of the Jordan block): 2, 3, ...
564 petermann : Real ndarray, shape (n,)
565 Largest Petermann factor at
566 each point (divergent at an EP: about :math:`10^{8}` or more once refined).
567 energy : Complex ndarray, shape (n,)
568 The coalescing eigenvalue.
569 lines : Real ndarray, shape (m, 2)
570 Points on exceptional lines
571 (rings), when a symmetry makes the discriminant real; empty otherwise.
572 '''
574 def __init__(self, k, charge, order, petermann, energy, lines) -> None:
575 self.k = k
576 self.charge = charge
577 self.order = order
578 self.petermann = petermann
579 self.energy = energy
580 self.lines = lines
582 @property
583 def total_charge(self) -> int:
584 '''
585 Sum of the charges: zero over a whole Brillouin zone (doubling theorem).
586 '''
587 return int(np.sum(self.charge))
590def _mesh(ks: KSpace, nk: int):
591 '''
592 Private function. Shifted nk x nk mesh of the zone: node k-points,
593 shape (nk + 1, nk + 1, 2) (the last row/column repeat the first ones
594 shifted by a reciprocal vector), and the plaquette size.
595 '''
596 fr = (np.arange(nk + 1)[:, None] + np.array(_MESH_SHIFT)[None, :]) / nk
597 fracs = np.stack(np.meshgrid(fr[:, 0], fr[:, 1], indexing='ij'), axis=-1)
598 size = min(np.linalg.norm(b) for b in ks.rec_vec_k) / nk
599 return fracs @ ks.rec_vec_k, size
602def _edges(nk: int):
603 '''
604 Private function. Mesh edges ((i, j), (i', j')) along b1 then along b2.
605 '''
606 return ([((i, j), (i + 1, j)) for i in range(nk) for j in range(nk)]
607 + [((i, j), (i, j + 1)) for i in range(nk) for j in range(nk)])
610def _bisect(func, k_a, k_b, f_a, n_iter=60):
611 '''
612 Private function. Root of the real function *func* on the segment
613 [k_a, k_b], across which it changes sign (f_a = func(k_a)).
614 '''
615 lo, hi = 0., 1.
616 for _ in range(n_iter):
617 mid = (lo + hi) / 2
618 f_m = func(k_a + mid * (k_b - k_a))
619 if np.sign(f_m) == np.sign(f_a):
620 lo, f_a = mid, f_m
621 else:
622 hi = mid
623 return k_a + (lo + hi) / 2 * (k_b - k_a)
626def _newton(disc, k0, mult, h, tol, max_iter):
627 r'''
628 Private function. Newton's method on (Re Delta, Im Delta) = 0, with a
629 central-difference Jacobian; the step is multiplied by the multiplicity
630 *mult* of the zero (Delta ~ L(k)^mult, L complex linear, as at a
631 higher-order exceptional point).
632 '''
633 k = np.array(k0, dtype='f8')
634 for _ in range(max_iter):
635 f = disc(k)
636 jac = np.zeros((2, 2))
637 for mu in range(2):
638 e = np.zeros(2)
639 e[mu] = h
640 d = (disc(k + e) - disc(k - e)) / (2 * h)
641 jac[:, mu] = d.real, d.imag
642 step = mult * np.linalg.lstsq(jac, np.array([f.real, f.imag]), rcond=None)[0]
643 k = k - step
644 if np.linalg.norm(step) < tol:
645 break
646 return k
649def find_exceptional_points(
650 ks: KSpace, nk: int = 40, tol: float = 1e-12, max_iter: int = 50,
651) -> ExceptionalPoints:
652 r'''
653 Locate the exceptional points of a 2D model over its Brillouin zone.
655 1. The discriminant :math:`\Delta(\mathbf{k})` (see *discriminant*) is
656 computed on an :math:`nk\times nk` mesh of the zone (shifted off the
657 high-symmetry lines), and its winding around every plaquette from the
658 phase changes along the mesh edges (bisected where :math:`\arg\Delta`
659 turns by more than :math:`\pi/4`). A plaquette with a nonzero winding
660 :math:`w` holds an EP.
661 2. Each one is refined by Newton's method on
662 :math:`(\mathrm{Re}\,\Delta, \mathrm{Im}\,\Delta) = 0` from the plaquette
663 centre (the step multiplied by :math:`|w|`, the multiplicity of the zero).
664 3. Its charge is the winding of :math:`\Delta` around a small circle
665 (a quarter of a plaquette), and its order the number of eigenvalues
666 whose Petermann factor diverges there (see *petermann_factors*).
668 If a symmetry makes :math:`\Delta` real everywhere (up to a constant
669 phase), EPs are not isolated: they form lines, exceptional rings, where
670 :math:`\Delta` changes sign. The lines are then sampled where they cross
671 the mesh edges (bisection to machine precision), and no isolated point is
672 returned. A Hermitian model has :math:`\Delta \geq 0`, and no EP.
674 Since :math:`\Delta` is periodic, the charges add up to zero
675 (*total_charge*): EPs come in pairs of opposite charge. The mesh must be
676 fine enough that no plaquette holds two EPs of opposite charge.
678 Parameters
679 ----------
680 ks : **KSpace** instance with two primitive vectors
681 nk : Positive integer, default 40
682 Mesh points along each
683 reciprocal vector.
684 tol : Positive real, default 1e-12
685 Newton's method stops
686 when a step is shorter than *tol* (units of k).
687 max_iter : Positive integer, default 50
688 Newton iterations.
690 Returns
691 -------
692 eps
693 **ExceptionalPoints** instance (positions *k*, *charge*,
694 *order*, *petermann*, *energy*, and the exceptional *lines*).
695 '''
696 error_handling.kspace(ks, KSpace)
697 error_handling.dim_exact(ks.dim, 2)
698 ham = _model(ks)
699 error_handling.positive_int(nk, 'nk')
700 error_handling.positive_real(tol, 'tol')
701 error_handling.positive_int(max_iter, 'max_iter')
703 def disc(k):
704 return _discriminant(ham(k))
706 kmesh, size = _mesh(ks, nk)
707 d = np.array([[disc(kmesh[i, j]) for j in range(nk)] for i in range(nk)])
708 d = np.pad(d, ((0, 1), (0, 1)), mode='wrap')
709 scale = float(np.max(np.abs(d)))
710 error_handling.discriminant_nonzero(scale)
711 phase = np.exp(-0.5j * np.angle(np.sum(d ** 2)))
712 empty = np.zeros((0, 2))
713 if np.max(np.abs((phase * d).imag)) <= 1e-8 * scale:
714 # a real discriminant: exceptional lines where it changes sign
715 lines = [_bisect(lambda k: (phase * disc(k)).real, kmesh[a], kmesh[b], (phase * d[a]).real)
716 for a, b in _edges(nk) if (phase * d[a]).real * (phase * d[b]).real < 0.]
717 return ExceptionalPoints(empty, np.zeros(0, int), np.zeros(0, int), np.zeros(0),
718 np.zeros(0, 'c16'), np.array(lines).reshape(-1, 2))
719 # phase change along the edge leaving node (i, j) along b1 (axis 0) or b2
720 # (axis 1); Delta is periodic, so the last row/column repeat the first ones
721 edge = np.zeros((2, nk, nk))
722 for a, b in _edges(nk):
723 edge[int(b[1] != a[1])][a] = _arg_change(
724 disc, lambda s, a=a, b=b: kmesh[a] + s * (kmesh[b] - kmesh[a]), 0., 1., d[a], d[b])
725 b1, b2 = ks.rec_vec_k
726 orient = np.sign(b1[0] * b2[1] - b1[1] * b2[0])
727 points: list = []
728 for i in range(nk):
729 for j in range(nk):
730 w = (edge[0, i, j] + edge[1, (i + 1) % nk, j]
731 - edge[0, i, (j + 1) % nk] - edge[1, i, j]) / (2 * PI)
732 w = int(np.rint(orient * w))
733 if w == 0:
734 continue
735 center = (kmesh[i, j] + kmesh[i + 1, j + 1]) / 2
736 k_ep = _newton(disc, center, abs(w), 1e-5 * size, tol, max_iter)
737 if all(np.linalg.norm(k_ep - p) > 1e-6 * size for p in points):
738 points.append(k_ep)
739 charge, order, peter, energy = [], [], [], []
740 for p in points:
741 charge.append(int(np.rint(_winding(ham, circle(p, 0.25 * size), 16))))
742 o, kp, e = _order(ham(p))
743 order.append(o)
744 peter.append(kp)
745 energy.append(e)
746 return ExceptionalPoints(np.array(points).reshape(-1, 2), np.array(charge, int),
747 np.array(order, int), np.array(peter),
748 np.array(energy, 'c16'), empty)
751def fermi_arcs(ks: KSpace, nk: int = 60) -> NDArray[np.float64]:
752 r'''
753 Get the bulk Fermi arcs of a 2D non-Hermitian model: the curves where
754 two eigenvalues have equal real parts but different imaginary parts,
755 :math:`\mathrm{Re}(E_m - E_n) = 0`, :math:`\mathrm{Im}(E_m - E_n) \neq 0`.
756 Such an arc joins two exceptional points of opposite charge (for a
757 two-band model it is where :math:`\Delta` is real and negative, see
758 *discriminant*); it is what a Hermitian Dirac point turns into when a
759 non-Hermitian term splits it into an EP pair (Kozii and Fu 2017; Zhou et
760 al., Science 359, 1009 (2018)).
762 Along every edge of an :math:`nk\times nk` mesh of the zone the
763 eigenvalues are followed by continuation (see *track_eigenvalues*), and
764 where :math:`\mathrm{Re}(E_m - E_n)` changes sign, the crossing is found by
765 bisection. Regions where the real parts coincide over an area (inside an
766 exceptional ring) show no sign change and are not reported, and neither
767 are edges that cross an exceptional point or line, along which the
768 eigenvalues cannot be followed.
770 Parameters
771 ----------
772 ks : **KSpace** instance with two primitive vectors
773 nk : Positive integer, default 60
774 Mesh points along each
775 reciprocal vector.
777 Returns
778 -------
779 points : Real ndarray, shape (m, 2)
780 Points on the arcs.
781 '''
782 error_handling.kspace(ks, KSpace)
783 error_handling.dim_exact(ks.dim, 2)
784 ham = _model(ks)
785 error_handling.positive_int(nk, 'nk')
786 kmesh, _ = _mesh(ks, nk)
787 points = []
788 for a, b in _edges(nk):
789 k_a, k_b = kmesh[a], kmesh[b]
790 try:
791 s, en, _, _ = _track(ham, lambda x: k_a + x * (k_b - k_a), 1., 1)
792 except ValueError:
793 continue # the edge crosses an exceptional point (or line)
794 noise = 1e-10 * max(1., float(np.max(np.abs(en))))
795 norb = en.shape[1]
796 for m in range(norb):
797 for n in range(m + 1, norb):
798 g = (en[:, m] - en[:, n]).real
799 for i in np.nonzero((g[:-1] * g[1:] < 0.) & (np.abs(g[:-1]) > noise)
800 & (np.abs(g[1:]) > noise))[0]:
801 k_lo = k_a + s[i] * (k_b - k_a)
802 k_hi = k_a + s[i + 1] * (k_b - k_a)
803 k_arc = _arc_point(ham, k_lo, k_hi, en[i], m, n)
804 # not a degeneracy (an EP at the end of the arc)
805 e = LA.eigvals(ham(k_arc))
806 sep = np.abs(e[:, None] - e[None, :])
807 np.fill_diagonal(sep, np.inf)
808 if np.min(sep) > noise:
809 points.append(k_arc)
810 return np.array(points).reshape(-1, 2)
813def _arc_point(ham, k_lo, k_hi, w_lo, m, n, n_iter=50):
814 '''
815 Private function. Bisection for Re(E_m - E_n) = 0 between k_lo and k_hi,
816 the labels carried along by matching to the eigenvalues at the lower end.
817 '''
818 g_lo = (w_lo[m] - w_lo[n]).real
819 for _ in range(n_iter):
820 k_mid = (k_lo + k_hi) / 2
821 w = LA.eigvals(ham(k_mid))
822 _, perm = linear_sum_assignment(np.abs(w_lo[:, None] - w[None, :]))
823 w = w[perm]
824 g = (w[m] - w[n]).real
825 if np.sign(g) == np.sign(g_lo):
826 k_lo, w_lo, g_lo = k_mid, w, g
827 else:
828 k_hi = k_mid
829 return (k_lo + k_hi) / 2
832#################################
833# ENCIRCLING
834#################################
837class Encircling():
838 r'''
839 An eigenstate carried around a loop by parallel transport, see *encircle*.
841 Attributes
842 ----------
843 s : Real ndarray, shape (n,)
844 Loop parameter, from 0 to *n_loops*.
845 k : Real ndarray, shape (n, 2)
846 Points of the loop.
847 en : Complex ndarray, shape (n, norb)
848 All eigenvalues, followed by
849 continuation (see *track_eigenvalues*).
850 vector : Complex ndarray, shape (n, norb)
851 The transported right
852 eigenvector :math:`|R(s)\rangle` of the followed state.
853 band : Integer
854 Label of the state at the start.
855 final_band : Integer
856 Label, at the start, of the eigenvalue the state
857 ends on (different from *band* after an odd number of loops around a
858 second-order EP: the states swap).
859 overlap : Complex number
860 :math:`c = \langle L_{f}(0)|R(\mathrm{end})\rangle`,
861 with :math:`f` = *final_band* and :math:`\langle L_f(0)|R_f(0)\rangle = 1`:
862 the transported state is :math:`c\,|R_f(0)\rangle`. If *final_band* is
863 *band*, :math:`c = e^{i\gamma}` (times a modulus that is 1 for a
864 Hermitian model), :math:`\gamma` the geometric phase; otherwise its
865 phase depends on the (arbitrary) phase of :math:`|R_f(0)\rangle`.
866 '''
868 def __init__(self, s, k, en, vector, band, final_band, overlap) -> None:
869 self.s, self.k, self.en = s, k, en
870 self.vector = vector
871 self.band, self.final_band = band, final_band
872 self.overlap = overlap
874 @property
875 def phase(self) -> float:
876 r'''
877 Accumulated phase :math:`\arg c` in :math:`(-\pi, \pi]` (the Berry
878 phase when the state returns to itself).
879 '''
880 return float(np.angle(self.overlap))
883def encircle(model, loop, band: int = 0, n_loops: int = 1, nk: int = 100) -> Encircling:
884 r'''
885 Carry an eigenstate around a closed loop *n_loops* times, by parallel
886 transport of its right eigenvector :math:`|R\rangle`, with the left one
887 :math:`\langle L|` normalized by :math:`\langle L|R\rangle = 1`:
889 .. math::
891 \langle L(\mathbf{k})|\partial_s R(\mathbf{k})\rangle = 0\, ,
893 discretized symmetrically between successive points,
894 :math:`\langle L_j + L_{j+1}|R_{j+1} - R_j\rangle = 0` (second order in
895 the step; the eigenvalue is followed by continuation, see
896 *track_eigenvalues*). For a Hermitian model :math:`L = R`, the norm is
897 kept exactly, and this is the usual parallel transport: the state comes
898 back as :math:`e^{i\gamma}|R\rangle`,
899 :math:`\gamma = \oint i\langle u|\nabla_{\mathbf{k}}u\rangle\cdot d\mathbf{k}` the
900 Berry phase (the convention of *KSpace.berry_phase*), :math:`\pi` around
901 a diabolical point. Around a second-order exceptional point:
903 * one loop swaps the two eigenstates (*final_band* differs from *band*);
904 * two loops bring the state back with a sign flip, :math:`\gamma = \pi`;
905 * four loops restore it.
907 Parameters
908 ----------
909 model
910 See *track_eigenvalues*.
911 loop
912 See *track_eigenvalues*.
913 band : Integer, default 0
914 Label of the state at the start of
915 the loop (by real part of the eigenvalue).
916 n_loops : Positive integer, default 1
917 Number of turns.
918 nk : Positive integer, default 100
919 Steps per turn before
920 refinement.
922 Returns
923 -------
924 result
925 **Encircling** instance (*en*, the transported
926 *vector*, *final_band*, *overlap* and *phase*).
927 '''
928 ham = _model(model)
929 path = _loop(loop)
930 error_handling.positive_int(n_loops, 'n_loops')
931 error_handling.positive_int(nk, 'nk')
932 norb = _closed(ham, path)
933 error_handling.band_index(band, norb)
934 s, en, vr, vl = _track(ham, path, float(n_loops), nk, vectors=True)
935 # biorthonormal pairs at the start: <L_b|R_b> = 1
936 left0 = vl[0].conj().T / np.sum(vl[0].conj() * vr[0], axis=0)[:, None]
937 vector = [vr[0][:, band]]
938 for j in range(1, len(s)):
939 # <L_j + L_j+1|R_j+1 - R_j> = 0 with <L|R> = 1 at both ends, for
940 # R_j+1 = c R_raw: c^2 = b/a (the root closest to the first-order 1/a).
941 # Second order in the step; |c| = 1 for a Hermitian model.
942 bra = vl[j - 1][:, band].conj()
943 bra = bra / (bra @ vector[-1])
944 raw, raw_bra = vr[j][:, band], vl[j][:, band].conj()
945 a = bra @ raw
946 c = np.sqrt((raw_bra @ vector[-1]) / (raw_bra @ raw) / a)
947 if abs(c - 1 / a) > abs(c + 1 / a):
948 c = -c
949 vector.append(c * raw)
950 final = int(np.argmin(np.abs(en[0] - en[-1, band])))
951 return Encircling(s, np.array([path(x % 1.) for x in s]), en, np.array(vector), band, final,
952 complex(left0[final] @ vector[-1]))