freealg 0.1.11__py3-none-any.whl → 0.7.12__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.
Files changed (59) hide show
  1. freealg/__init__.py +8 -2
  2. freealg/__version__.py +1 -1
  3. freealg/_algebraic_form/__init__.py +12 -0
  4. freealg/_algebraic_form/_branch_points.py +288 -0
  5. freealg/_algebraic_form/_constraints.py +139 -0
  6. freealg/_algebraic_form/_continuation_algebraic.py +706 -0
  7. freealg/_algebraic_form/_decompress.py +641 -0
  8. freealg/_algebraic_form/_decompress2.py +204 -0
  9. freealg/_algebraic_form/_edge.py +330 -0
  10. freealg/_algebraic_form/_homotopy.py +323 -0
  11. freealg/_algebraic_form/_moments.py +448 -0
  12. freealg/_algebraic_form/_sheets_util.py +145 -0
  13. freealg/_algebraic_form/_support.py +309 -0
  14. freealg/_algebraic_form/algebraic_form.py +1232 -0
  15. freealg/_free_form/__init__.py +16 -0
  16. freealg/{_chebyshev.py → _free_form/_chebyshev.py} +75 -43
  17. freealg/_free_form/_decompress.py +993 -0
  18. freealg/_free_form/_density_util.py +243 -0
  19. freealg/_free_form/_jacobi.py +359 -0
  20. freealg/_free_form/_linalg.py +508 -0
  21. freealg/{_pade.py → _free_form/_pade.py} +42 -208
  22. freealg/{_plot_util.py → _free_form/_plot_util.py} +37 -22
  23. freealg/{_sample.py → _free_form/_sample.py} +58 -22
  24. freealg/_free_form/_series.py +454 -0
  25. freealg/_free_form/_support.py +214 -0
  26. freealg/_free_form/free_form.py +1362 -0
  27. freealg/_geometric_form/__init__.py +13 -0
  28. freealg/_geometric_form/_continuation_genus0.py +175 -0
  29. freealg/_geometric_form/_continuation_genus1.py +275 -0
  30. freealg/_geometric_form/_elliptic_functions.py +174 -0
  31. freealg/_geometric_form/_sphere_maps.py +63 -0
  32. freealg/_geometric_form/_torus_maps.py +118 -0
  33. freealg/_geometric_form/geometric_form.py +1094 -0
  34. freealg/_util.py +56 -110
  35. freealg/distributions/__init__.py +7 -1
  36. freealg/distributions/_chiral_block.py +494 -0
  37. freealg/distributions/_deformed_marchenko_pastur.py +726 -0
  38. freealg/distributions/_deformed_wigner.py +386 -0
  39. freealg/distributions/_kesten_mckay.py +29 -15
  40. freealg/distributions/_marchenko_pastur.py +224 -95
  41. freealg/distributions/_meixner.py +47 -37
  42. freealg/distributions/_wachter.py +29 -17
  43. freealg/distributions/_wigner.py +27 -14
  44. freealg/visualization/__init__.py +12 -0
  45. freealg/visualization/_glue_util.py +32 -0
  46. freealg/visualization/_rgb_hsv.py +125 -0
  47. freealg-0.7.12.dist-info/METADATA +172 -0
  48. freealg-0.7.12.dist-info/RECORD +53 -0
  49. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/WHEEL +1 -1
  50. freealg/_decompress.py +0 -180
  51. freealg/_jacobi.py +0 -218
  52. freealg/_support.py +0 -85
  53. freealg/freeform.py +0 -967
  54. freealg-0.1.11.dist-info/METADATA +0 -140
  55. freealg-0.1.11.dist-info/RECORD +0 -24
  56. /freealg/{_damp.py → _free_form/_damp.py} +0 -0
  57. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/AUTHORS.txt +0 -0
  58. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/LICENSE.txt +0 -0
  59. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,448 @@
