Coverage for tbkit/floquet.py: 100%
331 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"""
2Floquet theory of periodically driven Tight-Binding models,
3:math:`H(t + T) = H(t)`, :math:`\omega = 2\pi/T` (:math:`\hbar = 1`).
5The evolution over one period, :math:`U(T) = \mathcal{T}e^{-i\int_0^TH(t)dt}
6= e^{-iH_FT}`, defines the Floquet Hamiltonian :math:`H_F`, whose
7eigenvalues -- the quasienergies :math:`\epsilon`, defined modulo
8:math:`\omega` -- replace the energies of a static model: the driven
9system, observed once per period, behaves as if it were static with
10Hamiltonian :math:`H_F`. Driving can therefore *engineer* band structures
11and their topology: circularly polarized light turns graphene into a
12Chern insulator (Oka and Aoki, Phys. Rev. B 79, 081406 (2009); Kitagawa,
13Oka, Brataas, Fu and Demler, Phys. Rev. B 84, 235108 (2011)).
15Two routes give the quasienergies, checked against each other in the tests:
17* the time-ordered product of short-time propagators (*evolution_operator*,
18 *quasienergies*, *effective_hamiltonian*);
19* the Sambe (extended) space of the harmonics
20 :math:`H(t) = \sum_m H_m e^{-im\omega t}`, a static eigenvalue problem
21 (*harmonics*, *sambe_hamiltonian*).
23:class:`FloquetKSpace` makes a driven **KSpace** model: its *get_ham* is
24the Floquet Hamiltonian :math:`H_F(\mathbf{k})`, so its bands, Berry
25curvature and Chern numbers are the driven ones.
27Anomalous Floquet phases. The quasienergies are defined modulo
28:math:`\omega`, so :math:`H_F` depends on where the branch cut of the
29logarithm is put (the *epsilon* arguments). The Chern numbers of the
30Floquet bands no longer count the edge states: every band can have
31:math:`C = 0` while chiral edge states cross every gap (Kitagawa, Berg,
32Rudner and Demler, Phys. Rev. B 82, 235114 (2010)). The winding number of
33Rudner, Lindner, Berg and Levin (Phys. Rev. X 3, 031005 (2013)), which
34uses the evolution at *all* times of the period, does
35(*DrivenKSpace.winding_number*):
37* :class:`DrivenKSpace` makes a driven Bloch model out of any
38 time-dependent :math:`H(\mathbf{k}, t)`;
39* *step_drive* builds a piecewise-constant drive from a list of models
40 (**KSpace** models, ribbons made by *kspace.ribbon*, real-space
41 **System** instances, or matrices), evolved exactly as a product of
42 step exponentials (:class:`StepDrive` in real space);
43* *DrivenKSpace.edge_state_count* counts the chiral edge states of a
44 driven ribbon, independently of the bulk winding number.
45"""
46from __future__ import annotations
48import copy
49from typing import TYPE_CHECKING, Callable
51import numpy as np
52from numpy.typing import ArrayLike, NDArray
53import scipy.linalg as LA
55import tbkit.error_handling as error_handling
57if TYPE_CHECKING:
58 from tbkit.kspace import KSpace
59 from tbkit.lattice import Lattice
62def evolution_operator(ham_t: Callable, period: float, n_steps: int = 100) -> NDArray[np.complex128]:
63 r'''
64 Get the one-period evolution operator
65 :math:`U(T) = \prod_m e^{-iH(t_m)\,\delta t}` (later times on the left,
66 midpoints :math:`t_m = (m + 1/2)\delta t`, error :math:`O(\delta t^2)`).
68 Parameters
69 ----------
70 ham_t : Callable
71 ``ham_t(t)`` returns the Hamiltonian at time t (a square matrix).
72 period : Positive real
73 :math:`T`.
74 n_steps : Positive integer, default 100
75 Time steps per period.
77 Returns
78 -------
79 U : Complex ndarray, the shape of the Hamiltonian
80 '''
81 error_handling.is_callable(ham_t, 'ham_t')
82 error_handling.positive_real(period, 'period')
83 error_handling.positive_int(n_steps, 'n_steps')
84 dt = period / n_steps
85 u = LA.expm(-1j * dt * np.asarray(ham_t(0.5 * dt), dtype='c16'))
86 for m in range(1, n_steps):
87 h = np.asarray(ham_t((m + 0.5) * dt), dtype='c16')
88 u = LA.expm(-1j * dt * h) @ u
89 return u
92def _fold(eps: NDArray[np.float64], period: float, epsilon: float) -> NDArray[np.float64]:
93 '''
94 Private function. Quasienergies folded into [epsilon - 2 pi/T, epsilon).
95 '''
96 omega = 2 * np.pi / period
97 rest = np.mod(eps - epsilon, omega)
98 rest = np.where(rest >= omega, rest - omega, rest) # np.mod may round up to omega
99 return epsilon - omega + rest
102def _floquet(
103 u: NDArray[np.complex128], period: float, epsilon: float | None = None,
104) -> tuple[NDArray[np.float64], NDArray[np.complex128]]:
105 '''
106 Private function. Quasienergies (sorted, in [-pi/T, pi/T), or in
107 [epsilon - 2 pi/T, epsilon)) and Floquet states of a unitary
108 U = exp(-i H_F T), from its complex Schur form.
109 '''
110 tri, z = LA.schur(u, output='complex')
111 eps = -np.angle(np.diag(tri)) / period
112 if epsilon is not None:
113 eps = _fold(eps, period, epsilon)
114 order = np.argsort(eps, kind='stable')
115 return eps[order], z[:, order]
118def _heff(u: NDArray[np.complex128], period: float, epsilon: float | None = None) -> NDArray[np.complex128]:
119 '''
120 Private function. The Floquet Hamiltonian (i/T) ln U, branch cut at epsilon.
121 '''
122 eps, z = _floquet(u, period, epsilon)
123 h_f = z @ np.diag(eps) @ z.conj().T
124 return (h_f + h_f.conj().T) / 2
127def quasienergies(
128 ham_t: Callable, period: float, n_steps: int = 100, epsilon: float | None = None,
129) -> NDArray[np.float64]:
130 r'''
131 Get the quasienergies :math:`\epsilon`, eigenvalues of the Floquet
132 Hamiltonian, in the first Floquet zone :math:`[-\omega/2, \omega/2)`,
133 or in :math:`[\epsilon_{cut} - \omega, \epsilon_{cut})`.
135 Parameters
136 ----------
137 ham_t : Callable
138 See *evolution_operator*.
139 period : Positive real
140 n_steps : Positive integer, default 100
141 epsilon : Real number
142 Default value None (the zone
143 :math:`[-\omega/2, \omega/2)`). Branch cut, see *effective_hamiltonian*.
145 Returns
146 -------
147 eps : Real ndarray, sorted
148 '''
149 error_handling.is_callable(ham_t, 'ham_t')
150 error_handling.positive_real(period, 'period')
151 error_handling.positive_int(n_steps, 'n_steps')
152 error_handling.branch_cut(epsilon)
153 return _floquet(evolution_operator(ham_t, period, n_steps), period, epsilon)[0]
156def effective_hamiltonian(
157 ham_t: Callable, period: float, n_steps: int = 100, epsilon: float | None = None,
158) -> NDArray[np.complex128]:
159 r'''
160 Get the Floquet Hamiltonian :math:`H_F = \frac{i}{T}\ln U(T)`, with its
161 quasienergies in the first Floquet zone (Hermitian for a Hermitian drive).
163 The logarithm is defined up to multiples of :math:`\omega` on each
164 eigenvalue: *epsilon* puts its branch cut at the quasienergy
165 :math:`\epsilon_{cut}` (the phase :math:`e^{-i\epsilon_{cut}T}` of
166 :math:`U`), so that the quasienergies lie in
167 :math:`[\epsilon_{cut} - \omega, \epsilon_{cut})`. The default, the zone
168 :math:`[-\omega/2, \omega/2)`, is the cut :math:`\epsilon_{cut} = \omega/2`.
169 Rudner et al. (2013) take :math:`(\epsilon_{cut}, \epsilon_{cut} + \omega]`
170 instead: :math:`H_F` shifted by :math:`\omega` (the same projectors,
171 so the same Chern numbers and winding numbers).
173 Parameters
174 ----------
175 ham_t : Callable
176 See *evolution_operator*.
177 period : Positive real
178 n_steps : Positive integer, default 100
179 epsilon : Real number
180 Default value None (:math:`\epsilon_{cut} = \omega/2`,
181 computed exactly as before this option existed). Branch cut,
182 best placed in a quasienergy gap.
184 Returns
185 -------
186 H_F : Complex ndarray
187 '''
188 error_handling.is_callable(ham_t, 'ham_t')
189 error_handling.positive_real(period, 'period')
190 error_handling.positive_int(n_steps, 'n_steps')
191 error_handling.branch_cut(epsilon)
192 return _heff(evolution_operator(ham_t, period, n_steps), period, epsilon)
195def harmonics(ham_t: Callable, period: float, n_harmonics: int, n_samples: int | None = None) -> dict:
196 r'''
197 Get the Fourier components :math:`H_m = \frac1T\int_0^T H(t)\,e^{im\omega t}\,dt`
198 of the drive, :math:`H(t) = \sum_m H_m e^{-im\omega t}`, for
199 :math:`|m| \le` *n_harmonics* (exact for a drive with no higher harmonics).
201 Parameters
202 ----------
203 ham_t : Callable
204 See *evolution_operator*.
205 period : Positive real
206 n_harmonics : Positive integer or zero
207 n_samples : Positive integer
208 Default value None (4 n_harmonics + 16).
209 Time samples per period.
211 Returns
212 -------
213 H_m : Dictionary {m: complex ndarray}
214 '''
215 error_handling.is_callable(ham_t, 'ham_t')
216 error_handling.positive_real(period, 'period')
217 error_handling.positive_int_zero(n_harmonics, 'n_harmonics')
218 if n_samples is None:
219 n_samples = 4 * n_harmonics + 16
220 error_handling.positive_int(n_samples, 'n_samples')
221 omega = 2 * np.pi / period
222 ts = period * np.arange(n_samples) / n_samples
223 hams = np.array([np.asarray(ham_t(t), dtype='c16') for t in ts])
224 return {m: np.mean(hams * np.exp(1j * m * omega * ts)[:, None, None], axis=0)
225 for m in range(-n_harmonics, n_harmonics + 1)}
228def sambe_hamiltonian(harm: dict, omega: float, n_max: int) -> NDArray[np.complex128]:
229 r'''
230 Get the Sambe-space Floquet Hamiltonian, truncated to the photon
231 sectors :math:`n = -n_{max}, \dots, n_{max}`:
233 .. math::
235 (\mathcal{H})_{nn'} = H_{n-n'} + n\omega\,\delta_{nn'}\, .
237 Its eigenvalues are the quasienergies, repeated in every Floquet zone
238 (shifted by multiples of :math:`\omega`); those of the central zone
239 converge fastest as *n_max* grows.
241 Parameters
242 ----------
243 harm : Dictionary {m: square complex array}, see *harmonics*
244 omega : Positive real
245 Driving frequency.
246 n_max : Positive integer or zero
248 Returns
249 -------
250 H : Complex ndarray, shape ((2 n_max + 1) d, (2 n_max + 1) d)
251 '''
252 error_handling.harmonics(harm)
253 error_handling.positive_real(omega, 'omega')
254 error_handling.positive_int_zero(n_max, 'n_max')
255 d = len(harm[0])
256 size = 2 * n_max + 1
257 out = np.zeros((size * d, size * d), 'c16')
258 for a, n in enumerate(range(-n_max, n_max + 1)):
259 for b, n2 in enumerate(range(-n_max, n_max + 1)):
260 block = np.asarray(harm.get(n - n2, np.zeros((d, d))), dtype='c16')
261 if n == n2:
262 block = block + n * omega * np.eye(d)
263 out[a*d:(a+1)*d, b*d:(b+1)*d] = block
264 return out
267# ----------------------------------------------------------------------
268# Piecewise-constant (step) drives
269# ----------------------------------------------------------------------
272def _step_evolution(steps: list, t: float | None = None) -> NDArray[np.complex128]:
273 r'''
274 Private function. The exact evolution of a piecewise-constant drive,
275 *steps* = [(H_1, dt_1), (H_2, dt_2), ...] in time order:
276 U(t) = exp(-i H_n (t - t_{n-1})) ... exp(-i H_1 dt_1), U(T) if t is None.
277 '''
278 u = np.eye(len(steps[0][0]), dtype='c16')
279 elapsed = 0.
280 for h, dt in steps:
281 tau = dt if t is None else min(dt, t - elapsed)
282 if tau <= 0:
283 break
284 u = LA.expm(-1j * tau * h) @ u
285 elapsed += dt
286 return u
289class StepDrive:
290 r'''
291 A finite (real-space) system driven by a piecewise-constant
292 Hamiltonian: :math:`H(t) = H_n` for
293 :math:`t_{n-1} \le t < t_n = \delta t_1 + \dots + \delta t_n`, repeated
294 with period :math:`T = \sum_n \delta t_n`. The evolution is exact, a
295 product of step exponentials,
297 .. math::
299 U(t) = e^{-iH_n(t - t_{n-1})}\,e^{-iH_{n-1}\delta t_{n-1}}\cdots e^{-iH_1\delta t_1}\, ,
301 with no time grid. Built by *step_drive* from **System** instances or
302 matrices (see *DrivenKSpace* for Bloch models and ribbons).
304 Parameters
305 ----------
306 hams
307 List of square complex ndarrays, one Hermitian matrix per step (all of one shape).
308 durations : List of positive reals, the step durations :math:`\delta t_n`
309 epsilon : Real number, default None
310 Default branch cut of
311 *quasienergies* and *effective_hamiltonian*.
312 '''
314 def __init__(self, hams: list, durations: ArrayLike, epsilon: float | None = None) -> None:
315 error_handling.step_matrices(hams)
316 error_handling.durations(durations, len(hams))
317 error_handling.branch_cut(epsilon)
318 self.hams = [np.asarray(h, dtype='c16') for h in hams]
319 self.durations = np.array(durations, dtype='f8')
320 self.period = float(self.durations.sum())
321 self.epsilon = epsilon
322 self.n_sites = len(self.hams[0])
324 def get_ham(self, t: float) -> NDArray[np.complex128]:
325 r'''
326 Get the Hamiltonian :math:`H(t)` (periodic in :math:`T`).
328 Parameters
329 ----------
330 t : Real number
331 Time.
333 Returns
334 -------
335 H : Complex ndarray
336 '''
337 error_handling.real_number(t, 't')
338 n = int(np.searchsorted(np.cumsum(self.durations), t % self.period, side='right'))
339 return self.hams[min(n, len(self.hams) - 1)]
341 def evolution_operator(self, t: float | None = None) -> NDArray[np.complex128]:
342 r'''
343 Get the exact evolution operator :math:`U(t)` from time 0.
345 Parameters
346 ----------
347 t : Real number in :math:`[0, T]`
348 Default value None (one period, :math:`U(T)`).
350 Returns
351 -------
352 U : Complex ndarray
353 '''
354 error_handling.time_in_period(t, self.period)
355 return _step_evolution(list(zip(self.hams, self.durations)), t)
357 def quasienergies(self, epsilon: float | None = None) -> NDArray[np.float64]:
358 r'''
359 Get the quasienergies, sorted, in :math:`[-\omega/2, \omega/2)` or
360 :math:`[\epsilon_{cut} - \omega, \epsilon_{cut})` (see *effective_hamiltonian*).
362 Parameters
363 ----------
364 epsilon : Real number
365 Default value None (the *epsilon* of the drive).
367 Returns
368 -------
369 eps : Real ndarray
370 '''
371 error_handling.branch_cut(epsilon)
372 epsilon = self.epsilon if epsilon is None else epsilon
373 return _floquet(self.evolution_operator(), self.period, epsilon)[0]
375 def effective_hamiltonian(self, epsilon: float | None = None) -> NDArray[np.complex128]:
376 r'''
377 Get the Floquet Hamiltonian :math:`H_F = \frac{i}{T}\ln U(T)`, branch
378 cut at *epsilon* (see *tbkit.floquet.effective_hamiltonian*).
380 Parameters
381 ----------
382 epsilon : Real number
383 Default value None (the *epsilon* of the drive).
385 Returns
386 -------
387 H_F : Complex ndarray
388 '''
389 error_handling.branch_cut(epsilon)
390 epsilon = self.epsilon if epsilon is None else epsilon
391 return _heff(self.evolution_operator(), self.period, epsilon)
393 def evolve(self, psi: NDArray, n_periods: int) -> NDArray[np.complex128]:
394 r'''
395 Evolve a state stroboscopically, :math:`\psi(nT) = U(T)^n\psi(0)`.
397 Parameters
398 ----------
399 psi : Complex ndarray, the initial state (one amplitude per site)
400 n_periods : Positive integer or zero
402 Returns
403 -------
404 psi_n
405 Complex ndarray, shape (n_periods + 1, n_sites):
406 the state after 0, 1, ..., n_periods periods.
407 '''
408 error_handling.ndarray(psi, 'psi', self.n_sites)
409 error_handling.positive_int_zero(n_periods, 'n_periods')
410 u = self.evolution_operator()
411 out = np.zeros((n_periods + 1, self.n_sites), 'c16')
412 out[0] = psi
413 for n in range(n_periods):
414 out[n + 1] = u @ out[n]
415 return out
418def step_drive(models: list, durations: ArrayLike, epsilon: float | None = None):
419 r'''
420 Build a piecewise-constant drive: the Hamiltonian of ``models[n]`` acts
421 for ``durations[n]``, in order, and the cycle repeats with period
422 :math:`T = \sum_n \delta t_n`. The evolution is exact (a product of step
423 exponentials, no time grid, see *StepDrive*).
425 Parameters
426 ----------
427 models
428 List of models of one size, all of one kind:
430 * **KSpace** instances (including ribbons made by *kspace.ribbon*)
431 -- returns a *DrivenKSpace*, whose *get_ham* is the Floquet
432 Hamiltonian :math:`H_F(\mathbf{k})`;
433 * **System** instances (their *get_ham* is called) or square
434 matrices (dense or scipy.sparse) -- returns a *StepDrive*.
436 Each must be Hermitian; KSpace models need an orthogonal basis.
437 durations : List of positive reals
438 :math:`\delta t_n`, one per model.
439 epsilon : Real number, default None
440 Branch cut of the
441 Floquet Hamiltonian (see *effective_hamiltonian*).
443 Returns
444 -------
445 drive
446 *DrivenKSpace* or *StepDrive*.
448 Examples
449 --------
450 Example usage::
452 # Rudner et al. (2013): hopping along one bond direction per step
453 drive = step_drive([ks1, ks2, ks3, ks4, ks5], [T/5] * 5)
454 w = drive.winding_number(epsilon=np.pi / T)
455 '''
456 from tbkit.kspace import KSpace
457 from tbkit.system import System
458 kind = error_handling.step_models(models, KSpace, System)
459 error_handling.durations(durations, len(models))
460 error_handling.branch_cut(epsilon)
461 if kind == 'kspace':
462 return DrivenKSpace._from_steps(models, durations, epsilon)
463 hams = []
464 for m in models:
465 if isinstance(m, System):
466 m.get_ham()
467 m = m.ham
468 hams.append(m.toarray() if hasattr(m, 'toarray') else np.asarray(m))
469 return StepDrive(hams, durations, epsilon)
472# ----------------------------------------------------------------------
473# Driven Bloch models
474# ----------------------------------------------------------------------
477def _bloch_grid(model, ks: NDArray[np.float64], a: ArrayLike | None = None) -> NDArray[np.complex128]:
478 r'''
479 Private function. The Bloch Hamiltonians of a static **KSpace** at many
480 k-points at once, shape (nk, norb, norb): *get_ham* (or, with a vector
481 potential *a*, *get_ham_peierls*) vectorized over k. A model whose class
482 redefines these methods is evaluated point by point.
483 '''
484 from tbkit.kspace import KSpace
485 if type(model).get_ham is not KSpace.get_ham or type(model).get_ham_peierls is not KSpace.get_ham_peierls:
486 if a is None:
487 return np.array([model.get_ham(k) for k in ks])
488 return np.array([model.get_ham_peierls(k, a) for k in ks])
489 return model._bloch_ham(ks @ model.k_basis.T, None if a is None else np.asarray(a, dtype='f8'))
492def _spectral_derivative(u: NDArray[np.complex128], axis: int) -> NDArray[np.complex128]:
493 r'''
494 Private function. Derivative of a function sampled on a uniform grid
495 of one period [0, 1) along *axis* (exact for a trigonometric
496 polynomial, exponentially accurate for an analytic periodic function).
497 '''
498 n = u.shape[axis]
499 m = np.fft.fftfreq(n, 1. / n)
500 if n % 2 == 0:
501 m[n // 2] = 0. # the Nyquist mode has no odd derivative
502 shape = [1] * u.ndim
503 shape[axis] = n
504 return np.fft.ifft(np.fft.fft(u, axis=axis) * (2j * np.pi * m).reshape(shape), axis=axis)
507def _winding_density(u: NDArray[np.complex128], a_t: NDArray[np.complexfloating]) -> float:
508 r'''
509 Private function. Brillouin-zone average of
510 Tr(A_t [A_1, A_2]), A_i = U^-1 d U / d f_i along the fractional
511 coordinates f_1, f_2 (u has shape (n1, n2, N, N)).
512 '''
513 u_inv = np.conj(np.swapaxes(u, -1, -2))
514 a1 = u_inv @ _spectral_derivative(u, 0)
515 a2 = u_inv @ _spectral_derivative(u, 1)
516 return float(np.mean(np.trace(a_t @ (a1 @ a2 - a2 @ a1), axis1=-2, axis2=-1)).real)
519class DrivenKSpace:
520 r'''
521 A periodic model driven by any time-periodic Bloch Hamiltonian
522 :math:`H(\mathbf{k}, t + T) = H(\mathbf{k}, t)`.
524 It is a copy of the **KSpace** model whose *get_ham* returns the Floquet
525 Hamiltonian :math:`H_F(\mathbf{k}) = \frac{i}{T}\ln U(\mathbf{k}, T)`
526 (branch cut at *epsilon*, see *effective_hamiltonian*), so *get_bands*,
527 *k_path*, *berry_curvature*, *chern_number*, ... describe the driven
528 system, and *winding_number* gives its anomalous topology. The
529 evolution :math:`U(\mathbf{k}, T)` is the product of *n_steps*
530 midpoint propagators (see *evolution_operator*); drives built by
531 *step_drive* are evolved exactly. Real-space methods that need static
532 hoppings (*finite_ham*, *get_ham_beta*, *gbz*) are not available.
534 Parameters
535 ----------
536 ks : **KSpace** instance
537 The model driven: its lattice, orbitals
538 and k-space conventions (periodic gauge) are those of the driven model.
539 ham_kt : Callable
540 ``ham_kt(k, t)`` returns the Hermitian Bloch
541 Hamiltonian :math:`H(\mathbf{k}, t)`, shape (norb, norb), in the
542 periodic gauge of *ks* (:math:`H(\mathbf{k} + \mathbf{G}, t) = H(\mathbf{k}, t)`).
543 period : Positive real
544 :math:`T`.
545 n_steps : Positive integer, default 100
546 Time steps per period.
547 epsilon : Real number
548 Default value None (quasienergies in
549 :math:`[-\omega/2, \omega/2)`). Branch cut of *get_ham*.
551 Examples
552 --------
553 Example usage::
555 # a sublattice mass switched on and off
556 drive = DrivenKSpace(ks, lambda k, t: ks.get_ham(k) + m * np.cos(w * t) * sz, 2 * np.pi / w)
557 '''
559 # Set by _copy (and _from_steps or __new__) on an instance whose class
560 # also derives from type(ks): the rest is the copied KSpace's.
561 static: KSpace
562 period: float
563 epsilon: float | None
564 steps: list | None
565 durations: NDArray[np.float64]
566 n_steps: int
567 ham_kt: Callable
568 lat: Lattice
569 dim: int
570 norb: int
571 spin: bool
572 rec_vec_k: NDArray[np.float64]
573 _plane_orientation: Callable
575 def __new__(cls, ks, ham_kt: Callable, period: float, n_steps: int = 100, epsilon: float | None = None):
576 from tbkit.kspace import KSpace
577 error_handling.kspace(ks, KSpace)
578 error_handling.is_callable(ham_kt, 'ham_kt')
579 error_handling.positive_real(period, 'period')
580 error_handling.positive_int(n_steps, 'n_steps')
581 error_handling.branch_cut(epsilon)
582 error_handling.drive_matrix(ham_kt(np.zeros(ks.dim), 0.), ks.norb)
583 obj = cls._copy(ks, period, epsilon)
584 obj.ham_kt = ham_kt
585 obj.n_steps = n_steps
586 return obj
588 def __init__(self, ks, ham_kt, period, n_steps=100, epsilon=None) -> None:
589 pass
591 @classmethod
592 def _copy(cls, ks, period: float, epsilon: float | None):
593 '''
594 Private method. A copy of *ks* whose class also derives from *cls*.
595 '''
596 driven_cls = type(cls.__name__, (cls, type(ks)), {})
597 obj: DrivenKSpace = object.__new__(driven_cls)
598 obj.__dict__.update(copy.deepcopy(ks.__dict__))
599 obj.static = ks
600 obj.period = period
601 obj.epsilon = epsilon
602 obj.steps = None
603 return obj
605 @classmethod
606 def _from_steps(cls, models: list, durations: ArrayLike, epsilon: float | None):
607 '''
608 Private method. The drive of *step_drive*: models[n] for durations[n].
609 '''
610 durations = np.array(durations, dtype='f8')
611 obj = cls._copy(models[0], float(durations.sum()), epsilon)
612 obj.steps = list(models)
613 obj.durations = durations
614 obj.n_steps = len(models)
615 obj.ham_kt = obj._step_ham
616 return obj
618 def _step_ham(self, k: ArrayLike, t: float) -> NDArray[np.complex128]:
619 '''
620 Private method. H(k, t) of a step drive.
621 '''
622 steps = self.steps or [] # set by _from_steps, the only caller
623 n = int(np.searchsorted(np.cumsum(self.durations), t % self.period, side='right'))
624 return steps[min(n, len(steps) - 1)].get_ham(k)
626 def _step_list(self, k: NDArray[np.float64]) -> list:
627 '''
628 Private method. The drive at k as steps [(H_n(k), dt_n)]: exact for
629 a step drive, the midpoint propagators otherwise.
630 '''
631 if self.steps is not None:
632 return [(m.get_ham(k), d) for m, d in zip(self.steps, self.durations)]
633 dt = self.period / self.n_steps
634 return [(np.asarray(self.ham_kt(k, (m + 0.5) * dt), dtype='c16'), dt) for m in range(self.n_steps)]
636 def _grid_steps(self, ks: NDArray[np.float64]) -> tuple[NDArray[np.complex128], list]:
637 '''
638 Private method. The steps of *_step_list* at many k-points at once:
639 the Hamiltonians, shape (nk, n_steps, norb, norb), and the durations.
640 '''
641 if self.steps is not None:
642 return np.stack([_bloch_grid(m, ks) for m in self.steps], axis=1), list(self.durations)
643 dt = self.period / self.n_steps
644 hams = np.array([[s[0] for s in self._step_list(k)] for k in ks])
645 return hams, [dt] * self.n_steps
647 def get_evolution(self, k: ArrayLike, t: float | None = None) -> NDArray[np.complex128]:
648 r'''
649 Get the evolution operator :math:`U(\mathbf{k}, t) = \mathcal{T}e^{-i\int_0^tH(\mathbf{k},t')dt'}`.
651 Parameters
652 ----------
653 k : k point (see *KSpace.get_ham*)
654 t : Real number in :math:`[0, T]`
655 Default value None (one
656 period: the Floquet operator :math:`U(\mathbf{k}, T)`).
658 Returns
659 -------
660 U : Complex ndarray, shape (norb, norb)
661 '''
662 error_handling.k_vector(k, 'k', self.dim)
663 error_handling.time_in_period(t, self.period)
664 k = np.asarray(k, dtype='f8')
665 if t is None and self.steps is None:
666 return evolution_operator(lambda tt: self.ham_kt(k, tt), self.period, self.n_steps)
667 return _step_evolution(self._step_list(k), t)
669 def get_ham(self, k: ArrayLike) -> NDArray[np.complex128]:
670 r'''
671 Get the Floquet Hamiltonian :math:`H_F(\mathbf{k})`, quasienergies in
672 :math:`[\epsilon_{cut} - \omega, \epsilon_{cut})` (default
673 :math:`[-\omega/2, \omega/2)`).
675 Parameters
676 ----------
677 k : k point (see *KSpace.get_ham*)
679 Returns
680 -------
681 H_F : Complex ndarray, shape (norb, norb)
682 '''
683 error_handling.k_vector(k, 'k', self.dim)
684 return self._hams(np.asarray(k, dtype='f8')[None])[0]
686 def _hams(self, ks: NDArray[np.float64]) -> NDArray[np.complex128]:
687 '''
688 Private method. *get_ham* at the k-points *ks* (shape (nk, dim)): the
689 evolutions of all the k-points advance together, one stacked
690 exponential per time step (the steps of *_grid_steps*), in chunks of
691 bounded memory.
692 '''
693 chunk = max(1, 400000 // (self.norb ** 2 * self.n_steps))
694 out = []
695 for c0 in range(0, len(ks), chunk):
696 hams, durations = self._grid_steps(ks[c0:c0 + chunk])
697 u = np.broadcast_to(np.eye(self.norb, dtype='c16'), (len(hams), self.norb, self.norb))
698 for s, dt in enumerate(durations):
699 u = LA.expm(-1j * dt * hams[:, s]) @ u
700 out += [_heff(uk, self.period, self.epsilon) for uk in u]
701 return np.array(out, dtype='c16').reshape(len(ks), self.norb, self.norb)
703 def is_hermitian(self) -> bool:
704 '''
705 The Floquet Hamiltonian of a Hermitian drive is Hermitian.
706 '''
707 return True
709 def _hop_cells(self):
710 error_handling.not_floquet()
712 def _check_static(self):
713 error_handling.not_floquet()
715 def winding_number(self, epsilon: float, nk: int | tuple[int, int] = 48, n_t: int = 40) -> float:
716 r'''
717 Get the winding number of Rudner, Lindner, Berg and Levin (Phys. Rev.
718 X 3, 031005 (2013)), the number of chiral edge states in the
719 quasienergy gap at *epsilon*:
721 .. math::
723 W[U_\epsilon] = \frac{1}{8\pi^2}\int_0^T dt\int_{BZ} dk_x dk_y\,
724 \mathrm{Tr}\left(U_\epsilon^{-1}\partial_tU_\epsilon
725 \left[U_\epsilon^{-1}\partial_{k_x}U_\epsilon, U_\epsilon^{-1}\partial_{k_y}U_\epsilon\right]\right),
727 of the loop :math:`U_\epsilon(\mathbf{k}, t) = U(\mathbf{k}, 2t)` for
728 :math:`t \le T/2`, then :math:`V_\epsilon(\mathbf{k}, 2T - 2t)`, with
729 the return map :math:`V_\epsilon(\mathbf{k}, t) = e^{-iH_F^\epsilon(\mathbf{k})t}`
730 and :math:`H_F^\epsilon` the Floquet Hamiltonian with its branch cut
731 at *epsilon* (see *effective_hamiltonian*). As the integrand is a
732 3-form, this is the winding of :math:`U` over :math:`[0, T]` minus
733 that of :math:`V_\epsilon` over :math:`[0, T]`.
735 Signs. :math:`W` counts the chiral edge states in the gap:
736 :math:`W = n_{lower} = -n_{upper}` of *edge_state_count*, so
737 :math:`W > 0` means states moving along :math:`+\mathbf{a}_1` on
738 the edge of a ribbon on the side of :math:`-\hat{z}\times\mathbf{a}_1`
739 (the bottom edge of a ribbon along x). Across a group of Floquet bands,
740 :math:`W[U_{\epsilon'}] - W[U_\epsilon] = -\sum_n C_n` for
741 :math:`\epsilon < \epsilon'`, the sum over the bands between the two
742 gaps, with the Chern numbers :math:`C_n` of *chern_number* (the
743 paper's :math:`\mathcal{C}_n = -\frac{1}{2\pi}\int\nabla\times\mathcal{A}_n`
744 is minus ours, so there :math:`W' - W = +\sum\mathcal{C}`). A
745 static model (whose bands fit in the zone below the cut) has
746 :math:`W = 0` at the zone edge, and :math:`W = -\sum C` of the
747 bands below any other gap. The anomalous phases are those with
748 :math:`W \ne 0` in every gap while all the :math:`C_n = 0`.
750 Discretization: :math:`U(\mathbf{k}, t)` on a uniform
751 :math:`n_1\times n_2` Brillouin-zone grid, differentiated spectrally
752 in :math:`\mathbf{k}` (exponentially accurate: both :math:`U` and,
753 while the gap at *epsilon* is open, :math:`V_\epsilon` are analytic
754 and periodic in :math:`\mathbf{k}`), and :math:`\partial_tU = -iHU`
755 exactly, integrated by Gauss-Legendre quadrature on each time step
756 (*n_t* nodes per period in total, at least 2 per step) and over
757 :math:`[0, T]` for the return map. Step drives are exact; a
758 continuous drive is replaced by its *n_steps* midpoint steps, whose
759 loop has the same :math:`W` as long as the gap stays open.
761 Tolerance. The result is returned raw. Its error falls off
762 exponentially with *nk*, at a rate set by the k-width of the
763 region where :math:`V_\epsilon` varies fast, gap / band velocity
764 near the smallest gap at *epsilon* (the Berry curvature is
765 concentrated there). For the five-step model
766 (:math:`\delta_{AB} = 0.5\pi/T`) the error is below
767 :math:`10^{-12}` at perfect transfer; with a gap of
768 :math:`0.067\omega` it is :math:`2\times10^{-5}` at the default
769 *nk* = 48; with a gap of :math:`0.021\omega` it is
770 :math:`10^{-2}` at *nk* = 48 and :math:`4\times10^{-4}` at
771 *nk* = 96. Circularly driven graphene (gap :math:`0.01\omega` at K)
772 needs *nk* = 96 for :math:`3\times10^{-3}`. Rounding to the nearest
773 integer is trustworthy when :math:`|W - \mathrm{round}(W)| < 0.01` and the
774 value is unchanged within that tolerance when *nk* is doubled; a
775 value far from an integer means the gap is too small for the grid
776 (or closed).
778 Parameters
779 ----------
780 epsilon : Real number
781 Quasienergy of the gap (branch cut).
782 nk : Positive integer (at least 4), or tuple of 2
783 Default
784 value 48. k-points along each reciprocal vector.
785 n_t : Positive integer, default 40
786 Gauss-Legendre
787 nodes per period (at least 2 per step; 2 n_t, and at least 24,
788 for the return map).
790 Returns
791 -------
792 W : Real number, close to an integer
793 '''
794 error_handling.dim_exact(self.dim, 2)
795 error_handling.real_number(epsilon, 'epsilon')
796 error_handling.nk(nk, 2)
797 if isinstance(nk, int):
798 nk = (nk, nk)
799 error_handling.nk_min(nk, 4)
800 error_handling.positive_int(n_t, 'n_t')
801 n1, n2 = nk
802 fracs = np.stack(np.meshgrid(np.arange(n1) / n1, np.arange(n2) / n2, indexing='ij'), -1)
803 ks = fracs @ self.rec_vec_k
804 hams, durations = self._grid_steps(ks.reshape(-1, 2))
805 hams = hams.reshape(n1, n2, len(durations), self.norb, self.norb)
806 x, w = np.polynomial.legendre.leggauss(max(2, -(-n_t // len(durations))))
807 # the driven evolution U(k, t), 0 <= t <= T
808 u = np.broadcast_to(np.eye(self.norb, dtype='c16'), (n1, n2, self.norb, self.norb))
809 integral = 0.
810 for s, dt in enumerate(durations):
811 h = hams[:, :, s]
812 e, q = np.linalg.eigh(h)
813 qh = np.conj(np.swapaxes(q, -1, -2))
814 for xj, wj in zip(x, w):
815 ut = (q * np.exp(-1j * e * dt * (1 + xj) / 2)[..., None, :]) @ qh @ u
816 a_t = -1j * np.conj(np.swapaxes(ut, -1, -2)) @ h @ ut
817 integral += wj * dt / 2 * _winding_density(ut, a_t)
818 u = (q * np.exp(-1j * e * dt)[..., None, :]) @ qh @ u
819 # the return map V(k, t) = exp(-i H_F t), 0 <= t <= T, run backwards
820 eps = np.zeros((n1, n2, self.norb))
821 z = np.zeros((n1, n2, self.norb, self.norb), 'c16')
822 for i1 in range(n1):
823 for i2 in range(n2):
824 eps[i1, i2], z[i1, i2] = _floquet(u[i1, i2], self.period, epsilon)
825 zh = np.conj(np.swapaxes(z, -1, -2))
826 h_f = (z * eps[..., None, :]) @ zh
827 x, w = np.polynomial.legendre.leggauss(max(24, 2 * n_t))
828 for xj, wj in zip(x, w):
829 tau = self.period * (1 + xj) / 2
830 v = (z * np.exp(-1j * eps * tau)[..., None, :]) @ zh
831 integral -= wj * self.period / 2 * _winding_density(v, -1j * h_f)
832 return self._plane_orientation((0, 1)) * integral / (8 * np.pi**2)
834 def edge_state_count(
835 self, epsilon: float, nk: int = 200, window: float | None = None,
836 ) -> tuple[float, float]:
837 r'''
838 Count the chiral edge states of a driven ribbon (a 1D model, e.g.
839 made by *step_drive* from *kspace.ribbon* models) crossing the
840 quasienergy gap at *epsilon*, on each of its two edges.
842 Following the quasienergies :math:`\epsilon_n(k)` over the
843 Brillouin zone (:math:`k` along :math:`\mathbf{a}_1`, *nk* points), a
844 state crossing *epsilon* upwards (:math:`d\epsilon/dk > 0`) counts
845 +1, downwards -1, weighted by its probability on each half of the
846 ribbon (the halves below and above its middle line, along
847 :math:`\hat{z}\times\mathbf{a}_1`). States are followed from one k to
848 the next by their largest overlap. Only states within *window* of
849 *epsilon* are followed; bulk states do not cross a gap, so only
850 edge states contribute. The counts are integers, :math:`\pm1` per
851 chiral mode, up to the tail of the edge states on the other half
852 (exponentially small in the width) and the two edges sum to zero.
853 With the conventions of *winding_number*,
854 ``n_upper = -n_lower = W`` (checked in the tests and examples).
856 Parameters
857 ----------
858 epsilon : Real number
859 Quasienergy of the gap.
860 nk : Positive integer (at least 4), default 200
861 k-points,
862 fine enough that a state moves by less than *window* between two.
863 window : Positive real
864 Default value None (:math:`\omega/8`).
866 Returns
867 -------
868 n_lower : Real number
869 Signed count on the lower edge.
870 n_upper : Real number
871 Signed count on the upper edge.
872 '''
873 error_handling.dim_exact(self.dim, 1)
874 error_handling.real_number(epsilon, 'epsilon')
875 error_handling.positive_int(nk, 'nk')
876 error_handling.nk_min((nk,), 4)
877 omega = 2 * np.pi / self.period
878 if window is None:
879 window = omega / 8
880 error_handling.positive_real(window, 'window')
881 a1 = np.asarray(self.lat.prim_vec[0], dtype='f8')[:2]
882 normal = np.array([-a1[1], a1[0]]) / np.linalg.norm(a1)
883 across = np.array([dic['r0'][:2] for dic in self.lat.unit_cell], dtype='f8') @ normal
884 if self.spin:
885 across = np.repeat(across, 2)
886 middle = (across.min() + across.max()) / 2
887 lower = np.where(across < middle, 1., np.where(across > middle, 0., 0.5))
888 ks = ((np.arange(nk) + 0.5) / nk)[:, None] * self.rec_vec_k[0][None, :]
889 states = []
890 for k in ks:
891 eps, z = _floquet(self.get_evolution(k), self.period, epsilon + omega / 2)
892 states.append((eps - epsilon, z))
893 n_lower = n_upper = 0.
894 for i in range(nk):
895 x1, z1 = states[i]
896 x2, z2 = states[(i + 1) % nk]
897 overlap = np.abs(z1.conj().T @ z2)
898 for a in np.where(np.abs(x1) < window)[0]:
899 b = np.argmax(overlap[a])
900 if abs(x2[b]) < window and np.sign(x1[a]) != np.sign(x2[b]):
901 direction = np.sign(x2[b] - x1[a])
902 w_low = (lower @ np.abs(z1[:, a])**2 + lower @ np.abs(z2[:, b])**2) / 2
903 n_lower += direction * w_low
904 n_upper += direction * (1 - w_low)
905 return float(n_lower), float(n_upper)
908class FloquetKSpace(DrivenKSpace):
909 r'''
910 A periodic model driven by a uniform, time-periodic vector potential
911 :math:`\mathbf{A}(t)` (e.g. circularly polarized light,
912 :math:`\mathbf{A}(t) = A_0(\cos\omega t, \sin\omega t)`), coupled through
913 the Peierls phases of its bonds (see *KSpace.get_ham_peierls*).
915 It is a copy of the **KSpace** model whose *get_ham* returns the Floquet
916 Hamiltonian :math:`H_F(\mathbf{k})` (see *effective_hamiltonian*), so
917 *get_bands*, *k_path*, *berry_curvature*, *chern_number*,
918 *wannier_centers*, ... describe the driven system (quasienergies in the
919 first Floquet zone: for Chern numbers, pick bands separated by gaps
920 that do not wrap around the zone edge, or move the zone edge with
921 *epsilon*). It is a *DrivenKSpace*, with
922 :math:`H(\mathbf{k}, t)` = ``ks.get_ham_peierls(k, vector_potential(t))``,
923 so *winding_number* applies. Real-space methods that need
924 static hoppings (*finite_ham*, *get_ham_beta*, *gbz*) are not available.
926 Parameters
927 ----------
928 ks : **KSpace** instance (the static model)
929 vector_potential : Callable
930 ``vector_potential(t)`` returns
931 :math:`\mathbf{A}(t)` (space_dim components).
932 period : Positive real
933 :math:`T`.
934 n_steps : Positive integer, default 100
935 Time steps per period.
936 epsilon : Real number
937 Default value None (quasienergies in
938 :math:`[-\omega/2, \omega/2)`, as before this option existed).
939 Branch cut of *get_ham*, see *effective_hamiltonian*.
940 '''
942 vector_potential: Callable
944 def __new__(cls, ks, vector_potential: Callable, period: float, n_steps: int = 100,
945 epsilon: float | None = None):
946 from tbkit.kspace import KSpace
947 error_handling.kspace(ks, KSpace)
948 error_handling.is_callable(vector_potential, 'vector_potential')
949 error_handling.positive_real(period, 'period')
950 error_handling.positive_int(n_steps, 'n_steps')
951 error_handling.branch_cut(epsilon)
952 obj = cls._copy(ks, period, epsilon)
953 obj.vector_potential = vector_potential
954 obj.n_steps = n_steps
955 obj.ham_kt = lambda k, t: obj.static.get_ham_peierls(k, obj.vector_potential(t))
956 return obj
958 def __init__(self, ks, vector_potential, period, n_steps=100, epsilon=None) -> None:
959 pass
961 def _grid_steps(self, ks: NDArray[np.float64]) -> tuple[NDArray[np.complex128], list]:
962 '''
963 Private method. The midpoint Hamiltonians at many k-points at once (see *DrivenKSpace*).
964 '''
965 dt = self.period / self.n_steps
966 hams = [_bloch_grid(self.static, ks, np.asarray(self.vector_potential((m + 0.5) * dt), dtype='f8'))
967 for m in range(self.n_steps)]
968 return np.stack(hams, axis=1), [dt] * self.n_steps
970 def is_hermitian(self) -> bool:
971 '''
972 The Floquet Hamiltonian of a Hermitian drive is Hermitian.
973 '''
974 return self.static.is_hermitian()