Coverage for tbkit/exceptional.py: 100%
351 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 06:13 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 06:13 +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 :param center: Tuple/list/ndarray of 2 real numbers. :math:`\mathbf{k}_0`.
100 :param radius: Positive real. :math:`r`.
102 :returns:
103 * **loop** -- Callable of s, returning a real ndarray of shape (2,).
104 '''
105 error_handling.k_vector(center, 'center', 2)
106 error_handling.positive_real(radius, 'radius')
107 k0 = np.asarray(center, dtype='f8')
109 def loop(s):
110 return k0 + radius * np.array([np.cos(2 * PI * s), np.sin(2 * PI * s)])
111 return loop
114def _loop(loop) -> Callable[[float], NDArray[np.float64]]:
115 '''
116 Private function. The callable s -> k(s), s in [0, 1], of a validated loop
117 (a callable, or the vertices of a polygon, each side covering an equal
118 range of s).
119 '''
120 error_handling.loop(loop)
121 if callable(loop):
122 def traced(s):
123 p = np.asarray(loop(float(s)), dtype='f8')
124 error_handling.loop_point(p)
125 return p
126 return traced
127 vert = np.asarray(loop, dtype='f8')
128 closed = np.vstack([vert, vert[:1]])
129 m = len(vert)
131 def path(s):
132 x = (s % 1.) * m
133 i = min(int(x), m - 1)
134 return closed[i] + (x - i) * (closed[i + 1] - closed[i])
135 return path
138def _closed(ham, path) -> int:
139 '''
140 Private function. Check that the loop is closed (H at its end equals H at
141 its start, which allows loops across the zone), and return norb.
142 '''
143 h0 = ham(path(0.))
144 error_handling.closed_loop(h0, ham(path(1.)))
145 return h0.shape[0]
148#################################
149# EIGENVALUE CONTINUATION
150#################################
153def _eig(h: NDArray[np.complex128], vectors: bool):
154 '''
155 Private function. Eigenvalues, right and left eigenvectors (as kets:
156 columns of vl with vl^H H = E vl^H) of the general eigenproblem.
157 '''
158 if vectors:
159 w, vl, vr = LA.eig(h, left=True, right=True)
160 return w, vr, vl
161 return LA.eigvals(h), None, None
164def _match(pred, w_old, w_new, vr_old, vr_new):
165 '''
166 Private function. Permutation of *w_new* continuing *w_old* (predicted
167 at *pred*), or None if the step is ambiguous: a matched eigenvalue must
168 be four times closer to its prediction than to any other eigenvalue,
169 no eigenvalue difference may turn by more than pi/4, and (with vectors)
170 each eigenvector must stay close to its predecessor. Eigenvalues equal to
171 rounding precision form a cluster and do not make the step ambiguous.
172 '''
173 _, perm = linear_sum_assignment(np.abs(pred[:, None] - w_new[None, :]))
174 w = w_new[perm]
175 deg = 1e-9 * max(1., float(np.max(np.abs(w))))
176 dist = np.abs(w[:, None] - w[None, :])
177 np.fill_diagonal(dist, np.inf)
178 single = dist.min(axis=1) >= deg # no partner equal to rounding precision
179 dist[dist < deg] = np.inf
180 if np.any(np.abs(w - pred) >= 0.25 * dist.min(axis=1)):
181 return None
182 d_old = w_old[:, None] - w_old[None, :]
183 d_new = w[:, None] - w[None, :]
184 keep = (np.abs(d_old) > deg) & (np.abs(d_new) > deg)
185 if np.any(np.abs(np.angle(d_new[keep] / d_old[keep])) > _ARG_STEP):
186 return None
187 if vr_old is not None:
188 # within a degenerate cluster the eigenvectors are arbitrary
189 overlap = np.abs(np.sum(vr_old.conj() * vr_new[:, perm], axis=0))
190 if np.any(overlap[single] < 0.9):
191 return None
192 return perm
195def _track(ham, path, s_end: float, nk: int, vectors: bool = False):
196 '''
197 Private function. Follow the eigenvalues of H(path(s)) by continuation
198 from s = 0 to *s_end* (loops are evaluated at s modulo 1): steps of at
199 most 1/nk, halved while a step is ambiguous (see *_match*), with a
200 linear prediction from the previous step. The labels are those of the
201 eigenvalues at s = 0 sorted by real part.
203 Returns s (n,), en (n, norb), and, with *vectors*, the right and left
204 eigenvectors (n, norb, norb), column m following label m.
205 '''
206 ds_max = 1. / nk
207 ds_min = ds_max / 2 ** _HALVINGS
208 w, vr, vl = _eig(ham(path(0.)), vectors)
209 order = np.argsort(w.real, kind='stable')
210 w = w[order]
211 if vectors:
212 vr, vl = vr[:, order], vl[:, order]
213 s_list, w_list, vr_list, vl_list = [0.], [w], [vr], [vl]
214 slope = np.zeros_like(w)
215 s, ds = 0., ds_max
216 while s < s_end:
217 last = ds >= s_end - s
218 s_new = s_end if last else s + ds
219 step = s_new - s
220 k = path(s_new % 1. if s_new != s_end or s_end % 1. else 1.)
221 w_new, vr_new, vl_new = _eig(ham(k), vectors)
222 perm = _match(w + slope * step, w, w_new, vr, vr_new)
223 if perm is None:
224 if step <= ds_min:
225 error_handling.tracking(k)
226 ds = step / 2
227 continue
228 slope = (w_new[perm] - w) / step
229 w, s = w_new[perm], s_new
230 if vectors:
231 vr, vl = vr_new[:, perm], vl_new[:, perm]
232 s_list.append(s)
233 w_list.append(w)
234 vr_list.append(vr)
235 vl_list.append(vl)
236 ds = min(2 * step, ds_max)
237 s_arr, en = np.array(s_list), np.array(w_list)
238 if vectors:
239 return s_arr, en, np.array(vr_list), np.array(vl_list)
240 return s_arr, en, None, None
243def track_eigenvalues(
244 model, loop, n_loops: int = 1, nk: int = 100,
245) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.complex128]]:
246 r'''
247 Follow the (complex) eigenvalues of :math:`H(\mathbf{k})` continuously
248 around a closed loop, *n_loops* times. Each step (at most 1/nk of the
249 loop) matches the new eigenvalues to a linear prediction from the
250 previous ones, and is halved while that matching is ambiguous; nothing
251 depends on how the eigensolver orders its output. Label :math:`m` is the
252 :math:`m`-th eigenvalue at the start of the loop, sorted by real part.
253 After one loop around a second-order exceptional point the labels come
254 back swapped: ``en[-1]`` is then a permutation of ``en[0]``.
256 :param model: 2D **KSpace**, or callable of a point of a plane returning
257 a square matrix.
258 :param loop: Closed loop: a callable of :math:`s\in[0, 1]` returning a
259 point (e.g. *circle*), or an array of polygon vertices, shape (m, 2).
260 For a **KSpace**, points are k in the coordinates of *get_ham*, and a
261 loop may close up to a reciprocal lattice vector.
262 :param n_loops: Positive integer. Default value 1. Number of turns.
263 :param nk: Positive integer. Default value 100. Steps per turn before
264 refinement.
266 :returns:
267 * **s** -- Real ndarray, shape (n,). Loop parameter, from 0 to *n_loops*.
268 * **k** -- Real ndarray, shape (n, 2). Points of the loop.
269 * **en** -- Complex ndarray, shape (n, norb). Eigenvalues, en[:, m]
270 continuous in s.
271 '''
272 ham = _model(model)
273 path = _loop(loop)
274 error_handling.positive_int(n_loops, 'n_loops')
275 error_handling.positive_int(nk, 'nk')
276 _closed(ham, path)
277 s, en, _, _ = _track(ham, path, float(n_loops), nk)
278 return s, np.array([path(x % 1.) for x in s]), en
281def vorticity(model, loop, bands: tuple[int, int] = (0, 1), nk: int = 100) -> float:
282 r'''
283 Get the eigenvalue vorticity of a pair of bands around a closed loop
284 :math:`\Gamma` (Shen, Zhen and Fu, Phys. Rev. Lett. 120, 146402 (2018)),
286 .. math::
288 \nu_{mn}(\Gamma) = -\frac{1}{2\pi}\oint_\Gamma
289 \nabla_{\mathbf{k}}\arg\left[E_m(\mathbf{k}) - E_n(\mathbf{k})\right]
290 \cdot d\mathbf{k}\, ,
292 with the eigenvalues followed by continuation (see *track_eigenvalues*).
293 :math:`\nu_{mn} = \pm1/2` around a second-order exceptional point, where
294 :math:`E_m - E_n \propto \sqrt{\mathbf{k}-\mathbf{k}_{EP}}` changes sign
295 after one loop; :math:`\pm1/3` for each pair of a third-order one;
296 0 around a diabolical point, or a loop enclosing no degeneracy.
297 :math:`\nu_{mn} = \nu_{nm}`.
299 :param model: See *track_eigenvalues*.
300 :param loop: See *track_eigenvalues*. Counterclockwise for the sign above.
301 :param bands: Pair of distinct integers. Default value (0, 1). Band labels
302 :math:`(m, n)` at the start of the loop, sorted by real part.
303 :param nk: Positive integer. Default value 100. See *track_eigenvalues*.
305 :returns:
306 * **nu** -- Real number, a multiple of 1/2 around second-order EPs.
307 '''
308 ham = _model(model)
309 path = _loop(loop)
310 error_handling.positive_int(nk, 'nk')
311 norb = _closed(ham, path)
312 error_handling.band_pair(bands, norb)
313 s, en, _, _ = _track(ham, path, 1., nk)
314 diff = en[:, bands[0]] - en[:, bands[1]]
315 if np.any(diff == 0.):
316 error_handling.tracking(path(s[np.argmin(np.abs(diff))] % 1.))
317 return float(-np.sum(np.angle(diff[1:] / diff[:-1])) / (2 * PI))
320#################################
321# DISCRIMINANT
322#################################
325def _charpoly(h: NDArray[np.complex128]) -> NDArray[np.complex128]:
326 r'''
327 Private function. Coefficients, highest degree first, of the monic
328 characteristic polynomial :math:`p(z) = \det(z - H)`, from its values
329 at N+1 points of a circle of radius :math:`\|H\|_F` (a discrete Fourier
330 transform; no eigenvalues).
331 '''
332 n = h.shape[0]
333 r = LA.norm(h) or 1.
334 z = r * np.exp(2j * PI * np.arange(n + 1) / (n + 1))
335 vals = np.array([LA.det(zz * np.eye(n) - h) for zz in z])
336 coef = np.fft.fft(vals) / (n + 1) / r ** np.arange(n + 1)
337 coef[n] = 1.
338 return coef[::-1]
341def _discriminant(h: NDArray[np.complex128]) -> complex:
342 r'''
343 Private function. :math:`\Delta = (-1)^{N(N-1)/2}\,\mathrm{Res}(p, p')`
344 of the monic characteristic polynomial, from the determinant of the
345 Sylvester matrix, computed for :math:`H/\|H\|_F` and rescaled.
346 '''
347 n = h.shape[0]
348 if n < 2:
349 return 1. + 0j
350 r = LA.norm(h)
351 if r == 0.:
352 return 0j
353 c = _charpoly(h / r)
354 dc = c[:-1] * np.arange(n, 0, -1)
355 syl = np.zeros((2 * n - 1, 2 * n - 1), 'c16')
356 for i in range(n - 1):
357 syl[i, i:i + n + 1] = c
358 for i in range(n):
359 syl[n - 1 + i, i:i + n] = dc
360 return complex((-1) ** (n * (n - 1) // 2) * LA.det(syl) * r ** (n * (n - 1)))
363def discriminant(model, k: ArrayLike) -> complex:
364 r'''
365 Get the discriminant of :math:`H(\mathbf{k})`,
367 .. math::
369 \Delta(\mathbf{k}) = \prod_{m<n}\left[E_m(\mathbf{k}) - E_n(\mathbf{k})\right]^2
370 = (-1)^{N(N-1)/2}\,\mathrm{Res}(p, p')\, ,
372 computed as the resultant of the characteristic polynomial
373 :math:`p(E) = \det[E - H(\mathbf{k})]` and its derivative (the determinant
374 of their Sylvester matrix), without the eigenvalues. It is a polynomial
375 in the matrix elements, hence smooth and single-valued, and it vanishes
376 exactly where two eigenvalues meet. For a two-band model
377 :math:`H = d_0 + \mathbf{d}\cdot\boldsymbol\sigma`,
378 :math:`\Delta = 4\,\mathbf{d}\cdot\mathbf{d}`.
380 :param model: See *track_eigenvalues*.
381 :param k: Tuple/list/ndarray of 2 real numbers. Point of the plane.
383 :returns:
384 * **delta** -- Complex number, in units of energy^(N(N-1)) (1 if N = 1).
385 '''
386 ham = _model(model)
387 error_handling.k_vector(k, 'k', 2)
388 return _discriminant(ham(np.asarray(k, dtype='f8')))
391def _arg_change(disc, path, s_a, s_b, d_a, d_b, depth=0) -> float:
392 '''
393 Private function. Change of arg(Delta) from path(s_a) to path(s_b),
394 bisecting until each piece turns by at most pi/4.
395 '''
396 if d_a == 0. or d_b == 0. or depth > 40:
397 error_handling.winding_path(path(s_b if d_b == 0. else s_a))
398 step = float(np.angle(d_b / d_a))
399 if abs(step) <= _ARG_STEP:
400 return step
401 s_m = (s_a + s_b) / 2
402 d_m = disc(path(s_m))
403 return (_arg_change(disc, path, s_a, s_m, d_a, d_m, depth + 1)
404 + _arg_change(disc, path, s_m, s_b, d_m, d_b, depth + 1))
407def _winding(ham, path, nk: int) -> float:
408 '''
409 Private function. Winding of the discriminant along path(s), s in [0, 1].
410 '''
411 def disc(p):
412 return _discriminant(ham(p))
413 s = np.linspace(0., 1., nk + 1)
414 d = [disc(path(x)) for x in s]
415 total = sum(_arg_change(disc, path, s[i], s[i + 1], d[i], d[i + 1]) for i in range(nk))
416 return total / (2 * PI)
419def discriminant_winding(model, loop, nk: int = 100) -> float:
420 r'''
421 Get the winding number of the discriminant (see *discriminant*) around a
422 closed loop :math:`\Gamma` (Yang, Schnyder, Hu and Chiu, Phys. Rev. Lett.
423 126, 086401 (2021)),
425 .. math::
427 W(\Gamma) = \frac{1}{2\pi}\oint_\Gamma \nabla_{\mathbf{k}}\arg\Delta(\mathbf{k})
428 \cdot d\mathbf{k} = -2\sum_{m<n}\nu_{mn}(\Gamma)\, ,
430 the total charge of the exceptional points inside :math:`\Gamma`
431 (:math:`\pm1` for a second-order EP, where :math:`\Delta` vanishes
432 linearly). It needs no eigenvalue tracking. A diabolical point has
433 :math:`W = 0`: :math:`\Delta \geq 0` for a Hermitian model. The relation to
434 the vorticities *vorticity* holds with the signs as written, the minus
435 sign coming from Shen, Zhen and Fu's definition of :math:`\nu`.
437 :param model: See *track_eigenvalues*.
438 :param loop: See *track_eigenvalues*. Counterclockwise for the sign above.
439 :param nk: Positive integer. Default value 100. Points on the loop; a
440 step along which :math:`\arg\Delta` turns by more than :math:`\pi/4`
441 is bisected.
443 :returns:
444 * **W** -- Real number, close to an integer.
445 '''
446 ham = _model(model)
447 path = _loop(loop)
448 error_handling.positive_int(nk, 'nk')
449 _closed(ham, path)
450 return float(_winding(ham, path, nk))
453#################################
454# EXCEPTIONAL POINTS
455#################################
458def _petermann(h: NDArray[np.complex128]):
459 '''
460 Private function. Eigenvalues (sorted by real part) and Petermann factors.
461 '''
462 w, vl, vr = LA.eig(h, left=True, right=True)
463 order = np.argsort(w.real, kind='stable')
464 w, vl, vr = w[order], vl[:, order], vr[:, order]
465 norms = np.sum(np.abs(vl) ** 2, axis=0) * np.sum(np.abs(vr) ** 2, axis=0)
466 pair = np.abs(np.sum(vl.conj() * vr, axis=0)) ** 2
467 return w, norms / np.maximum(pair, np.finfo(float).tiny)
470def petermann_factors(model, k: ArrayLike) -> tuple[NDArray[np.complex128], NDArray[np.float64]]:
471 r'''
472 Get the eigenvalues of :math:`H(\mathbf{k})` and their Petermann factors
474 .. math::
476 K_n = \frac{\langle L_n|L_n\rangle\langle R_n|R_n\rangle}{|\langle L_n|R_n\rangle|^2}
477 \geq 1\, ,
479 with :math:`|R_n\rangle`, :math:`|L_n\rangle` the right and left
480 eigenvectors: 1 for a normal (e.g. Hermitian) matrix, diverging at an
481 exceptional point, where :math:`\langle L_n|R_n\rangle \to 0`
482 (as :math:`|\mathbf{k}-\mathbf{k}_{EP}|^{-1}` near a second-order one).
484 :param model: See *track_eigenvalues*.
485 :param k: Tuple/list/ndarray of 2 real numbers.
487 :returns:
488 * **en** -- Complex ndarray, shape (norb,). Eigenvalues, sorted by real part.
489 * **K** -- Real ndarray, shape (norb,). Petermann factors.
490 '''
491 ham = _model(model)
492 error_handling.k_vector(k, 'k', 2)
493 return _petermann(ham(np.asarray(k, dtype='f8')))
496def _order(h: NDArray[np.complex128], k_min: float = 1e4) -> tuple[int, float, complex]:
497 '''
498 Private function. Order of the exceptional point at H: the number of
499 eigenvalues whose Petermann factor exceeds sqrt(max K) (the coalescing
500 ones, all of order max K), 1 if max K < k_min (no EP); with max K and
501 the mean coalescing eigenvalue.
502 '''
503 w, kp = _petermann(h)
504 k_max = float(np.max(kp))
505 if k_max < k_min:
506 return 1, k_max, complex(w[np.argmax(kp)])
507 big = kp > np.sqrt(k_max)
508 return int(np.sum(big)), k_max, complex(np.mean(w[big]))
511class ExceptionalPoints():
512 r'''
513 Exceptional points of a 2D model, see *find_exceptional_points*.
515 :ivar k: Real ndarray, shape (n, 2). Isolated exceptional points.
516 :ivar charge: Integer ndarray, shape (n,). Discriminant winding around each
517 one (see *discriminant_winding*): :math:`\pm1` for a second-order EP.
518 :ivar order: Integer ndarray, shape (n,). Number of coalescing eigenvalues
519 (the size of the Jordan block): 2, 3, ...
520 :ivar petermann: Real ndarray, shape (n,). Largest Petermann factor at
521 each point (divergent at an EP: about :math:`10^{8}` or more once refined).
522 :ivar energy: Complex ndarray, shape (n,). The coalescing eigenvalue.
523 :ivar lines: Real ndarray, shape (m, 2). Points on exceptional lines
524 (rings), when a symmetry makes the discriminant real; empty otherwise.
525 '''
527 def __init__(self, k, charge, order, petermann, energy, lines) -> None:
528 self.k = k
529 self.charge = charge
530 self.order = order
531 self.petermann = petermann
532 self.energy = energy
533 self.lines = lines
535 @property
536 def total_charge(self) -> int:
537 '''
538 Sum of the charges: zero over a whole Brillouin zone (doubling theorem).
539 '''
540 return int(np.sum(self.charge))
543def _mesh(ks: KSpace, nk: int):
544 '''
545 Private function. Shifted nk x nk mesh of the zone: node k-points,
546 shape (nk + 1, nk + 1, 2) (the last row/column repeat the first ones
547 shifted by a reciprocal vector), and the plaquette size.
548 '''
549 fr = (np.arange(nk + 1)[:, None] + np.array(_MESH_SHIFT)[None, :]) / nk
550 fracs = np.stack(np.meshgrid(fr[:, 0], fr[:, 1], indexing='ij'), axis=-1)
551 size = min(np.linalg.norm(b) for b in ks.rec_vec_k) / nk
552 return fracs @ ks.rec_vec_k, size
555def _edges(nk: int):
556 '''
557 Private function. Mesh edges ((i, j), (i', j')) along b1 then along b2.
558 '''
559 return ([((i, j), (i + 1, j)) for i in range(nk) for j in range(nk)]
560 + [((i, j), (i, j + 1)) for i in range(nk) for j in range(nk)])
563def _bisect(func, k_a, k_b, f_a, n_iter=60):
564 '''
565 Private function. Root of the real function *func* on the segment
566 [k_a, k_b], across which it changes sign (f_a = func(k_a)).
567 '''
568 lo, hi = 0., 1.
569 for _ in range(n_iter):
570 mid = (lo + hi) / 2
571 f_m = func(k_a + mid * (k_b - k_a))
572 if np.sign(f_m) == np.sign(f_a):
573 lo, f_a = mid, f_m
574 else:
575 hi = mid
576 return k_a + (lo + hi) / 2 * (k_b - k_a)
579def _newton(disc, k0, mult, h, tol, max_iter):
580 r'''
581 Private function. Newton's method on (Re Delta, Im Delta) = 0, with a
582 central-difference Jacobian; the step is multiplied by the multiplicity
583 *mult* of the zero (Delta ~ L(k)^mult, L complex linear, as at a
584 higher-order exceptional point).
585 '''
586 k = np.array(k0, dtype='f8')
587 for _ in range(max_iter):
588 f = disc(k)
589 jac = np.zeros((2, 2))
590 for mu in range(2):
591 e = np.zeros(2)
592 e[mu] = h
593 d = (disc(k + e) - disc(k - e)) / (2 * h)
594 jac[:, mu] = d.real, d.imag
595 step = mult * np.linalg.lstsq(jac, np.array([f.real, f.imag]), rcond=None)[0]
596 k = k - step
597 if np.linalg.norm(step) < tol:
598 break
599 return k
602def find_exceptional_points(
603 ks: KSpace, nk: int = 40, tol: float = 1e-12, max_iter: int = 50,
604) -> ExceptionalPoints:
605 r'''
606 Locate the exceptional points of a 2D model over its Brillouin zone.
608 1. The discriminant :math:`\Delta(\mathbf{k})` (see *discriminant*) is
609 computed on an :math:`nk\times nk` mesh of the zone (shifted off the
610 high-symmetry lines), and its winding around every plaquette from the
611 phase changes along the mesh edges (bisected where :math:`\arg\Delta`
612 turns by more than :math:`\pi/4`). A plaquette with a nonzero winding
613 :math:`w` holds an EP.
614 2. Each one is refined by Newton's method on
615 :math:`(\mathrm{Re}\,\Delta, \mathrm{Im}\,\Delta) = 0` from the plaquette
616 centre (the step multiplied by :math:`|w|`, the multiplicity of the zero).
617 3. Its charge is the winding of :math:`\Delta` around a small circle
618 (a quarter of a plaquette), and its order the number of eigenvalues
619 whose Petermann factor diverges there (see *petermann_factors*).
621 If a symmetry makes :math:`\Delta` real everywhere (up to a constant
622 phase), EPs are not isolated: they form lines, exceptional rings, where
623 :math:`\Delta` changes sign. The lines are then sampled where they cross
624 the mesh edges (bisection to machine precision), and no isolated point is
625 returned. A Hermitian model has :math:`\Delta \geq 0`, and no EP.
627 Since :math:`\Delta` is periodic, the charges add up to zero
628 (*total_charge*): EPs come in pairs of opposite charge. The mesh must be
629 fine enough that no plaquette holds two EPs of opposite charge.
631 :param ks: **KSpace** instance with two primitive vectors.
632 :param nk: Positive integer. Default value 40. Mesh points along each
633 reciprocal vector.
634 :param tol: Positive real. Default value 1e-12. Newton's method stops
635 when a step is shorter than *tol* (units of k).
636 :param max_iter: Positive integer. Default value 50. Newton iterations.
638 :returns:
639 * **eps** -- **ExceptionalPoints** instance (positions *k*, *charge*,
640 *order*, *petermann*, *energy*, and the exceptional *lines*).
641 '''
642 error_handling.kspace(ks, KSpace)
643 error_handling.dim_exact(ks.dim, 2)
644 ham = _model(ks)
645 error_handling.positive_int(nk, 'nk')
646 error_handling.positive_real(tol, 'tol')
647 error_handling.positive_int(max_iter, 'max_iter')
649 def disc(k):
650 return _discriminant(ham(k))
652 kmesh, size = _mesh(ks, nk)
653 d = np.array([[disc(kmesh[i, j]) for j in range(nk)] for i in range(nk)])
654 d = np.pad(d, ((0, 1), (0, 1)), mode='wrap')
655 scale = float(np.max(np.abs(d)))
656 error_handling.discriminant_nonzero(scale)
657 phase = np.exp(-0.5j * np.angle(np.sum(d ** 2)))
658 empty = np.zeros((0, 2))
659 if np.max(np.abs((phase * d).imag)) <= 1e-8 * scale:
660 # a real discriminant: exceptional lines where it changes sign
661 lines = [_bisect(lambda k: (phase * disc(k)).real, kmesh[a], kmesh[b], (phase * d[a]).real)
662 for a, b in _edges(nk) if (phase * d[a]).real * (phase * d[b]).real < 0.]
663 return ExceptionalPoints(empty, np.zeros(0, int), np.zeros(0, int), np.zeros(0),
664 np.zeros(0, 'c16'), np.array(lines).reshape(-1, 2))
665 # phase change along the edge leaving node (i, j) along b1 (axis 0) or b2
666 # (axis 1); Delta is periodic, so the last row/column repeat the first ones
667 edge = np.zeros((2, nk, nk))
668 for a, b in _edges(nk):
669 edge[int(b[1] != a[1])][a] = _arg_change(
670 disc, lambda s, a=a, b=b: kmesh[a] + s * (kmesh[b] - kmesh[a]), 0., 1., d[a], d[b])
671 b1, b2 = ks.rec_vec_k
672 orient = np.sign(b1[0] * b2[1] - b1[1] * b2[0])
673 points: list = []
674 for i in range(nk):
675 for j in range(nk):
676 w = (edge[0, i, j] + edge[1, (i + 1) % nk, j]
677 - edge[0, i, (j + 1) % nk] - edge[1, i, j]) / (2 * PI)
678 w = int(np.rint(orient * w))
679 if w == 0:
680 continue
681 center = (kmesh[i, j] + kmesh[i + 1, j + 1]) / 2
682 k_ep = _newton(disc, center, abs(w), 1e-5 * size, tol, max_iter)
683 if all(np.linalg.norm(k_ep - p) > 1e-6 * size for p in points):
684 points.append(k_ep)
685 charge, order, peter, energy = [], [], [], []
686 for p in points:
687 charge.append(int(np.rint(_winding(ham, circle(p, 0.25 * size), 16))))
688 o, kp, e = _order(ham(p))
689 order.append(o)
690 peter.append(kp)
691 energy.append(e)
692 return ExceptionalPoints(np.array(points).reshape(-1, 2), np.array(charge, int),
693 np.array(order, int), np.array(peter),
694 np.array(energy, 'c16'), empty)
697def fermi_arcs(ks: KSpace, nk: int = 60) -> NDArray[np.float64]:
698 r'''
699 Get the bulk Fermi arcs of a 2D non-Hermitian model: the curves where
700 two eigenvalues have equal real parts but different imaginary parts,
701 :math:`\mathrm{Re}(E_m - E_n) = 0`, :math:`\mathrm{Im}(E_m - E_n) \neq 0`.
702 Such an arc joins two exceptional points of opposite charge (for a
703 two-band model it is where :math:`\Delta` is real and negative, see
704 *discriminant*); it is what a Hermitian Dirac point turns into when a
705 non-Hermitian term splits it into an EP pair (Kozii and Fu 2017; Zhou et
706 al., Science 359, 1009 (2018)).
708 Along every edge of an :math:`nk\times nk` mesh of the zone the
709 eigenvalues are followed by continuation (see *track_eigenvalues*), and
710 where :math:`\mathrm{Re}(E_m - E_n)` changes sign, the crossing is found by
711 bisection. Regions where the real parts coincide over an area (inside an
712 exceptional ring) show no sign change and are not reported, and neither
713 are edges that cross an exceptional point or line, along which the
714 eigenvalues cannot be followed.
716 :param ks: **KSpace** instance with two primitive vectors.
717 :param nk: Positive integer. Default value 60. Mesh points along each
718 reciprocal vector.
720 :returns:
721 * **points** -- Real ndarray, shape (m, 2). Points on the arcs.
722 '''
723 error_handling.kspace(ks, KSpace)
724 error_handling.dim_exact(ks.dim, 2)
725 ham = _model(ks)
726 error_handling.positive_int(nk, 'nk')
727 kmesh, _ = _mesh(ks, nk)
728 points = []
729 for a, b in _edges(nk):
730 k_a, k_b = kmesh[a], kmesh[b]
731 try:
732 s, en, _, _ = _track(ham, lambda x: k_a + x * (k_b - k_a), 1., 1)
733 except ValueError:
734 continue # the edge crosses an exceptional point (or line)
735 noise = 1e-10 * max(1., float(np.max(np.abs(en))))
736 norb = en.shape[1]
737 for m in range(norb):
738 for n in range(m + 1, norb):
739 g = (en[:, m] - en[:, n]).real
740 for i in np.nonzero((g[:-1] * g[1:] < 0.) & (np.abs(g[:-1]) > noise)
741 & (np.abs(g[1:]) > noise))[0]:
742 k_lo = k_a + s[i] * (k_b - k_a)
743 k_hi = k_a + s[i + 1] * (k_b - k_a)
744 k_arc = _arc_point(ham, k_lo, k_hi, en[i], m, n)
745 # not a degeneracy (an EP at the end of the arc)
746 e = LA.eigvals(ham(k_arc))
747 sep = np.abs(e[:, None] - e[None, :])
748 np.fill_diagonal(sep, np.inf)
749 if np.min(sep) > noise:
750 points.append(k_arc)
751 return np.array(points).reshape(-1, 2)
754def _arc_point(ham, k_lo, k_hi, w_lo, m, n, n_iter=50):
755 '''
756 Private function. Bisection for Re(E_m - E_n) = 0 between k_lo and k_hi,
757 the labels carried along by matching to the eigenvalues at the lower end.
758 '''
759 g_lo = (w_lo[m] - w_lo[n]).real
760 for _ in range(n_iter):
761 k_mid = (k_lo + k_hi) / 2
762 w = LA.eigvals(ham(k_mid))
763 _, perm = linear_sum_assignment(np.abs(w_lo[:, None] - w[None, :]))
764 w = w[perm]
765 g = (w[m] - w[n]).real
766 if np.sign(g) == np.sign(g_lo):
767 k_lo, w_lo, g_lo = k_mid, w, g
768 else:
769 k_hi = k_mid
770 return (k_lo + k_hi) / 2
773#################################
774# ENCIRCLING
775#################################
778class Encircling():
779 r'''
780 An eigenstate carried around a loop by parallel transport, see *encircle*.
782 :ivar s: Real ndarray, shape (n,). Loop parameter, from 0 to *n_loops*.
783 :ivar k: Real ndarray, shape (n, 2). Points of the loop.
784 :ivar en: Complex ndarray, shape (n, norb). All eigenvalues, followed by
785 continuation (see *track_eigenvalues*).
786 :ivar vector: Complex ndarray, shape (n, norb). The transported right
787 eigenvector :math:`|R(s)\rangle` of the followed state.
788 :ivar band: Integer. Label of the state at the start.
789 :ivar final_band: Integer. Label, at the start, of the eigenvalue the state
790 ends on (different from *band* after an odd number of loops around a
791 second-order EP: the states swap).
792 :ivar overlap: Complex number. :math:`c = \langle L_{f}(0)|R(\mathrm{end})\rangle`,
793 with :math:`f` = *final_band* and :math:`\langle L_f(0)|R_f(0)\rangle = 1`:
794 the transported state is :math:`c\,|R_f(0)\rangle`. If *final_band* is
795 *band*, :math:`c = e^{i\gamma}` (times a modulus that is 1 for a
796 Hermitian model), :math:`\gamma` the geometric phase; otherwise its
797 phase depends on the (arbitrary) phase of :math:`|R_f(0)\rangle`.
798 '''
800 def __init__(self, s, k, en, vector, band, final_band, overlap) -> None:
801 self.s, self.k, self.en = s, k, en
802 self.vector = vector
803 self.band, self.final_band = band, final_band
804 self.overlap = overlap
806 @property
807 def phase(self) -> float:
808 r'''
809 Accumulated phase :math:`\arg c` in :math:`(-\pi, \pi]` (the Berry
810 phase when the state returns to itself).
811 '''
812 return float(np.angle(self.overlap))
815def encircle(model, loop, band: int = 0, n_loops: int = 1, nk: int = 100) -> Encircling:
816 r'''
817 Carry an eigenstate around a closed loop *n_loops* times, by parallel
818 transport of its right eigenvector :math:`|R\rangle`, with the left one
819 :math:`\langle L|` normalized by :math:`\langle L|R\rangle = 1`:
821 .. math::
823 \langle L(\mathbf{k})|\partial_s R(\mathbf{k})\rangle = 0\, ,
825 discretized symmetrically between successive points,
826 :math:`\langle L_j + L_{j+1}|R_{j+1} - R_j\rangle = 0` (second order in
827 the step; the eigenvalue is followed by continuation, see
828 *track_eigenvalues*). For a Hermitian model :math:`L = R`, the norm is
829 kept exactly, and this is the usual parallel transport: the state comes
830 back as :math:`e^{i\gamma}|R\rangle`,
831 :math:`\gamma = \oint i\langle u|\nabla_{\mathbf{k}}u\rangle\cdot d\mathbf{k}` the
832 Berry phase (the convention of *KSpace.berry_phase*), :math:`\pi` around
833 a diabolical point. Around a second-order exceptional point:
835 * one loop swaps the two eigenstates (*final_band* differs from *band*);
836 * two loops bring the state back with a sign flip, :math:`\gamma = \pi`;
837 * four loops restore it.
839 :param model: See *track_eigenvalues*.
840 :param loop: See *track_eigenvalues*.
841 :param band: Integer. Default value 0. Label of the state at the start of
842 the loop (by real part of the eigenvalue).
843 :param n_loops: Positive integer. Default value 1. Number of turns.
844 :param nk: Positive integer. Default value 100. Steps per turn before
845 refinement.
847 :returns:
848 * **result** -- **Encircling** instance (*en*, the transported
849 *vector*, *final_band*, *overlap* and *phase*).
850 '''
851 ham = _model(model)
852 path = _loop(loop)
853 error_handling.positive_int(n_loops, 'n_loops')
854 error_handling.positive_int(nk, 'nk')
855 norb = _closed(ham, path)
856 error_handling.band_index(band, norb)
857 s, en, vr, vl = _track(ham, path, float(n_loops), nk, vectors=True)
858 # biorthonormal pairs at the start: <L_b|R_b> = 1
859 left0 = vl[0].conj().T / np.sum(vl[0].conj() * vr[0], axis=0)[:, None]
860 vector = [vr[0][:, band]]
861 for j in range(1, len(s)):
862 # <L_j + L_j+1|R_j+1 - R_j> = 0 with <L|R> = 1 at both ends, for
863 # R_j+1 = c R_raw: c^2 = b/a (the root closest to the first-order 1/a).
864 # Second order in the step; |c| = 1 for a Hermitian model.
865 bra = vl[j - 1][:, band].conj()
866 bra = bra / (bra @ vector[-1])
867 raw, raw_bra = vr[j][:, band], vl[j][:, band].conj()
868 a = bra @ raw
869 c = np.sqrt((raw_bra @ vector[-1]) / (raw_bra @ raw) / a)
870 if abs(c - 1 / a) > abs(c + 1 / a):
871 c = -c
872 vector.append(c * raw)
873 final = int(np.argmin(np.abs(en[0] - en[-1, band])))
874 return Encircling(s, np.array([path(x % 1.) for x in s]), en, np.array(vector), band, final,
875 complex(left0[final] @ vector[-1]))