1
+ # =======
2
+ # Imports
3
+ # =======
4
+
5
+ import numpy
6
+
7
+
8
+ # =======
9
+ # Moments
10
+ # =======
11
+
12
+ class Moments(object):
13
+ """
14
+ Moments :math:`\\mu_n(t)` generated from eigenvalues, under
15
+ free decompression, where
16
+
17
+ .. math::
18
+
19
+ m_n = \\mu_n(0) = \\mathbb{E}[\\lambda^n],
20
+
21
+ and :math:`\\lambda` denotes an eigenvalue sample.
22
+
23
+ Parameters
24
+ ----------
25
+
26
+ source : array_like or callable
27
+ Either
28
+
29
+ * a 1D array of eigenvalues (or samples), or
30
+ * a callable returning the raw moments at zero, ``source(n) = m_n``.
31
+
32
+ If an array is provided, moments are estimated via sample averages.
33
+ If a callable is provided, it is assumed to return exact values of
34
+ :math:`m_n`.
35
+
36
+ Attributes
37
+ ----------
38
+
39
+ eig : numpy.ndarray or None
40
+ Eigenvalue samples, if provided.
41
+
42
+ Methods
43
+ -------
44
+
45
+ m
46
+ Compute the raw moment :math:`m_n = \\mathbb{E}[\\lambda^n]`.
47
+
48
+ coeffs
49
+ Compute the coefficient vector :math:`a_n`.
50
+
51
+ __call__
52
+ Evaluate :math:`\\mu_n(t)` for a given :math:`n` and :math:`t`.
53
+
54
+ Notes
55
+ -----
56
+
57
+ The recursion memoizes:
58
+
59
+ * Moments ``_m[n] = m_n``.
60
+ * Coefficients ``_a[n] = a_n`` where ``a_n`` has length ``n`` and contains
61
+ :math:`(a_{n,0}, \\dots, a_{n,n-1})`.
62
+
63
+ The coefficient row :math:`a_n` is computed using an intermediate quantity
64
+ :math:`R_{n,k}` formed via discrete convolutions of previous rows.
65
+ """
66
+
67
+ # ====
68
+ # init
69
+ # ====
70
+
71
+ def __init__(self, source):
72
+ """
73
+ Initialization.
74
+ """
75
+ self.eig = None
76
+ self._moment_fn = None
77
+
78
+ if callable(source):
79
+ self._moment_fn = source
80
+ else:
81
+ self.eig = numpy.asarray(source, dtype=float)
82
+
83
+ # Memoized moments m_n
84
+ self._m = {0: 1.0}
85
+
86
+ # Memoized coefficients a[n] = array of length n
87
+ # (a_{n,0},...,a_{n,n-1})
88
+ self._a = {0: numpy.array([1.0])}
89
+
90
+ # =
91
+ # m
92
+ # =
93
+
94
+ def m(self, n):
95
+ """
96
+ Compute raw moment :math:`m_n`.
97
+
98
+ Parameters
99
+ ----------
100
+
101
+ n : int
102
+ Order of the moment.
103
+
104
+ Returns
105
+ -------
106
+
107
+ m_n : float
108
+ The raw moment :math:`m_n = \\mathbb{E}[\\lambda^n]`.
109
+
110
+ Notes
111
+ -----
112
+
113
+ If the instance was initialized with eigenvalue samples, the moment is
114
+ estimated by the sample mean of ``eig**n``. If initialized with a
115
+ callable, the callable is used directly.
116
+ """
117
+ n = int(n)
118
+ if n < 0:
119
+ raise ValueError("Moment order n must be >= 0.")
120
+
121
+ if n not in self._m:
122
+ if self._moment_fn is not None:
123
+ self._m[n] = float(self._moment_fn(n))
124
+ else:
125
+ self._m[n] = float(numpy.mean(self.eig ** n))
126
+
127
+ return self._m[n]
128
+
129
+ # ======
130
+ # coeffs
131
+ # ======
132
+
133
+ def coeffs(self, n):
134
+ """
135
+ Get coefficients :math:`a_n` for :math:`\\mu_n(t)`.
136
+
137
+ Parameters
138
+ ----------
139
+
140
+ n : int
141
+ Order of :math:`\\mu_n(t)`.
142
+
143
+ Returns
144
+ -------
145
+
146
+ a_n : numpy.ndarray
147
+ Array of shape ``(n,)`` containing :math:`(a_{n,0},
148
+ \\dots, a_{n,n-1})`.
149
+ """
150
+ n = int(n)
151
+ if n < 0:
152
+ raise ValueError("Order n must be >= 0.")
153
+
154
+ if n in self._a:
155
+ return self._a[n]
156
+
157
+ # Ensure previous rows exist
158
+ for r in range(1, n):
159
+ if r not in self._a:
160
+ self._compute_row(r)
161
+
162
+ self._compute_row(n)
163
+ return self._a[n]
164
+
165
+ # ===========
166
+ # compute row
167
+ # ===========
168
+
169
+ def _compute_row(self, n):
170
+ """
171
+ Compute and memoize the coefficient row :math:`a_n`.
172
+
173
+ Parameters
174
+ ----------
175
+
176
+ n : int
177
+ Row index to compute.
178
+ """
179
+ if n in self._a:
180
+ return
181
+
182
+ if n == 1:
183
+ self._a[1] = numpy.array([self.m(1)])
184
+ return
185
+
186
+ # Ensure all smaller rows exist
187
+ for r in range(1, n):
188
+ if r not in self._a:
189
+ self._compute_row(r)
190
+
191
+ a_n = numpy.zeros(n, dtype=float)
192
+
193
+ # Compute R_{n,k} via convolutions
194
+ R = numpy.zeros(n - 1, dtype=float)
195
+ for i in range(1, n):
196
+ conv = numpy.convolve(self._a[i], self._a[n - i])
197
+ R += conv[: n - 1]
198
+
199
+ k = numpy.arange(n - 1, dtype=float)
200
+ factors = (1.0 + 0.5 * k) / (n - 1 - k)
201
+ a_n[: n - 1] = factors * R
202
+
203
+ # k = n-1 from the initial condition mu_n(0) = m_n
204
+ a_n[n - 1] = self.m(n) - a_n[: n - 1].sum()
205
+
206
+ self._a[n] = a_n
207
+
208
+ # --------
209
+ # evaluate
210
+ # --------
211
+
212
+ def __call__(self, n, t=0.0):
213
+ """
214
+ Evaluate :math:`\\mu_n(t)`.
215
+
216
+ Parameters
217
+ ----------
218
+
219
+ n : int
220
+ Order of :math:`\\mu_n(t)`.
221
+
222
+ t : float, default=0.0
223
+ Deformation parameter.
224
+
225
+ Returns
226
+ -------
227
+
228
+ mu_n : float
229
+ The value of :math:`\\mu_n(t)`.
230
+
231
+ Notes
232
+ -----
233
+
234
+ This function evaluates
235
+
236
+ .. math::
237
+
238
+ \\mu_n(t) = \\sum_{k=0}^{n-1} a_{n,k} \\, e^{k t}.
239
+
240
+ For ``n == 0``, it returns ``1.0``.
241
+ """
242
+ n = int(n)
243
+ if n < 0:
244
+ raise ValueError("Order n must be >= 0.")
245
+ if n == 0:
246
+ return 1.0
247
+
248
+ a_n = self.coeffs(n)
249
+ k = numpy.arange(n, dtype=float)
250
+ return float(numpy.dot(a_n, numpy.exp(k * t)))
251
+
252
+
253
+
254
+ # ===========================
255
+ # Algebraic Stieltjes Moments
256
+ # ===========================
257
+
258
+ class AlgebraicStieltjesMoments(object):
259
+ """
260
+ Given coefficients a[i,j] for P(z,m)=sum_{i,j} a[i,j] z^i m^j,
261
+ compute the large-|z| branch
262
+ m(z) = sum_{k>=0} mu_series[k] / z^{k+1}.
263
+
264
+ Convention here: choose mu0 (the leading coefficient) by solving the
265
+ leading-diagonal equation and (by default) picking the root closest
266
+ to -1, i.e. m(z) ~ -1/z.
267
+
268
+ The returned 'moments(N)' are normalized density moments:
269
+ mu_density[k] = mu_series[k] / mu_series[0]
270
+ so mu_density[0] = 1.
271
+ """
272
+
273
+ def __init__(self, a, mu0=None):
274
+ self.a = numpy.asarray(a)
275
+ # Ensure valid
276
+ self.a[-1, 0] = 0.0
277
+ if self.a.ndim != 2:
278
+ raise ValueError("a must be a 2D NumPy array with a[i,j]=a_{ij}.")
279
+
280
+ self.I = self.a.shape[0] - 1 # noqa: E741
281
+ self.J = self.a.shape[1] - 1
282
+
283
+ nz = numpy.argwhere(self.a != 0)
284
+ if nz.size == 0:
285
+ raise ValueError("All coefficients are zero.")
286
+
287
+ # r = max(i-j) over nonzero terms
288
+ self.r = int(numpy.max(nz[:, 0] - nz[:, 1]))
289
+
290
+ # Group coefficients by diagonal offset s = r - (i-j) >= 0
291
+ # diag[s] is list of (j, a_ij) for which i-j = r-s
292
+ self.diag = {}
293
+ for i, j in nz:
294
+ i = int(i)
295
+ j = int(j)
296
+ coeff = self.a[i, j]
297
+ s = self.r - (i - j)
298
+ if s >= 0:
299
+ self.diag.setdefault(int(s), []).append((j, coeff))
300
+
301
+ # Choose mu0 (series leading coefficient). This should be
302
+ # -1 for m(z) ~ -1/z, but it may only hold approximately.
303
+ if mu0 is None:
304
+ self.mu0 = self._solve_mu0()
305
+ else:
306
+ self.mu0 = mu0
307
+
308
+ # Precompute mu0^p up to p=J
309
+ self.mu0pow = [1]
310
+ for _ in range(self.J):
311
+ self.mu0pow.append(self.mu0pow[-1] * self.mu0)
312
+
313
+ # Linear coefficient A0 = sum_{i-j=r} j a_ij mu0^{j-1}
314
+ self.A0 = 0
315
+ for j, coeff in self.diag.get(0, []):
316
+ if j > 0:
317
+ self.A0 += j * coeff * self.mu0pow[j - 1]
318
+ if self.A0 == 0:
319
+ raise ValueError("A0 is zero for this mu0; the sequential " +
320
+ "recursion is degenerate.")
321
+
322
+ # Stored series moments mu_series[0..]
323
+ self._mu = [self.mu0]
324
+
325
+ # Convolution table c[j][n] = coefficient of w^n in (S(w))^j,
326
+ # where S(w) = sum_{t>=0} mu_series[t] w^t and m(z)=w S(w), w=1/z.
327
+ #
328
+ # We store c as lists growing in n: c[j][n] for j=0..J.
329
+ self._c = [[0] for _ in range(self.J + 1)]
330
+ self._c[0][0] = 1
331
+ for j in range(1, self.J + 1):
332
+ self._c[j][0] = self.mu0pow[j]
333
+
334
+ def _solve_mu0(self):
335
+ # Leading diagonal polynomial L(m) = sum_{i-j=r} a_ij m^j.
336
+ # That means i = j + r, so coefficient is a[j+r, j] if in bounds.
337
+ coeffs = numpy.zeros(self.J + 1, dtype=numpy.complex128)
338
+ for j in range(self.J + 1):
339
+ i = j + self.r
340
+ if 0 <= i <= self.I:
341
+ coeffs[j] = self.a[i, j]
342
+
343
+ if not numpy.any(coeffs != 0):
344
+ raise ValueError("Leading diagonal polynomial is identically " +
345
+ "zero; cannot determine mu0.")
346
+
347
+ deg = int(numpy.max(numpy.nonzero(coeffs)[0]))
348
+
349
+ # descending powers for numpy.roots
350
+ roots = numpy.roots(coeffs[:deg + 1][::-1])
351
+
352
+ # Targetting mu0 = -1 for ~ -1/z asymptotics
353
+ mu0 = roots[numpy.argmin(numpy.abs(roots + 1))]
354
+
355
+ if abs(mu0.imag) < 1e-12:
356
+ mu0 = mu0.real
357
+ return mu0
358
+
359
+ def _ensure(self, N):
360
+ # Compute mu_series up to index N (inclusive)
361
+ while len(self._mu) <= N:
362
+ k = len(self._mu) # compute mu_k
363
+
364
+ # Compute f[j] = coefficient of w^k in (S_trunc(w))^j,
365
+ # where S_trunc uses mu_0..mu_{k-1} only (i.e. mu_k treated as 0).
366
+ # Key fact: in the true c[j,k], mu_k can only appear linearly as
367
+ # j*mu_k*mu0^{j-1}.
368
+ f = [0] * (self.J + 1)
369
+ f[0] = 0
370
+ for j in range(1, self.J + 1):
371
+ ssum = 0
372
+ # sum_{t=1..k-1} mu_t * c[j-1, k-t]
373
+ for t in range(1, k):
374
+ ssum += self._mu[t] * self._c[j - 1][k - t]
375
+ # recurrence: c[j,k] = mu0*c[j-1,k] + sum_{t=1..k-1}
376
+ # mu_t*c[j-1,k-t] + mu_k*c[j-1,0] with mu_k=0 for f,
377
+ # and c[j-1,k]=f[j-1]
378
+ f[j] = self.mu0 * f[j - 1] + ssum
379
+
380
+ # Build the linear equation for mu_k:
381
+ # A0*mu_k + rest = 0
382
+ rest = 0
383
+
384
+ # s=0 diagonal contributes coeff*(f[j]) (the mu_k-free part)
385
+ for j, coeff in self.diag.get(0, []):
386
+ if j == 0:
387
+ # only affects k=0, but we never come here with k=0
388
+ continue
389
+ rest += coeff * f[j]
390
+
391
+ # lower diagonals s=1..k contribute coeff*c[j,k-s] (already known
392
+ # since k-s < k)
393
+ for s in range(1, k + 1):
394
+ entries = self.diag.get(s)
395
+ if not entries:
396
+ continue
397
+ n = k - s
398
+ for j, coeff in entries:
399
+ if j == 0:
400
+ if n == 0:
401
+ rest += coeff
402
+ else:
403
+ rest += coeff * self._c[j][n]
404
+
405
+ mu_k = -rest / self.A0
406
+ self._mu.append(mu_k)
407
+
408
+ # Now append the new column k to c using the full convolution
409
+ # recurrence:
410
+ # c[j,k] = sum_{t=0..k} mu_t * c[j-1,k-t]
411
+ for j in range(self.J + 1):
412
+ self._c[j].append(0)
413
+
414
+ self._c[0][k] = 0
415
+ for j in range(1, self.J + 1):
416
+ val = 0
417
+ for t in range(0, k + 1):
418
+ val += self._mu[t] * self._c[j - 1][k - t]
419
+ self._c[j][k] = val
420
+
421
+ # --- API ---
422
+
423
+ def __call__(self, k):
424
+ self._ensure(k)
425
+ return self._mu[k] / self._mu[0]
426
+
427
+ def moments(self, N):
428
+ # normalized density moments so moment 0 is 1
429
+ self._ensure(N)
430
+ mu0 = self._mu[0]
431
+ return numpy.array([self._mu[k] / mu0 for k in range(N + 1)])
432
+
433
+ def radius(self, N):
434
+ # Estimate the radius of convergence of the Stieltjes
435
+ # series
436
+ if N < 3:
437
+ raise RuntimeError("N is too small, choose a larger value.")
438
+ self._ensure(N)
439
+ return max([numpy.abs(self._mu[j] / self._mu[j-1])
440
+ for j in range(2, N+1)])
441
+
442
+ def stieltjes(self, z, N):
443
+ # Estimate Stieltjes transform (root) using moment
444
+ # expansion
445
+ z = numpy.asarray(z)
446
+ mu = self.moments(N)
447
+ return -numpy.sum(z[..., numpy.newaxis]**(-numpy.arange(N+1)-1) * mu,
448
+ axis=-1)
@@ -0,0 +1,145 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026, Siavash Ameli <sameli@berkeley.edu>
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # SPDX-FileType: SOURCE
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+
16
+ __all__ = ['_pick_physical_root_scalar', 'track_roots_on_grid',
17
+ 'infer_m1_partners_on_cuts']
18
+
19
+
20
+ # =========================
21
+ # pick physical root scalar
22
+ # =========================
23
+
24
+ def _pick_physical_root_scalar(z, roots):
25
+ """
26
+ Pick the Herglotz root: Im(root) has the same sign as Im(z).
27
+ """
28
+
29
+ s = 1.0 if (z.imag >= 0.0) else -1.0
30
+ k = int(numpy.argmax(s * roots.imag))
31
+ return roots[k]
32
+
33
+
34
+ # ============
35
+ # permutations
36
+ # ============
37
+
38
+ def _permutations(items):
39
+
40
+ items = list(items)
41
+ if len(items) <= 1:
42
+ yield tuple(items)
43
+ return
44
+ for i in range(len(items)):
45
+ rest = items[:i] + items[i + 1:]
46
+ for p in _permutations(rest):
47
+ yield (items[i],) + p
48
+
49
+
50
+ # ===================
51
+ # track roots on grid
52
+ # ===================
53
+
54
+ def track_roots_on_grid(m_all, z=None, i0=0, j0=0):
55
+
56
+ m_all = numpy.asarray(m_all, dtype=numpy.complex128)
57
+ n_y, n_x, s = m_all.shape
58
+
59
+ sheets = numpy.full_like(m_all, numpy.nan + 1j * numpy.nan)
60
+
61
+ perms = numpy.array(list(_permutations(range(s))), dtype=int)
62
+
63
+ def sort_seed(v):
64
+ v = numpy.asarray(v, dtype=numpy.complex128)
65
+ order = numpy.argsort(-numpy.imag(v))
66
+ return v[order]
67
+
68
+ v0 = m_all[i0, j0, :]
69
+ if numpy.all(numpy.isfinite(v0)):
70
+ sheets[i0, j0, :] = sort_seed(v0)
71
+
72
+ for i in range(i0, n_y):
73
+ for j in range((j0 if i == i0 else 0), n_x):
74
+ if i == i0 and j == j0:
75
+ continue
76
+
77
+ v = m_all[i, j, :]
78
+ if not numpy.all(numpy.isfinite(v)):
79
+ continue
80
+
81
+ if j > 0 and numpy.all(numpy.isfinite(sheets[i, j - 1, :])):
82
+ ref = sheets[i, j - 1, :]
83
+ elif i > 0 and numpy.all(numpy.isfinite(sheets[i - 1, j, :])):
84
+ ref = sheets[i - 1, j, :]
85
+ else:
86
+ sheets[i, j, :] = sort_seed(v)
87
+ continue
88
+
89
+ v_perm = v[perms]
90
+ cost = numpy.abs(v_perm - ref[None, :]).sum(axis=1)
91
+ p = perms[int(numpy.argmin(cost))]
92
+ sheets[i, j, :] = v[p]
93
+
94
+ if z is not None:
95
+ z = numpy.asarray(z)
96
+ if z.shape != (n_y, n_x):
97
+ raise ValueError("z must have shape (n_y, n_x) matching m_all.")
98
+ mask_up = numpy.imag(z) > 0.0
99
+ scores = numpy.full(s, -numpy.inf, dtype=numpy.float64)
100
+ for r in range(s):
101
+ v = sheets[:, :, r]
102
+ vv = v[mask_up]
103
+ finite = numpy.isfinite(vv)
104
+ if numpy.any(finite):
105
+ scores[r] = float(numpy.mean(numpy.imag(vv[finite])))
106
+ r_phys = int(numpy.argmax(scores))
107
+ perm = [r_phys] + [r for r in range(s) if r != r_phys]
108
+ sheets = sheets[:, :, perm]
109
+
110
+ return sheets
111
+
112
+
113
+ # =========================
114
+ # infer m1 partners on cuts
115
+ # =========================
116
+
117
+ def infer_m1_partners_on_cuts(z, sheets, support):
118
+ # sheets: [m1, m2, m3] arrays on the same z-grid
119
+ X = numpy.real(z[0, :])
120
+ ycol = numpy.imag(z[:, 0])
121
+
122
+ # pick nearest rows just above and below 0
123
+ i_up = numpy.where(ycol > 0)[0][0]
124
+ i_dn = numpy.where(ycol < 0)[0][-1]
125
+
126
+ partners = []
127
+ for (a, b) in support:
128
+ x0 = 0.5 * (a + b)
129
+ j = int(numpy.argmin(numpy.abs(X - x0)))
130
+
131
+ m1_up = sheets[0][i_up, j]
132
+ m1_dn = sheets[0][i_dn, j]
133
+
134
+ # who matches across the cut?
135
+ d_up_to_dn = [abs(m1_up - sheets[k][i_dn, j])
136
+ for k in range(len(sheets))]
137
+ d_dn_to_up = [abs(m1_dn - sheets[k][i_up, j])
138
+ for k in range(len(sheets))]
139
+
140
+ # ignore k=0 (trivial match away from cuts); take best among {1,2}
141
+ k1 = min([1, 2], key=lambda k: d_up_to_dn[k] + d_dn_to_up[k])
142
+ partners.append(k1)
143
+
144
+ # e.g. [1,2] means I1 swaps with m2, I2 swaps with m3
145
+ return partners