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,204 @@
1
+ # =======
2
+ # Imports
3
+ # =======
4
+
5
+ import numpy
6
+ import matplotlib.pyplot as plt
7
+ from scipy.special import comb
8
+ import texplot
9
+ from ._continuation_algebraic import _normalize_coefficients
10
+
11
+ __all__ = ['decompress_coeffs']
12
+
13
+
14
+ # =================
15
+ # decompress_coeffs
16
+ # =================
17
+
18
+ def decompress_coeffs(a, t, normalize=True):
19
+ """
20
+ Compute the decompressed coefficients A[r, s](t) induced by
21
+ the transform Q_t(z, m) = m^L P(z + (1 - e^{-t}) / m, e^t m).
22
+
23
+ Parameters
24
+ ----------
25
+ a : array_like of float, shape (L+1, K+1)
26
+ Coefficients defining P(z, m) in the monomial basis:
27
+ P(z, m) = sum_{j=0..L} sum_{k=0..K} a[j, k] z^j m^k.
28
+ t : float
29
+ Time parameter.
30
+
31
+ Returns
32
+ -------
33
+ A : ndarray, shape (L+1, L+K+1)
34
+ Coefficients A[r, s](t) such that
35
+ sum_{r=0..L} sum_{s=0..L+K} A[r, s](t) z^r m^s = 0,
36
+ normalized by normalize_coefficients.
37
+ """
38
+ a = numpy.asarray(a)
39
+ a[-1, 0] = 0.0
40
+ if a.ndim != 2:
41
+ raise ValueError("a must be a 2D array-like of shape (L+1, K+1).")
42
+
43
+ l_degree = a.shape[0] - 1
44
+ k_degree = a.shape[1] - 1
45
+
46
+ c = 1.0 - numpy.exp(-t)
47
+
48
+ # Scale columns of a by e^{t k}: scaled[j, k] = a[j, k] e^{t k}.
49
+ exp_factors = numpy.exp(numpy.arange(k_degree + 1) * t)
50
+ scaled = a * exp_factors
51
+
52
+ # Output coefficients.
53
+ out_dtype = numpy.result_type(a, float)
54
+ a_out = numpy.zeros((l_degree + 1, l_degree + k_degree + 1),
55
+ dtype=out_dtype)
56
+
57
+ # Precompute binomial(j, r) * c^{j-r} for all j, r (lower-triangular).
58
+ j_inds = numpy.arange(l_degree + 1)[:, None]
59
+ r_inds = numpy.arange(l_degree + 1)[None, :]
60
+ mask = r_inds <= j_inds
61
+
62
+ binom_weights = numpy.zeros((l_degree + 1, l_degree + 1), dtype=float)
63
+ binom_weights[mask] = comb(j_inds, r_inds, exact=False)[mask]
64
+ binom_weights[mask] *= (c ** (j_inds - r_inds))[mask]
65
+
66
+ # Main accumulation:
67
+ # For fixed j and r, add:
68
+ # A[r, (L - j + r) + k] += binom_weights[j, r] * scaled[j, k],
69
+ # for k = 0..K.
70
+ for j in range(l_degree + 1):
71
+ row_scaled = scaled[j]
72
+ if numpy.all(row_scaled == 0):
73
+ continue
74
+
75
+ base0 = l_degree - j
76
+ row_b = binom_weights[j]
77
+
78
+ for r in range(j + 1):
79
+ coeff = row_b[r]
80
+ if coeff == 0:
81
+ continue
82
+
83
+ start = base0 + r
84
+ a_out[r, start:start + (k_degree + 1)] += coeff * row_scaled
85
+
86
+ if normalize:
87
+ return _normalize_coefficients(a_out)
88
+
89
+ return a_out
90
+
91
+
92
+ def plot_candidates(a, x, delta=1e-4, size=None, latex=False, verbose=False):
93
+ """
94
+ Visualize candidate densities implied by an algebraic Stieltjes-transform
95
+ relation:
96
+ P(z, m) = sum_{i=0..I} sum_{j=0..J} a[i, j] z^i m^j,
97
+ where m(z) is defined implicitly by P(z, m(z)) = 0.
98
+
99
+ For each grid point x_k, set z = x_k + i * delta, form the polynomial in m
100
+ given by P(z, m) = 0, solve for its roots, and plot the cloud of candidate
101
+ densities:
102
+ (1 / pi) * Im(m_root),
103
+ keeping only roots with Im(m_root) > 0 (roots are not tracked/paired across
104
+ x-values).
105
+
106
+ Parameters
107
+ ----------
108
+ a : array_like of complex or float, shape (I+1, J+1)
109
+ Coefficients defining P(z, m) in the monomial basis:
110
+ P(z, m) = sum_{i=0..I} sum_{j=0..J} a[i, j] z^i m^j.
111
+ x : array_like of float, shape (N,)
112
+ 1D array of real x-values (evaluation grid).
113
+ delta : float, optional
114
+ Small positive imaginary offset used to evaluate m(x + i * delta).
115
+ size : integer, optional
116
+ For labelling purposes, the size of the corresponding matrix can
117
+ be provided.
118
+
119
+ Returns
120
+ -------
121
+ fig : matplotlib.figure.Figure
122
+ The created figure.
123
+ ax : matplotlib.axes.Axes
124
+ The axes the scatter plot was drawn on.
125
+ """
126
+ if not (isinstance(delta, (float, int)) and delta > 0):
127
+ raise ValueError("delta must be a positive scalar.")
128
+
129
+ x = numpy.asarray(x)
130
+ if x.ndim != 1:
131
+ raise ValueError("x must be a 1D NumPy array.")
132
+
133
+ a = numpy.asarray(a)
134
+ if a.ndim != 2:
135
+ raise ValueError("a must be a 2D NumPy array with a[i, j] coefficients.")
136
+ if not numpy.issubdtype(a.dtype, numpy.number):
137
+ raise ValueError("a must be numeric.")
138
+
139
+ i_degree = a.shape[0] - 1
140
+
141
+ xs = []
142
+ ys = []
143
+ max_ys = numpy.zeros_like(x)
144
+
145
+ # Precompute i-powers indices to avoid repeated arange creation.
146
+ i_idx = numpy.arange(i_degree + 1)
147
+
148
+ for idx, xk in enumerate(x):
149
+ z = complex(float(xk), float(delta)) # x + i * delta
150
+
151
+ # b[j] = sum_i a[i, j] * z^i => polynomial in m:
152
+ # sum_{j=0..J} b[j] m^j = 0
153
+ z_pows = z ** i_idx # length I+1
154
+ b = (z_pows[:, None] * a).sum(axis=0) # length J+1, low->high in m
155
+
156
+ # Trim trailing (highest-degree) coefficients near zero to avoid
157
+ # numerical issues in numpy.roots. b is low->high, so trim from end.
158
+ tol = 1e-14
159
+ b_trim = b.copy()
160
+ while b_trim.size > 1 and abs(b_trim[-1]) < tol:
161
+ b_trim = b_trim[:-1]
162
+
163
+ # If constant polynomial (no roots), skip.
164
+ if b_trim.size <= 1:
165
+ continue
166
+
167
+ # numpy.roots expects highest degree first.
168
+ coeffs_high_to_low = b_trim[::-1]
169
+ roots = numpy.roots(coeffs_high_to_low)
170
+
171
+ # Keep only roots with positive imaginary part.
172
+ im = numpy.imag(roots)
173
+ mask = im > 0
174
+ if numpy.any(mask):
175
+ xs.append(numpy.full(mask.sum(), float(xk)))
176
+ ys.append(im[mask] / numpy.pi)
177
+ max_ys[idx] = max(ys[-1])
178
+
179
+ if verbose:
180
+ max_density = numpy.trapezoid(max_ys, x)
181
+ print("Max density: {}".format(max_density))
182
+
183
+ if xs:
184
+ xs = numpy.concatenate(xs)
185
+ ys = numpy.concatenate(ys)
186
+ else:
187
+ xs = numpy.array([], dtype=float)
188
+ ys = numpy.array([], dtype=float)
189
+
190
+ with texplot.theme(use_latex=latex):
191
+ fig, ax = plt.subplots(figsize=(6, 2.7))
192
+ ax.scatter(xs, ys, s=8, alpha=1, linewidths=0, c='k')
193
+
194
+ ax.set_xlabel(r'$\lambda$')
195
+ ax.set_ylabel(r'$\rho(\lambda)$''')
196
+ ax.set_title("Candidate Density Cloud")
197
+ if size is not None:
198
+ ax.set_title("Candidate Density Cloud (size = {})".format(size))
199
+ ax.grid(True, alpha=1)
200
+ save_status = False
201
+ save_filename = ''
202
+ texplot.show_or_save_plot(plt, default_filename=save_filename,
203
+ transparent_background=True, dpi=400,
204
+ show_and_save=save_status, verbose=True)
@@ -0,0 +1,330 @@
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 under
6
+ # the terms of the license found in the LICENSE.txt file in the root directory
7
+ # of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+ from ._continuation_algebraic import eval_roots
16
+ from ._decompress_util import eval_P_partials
17
+
18
+ __all__ = ['evolve_edges', 'merge_edges']
19
+
20
+
21
+ # ================
22
+ # edge newton step
23
+ # ================
24
+
25
+ def _edge_newton_step(t, zeta, y, a_coeffs, max_iter=30, tol=1e-12):
26
+ """
27
+ """
28
+
29
+ tau = float(numpy.exp(t))
30
+ c = tau - 1.0
31
+
32
+ for _ in range(max_iter):
33
+ P, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
34
+
35
+ # F1 = P(zeta,y)
36
+ F1 = complex(P)
37
+
38
+ # F2 = y^2 Py - c Pz
39
+ F2 = complex((y * y) * Py - c * Pz)
40
+
41
+ if max(abs(F1), abs(F2)) <= tol:
42
+ return zeta, y, True
43
+
44
+ # Numerical Jacobian (2x2) in (zeta,y)
45
+ eps_z = 1e-8 * (1.0 + abs(zeta))
46
+ eps_y = 1e-8 * (1.0 + abs(y))
47
+
48
+ Pp, Pzp, Pyp = eval_P_partials(zeta + eps_z, y, a_coeffs)
49
+ F1_zp = (complex(Pp) - F1) / eps_z
50
+ F2_zp = (complex((y * y) * Pyp - c * Pzp) - F2) / eps_z
51
+
52
+ Pp, Pzp, Pyp = eval_P_partials(zeta, y + eps_y, a_coeffs)
53
+ F1_yp = (complex(Pp) - F1) / eps_y
54
+ F2_yp = (complex(((y + eps_y) * (y + eps_y)) * Pyp - c * Pzp) - F2) / \
55
+ eps_y
56
+
57
+ # Solve J * [dz, dy] = -F
58
+ det = F1_zp * F2_yp - F1_yp * F2_zp
59
+ if det == 0.0:
60
+ return zeta, y, False
61
+
62
+ dz = (-F1 * F2_yp + F1_yp * F2) / det
63
+ dy = (-F1_zp * F2 + F1 * F2_zp) / det
64
+
65
+ # Mild damping if update is huge
66
+ lam = 1.0
67
+ if abs(dz) + abs(dy) > 10.0 * (1.0 + abs(zeta) + abs(y)):
68
+ lam = 0.2
69
+
70
+ zeta = zeta + lam * dz
71
+ y = y + lam * dy
72
+
73
+ return zeta, y, False
74
+
75
+
76
+ # ==================
77
+ # pick physical root
78
+ # ==================
79
+
80
+ def _pick_physical_root(z, roots):
81
+ """
82
+ Pick the Herglotz/physical root at a point z in C+.
83
+
84
+ Heuristic: choose the root with maximal Im(root) when Im(z)>0,
85
+ then enforce Im(root)>0. Falls back to closest-to -1/z if needed.
86
+ """
87
+
88
+ r = numpy.asarray(roots, dtype=complex).ravel()
89
+ if r.size == 0:
90
+ return numpy.nan + 1j * numpy.nan
91
+
92
+ if z.imag > 0.0:
93
+ pos = r[numpy.imag(r) > 0.0]
94
+ if pos.size > 0:
95
+ return pos[numpy.argmax(numpy.imag(pos))]
96
+
97
+ target = -1.0 / z
98
+ return r[numpy.argmin(numpy.abs(r - target))]
99
+
100
+
101
+ # ============================
102
+ # init edge point from support
103
+ # ============================
104
+
105
+ def _init_edge_point_from_support(x_edge, a_coeffs, eta=1e-3):
106
+ """
107
+ Initialize (zeta,y) at t=0 for an edge near x_edge.
108
+
109
+ Uses z = x_edge + i*eta, picks physical root y, then refines zeta on real
110
+ axis.
111
+ """
112
+
113
+ z = complex(x_edge + 1j * eta)
114
+ roots = eval_roots(numpy.array([z]), a_coeffs)[0]
115
+ y = _pick_physical_root(z, roots)
116
+
117
+ # Move zeta to real axis as initial guess
118
+ zeta = complex(x_edge)
119
+
120
+ # Refine zeta,y to satisfy P=0 and Py=0 at t=0 (branch point)
121
+ # This uses the same Newton system with c=0, i.e. F2 = y^2 Py.
122
+ zeta, y, ok = _edge_newton_step(0.0, zeta, y, a_coeffs, max_iter=50,
123
+ tol=1e-10)
124
+
125
+ return zeta, y, ok
126
+
127
+
128
+ # ============
129
+ # evolve edges
130
+ # ============
131
+
132
+ def evolve_edges(
133
+ t_grid,
134
+ a_coeffs,
135
+ support=None,
136
+ eta=1e-3,
137
+ dt_max=0.1,
138
+ max_iter=30,
139
+ tol=1e-12,
140
+ return_preimage=False):
141
+ """
142
+ Evolve spectral edges under free decompression using the fitted polynomial
143
+ P.
144
+
145
+ Solves for (zeta(t), y(t)) on the spectral curve:
146
+ P(zeta,y) = 0,
147
+ y^2 * Py(zeta,y) - (exp(t)-1) * Pzeta(zeta,y) = 0,
148
+
149
+ then maps to physical coordinate:
150
+ z_edge(t) = zeta - (exp(t)-1)/y.
151
+
152
+ If return_preimage=True, also returns zeta_hist and y_hist of shape
153
+ (nt, 2k).
154
+ """
155
+
156
+ t_grid = numpy.asarray(t_grid, dtype=float).ravel()
157
+ if t_grid.size < 1:
158
+ raise ValueError("t_grid must be non-empty.")
159
+ if numpy.any(numpy.diff(t_grid) <= 0.0):
160
+ raise ValueError("t_grid must be strictly increasing.")
161
+
162
+ if support is None:
163
+ raise ValueError("support must be provided (auto-detection not " +
164
+ "implemented).")
165
+
166
+ # Flatten endpoints in fixed order [a1,b1,a2,b2,...]
167
+ endpoints0 = []
168
+ for a, b in support:
169
+ endpoints0.append(float(a))
170
+ endpoints0.append(float(b))
171
+
172
+ m = len(endpoints0)
173
+ complex_edges = numpy.empty((t_grid.size, m), dtype=numpy.complex128)
174
+ ok = numpy.zeros((t_grid.size, m), dtype=bool)
175
+
176
+ if return_preimage:
177
+ zeta_hist = numpy.empty((t_grid.size, m), dtype=numpy.complex128)
178
+ y_hist = numpy.empty((t_grid.size, m), dtype=numpy.complex128)
179
+ else:
180
+ zeta_hist = None
181
+ y_hist = None
182
+
183
+ # Initialize (zeta,y) at t=0 from support endpoints
184
+ zeta = numpy.empty(m, dtype=numpy.complex128)
185
+ y = numpy.empty(m, dtype=numpy.complex128)
186
+
187
+ for j in range(m):
188
+ z0, y0, ok0 = _init_edge_point_from_support(endpoints0[j], a_coeffs,
189
+ eta=eta)
190
+ zeta[j] = z0
191
+ y[j] = y0
192
+ ok[0, j] = ok0
193
+ complex_edges[0, j] = z0 # at t=0, tau-1 = 0 => z_edge = zeta
194
+
195
+ if return_preimage:
196
+ zeta_hist[0, :] = zeta
197
+ y_hist[0, :] = y
198
+
199
+ # Time stepping
200
+ for it in range(1, t_grid.size):
201
+ t0 = float(t_grid[it - 1])
202
+ t1 = float(t_grid[it])
203
+ dt = t1 - t0
204
+
205
+ n_sub = int(numpy.ceil(dt / float(dt_max)))
206
+ if n_sub < 1:
207
+ n_sub = 1
208
+
209
+ for ks in range(1, n_sub + 1):
210
+ t = t0 + dt * (ks / float(n_sub))
211
+ for j in range(m):
212
+ zeta[j], y[j], okj = _edge_newton_step(
213
+ t, zeta[j], y[j], a_coeffs, max_iter=max_iter, tol=tol
214
+ )
215
+ ok[it, j] = okj
216
+
217
+ tau = float(numpy.exp(t1))
218
+ c = tau - 1.0
219
+ complex_edges[it, :] = zeta - c / y
220
+
221
+ if return_preimage:
222
+ zeta_hist[it, :] = zeta
223
+ y_hist[it, :] = y
224
+
225
+ if return_preimage:
226
+ return complex_edges, ok, zeta_hist, y_hist
227
+
228
+ return complex_edges, ok
229
+
230
+
231
+ # ===========
232
+ # merge edges
233
+ # ===========
234
+
235
+ def merge_edges(edges, tol=0.0):
236
+ """
237
+ Merge bulks when inner edges cross, without shifting columns.
238
+
239
+ Columns are fixed as [a1,b1,a2,b2,...,ak,bk]. When the gap between bulk j
240
+ and bulk j+1 closes (b_j >= a_{j+1} - tol), we annihilate the two inner
241
+ edges by setting b_j and a_{j+1} to NaN. All other columns remain in place.
242
+
243
+ This preserves smooth plotting per original edge index (e.g. b2 stays in
244
+ the same column for all t). The number of active bulks is computed as the
245
+ number of connected components after merges.
246
+
247
+ Parameters
248
+ ----------
249
+ edges : ndarray, shape (nt, 2k)
250
+ Edge trajectories [a1,b1,a2,b2,...].
251
+ tol : float
252
+ Merge tolerance in x-units.
253
+
254
+ Returns
255
+ -------
256
+ edges2 : ndarray, shape (nt, 2k)
257
+ Same shape as input. Inner merged edges are NaN. No columns are
258
+ shifted.
259
+ active_k : ndarray, shape (nt,)
260
+ Number of remaining bulks (connected components) at each time.
261
+ """
262
+
263
+ edges = numpy.asarray(edges, dtype=float)
264
+ nt, m = edges.shape
265
+ if m % 2 != 0:
266
+ raise ValueError("edges must have even number of columns.")
267
+ k0 = m // 2
268
+
269
+ edges2 = edges.copy()
270
+ active_k = numpy.zeros(nt, dtype=int)
271
+
272
+ for it in range(nt):
273
+ row = edges2[it, :].copy()
274
+ a = row[0::2].copy()
275
+ b = row[1::2].copy()
276
+
277
+ # Initialize blocks as list of (L_index, R_index) in bulk indices.
278
+ blocks = []
279
+ for j in range(k0):
280
+ if numpy.isfinite(a[j]) and numpy.isfinite(b[j]) and (b[j] > a[j]):
281
+ blocks.append([j, j])
282
+
283
+ if len(blocks) == 0:
284
+ active_k[it] = 0
285
+ edges2[it, :] = row
286
+ continue
287
+
288
+ # Helper to get current left/right edge value of a block.
289
+ def left_edge(block):
290
+ return a[block[0]]
291
+
292
+ def right_edge(block):
293
+ return b[block[1]]
294
+
295
+ # Iteratively merge adjacent blocks when they overlap / touch.
296
+ merged = True
297
+ while merged and (len(blocks) > 1):
298
+ merged = False
299
+ new_blocks = [blocks[0]]
300
+ for blk in blocks[1:]:
301
+ prev = new_blocks[-1]
302
+ # If right(prev) crosses left(blk), merge.
303
+ if numpy.isfinite(right_edge(prev)) and \
304
+ numpy.isfinite(left_edge(blk)) and \
305
+ (right_edge(prev) >= left_edge(blk) - float(tol)):
306
+
307
+ # Annihilate inner boundary edges in fixed columns:
308
+ # b_{prev.right_bulk} and a_{blk.left_bulk}
309
+ bj = prev[1]
310
+ aj = blk[0]
311
+ b[bj] = numpy.nan
312
+ a[aj] = numpy.nan
313
+
314
+ # Merge block indices: left stays prev.left, right becomes
315
+ # blk.right
316
+ prev[1] = blk[1]
317
+ merged = True
318
+ else:
319
+ new_blocks.append(blk)
320
+ blocks = new_blocks
321
+
322
+ active_k[it] = len(blocks)
323
+
324
+ # Write back modified a,b into the row without shifting any columns.
325
+ row2 = row.copy()
326
+ row2[0::2] = a
327
+ row2[1::2] = b
328
+ edges2[it, :] = row2
329
+
330
+ return edges2, active_k