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 ADDED
@@ -0,0 +1,15 @@
1
+ """Locking kernel universality: thresholds and exponents.
2
+
3
+ The package is organised around one object, the locking kernel W(u), and one
4
+ identity, the exact parametric solution of the locking self consistency in
5
+ `lockkernel.parametric`. The `lockkernel.measured` module is the lab half:
6
+ it fits a MEASURED synchronization branch for the threshold and the exponent,
7
+ with error bars and refusals, and reads the kernel tail back off the exponent
8
+ through the same dictionary the theory half proves.
9
+ """
10
+ __version__ = "1.1.0"
11
+
12
+ from . import kernels, lineshapes, measured, parametric # noqa: F401
13
+ from .measured import (BranchFit, beta_relative_sigma, # noqa: F401
14
+ fit_branch, kernel_tail_from_beta,
15
+ points_for_beta)
lockkernel/cumulant.py ADDED
@@ -0,0 +1,399 @@
1
+ """Second order cumulant dynamics of the conservative model.
2
+
3
+ MODEL. Spin one half emitters with uniform all to all exchange and an
4
+ inhomogeneous distribution of detunings,
5
+
6
+ H = chi1 sum_{j,k} sigma+_j sigma-_k + sum_j (delta_j / 2) sigma z_j,
7
+ chi1 = chiN / N.
8
+
9
+ There is no damping, no drive and no thermal bath: the evolution is unitary.
10
+ This is the quantum model whose mean field limit obeys the locking law of
11
+ `lockkernel.parametric`.
12
+
13
+ VARIABLES. Emitters are grouped into classes of equal detuning. Within a
14
+ class every emitter is equivalent, so the state is carried by the class means
15
+
16
+ s_m = <sigma+_a>, z_m = <sigma z_a>, a in class m,
17
+
18
+ and by the CONNECTED pair correlations of two DISTINCT emitters a in class m
19
+ and b in class n,
20
+
21
+ P_mn = <sigma+_a sigma-_b> - s_m conj(s_n),
22
+ Q_mn = <sigma+_a sigma+_b> - s_m s_n,
23
+ R_mn = <sigma z_a sigma+_b> - z_m s_n,
24
+ Z_mn = <sigma z_a sigma z_b> - z_m z_n.
25
+
26
+ Connected correlations are of order 1/N while the raw moments are of order
27
+ one, so evolving them directly avoids the cancellation that makes raw moments
28
+ useless beyond N of order 1e8. The hierarchy is closed at third order by the
29
+ Gaussian rule <XYB>_c = <X><YB>_c + <Y><XB>_c.
30
+
31
+ FAR DETUNED EMITTERS. Emitters whose detuning is far outside the locking
32
+ bandwidth are propagated exactly as free spins rather than integrated. Their
33
+ population is retained in full, so the line is never truncated; only their
34
+ interaction, whose relative size is (chiN/delta)^2, is dropped. This removes
35
+ the stiffness of the far tail from the differential equation.
36
+
37
+ VALIDATION. `tests/test_cumulant.py` checks this solver against exact
38
+ diagonalisation of the full Hamiltonian for small emitter numbers with several
39
+ distinct detunings, and against the closed form of one axis twisting on
40
+ resonance.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import dataclasses
45
+
46
+ import numpy as np
47
+ from scipy.integrate import solve_ivp
48
+
49
+ __all__ = ["System", "State", "coherent_state_x", "evolve", "evolve_meanfield",
50
+ "wineland_xi2", "coherence", "collective_moments"]
51
+
52
+
53
+ @dataclasses.dataclass
54
+ class System:
55
+ """Classes, populations and coupling.
56
+
57
+ Attributes
58
+ ----------
59
+ delta : (M,) float
60
+ Detuning of each interacting class.
61
+ n : (M,) float
62
+ Population of each interacting class.
63
+ chiN : float
64
+ Coupling per emitter times the total emitter number.
65
+ spec_delta, spec_n : (K,) float
66
+ Detuning and population of the free, far detuned classes.
67
+ """
68
+
69
+ delta: np.ndarray
70
+ n: np.ndarray
71
+ chiN: float
72
+ spec_delta: np.ndarray = None
73
+ spec_n: np.ndarray = None
74
+
75
+ def __post_init__(self):
76
+ self.delta = np.asarray(self.delta, float)
77
+ self.n = np.asarray(self.n, float)
78
+ if self.spec_delta is None:
79
+ self.spec_delta = np.zeros(0)
80
+ self.spec_n = np.zeros(0)
81
+ self.spec_delta = np.asarray(self.spec_delta, float)
82
+ self.spec_n = np.asarray(self.spec_n, float)
83
+
84
+ @property
85
+ def M(self) -> int:
86
+ return len(self.delta)
87
+
88
+ @property
89
+ def K(self) -> int:
90
+ return len(self.spec_delta)
91
+
92
+ @property
93
+ def N(self) -> float:
94
+ """Total emitter number, interacting plus free."""
95
+ return float(self.n.sum() + self.spec_n.sum())
96
+
97
+ @property
98
+ def chi1(self) -> float:
99
+ return float(self.chiN) / self.N
100
+
101
+
102
+ @dataclasses.dataclass
103
+ class State:
104
+ """Class means and connected pair correlations."""
105
+
106
+ s: np.ndarray
107
+ z: np.ndarray
108
+ P: np.ndarray
109
+ Q: np.ndarray
110
+ R: np.ndarray
111
+ Z: np.ndarray
112
+ vs: np.ndarray = None # (K,3) Bloch vectors of the free classes
113
+
114
+ def __post_init__(self):
115
+ if self.vs is None:
116
+ self.vs = np.zeros((0, 3))
117
+
118
+ @property
119
+ def M(self):
120
+ return len(self.s)
121
+
122
+ def pack(self):
123
+ return np.concatenate([self.s, self.z.astype(complex),
124
+ self.P.ravel(), self.Q.ravel(),
125
+ self.R.ravel(), self.Z.ravel()])
126
+
127
+ @classmethod
128
+ def unpack(cls, y, M, vs=None):
129
+ b = y[2 * M:].reshape(4, M, M)
130
+ return cls(s=y[:M], z=y[M:2 * M], P=b[0], Q=b[1], R=b[2], Z=b[3], vs=vs)
131
+
132
+
133
+ def coherent_state_x(sys: System) -> State:
134
+ """All emitters along +x: a coherent spin state, the standard start."""
135
+ M, K = sys.M, sys.K
136
+ zero = np.zeros((M, M), complex)
137
+ return State(np.full(M, 0.5, complex), np.zeros(M, complex),
138
+ zero.copy(), zero.copy(), zero.copy(), zero.copy(),
139
+ np.tile(np.array([1.0, 0.0, 0.0]), (K, 1)))
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Equations of motion
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def _rhs(t, y, delta, n, chi1):
147
+ """Right hand side in connected variables.
148
+
149
+ Every sum over partners excludes the emitters that already carry a free
150
+ index, which is what the `_ex` helper subtracts.
151
+ """
152
+ M = len(delta)
153
+ st = State.unpack(y, M)
154
+ s, z, P, Q, R, Z = st.s, st.z, st.P, st.Q, st.R, st.Z
155
+ c = 1j * chi1
156
+ cc = -1j * chi1
157
+ A = 1j * (delta + chi1) # free precession of sigma-
158
+ Ac = np.conj(A)
159
+ sc = np.conj(s)
160
+ Rc = np.conj(R)
161
+ ns = n @ s # sum_p n_p s_p
162
+
163
+ # ---- first moments -----------------------------------------------------
164
+ S_zp = (R @ n - np.diag(R)) + z * (ns - s)
165
+ ds = -Ac * s + cc * S_zp
166
+ S_pm = (P @ n - np.diag(P)) + s * np.conj(ns - s)
167
+ dz = -4.0 * np.real(c * S_pm)
168
+
169
+ # ---- shorthands --------------------------------------------------------
170
+ zm, zn = z[:, None], z[None, :]
171
+ sm, sn = s[:, None], s[None, :]
172
+ scm, scn = sc[:, None], sc[None, :]
173
+
174
+ def _ex(full, at_m, at_n):
175
+ """sum_p n_p T(p) with the terms p = m and p = n removed."""
176
+ return full - at_m - at_n
177
+
178
+ # ---- P -----------------------------------------------------------------
179
+ S1 = _ex(zm * (n @ P)[None, :] + Rc * ns,
180
+ zm * P + Rc * sm,
181
+ zm * np.diag(P)[None, :] + Rc * sn)
182
+ S2 = _ex(zn * (P @ n)[:, None] + R.T * np.conj(ns),
183
+ zn * np.diag(P)[:, None] + R.T * scm,
184
+ zn * P + R.T * scn)
185
+ src_a = 0.5 * (zm + zm * zn + Z) - R * scn - zm * (sn * scn)
186
+ src_b = 0.5 * (zn + zm * zn + Z) - sm * np.conj(R.T) - zn * (sm * scm)
187
+ dP = (-(Ac[:, None] + A[None, :]) * P
188
+ + cc * (src_a + S1)
189
+ + c * (src_b + S2))
190
+
191
+ # ---- Q -----------------------------------------------------------------
192
+ S3 = _ex(zm * (n @ Q)[None, :] + R * ns,
193
+ zm * Q + R * sm,
194
+ zm * np.diag(Q)[None, :] + R * sn)
195
+ S4 = _ex(zn * (Q @ n)[:, None] + R.T * ns,
196
+ zn * np.diag(Q)[:, None] + R.T * sm,
197
+ zn * Q + R.T * sn)
198
+ dQ = (-(Ac[:, None] + Ac[None, :]) * Q
199
+ + cc * (-(R + zm * sn) * sn + S3)
200
+ + cc * (-sm * (R.T + zn * sm) + S4))
201
+
202
+ # ---- R -----------------------------------------------------------------
203
+ S5 = _ex(c * (sm * (P @ n)[None, :] + Q * np.conj(ns))
204
+ + cc * (P.T * ns + scm * (n @ Q)[None, :]),
205
+ c * (sm * P.T + Q * scm) + cc * (P.T * sm + scm * Q),
206
+ c * (sm * np.diag(P)[None, :] + Q * scn)
207
+ + cc * (P.T * sn + scm * np.diag(Q)[None, :]))
208
+ S7 = _ex(zn * (R @ n)[:, None] + Z * ns,
209
+ zn * np.diag(R)[:, None] + Z * sm,
210
+ zn * R + Z * sn)
211
+ P_mn = P + sm * scn
212
+ P_nm = P.T + sn * scm
213
+ src_za = c * (0.5 * (sm - R.T - sm * zn) - P_mn * sn) + cc * (-P_nm * sn)
214
+ dR = (-2.0 * src_za
215
+ - 2.0 * S5
216
+ - Ac[None, :] * R
217
+ + cc * (1.0 - zm) * (R.T + sm * zn)
218
+ + cc * S7)
219
+
220
+ # ---- Z -----------------------------------------------------------------
221
+ S8 = _ex(c * (sm * (Rc @ n)[None, :] + R.T * np.conj(ns))
222
+ + cc * (Rc.T * ns + scm * (R @ n)[None, :]),
223
+ c * (sm * Rc.T + R.T * scm) + cc * (Rc.T * sm + scm * R.T),
224
+ c * (sm * np.diag(Rc)[None, :] + R.T * scn)
225
+ + cc * (Rc.T * sn + scm * np.diag(R)[None, :]))
226
+ S9 = _ex(c * (sn * (Rc @ n)[:, None] + R * np.conj(ns))
227
+ + cc * (Rc * ns + scn * (R @ n)[:, None]),
228
+ c * (sn * np.diag(Rc)[:, None] + R * scm)
229
+ + cc * (Rc * sm + scn * np.diag(R)[:, None]),
230
+ c * (sn * Rc + R * scn) + cc * (Rc * sn + scn * R))
231
+ src_zz_a = c * P_mn * (1.0 - zn) - cc * P_nm * (1.0 + zn)
232
+ src_zz_b = -c * P_nm * (1.0 + zm) + cc * P_mn * (1.0 - zm)
233
+ dZ = (-2.0 * (src_zz_a + src_zz_b) - 2.0 * S8 - 2.0 * S9)
234
+
235
+ return State(ds, dz, dP, dQ, dR, dZ).pack()
236
+
237
+
238
+ def _rhs_meanfield(t, y, delta, n, chi1):
239
+ """Mean field limit: the same equations with all correlations set to zero."""
240
+ M = len(delta)
241
+ s, z = y[:M], y[M:]
242
+ c = 1j * chi1
243
+ A = 1j * (delta + chi1)
244
+ ns = n @ s
245
+ ds = -np.conj(A) * s + np.conj(c) * z * (ns - s)
246
+ dz = -4.0 * np.real(c * s * np.conj(ns - s))
247
+ return np.concatenate([ds, dz])
248
+
249
+
250
+ def _free(vs, spec_delta, tau):
251
+ """Exact free precession of the far detuned classes."""
252
+ if vs.shape[0] == 0:
253
+ return vs
254
+ ph = np.asarray(spec_delta) * tau
255
+ x, y, z = vs[:, 0], vs[:, 1], vs[:, 2]
256
+ return np.stack([x * np.cos(ph) - y * np.sin(ph),
257
+ y * np.cos(ph) + x * np.sin(ph), z], axis=1)
258
+
259
+
260
+ def evolve(st: State, sys: System, t: float, t_eval=None, rtol=1e-8,
261
+ atol=None, method="DOP853"):
262
+ """Evolve the cumulant state. Returns a State, or a list at `t_eval`."""
263
+ if atol is None:
264
+ atol = 1e-10 / max(sys.N, 1.0)
265
+ args = (sys.delta, sys.n, sys.chi1)
266
+ sol = solve_ivp(_rhs, (0.0, t), st.pack(), args=args, t_eval=t_eval,
267
+ rtol=rtol, atol=atol, method=method)
268
+ if not sol.success:
269
+ raise RuntimeError(sol.message)
270
+ if t_eval is None:
271
+ return State.unpack(sol.y[:, -1], sys.M, _free(st.vs, sys.spec_delta, t))
272
+ return [State.unpack(sol.y[:, k], sys.M,
273
+ _free(st.vs, sys.spec_delta, sol.t[k]))
274
+ for k in range(sol.y.shape[1])]
275
+
276
+
277
+ def evolve_meanfield(sys: System, t: float, t_eval=None, rtol=1e-8, atol=1e-11,
278
+ method="DOP853"):
279
+ """Mean field evolution from the coherent spin state along +x.
280
+
281
+ Returns (s, z) arrays with a column per requested time.
282
+ """
283
+ y0 = np.concatenate([np.full(sys.M, 0.5, complex), np.zeros(sys.M, complex)])
284
+ sol = solve_ivp(_rhs_meanfield, (0.0, t), y0, args=(sys.delta, sys.n, sys.chi1),
285
+ t_eval=t_eval, rtol=rtol, atol=atol, method=method)
286
+ if not sol.success:
287
+ raise RuntimeError(sol.message)
288
+ return sol.y[:sys.M, :], sol.y[sys.M:, :]
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Collective observables
293
+ # ---------------------------------------------------------------------------
294
+
295
+ def _cartesian(st: State):
296
+ """Bloch vectors and the connected correlation tensor in Cartesian form."""
297
+ s, z, P, Q, R, Z = st.s, st.z, st.P, st.Q, st.R, st.Z
298
+ v = np.stack([2 * s.real, 2 * s.imag, z.real], axis=1)
299
+ M = st.M
300
+ C = np.empty((M, M, 3, 3), complex)
301
+ Qc, PT = np.conj(Q), P.T
302
+ C[:, :, 0, 0] = Q + P + PT + Qc
303
+ C[:, :, 0, 1] = -1j * (Q - P + PT - Qc)
304
+ C[:, :, 1, 0] = -1j * (Q + P - PT - Qc)
305
+ C[:, :, 1, 1] = -(Q - P - PT + Qc)
306
+ C[:, :, 0, 2] = 2 * R.T.real
307
+ C[:, :, 1, 2] = 2 * R.T.imag
308
+ C[:, :, 2, 0] = 2 * R.real
309
+ C[:, :, 2, 1] = 2 * R.imag
310
+ C[:, :, 2, 2] = Z
311
+ return v, C
312
+
313
+
314
+ def collective_moments(st: State, sys: System):
315
+ """Mean and symmetrised covariance of J = (1/2) sum_a sigma_a."""
316
+ n = sys.n
317
+ v, C = _cartesian(st)
318
+ J = 0.5 * (n @ v)
319
+ pair = np.einsum("m,n,mnab->ab", n, n, C) - np.einsum("m,mmab->ab", n, C)
320
+ same = np.sum(n) * np.eye(3) - np.einsum("m,ma,mb->ab", n, v, v)
321
+ Cov = 0.25 * (pair.real + same)
322
+ S1 = float(n.sum())
323
+ if sys.K:
324
+ sn = sys.spec_n
325
+ J = J + 0.5 * (sn @ st.vs)
326
+ Cov = Cov + 0.25 * (np.sum(sn) * np.eye(3)
327
+ - np.einsum("k,ka,kb->ab", sn, st.vs, st.vs))
328
+ S1 += float(sn.sum())
329
+ return J, 0.5 * (Cov + Cov.T), S1
330
+
331
+
332
+ def _transverse(J, Cov):
333
+ """Smallest and largest variance in the plane perpendicular to J."""
334
+ Jn = np.linalg.norm(J)
335
+ e3 = J / Jn
336
+ trial = np.array([0.0, 0.0, 1.0]) if abs(e3[2]) < 0.9 else np.array([1.0, 0.0, 0.0])
337
+ e1 = np.cross(e3, trial)
338
+ e1 /= np.linalg.norm(e1)
339
+ e2 = np.cross(e3, e1)
340
+ B = np.stack([e1, e2], axis=1)
341
+ vals = np.linalg.eigvalsh(B.T @ Cov @ B)
342
+ return vals[0], vals[1], Jn
343
+
344
+
345
+ def wineland_xi2(st: State, sys: System):
346
+ """Wineland spin squeezing parameter xi_R^2 = N var_min / |<J>|^2.
347
+
348
+ Equal to one for a coherent spin state. A value below one certifies
349
+ metrological entanglement and a phase sensitivity beyond the standard
350
+ quantum limit.
351
+ """
352
+ J, Cov, S1 = collective_moments(st, sys)
353
+ if np.linalg.norm(J) == 0:
354
+ return np.inf
355
+ vmin, _, Jn = _transverse(J, Cov)
356
+ return float(vmin * S1 / Jn ** 2)
357
+
358
+
359
+ def coherence(st: State, sys: System) -> float:
360
+ """Transverse contrast 2 |<J_perp>| / N, the order parameter R."""
361
+ J, _, S1 = collective_moments(st, sys)
362
+ return float(2.0 * np.hypot(J[0], J[1]) / S1)
363
+
364
+
365
+ def physicality(st: State, sys: System):
366
+ """Diagnostics that say whether the truncated state is still physical.
367
+
368
+ A second order cumulant expansion is not guaranteed to stay inside the set
369
+ of physical states, and in an undamped model the truncated hierarchy is
370
+ secularly unstable: the connected correlations, which should stay of order
371
+ 1/N, eventually grow and the reconstructed covariance loses positivity.
372
+ Two exact properties of any true state are therefore monitored.
373
+
374
+ * The collective covariance must be positive semidefinite.
375
+ * Every class Bloch vector must lie inside the unit sphere.
376
+
377
+ Returns (min_eigenvalue_of_covariance, max_bloch_length). The state is
378
+ treated as physical while the first is not negative and the second does
379
+ not exceed one.
380
+ """
381
+ _, Cov, _ = collective_moments(st, sys)
382
+ v, _ = _cartesian(st)
383
+ return (float(np.linalg.eigvalsh(Cov)[0]),
384
+ float(np.max(np.linalg.norm(v.real, axis=1))))
385
+
386
+
387
+ def valid_window(states, sys: System, bloch_tol=1e-6):
388
+ """Index of the last state that is still physical, walking forward in time.
389
+
390
+ Once the truncation has failed it does not recover, so the window is the
391
+ leading run of physical states rather than the set of all of them.
392
+ """
393
+ last = -1
394
+ for k, st in enumerate(states):
395
+ mev, vmax = physicality(st, sys)
396
+ if mev < 0.0 or vmax > 1.0 + bloch_tol:
397
+ break
398
+ last = k
399
+ return last
lockkernel/ensemble.py ADDED
@@ -0,0 +1,105 @@
1
+ """Discretisation of a line into detuning classes.
2
+
3
+ The dynamics is solved class by class, so a continuous line has to be replaced
4
+ by a finite set of detunings carrying the right populations. Two features of
5
+ the problem dictate the construction.
6
+
7
+ * The line can have ALGEBRAIC TAILS. A uniform grid, or a grid truncated at a
8
+ few widths, changes the density at the centre once it is renormalised, and
9
+ the threshold depends on that density directly. The construction below
10
+ therefore keeps the entire population: nothing is discarded.
11
+
12
+ * The far tail is STIFF and IRRELEVANT. An emitter detuned by a thousand
13
+ linewidths precesses a thousand times faster than anything else in the
14
+ problem, which forces the integrator to tiny steps, while its interaction
15
+ with the mean field is smaller by (chiN/delta)^2. Such emitters are
16
+ therefore removed from the differential equation and propagated exactly as
17
+ free spins, with their population fully retained.
18
+
19
+ The three regions are
20
+
21
+ core |delta| < core_edge M_core equal probability bins
22
+ tail core_edge < |delta| < free M_tail logarithmic bins
23
+ free |delta| > free M_spec logarithmic bins, exact
24
+
25
+ with the node of each bin placed at its median, so that the first moment of
26
+ the population within the bin is represented rather than its edge.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import numpy as np
31
+
32
+ from .cumulant import System
33
+ from .lineshapes import LineShape
34
+
35
+ __all__ = ["build_system", "class_table"]
36
+
37
+
38
+ def _log_bins(dist, lo, hi, M, lump_last):
39
+ """Logarithmic bins on [lo, hi] of the positive half line."""
40
+ edges = np.geomspace(lo, hi, M + 1)
41
+ nodes, mass = [], []
42
+ for k in range(M):
43
+ a, b = edges[k], edges[k + 1]
44
+ m = float(1.0 - dist.cdf(a)) if (lump_last and k == M - 1) \
45
+ else float(dist.cdf(b) - dist.cdf(a))
46
+ if not np.isfinite(m) or m <= 1e-14:
47
+ continue
48
+ node = float(dist.ppf(dist.cdf(a) + 0.5 * m))
49
+ if not np.isfinite(node):
50
+ node = float(np.sqrt(a * b))
51
+ nodes.append(node)
52
+ mass.append(m)
53
+ return np.array(nodes), np.array(mass)
54
+
55
+
56
+ def class_table(line: LineShape, M_core: int = 64, M_tail: int = 14,
57
+ M_spec: int = 8, core_edge: float = None,
58
+ free_beyond: float = None, delta_max: float = None):
59
+ """Return (delta, n, spec_delta, spec_n) with populations summing to one."""
60
+ dist = line.frozen
61
+ if dist is None:
62
+ raise ValueError(f"{line.name} has no float distribution to discretise")
63
+ scale = float(line.scale)
64
+ core_edge = 3.0 * scale if core_edge is None else float(core_edge)
65
+ delta_max = 1000.0 * scale if delta_max is None else float(delta_max)
66
+ tail_end = delta_max if free_beyond is None else max(float(free_beyond),
67
+ core_edge * 1.001)
68
+
69
+ c_lo, c_hi = float(dist.cdf(-core_edge)), float(dist.cdf(core_edge))
70
+ q = c_lo + (c_hi - c_lo) * (np.arange(M_core) + 0.5) / M_core
71
+ d_core = np.asarray(dist.ppf(q), float)
72
+ n_core = np.full(M_core, (c_hi - c_lo) / M_core)
73
+
74
+ lump = free_beyond is None
75
+ d_t, n_t = _log_bins(dist, core_edge, tail_end, M_tail, lump)
76
+ delta = np.concatenate([-d_t[::-1], d_core, d_t])
77
+ n = np.concatenate([n_t[::-1], n_core, n_t])
78
+
79
+ if free_beyond is None:
80
+ s_d, s_n = np.zeros(0), np.zeros(0)
81
+ else:
82
+ d_s, n_s = _log_bins(dist, tail_end, delta_max, M_spec, True)
83
+ s_d = np.concatenate([-d_s[::-1], d_s])
84
+ s_n = np.concatenate([n_s[::-1], n_s])
85
+
86
+ total = n.sum() + s_n.sum()
87
+ return delta, n / total, s_d, s_n / total
88
+
89
+
90
+ def build_system(line: LineShape, N: float, chiN: float, M_core: int = 64,
91
+ M_tail: int = 14, M_spec: int = 8, core_edge: float = None,
92
+ free_factor: float = 8.0, delta_max: float = None) -> System:
93
+ """Build a `System` for a line, an emitter number and a coupling.
94
+
95
+ The boundary of the free region is placed at `free_factor` times the
96
+ larger of the coupling and twice the line width, so that it always sits
97
+ well outside the locking bandwidth. Convergence in `free_factor` is
98
+ checked in `scripts/vlasov_check.py`.
99
+ """
100
+ scale = float(line.scale)
101
+ free = free_factor * max(abs(float(chiN)), 2.0 * scale)
102
+ delta, n, s_d, s_n = class_table(line, M_core, M_tail, M_spec,
103
+ core_edge, free, delta_max)
104
+ return System(delta=delta, n=n * N, chiN=float(chiN),
105
+ spec_delta=s_d, spec_n=s_n * N)