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/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))
@@ -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)))))