lockkernel 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockkernel/__init__.py +15 -0
- lockkernel/cumulant.py +399 -0
- lockkernel/ensemble.py +105 -0
- lockkernel/exact.py +243 -0
- lockkernel/kernels.py +214 -0
- lockkernel/lineshapes.py +148 -0
- lockkernel/measured.py +271 -0
- lockkernel/parametric.py +266 -0
- lockkernel-1.1.0.dist-info/METADATA +207 -0
- lockkernel-1.1.0.dist-info/RECORD +14 -0
- lockkernel-1.1.0.dist-info/WHEEL +5 -0
- lockkernel-1.1.0.dist-info/licenses/LICENSE +202 -0
- lockkernel-1.1.0.dist-info/licenses/NOTICE +17 -0
- lockkernel-1.1.0.dist-info/top_level.txt +1 -0
lockkernel/exact.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Exact references used to validate the cumulant solver.
|
|
2
|
+
|
|
3
|
+
Two independent references are provided.
|
|
4
|
+
|
|
5
|
+
DEGENERATE LINE. When every detuning is equal the Hamiltonian reduces, on the
|
|
6
|
+
maximal spin manifold that the coherent spin state occupies, to
|
|
7
|
+
|
|
8
|
+
H = chi1 S+ S- = chi1 (S^2 - S_z^2 + S_z),
|
|
9
|
+
|
|
10
|
+
which is diagonal in the S_z basis. The evolution of the coherent spin state
|
|
11
|
+
is therefore exact and cheap for any emitter number: it is one axis twisting
|
|
12
|
+
in disguise. `symmetric_exact` uses this.
|
|
13
|
+
|
|
14
|
+
INHOMOGENEOUS LINE. With several distinct detunings the maximal spin manifold
|
|
15
|
+
is no longer closed, and the reference is exact diagonalisation of the full
|
|
16
|
+
Hamiltonian in the 2^N dimensional Hilbert space. `full_exact` does this for
|
|
17
|
+
small emitter numbers, which is enough to test the structure of the cumulant
|
|
18
|
+
equations term by term.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
__all__ = ["symmetric_exact", "full_exact"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _wineland_from_moments(J, Cov, N):
|
|
28
|
+
Jn = np.linalg.norm(J)
|
|
29
|
+
if Jn == 0:
|
|
30
|
+
return np.inf
|
|
31
|
+
e3 = J / Jn
|
|
32
|
+
trial = np.array([0.0, 0.0, 1.0]) if abs(e3[2]) < 0.9 else np.array([1.0, 0.0, 0.0])
|
|
33
|
+
e1 = np.cross(e3, trial)
|
|
34
|
+
e1 /= np.linalg.norm(e1)
|
|
35
|
+
e2 = np.cross(e3, e1)
|
|
36
|
+
B = np.stack([e1, e2], axis=1)
|
|
37
|
+
vals = np.linalg.eigvalsh(B.T @ Cov @ B)
|
|
38
|
+
return float(vals[0] * N / Jn ** 2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def symmetric_exact(N: int, chiN: float, times):
|
|
42
|
+
"""Exact contrast and Wineland parameter for a degenerate line.
|
|
43
|
+
|
|
44
|
+
The state stays in the maximal spin manifold, of dimension N+1, and the
|
|
45
|
+
Hamiltonian is diagonal there, so this is exact to machine precision at
|
|
46
|
+
any emitter number.
|
|
47
|
+
|
|
48
|
+
Returns (contrast, xi2), each an array over `times`.
|
|
49
|
+
"""
|
|
50
|
+
S = N / 2.0
|
|
51
|
+
m = np.arange(-S, S + 1) # S_z eigenvalues
|
|
52
|
+
chi1 = chiN / N
|
|
53
|
+
E = chi1 * (S * (S + 1) - m ** 2 + m) # eigenvalues of chi1 S+ S-
|
|
54
|
+
|
|
55
|
+
# Coherent spin state along +x in the S_z basis: binomial amplitudes.
|
|
56
|
+
k = np.arange(N + 1)
|
|
57
|
+
logc = 0.5 * (np.array([_log_binom(N, kk) for kk in k]) - N * np.log(2.0))
|
|
58
|
+
amp = np.exp(logc) # real and positive
|
|
59
|
+
|
|
60
|
+
sp = np.sqrt(S * (S + 1) - m[:-1] * (m[:-1] + 1)) # <m+1|S+|m>
|
|
61
|
+
contrast, xi2 = [], []
|
|
62
|
+
for t in np.atleast_1d(times):
|
|
63
|
+
psi = amp * np.exp(-1j * E * t)
|
|
64
|
+
# <S+> = sum_m conj(psi_{m+1}) sp_m psi_m
|
|
65
|
+
Sp = np.sum(np.conj(psi[1:]) * sp * psi[:-1])
|
|
66
|
+
Sz = np.sum(np.abs(psi) ** 2 * m)
|
|
67
|
+
Sz2 = np.sum(np.abs(psi) ** 2 * m ** 2)
|
|
68
|
+
# <S+ S+>
|
|
69
|
+
spp = sp[:-1] * sp[1:]
|
|
70
|
+
Spp = np.sum(np.conj(psi[2:]) * spp * psi[:-2])
|
|
71
|
+
# <S+ S-> = <S^2 - S_z^2 + S_z>
|
|
72
|
+
Spm = S * (S + 1) - Sz2 + Sz
|
|
73
|
+
# <S_z S+ + S+ S_z>
|
|
74
|
+
SzSp = np.sum(np.conj(psi[1:]) * m[1:] * sp * psi[:-1])
|
|
75
|
+
SpSz = np.sum(np.conj(psi[1:]) * sp * m[:-1] * psi[:-1])
|
|
76
|
+
|
|
77
|
+
Sx = Sp.real
|
|
78
|
+
Sy = Sp.imag
|
|
79
|
+
Sxx = 0.5 * (Spp.real + Spm.real) # (S+^2 + S-^2 + S+S- + S-S+)/4
|
|
80
|
+
Syy = 0.5 * (-Spp.real + Spm.real)
|
|
81
|
+
Sxy = 0.5 * Spp.imag # symmetrised
|
|
82
|
+
Sxz = 0.5 * (SzSp + SpSz).real
|
|
83
|
+
Syz = 0.5 * (SzSp + SpSz).imag
|
|
84
|
+
J = np.array([Sx, Sy, Sz.real])
|
|
85
|
+
Cov = np.array([[Sxx - Sx ** 2, Sxy - Sx * Sy, Sxz - Sx * Sz.real],
|
|
86
|
+
[Sxy - Sx * Sy, Syy - Sy ** 2, Syz - Sy * Sz.real],
|
|
87
|
+
[Sxz - Sx * Sz.real, Syz - Sy * Sz.real, Sz2 - Sz.real ** 2]])
|
|
88
|
+
Cov = 0.5 * (Cov + Cov.T).real
|
|
89
|
+
contrast.append(2.0 * np.hypot(Sx, Sy) / N)
|
|
90
|
+
xi2.append(_wineland_from_moments(J, Cov, N))
|
|
91
|
+
return np.array(contrast), np.array(xi2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log_binom(n, k):
|
|
95
|
+
from math import lgamma
|
|
96
|
+
return lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def full_exact(deltas, chiN: float, times):
|
|
100
|
+
"""Exact diagonalisation of the full Hamiltonian for a small ensemble.
|
|
101
|
+
|
|
102
|
+
`deltas` lists the detuning of every individual emitter, so the emitter
|
|
103
|
+
number is len(deltas) and the Hilbert space has 2^N dimensions. Practical
|
|
104
|
+
up to about twelve emitters.
|
|
105
|
+
|
|
106
|
+
Returns (contrast, xi2), each an array over `times`.
|
|
107
|
+
"""
|
|
108
|
+
deltas = np.asarray(deltas, float)
|
|
109
|
+
N = len(deltas)
|
|
110
|
+
dim = 2 ** N
|
|
111
|
+
chi1 = chiN / N
|
|
112
|
+
|
|
113
|
+
sx = np.array([[0, 1], [1, 0]], complex)
|
|
114
|
+
sy = np.array([[0, -1j], [1j, 0]], complex)
|
|
115
|
+
sz = np.array([[1, 0], [0, -1]], complex)
|
|
116
|
+
sp = np.array([[0, 1], [0, 0]], complex) # |up><down|
|
|
117
|
+
sm = sp.T.copy()
|
|
118
|
+
|
|
119
|
+
def op(single, j):
|
|
120
|
+
out = np.array([[1.0 + 0j]])
|
|
121
|
+
for i in range(N):
|
|
122
|
+
out = np.kron(out, single if i == j else np.eye(2))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
SP = [op(sp, j) for j in range(N)]
|
|
126
|
+
SM = [op(sm, j) for j in range(N)]
|
|
127
|
+
SZ = [op(sz, j) for j in range(N)]
|
|
128
|
+
SX = [op(sx, j) for j in range(N)]
|
|
129
|
+
SY = [op(sy, j) for j in range(N)]
|
|
130
|
+
|
|
131
|
+
H = np.zeros((dim, dim), complex)
|
|
132
|
+
for j in range(N):
|
|
133
|
+
H += 0.5 * deltas[j] * SZ[j]
|
|
134
|
+
for k in range(N):
|
|
135
|
+
H += chi1 * (SP[j] @ SM[k])
|
|
136
|
+
|
|
137
|
+
Jx = 0.5 * sum(SX)
|
|
138
|
+
Jy = 0.5 * sum(SY)
|
|
139
|
+
Jz = 0.5 * sum(SZ)
|
|
140
|
+
|
|
141
|
+
w, V = np.linalg.eigh(H)
|
|
142
|
+
psi0 = np.ones(dim, complex) / np.sqrt(dim) # all emitters along +x
|
|
143
|
+
c0 = V.conj().T @ psi0
|
|
144
|
+
|
|
145
|
+
contrast, xi2 = [], []
|
|
146
|
+
Js = [Jx, Jy, Jz]
|
|
147
|
+
for t in np.atleast_1d(times):
|
|
148
|
+
psi = V @ (np.exp(-1j * w * t) * c0)
|
|
149
|
+
mean = np.array([np.vdot(psi, A @ psi).real for A in Js])
|
|
150
|
+
Cov = np.empty((3, 3))
|
|
151
|
+
for a in range(3):
|
|
152
|
+
for b in range(3):
|
|
153
|
+
Cov[a, b] = 0.5 * np.vdot(psi, (Js[a] @ Js[b] + Js[b] @ Js[a]) @ psi).real
|
|
154
|
+
Cov -= np.outer(mean, mean)
|
|
155
|
+
contrast.append(2.0 * np.hypot(mean[0], mean[1]) / N)
|
|
156
|
+
xi2.append(_wineland_from_moments(mean, Cov, N))
|
|
157
|
+
return np.array(contrast), np.array(xi2)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def class_exact(deltas, populations, chiN: float, times, tol=1e-9):
|
|
161
|
+
"""Exact dynamics of an inhomogeneous ensemble in the collective spin basis.
|
|
162
|
+
|
|
163
|
+
Emitters within a class are exchange symmetric and start in a symmetric
|
|
164
|
+
state, so each class stays in its maximal spin manifold of dimension
|
|
165
|
+
n_j + 1. The Hilbert space is therefore the product of those manifolds,
|
|
166
|
+
of dimension prod(n_j + 1) rather than 2^N, which puts exact dynamics of a
|
|
167
|
+
genuinely inhomogeneous ensemble within reach for emitter numbers of a few
|
|
168
|
+
tens. This is the reference that fixes how far the cumulant expansion can
|
|
169
|
+
be trusted at finite N.
|
|
170
|
+
|
|
171
|
+
Returns (contrast, xi2), each an array over `times`.
|
|
172
|
+
"""
|
|
173
|
+
import numpy as _np
|
|
174
|
+
from scipy import sparse
|
|
175
|
+
from scipy.sparse.linalg import expm_multiply
|
|
176
|
+
|
|
177
|
+
deltas = _np.asarray(deltas, float)
|
|
178
|
+
pops = _np.asarray(populations, int)
|
|
179
|
+
M = len(pops)
|
|
180
|
+
N = int(pops.sum())
|
|
181
|
+
chi1 = chiN / N
|
|
182
|
+
dims = pops + 1
|
|
183
|
+
|
|
184
|
+
def spin_ops(n):
|
|
185
|
+
"""S+, Sz for a spin of length n/2, in the S_z basis, as sparse."""
|
|
186
|
+
S = n / 2.0
|
|
187
|
+
m = _np.arange(-S, S + 1)
|
|
188
|
+
d = n + 1
|
|
189
|
+
up = _np.sqrt(S * (S + 1) - m[:-1] * (m[:-1] + 1))
|
|
190
|
+
Sp = sparse.diags(up, offsets=-1, shape=(d, d), format="csr") # |m+1><m|
|
|
191
|
+
Sz = sparse.diags(m, format="csr")
|
|
192
|
+
return Sp, Sz
|
|
193
|
+
|
|
194
|
+
def embed(op, j):
|
|
195
|
+
out = sparse.identity(1, format="csr")
|
|
196
|
+
for i in range(M):
|
|
197
|
+
out = sparse.kron(out, op if i == j else sparse.identity(dims[i]),
|
|
198
|
+
format="csr")
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
SP, SZ = [], []
|
|
202
|
+
for j in range(M):
|
|
203
|
+
sp, sz = spin_ops(pops[j])
|
|
204
|
+
SP.append(embed(sp, j))
|
|
205
|
+
SZ.append(embed(sz, j))
|
|
206
|
+
|
|
207
|
+
Jp = sum(SP)
|
|
208
|
+
Jz = sum(SZ)
|
|
209
|
+
H = chi1 * (Jp @ Jp.getH()) + sum(deltas[j] * SZ[j] for j in range(M))
|
|
210
|
+
H = ((H + H.getH()) * 0.5).tocsr()
|
|
211
|
+
|
|
212
|
+
Jx = 0.5 * (Jp + Jp.getH())
|
|
213
|
+
Jy = -0.5j * (Jp - Jp.getH())
|
|
214
|
+
Js = [Jx.tocsr(), Jy.tocsr(), Jz.tocsr()]
|
|
215
|
+
|
|
216
|
+
# coherent spin state along +x: a product of x polarised class states
|
|
217
|
+
psi = _np.ones(1, complex)
|
|
218
|
+
for j in range(M):
|
|
219
|
+
n = pops[j]
|
|
220
|
+
k = _np.arange(n + 1)
|
|
221
|
+
amp = _np.exp(0.5 * (_np.array([_log_binom(n, kk) for kk in k])
|
|
222
|
+
- n * _np.log(2.0)))
|
|
223
|
+
psi = _np.kron(psi, amp)
|
|
224
|
+
psi /= _np.linalg.norm(psi)
|
|
225
|
+
|
|
226
|
+
contrast, xi2 = [], []
|
|
227
|
+
prev = 0.0
|
|
228
|
+
state = psi
|
|
229
|
+
for t in _np.atleast_1d(times):
|
|
230
|
+
if t > prev:
|
|
231
|
+
state = expm_multiply(-1j * (t - prev) * H, state)
|
|
232
|
+
prev = t
|
|
233
|
+
mean = _np.array([_np.vdot(state, A @ state).real for A in Js])
|
|
234
|
+
Cov = _np.empty((3, 3))
|
|
235
|
+
for a in range(3):
|
|
236
|
+
va = Js[a] @ state
|
|
237
|
+
for b in range(a, 3):
|
|
238
|
+
vb = Js[b] @ state
|
|
239
|
+
Cov[a, b] = Cov[b, a] = _np.vdot(va, vb).real
|
|
240
|
+
Cov = 0.5 * (Cov + Cov.T) - _np.outer(mean, mean)
|
|
241
|
+
contrast.append(2.0 * _np.hypot(mean[0], mean[1]) / N)
|
|
242
|
+
xi2.append(_wineland_from_moments(mean, Cov, N))
|
|
243
|
+
return _np.array(contrast), _np.array(xi2)
|
lockkernel/kernels.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Locking kernels.
|
|
2
|
+
|
|
3
|
+
A locking kernel W(u) is the time averaged projection of an oscillator on the
|
|
4
|
+
mean field, as a function of the detuning measured in units of the locking
|
|
5
|
+
bandwidth, u = delta / Omega. It is normalised so that a resonant oscillator
|
|
6
|
+
follows the field perfectly, W(0) = 1, and it is even.
|
|
7
|
+
|
|
8
|
+
Two kernels matter physically.
|
|
9
|
+
|
|
10
|
+
* Conservative spins. An off resonant spin precesses about a tilted axis and
|
|
11
|
+
keeps a nonzero time averaged projection on the mean field for every
|
|
12
|
+
detuning. The projection is
|
|
13
|
+
|
|
14
|
+
W(u) = 1 / (1 + u^2),
|
|
15
|
+
|
|
16
|
+
an ALGEBRAIC kernel with tail exponent s = 2.
|
|
17
|
+
|
|
18
|
+
* Overdamped phase oscillators, the Kuramoto case. An oscillator that cannot
|
|
19
|
+
be entrained drifts, and its time averaged contribution vanishes exactly.
|
|
20
|
+
The kernel is COMPACTLY SUPPORTED,
|
|
21
|
+
|
|
22
|
+
W(u) = sqrt(1 - u^2) for |u| < 1 and 0 otherwise.
|
|
23
|
+
|
|
24
|
+
The one parameter family
|
|
25
|
+
|
|
26
|
+
W_s(u) = 1 / (1 + |u|^s), s > 1,
|
|
27
|
+
|
|
28
|
+
interpolates between them in the sense that matters here, namely the tail, and
|
|
29
|
+
is what the universality line is tested against.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import dataclasses
|
|
34
|
+
from typing import Callable
|
|
35
|
+
|
|
36
|
+
import mpmath as mp
|
|
37
|
+
|
|
38
|
+
__all__ = ["Kernel", "conservative", "kuramoto", "power_tail",
|
|
39
|
+
"gaussian_kernel", "KERNELS"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclasses.dataclass(frozen=True)
|
|
43
|
+
class Kernel:
|
|
44
|
+
"""An even locking kernel with W(0) = 1.
|
|
45
|
+
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
name : str
|
|
49
|
+
W : callable
|
|
50
|
+
The kernel, on `mpmath` scalars.
|
|
51
|
+
tail : float or None
|
|
52
|
+
Exponent s of the algebraic tail W ~ |u|^-s, or None for compact
|
|
53
|
+
support or faster than algebraic decay.
|
|
54
|
+
support : float or None
|
|
55
|
+
Half width of the support if compact, otherwise None.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
W: Callable
|
|
60
|
+
tail: float = None
|
|
61
|
+
support: float = None
|
|
62
|
+
|
|
63
|
+
def __call__(self, u):
|
|
64
|
+
return self.W(u)
|
|
65
|
+
|
|
66
|
+
def mass(self) -> mp.mpf:
|
|
67
|
+
"""m = int W(u) du, which fixes the threshold coupling."""
|
|
68
|
+
if self.support is not None:
|
|
69
|
+
return 2 * mp.quad(self.W, [0, self.support])
|
|
70
|
+
# A slowly decaying tail needs the decades resolved explicitly.
|
|
71
|
+
return 2 * mp.quad(self.W, [0, 1, 10, 100, 1000, mp.inf])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def conservative() -> Kernel:
|
|
75
|
+
"""The conservative spin kernel, W = 1/(1+u^2), mass pi."""
|
|
76
|
+
return Kernel("conservative", lambda u: 1 / (1 + mp.mpf(u) ** 2), tail=2.0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def kuramoto() -> Kernel:
|
|
80
|
+
"""The Kuramoto kernel, W = sqrt(1-u^2) on |u| < 1, mass pi/2."""
|
|
81
|
+
|
|
82
|
+
def W(u):
|
|
83
|
+
u = mp.mpf(u)
|
|
84
|
+
return mp.sqrt(1 - u ** 2) if abs(u) < 1 else mp.mpf(0)
|
|
85
|
+
|
|
86
|
+
return Kernel("kuramoto", W, tail=None, support=1.0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def power_tail(s: float) -> Kernel:
|
|
90
|
+
"""The test family W_s(u) = 1/(1+|u|^s). Requires s > 1 for a finite mass."""
|
|
91
|
+
s = mp.mpf(s)
|
|
92
|
+
if s <= 1:
|
|
93
|
+
raise ValueError("power_tail needs s > 1 for the kernel mass to converge")
|
|
94
|
+
|
|
95
|
+
def W(u):
|
|
96
|
+
return 1 / (1 + abs(mp.mpf(u)) ** s)
|
|
97
|
+
|
|
98
|
+
return Kernel(f"power{float(s):g}", W, tail=float(s))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def gaussian_kernel() -> Kernel:
|
|
102
|
+
"""A Gaussian kernel, faster than any algebraic decay. Control case."""
|
|
103
|
+
return Kernel("gaussian", lambda u: mp.e ** (-mp.mpf(u) ** 2), tail=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
KERNELS = {
|
|
107
|
+
"conservative": conservative,
|
|
108
|
+
"kuramoto": kuramoto,
|
|
109
|
+
"power_tail": power_tail,
|
|
110
|
+
"gaussian": gaussian_kernel,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def predicted_beta(s) -> float:
|
|
115
|
+
"""The order parameter exponent predicted for a kernel of tail exponent s.
|
|
116
|
+
|
|
117
|
+
beta = 1/(s-1) for 1 < s < 3, and beta = 1/2 for s >= 3 or for a kernel
|
|
118
|
+
with compact support or faster than algebraic decay (s is None). The
|
|
119
|
+
crossover at s = 3 is where the second moment of the kernel stops
|
|
120
|
+
converging, so that the leading correction to the self consistency changes
|
|
121
|
+
from being set by the tail to being set by the curvature of the line.
|
|
122
|
+
"""
|
|
123
|
+
if s is None or s >= 3:
|
|
124
|
+
return 0.5
|
|
125
|
+
return 1.0 / (float(s) - 1.0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def heterogeneous(base: Kernel, degree_exponent: float, eta: float = 1.0,
|
|
129
|
+
k_min: float = 1.0) -> Kernel:
|
|
130
|
+
"""The kernel produced by a broad distribution of coupling strengths.
|
|
131
|
+
|
|
132
|
+
When the oscillators do not all couple with the same strength, the kernel
|
|
133
|
+
that appears in the self consistency is not the single oscillator response
|
|
134
|
+
but an average of it. Let oscillator i carry weight k_i drawn from
|
|
135
|
+
P(k) = (gamma-1) k_min^(gamma-1) k^-gamma above k_min, let the field it
|
|
136
|
+
feels be proportional to k_i^eta, and let its contribution to the
|
|
137
|
+
collective field carry the weight k_i. Then
|
|
138
|
+
|
|
139
|
+
Wt(v) = (1 / <k>) int P(k) k W(v / k^eta) dk,
|
|
140
|
+
|
|
141
|
+
which is again a function of v alone, so the parametric solution applies
|
|
142
|
+
unchanged with this kernel in place of W.
|
|
143
|
+
|
|
144
|
+
The tail of the average is not the tail of W. A weight far above the
|
|
145
|
+
typical one gives that oscillator a locking bandwidth far above the
|
|
146
|
+
typical one, so it stays locked at detunings where a typical oscillator
|
|
147
|
+
has long since been released, and the heavy tail of P is inherited by the
|
|
148
|
+
kernel. For a base kernel of tail exponent s0, including s0 = infinity for
|
|
149
|
+
compact support,
|
|
150
|
+
|
|
151
|
+
s = min(s0, (gamma - 2) / eta).
|
|
152
|
+
|
|
153
|
+
The two cases that matter are the overdamped kernel, where s0 is infinite
|
|
154
|
+
so that the coupling distribution alone sets the tail, and the
|
|
155
|
+
conservative kernel, where s0 = 2 caps it. `eta` covers couplings that
|
|
156
|
+
themselves depend on the weight, J k^(eta-1), for which the field on an
|
|
157
|
+
oscillator scales as k^eta.
|
|
158
|
+
|
|
159
|
+
For the two base kernels the weight integral is reduced analytically to a
|
|
160
|
+
single smooth quadrature, which is what makes the exponent reachable.
|
|
161
|
+
"""
|
|
162
|
+
g = mp.mpf(degree_exponent)
|
|
163
|
+
eta = mp.mpf(eta)
|
|
164
|
+
kmin = mp.mpf(k_min)
|
|
165
|
+
if g <= 2:
|
|
166
|
+
raise ValueError("the mean weight diverges unless gamma > 2")
|
|
167
|
+
s0 = mp.mpf(base.tail) if base.tail is not None else mp.inf
|
|
168
|
+
tail = min(s0, (g - 2) / eta)
|
|
169
|
+
if tail <= 1:
|
|
170
|
+
raise ValueError("the kernel mass diverges unless min(s0, (gamma-2)/eta) > 1")
|
|
171
|
+
|
|
172
|
+
if base.name == "kuramoto" and kmin == 1:
|
|
173
|
+
# W(x) = sqrt(1-x^2) on |x| < 1. Only weights with k^eta > v are
|
|
174
|
+
# locked at detuning v, and substituting w = k^(2 eta) reduces the
|
|
175
|
+
# weight integral to a Beta function above v = 1 and to a Gauss
|
|
176
|
+
# hypergeometric below it, both exact:
|
|
177
|
+
# a = (2-gamma)/(2 eta) < 0,
|
|
178
|
+
# v >= 1: Wt = (gamma-1) v^(2a) B(-a, 3/2) / (2 eta),
|
|
179
|
+
# v < 1: Wt = (gamma-1) 2F1(-1/2, -a; 1-a; v^2) / (2 eta (-a)).
|
|
180
|
+
# The first line shows directly that the tail is an exact power law
|
|
181
|
+
# with exponent (gamma-2)/eta.
|
|
182
|
+
a = (2 - g) / (2 * eta)
|
|
183
|
+
|
|
184
|
+
def Wt(v):
|
|
185
|
+
v = abs(mp.mpf(v))
|
|
186
|
+
if v >= 1:
|
|
187
|
+
return (g - 1) * v ** (2 * a) * mp.beta(-a, mp.mpf(3) / 2) / (2 * eta)
|
|
188
|
+
return (g - 1) * mp.hyp2f1(-mp.mpf(1) / 2, -a, 1 - a, v ** 2) \
|
|
189
|
+
/ (2 * eta * (-a))
|
|
190
|
+
elif base.support is not None:
|
|
191
|
+
sup = mp.mpf(base.support)
|
|
192
|
+
|
|
193
|
+
def Wt(v):
|
|
194
|
+
v = abs(mp.mpf(v))
|
|
195
|
+
if v == 0:
|
|
196
|
+
return (g - 1) * kmin / (g - 2)
|
|
197
|
+
edge = mp.power(v / sup, 1 / eta)
|
|
198
|
+
lo = max(kmin, edge)
|
|
199
|
+
f = lambda k: k ** (1 - g) * base.W(v / k ** eta)
|
|
200
|
+
return (g - 1) * kmin ** (g - 1) * mp.quad(
|
|
201
|
+
f, [lo, 2 * lo, 10 * lo, 100 * lo, mp.inf])
|
|
202
|
+
else:
|
|
203
|
+
def Wt(v):
|
|
204
|
+
v = abs(mp.mpf(v))
|
|
205
|
+
f = lambda k: k ** (1 - g) * base.W(v / k ** eta)
|
|
206
|
+
scale = mp.power(max(v, mp.mpf("1e-40")), 1 / eta)
|
|
207
|
+
pts = sorted({kmin, max(kmin, scale), max(kmin, 10 * scale),
|
|
208
|
+
max(kmin, 100 * scale)})
|
|
209
|
+
return (g - 1) * kmin ** (g - 1) * mp.quad(list(pts) and f,
|
|
210
|
+
list(pts) + [mp.inf])
|
|
211
|
+
|
|
212
|
+
W0 = Wt(0)
|
|
213
|
+
return Kernel(f"het_{base.name}_g{float(g):g}_e{float(eta):g}",
|
|
214
|
+
lambda u: Wt(u) / W0, tail=float(tail))
|
lockkernel/lineshapes.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Inhomogeneous line shapes p(delta).
|
|
2
|
+
|
|
3
|
+
Each line shape is returned as a `LineShape`: a callable density together with
|
|
4
|
+
the pieces the rest of the package needs, namely the density at the centre,
|
|
5
|
+
a cumulative distribution and its inverse for building quadrature classes, and
|
|
6
|
+
a nominal width used to set integration scales.
|
|
7
|
+
|
|
8
|
+
Densities are written for `mpmath` scalars so that the same object can be used
|
|
9
|
+
both in the arbitrary precision parametric solver and, through `numpy`
|
|
10
|
+
broadcasting of the float path, in the finite precision dynamics.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
import mpmath as mp
|
|
18
|
+
import numpy as np
|
|
19
|
+
from scipy import stats
|
|
20
|
+
|
|
21
|
+
__all__ = ["LineShape", "lorentzian", "gaussian", "student_t", "box",
|
|
22
|
+
"bimodal_gaussian", "LINESHAPES"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class LineShape:
|
|
27
|
+
"""A symmetric probability density on the detuning axis.
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
name : str
|
|
32
|
+
Short identifier used in output files.
|
|
33
|
+
pdf : callable
|
|
34
|
+
Density, accepting an `mpmath` scalar and returning one.
|
|
35
|
+
scale : float
|
|
36
|
+
A nominal width, used only to choose integration break points.
|
|
37
|
+
frozen : scipy.stats distribution or None
|
|
38
|
+
Float precision distribution supplying `cdf` and `ppf`. Line shapes
|
|
39
|
+
that are only used analytically may leave this as None.
|
|
40
|
+
compact_support : float or None
|
|
41
|
+
Half width of the support when the density has compact support.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
pdf: Callable
|
|
46
|
+
scale: float
|
|
47
|
+
frozen: object = None
|
|
48
|
+
compact_support: float = None
|
|
49
|
+
|
|
50
|
+
def __call__(self, delta):
|
|
51
|
+
return self.pdf(delta)
|
|
52
|
+
|
|
53
|
+
def p0(self):
|
|
54
|
+
"""Density at the line centre."""
|
|
55
|
+
return self.pdf(mp.mpf(0))
|
|
56
|
+
|
|
57
|
+
def cdf(self, x):
|
|
58
|
+
if self.frozen is None:
|
|
59
|
+
raise NotImplementedError(f"{self.name} has no float cdf")
|
|
60
|
+
return self.frozen.cdf(x)
|
|
61
|
+
|
|
62
|
+
def ppf(self, q):
|
|
63
|
+
if self.frozen is None:
|
|
64
|
+
raise NotImplementedError(f"{self.name} has no float ppf")
|
|
65
|
+
return self.frozen.ppf(q)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lorentzian(fwhm: float = 1.0) -> LineShape:
|
|
69
|
+
"""Lorentzian of the given full width at half maximum."""
|
|
70
|
+
g = mp.mpf(fwhm) / 2 # half width at half maximum
|
|
71
|
+
|
|
72
|
+
def pdf(d):
|
|
73
|
+
d = mp.mpf(d)
|
|
74
|
+
return g / mp.pi / (d ** 2 + g ** 2)
|
|
75
|
+
|
|
76
|
+
return LineShape("lorentzian", pdf, float(fwhm),
|
|
77
|
+
stats.cauchy(loc=0.0, scale=float(g)))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def gaussian(fwhm: float = 1.0) -> LineShape:
|
|
81
|
+
"""Gaussian of the given full width at half maximum."""
|
|
82
|
+
sig = mp.mpf(fwhm) / (2 * mp.sqrt(2 * mp.log(2)))
|
|
83
|
+
|
|
84
|
+
def pdf(d):
|
|
85
|
+
d = mp.mpf(d)
|
|
86
|
+
return mp.e ** (-d ** 2 / (2 * sig ** 2)) / (sig * mp.sqrt(2 * mp.pi))
|
|
87
|
+
|
|
88
|
+
return LineShape("gaussian", pdf, float(fwhm),
|
|
89
|
+
stats.norm(loc=0.0, scale=float(sig)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def student_t(nu: float = 3.0, scale: float = 1.0) -> LineShape:
|
|
93
|
+
"""Student t density, a heavy tailed line with p(d) ~ |d|^-(nu+1)."""
|
|
94
|
+
nu = mp.mpf(nu)
|
|
95
|
+
sc = mp.mpf(scale)
|
|
96
|
+
norm = mp.gamma((nu + 1) / 2) / (mp.sqrt(nu * mp.pi) * mp.gamma(nu / 2) * sc)
|
|
97
|
+
|
|
98
|
+
def pdf(d):
|
|
99
|
+
d = mp.mpf(d) / sc
|
|
100
|
+
return norm * (1 + d ** 2 / nu) ** (-(nu + 1) / 2)
|
|
101
|
+
|
|
102
|
+
return LineShape(f"student_t{float(nu):g}", pdf, float(scale),
|
|
103
|
+
stats.t(df=float(nu), scale=float(scale)))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def box(halfwidth: float = 1.0) -> LineShape:
|
|
107
|
+
"""Uniform line on [-w, w]. Discontinuous, kept as a stress test."""
|
|
108
|
+
w = mp.mpf(halfwidth)
|
|
109
|
+
|
|
110
|
+
def pdf(d):
|
|
111
|
+
return 1 / (2 * w) if abs(mp.mpf(d)) <= w else mp.mpf(0)
|
|
112
|
+
|
|
113
|
+
return LineShape("box", pdf, float(halfwidth),
|
|
114
|
+
stats.uniform(loc=-float(w), scale=2 * float(w)),
|
|
115
|
+
compact_support=float(halfwidth))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def bimodal_gaussian(sep: float, width: float = 1.0) -> LineShape:
|
|
119
|
+
"""Two equal Gaussians of standard deviation `width` at +/- sep/2.
|
|
120
|
+
|
|
121
|
+
The ratio sep/width controls the order of the transition; see
|
|
122
|
+
`lockkernel.parametric.c_coefficient`.
|
|
123
|
+
"""
|
|
124
|
+
a = mp.mpf(sep) / 2
|
|
125
|
+
s = mp.mpf(width)
|
|
126
|
+
|
|
127
|
+
def pdf(d):
|
|
128
|
+
d = mp.mpf(d)
|
|
129
|
+
z = 1 / (2 * s * mp.sqrt(2 * mp.pi))
|
|
130
|
+
return z * (mp.e ** (-(d - a) ** 2 / (2 * s ** 2))
|
|
131
|
+
+ mp.e ** (-(d + a) ** 2 / (2 * s ** 2)))
|
|
132
|
+
|
|
133
|
+
return LineShape(f"bimodal{float(sep) / float(width):g}", pdf,
|
|
134
|
+
float(width) + float(a))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
LINESHAPES = {
|
|
138
|
+
"lorentzian": lorentzian,
|
|
139
|
+
"gaussian": gaussian,
|
|
140
|
+
"student_t": student_t,
|
|
141
|
+
"box": box,
|
|
142
|
+
"bimodal_gaussian": bimodal_gaussian,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def float_pdf(line: LineShape):
|
|
147
|
+
"""A vectorised float precision version of a line shape density."""
|
|
148
|
+
return np.vectorize(lambda x: float(line.pdf(mp.mpf(float(x)))))
|