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,641 @@
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
+
16
+ __all__ = ['build_time_grid', 'decompress_newton_old', 'decompress_newton']
17
+
18
+
19
+ # ===============
20
+ # build time grid
21
+ # ===============
22
+
23
+ def build_time_grid(sizes, n0, min_n_times=0):
24
+ """
25
+ sizes: list/array of requested matrix sizes (e.g. [2000,3000,4000,8000])
26
+ n0: initial size (self.n)
27
+ min_n_times: minimum number of time points to run Newton sweep on
28
+
29
+ Returns
30
+ -------
31
+ t_all: sorted time grid to run solver on
32
+ idx_req: indices of requested times inside t_all (same order as sizes)
33
+ """
34
+
35
+ sizes = numpy.asarray(sizes, dtype=float)
36
+ alpha = sizes / float(n0)
37
+ t_req = numpy.log(alpha)
38
+
39
+ # Always include t=0 and T=max(t_req)
40
+ T = float(numpy.max(t_req)) if t_req.size else 0.0
41
+ base = numpy.unique(numpy.r_[0.0, t_req, T])
42
+ t_all = numpy.sort(base)
43
+
44
+ # Add points only if needed: split largest gaps
45
+ N = int(min_n_times) if min_n_times is not None else 0
46
+ while t_all.size < N and t_all.size >= 2:
47
+ gaps = numpy.diff(t_all)
48
+ k = int(numpy.argmax(gaps))
49
+ mid = 0.5 * (t_all[k] + t_all[k+1])
50
+ t_all = numpy.sort(numpy.unique(numpy.r_[t_all, mid]))
51
+
52
+ # Map each requested time to an index in t_all (stable, no float drama)
53
+ # (t_req values came from same construction, so they should match exactly;
54
+ # still: use searchsorted + assert)
55
+ idx_req = numpy.searchsorted(t_all, t_req)
56
+ # optional sanity:
57
+ # assert numpy.allclose(t_all[idx_req], t_req, rtol=0, atol=0)
58
+
59
+ return t_all, idx_req
60
+
61
+
62
+
63
+
64
+
65
+ def fd_solve_w(z, t, a_coeffs, w_init, max_iter=50, tol=1e-12,
66
+ armijo=1e-4, min_lam=1e-6, w_min=1e-14):
67
+ """
68
+ Solve for w = m(t,z) from the implicit FD equation using damped Newton.
69
+
70
+ We solve in w the equation
71
+
72
+ F(w) = P(z + alpha/w, tau*w) = 0,
73
+
74
+ where tau = exp(t) and alpha = 1 - 1/tau.
75
+
76
+ A backtracking (Armijo) line search is used to stabilize Newton updates.
77
+ When Im(z) > 0, the iterate is constrained to remain in the upper
78
+ half-plane (Im(w) > 0), enforcing the Herglotz branch.
79
+
80
+ Parameters
81
+ ----------
82
+ z : complex
83
+ Query point in the complex plane.
84
+ t : float
85
+ Time parameter (tau = exp(t)).
86
+ a_coeffs : ndarray
87
+ Coefficients defining P(zeta,y) in the monomial basis.
88
+ w_init : complex
89
+ Initial guess for w.
90
+ max_iter : int, optional
91
+ Maximum number of Newton iterations.
92
+ tol : float, optional
93
+ Residual tolerance on |F(w)|.
94
+ armijo : float, optional
95
+ Armijo parameter for backtracking sufficient decrease.
96
+ min_lam : float, optional
97
+ Minimum damping factor allowed in backtracking.
98
+ w_min : float, optional
99
+ Minimum |w| allowed to avoid singularity in z + alpha/w.
100
+
101
+ Returns
102
+ -------
103
+ w : complex
104
+ The computed solution (last iterate if not successful).
105
+ success : bool
106
+ True if convergence criteria were met, False otherwise.
107
+
108
+ Notes
109
+ -----
110
+ This function does not choose the correct branch globally by itself; it
111
+ relies on a good initialization strategy (e.g. time continuation and/or
112
+ x-sweeps) to avoid converging to a different valid root of the implicit
113
+ equation.
114
+
115
+ Examples
116
+ --------
117
+ .. code-block:: python
118
+
119
+ w, ok = fd_solve_w(
120
+ z=0.5 + 1e-6j, t=2.0, a_coeffs=a_coeffs, w_init=m1_fn(0.5 + 1e-6j),
121
+ max_iter=50, tol=1e-12
122
+ )
123
+ """
124
+
125
+ z = complex(z)
126
+ w = complex(w_init)
127
+
128
+ tau = float(numpy.exp(t))
129
+ alpha = 1.0 - 1.0 / tau
130
+
131
+ want_pos_imag = (z.imag > 0.0)
132
+
133
+ for _ in range(max_iter):
134
+
135
+ a = numpy.asarray(a_coeffs, dtype=numpy.complex128)
136
+ deg_z = a.shape[0] - 1
137
+ deg_m = a.shape[1] - 1
138
+
139
+ beta = tau - 1.0
140
+
141
+ # poly_y[p] stores coeff of y^p after clearing denominators
142
+ poly_y = numpy.zeros(deg_z + deg_m + 1, dtype=numpy.complex128)
143
+
144
+ # Build polynomial: sum_{i,j} a[i,j] (z + beta/y)^i y^j * y^{deg_z}
145
+ # Expand (z + beta/y)^i = sum_{k=0}^i C(i,k) z^{i-k} (beta/y)^k
146
+ # Term contributes to power p = deg_z + j - k.
147
+ from math import comb
148
+ for i in range(deg_z + 1):
149
+ for j in range(deg_m + 1):
150
+ aij = a[i, j]
151
+ if aij == 0:
152
+ continue
153
+ for k in range(i + 1):
154
+ p = deg_z + j - k
155
+ poly_y[p] += aij * comb(i, k) * (z ** (i - k)) * (beta ** k)
156
+
157
+ # numpy.roots expects highest degree first
158
+ coeffs = poly_y[::-1]
159
+
160
+ # If leading coefficients are ~0, trim (rare but safe)
161
+ nz_lead = numpy.flatnonzero(numpy.abs(coeffs) > 0)
162
+ if nz_lead.size == 0:
163
+ return w, False
164
+ coeffs = coeffs[nz_lead[0]:]
165
+
166
+ roots_y = numpy.roots(coeffs)
167
+
168
+ # Pick root with Im(w)>0 (if z in upper half-plane), closest to time seed
169
+ y_seed = tau * w_init
170
+ best = None
171
+ best_score = None
172
+
173
+ for y in roots_y:
174
+ if not numpy.isfinite(y.real) or not numpy.isfinite(y.imag):
175
+ continue
176
+
177
+ w_cand = y / tau
178
+
179
+ if want_pos_imag and (w_cand.imag <= 0.0):
180
+ continue
181
+
182
+ if abs(w_cand) < w_min:
183
+ continue
184
+
185
+ # score: stick to time continuation
186
+ score = abs(y - y_seed)
187
+
188
+ if (best_score is None) or (score < best_score):
189
+ best = w_cand
190
+ best_score = score
191
+
192
+ if best is None:
193
+ return w, False
194
+
195
+ w = complex(best)
196
+
197
+ # final residual check
198
+ F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
199
+ return w, (abs(F_end) <= 1e3 * tol)
200
+
201
+ # -------------------
202
+
203
+
204
+ F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
205
+ return w, (abs(F_end) <= 10.0 * tol)
206
+
207
+
208
+ # ============
209
+ # NEW FUNCTION
210
+ # ============
211
+
212
+ def fd_candidates_w(z, t, a_coeffs, w_min=1e-14):
213
+ """
214
+ Return candidate roots w solving P(z + alpha/w, tau*w)=0 with Im(w)>0 (if Im(z)>0).
215
+ """
216
+ z = complex(z)
217
+ tau = float(numpy.exp(t))
218
+ alpha = 1.0 - 1.0 / tau
219
+ want_pos_imag = (z.imag > 0.0)
220
+
221
+ a = numpy.asarray(a_coeffs, dtype=numpy.complex128)
222
+ deg_z = a.shape[0] - 1
223
+ deg_m = a.shape[1] - 1
224
+
225
+ beta = tau - 1.0 # since alpha/w = (tau-1)/(tau*w) = beta / y with y=tau*w
226
+
227
+ poly_y = numpy.zeros(deg_z + deg_m + 1, dtype=numpy.complex128)
228
+
229
+ from math import comb
230
+ for i in range(deg_z + 1):
231
+ for j in range(deg_m + 1):
232
+ aij = a[i, j]
233
+ if aij == 0:
234
+ continue
235
+ for k in range(i + 1):
236
+ p = deg_z + j - k
237
+ poly_y[p] += aij * comb(i, k) * (z ** (i - k)) * (beta ** k)
238
+
239
+ coeffs = poly_y[::-1]
240
+ nz_lead = numpy.flatnonzero(numpy.abs(coeffs) > 0)
241
+ if nz_lead.size == 0:
242
+ return []
243
+
244
+ coeffs = coeffs[nz_lead[0]:]
245
+ roots_y = numpy.roots(coeffs)
246
+
247
+ cands = []
248
+ for y in roots_y:
249
+ if not numpy.isfinite(y.real) or not numpy.isfinite(y.imag):
250
+ continue
251
+ w = y / tau
252
+ if abs(w) < w_min:
253
+ continue
254
+ if want_pos_imag and (w.imag <= 0.0):
255
+ continue
256
+ # residual filter (optional but helps)
257
+ # -------------
258
+ # TEST
259
+ # F = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
260
+ # if abs(F) < 1e-6:
261
+ # cands.append(complex(w))
262
+ # ---------------
263
+ # TEST
264
+ cands.append(complex(w))
265
+ # ------------------
266
+
267
+ return cands
268
+
269
+
270
+ # =================
271
+ # decompress newton
272
+ # =================
273
+
274
+ def decompress_newton(z_list, t_grid, a_coeffs, w0_list=None,
275
+ dt_max=0.1, sweep=True, time_rel_tol=5.0,
276
+ active_imag_eps=None, sweep_pad=20,
277
+ max_iter=50, tol=1e-12, armijo=1e-4,
278
+ min_lam=1e-6, w_min=1e-14, min_n_time=None):
279
+ """
280
+ Evolve w = m(t,z) on a fixed z grid and time grid using FD.
281
+
282
+ Parameters
283
+ ----------
284
+ z_list : array_like of complex
285
+ Query points z (typically x + 1j*eta with eta > 0), ordered along x.
286
+ t_grid : array_like of float
287
+ Strictly increasing time grid.
288
+ a_coeffs : ndarray
289
+ Coefficients defining P(zeta,y) in the monomial basis.
290
+ w0_list : array_like of complex
291
+ Initial values at t_grid[0] (typically m0(z_list) on the physical
292
+ branch).
293
+ dt_max : float, optional
294
+ Maximum internal time step. Larger dt is handled by substepping.
295
+ sweep : bool, optional
296
+ If True, enforce spatial continuity within active (bulk) regions and
297
+ allow edge activation via padding. If False, solve each z independently
298
+ from previous-time seeds (may fail to "activate" new support near
299
+ edges).
300
+ time_rel_tol : float, optional
301
+ When sweep=True, reject neighbor-propagated solutions that drift too
302
+ far from the previous-time value, using a time-consistent fallback.
303
+ active_imag_eps : float or None, optional
304
+ Threshold on |Im(w_prev)| to define active/bulk indices. If None, it is
305
+ set to 50*Im(z_list[0]) (works well when z_list=x+i*eta).
306
+ sweep_pad : int, optional
307
+ Number of indices used to dilate the active region. This is crucial for
308
+ multi-bulk laws so that edges can move and points just outside a bulk
309
+ can be initialized from the interior.
310
+ max_iter, tol, armijo, min_lam, w_min : optional
311
+ Newton/backtracking controls passed to fd_solve_w.
312
+
313
+ Returns
314
+ -------
315
+ W : ndarray, shape (len(t_grid), len(z_list))
316
+ Evolved values w(t,z).
317
+ ok : ndarray of bool, same shape as W
318
+ Convergence flags from the accepted solve at each point.
319
+ """
320
+
321
+ z_list = numpy.asarray(z_list, dtype=complex).ravel()
322
+ t_grid = numpy.asarray(t_grid, dtype=float).ravel()
323
+ nt = t_grid.size
324
+ nz = z_list.size
325
+
326
+ W = numpy.empty((nt, nz), dtype=complex)
327
+ ok = numpy.zeros((nt, nz), dtype=bool)
328
+
329
+ if w0_list is None:
330
+ raise ValueError(
331
+ "w0_list must be provided (e.g. m1_fn(z_list) at t=0).")
332
+ w_prev = numpy.asarray(w0_list, dtype=complex).ravel()
333
+ if w_prev.size != nz:
334
+ raise ValueError("w0_list must have same size as z_list.")
335
+
336
+ W[0, :] = w_prev
337
+ ok[0, :] = True
338
+
339
+ sweep = bool(sweep)
340
+ time_rel_tol = float(time_rel_tol)
341
+ sweep_pad = int(sweep_pad)
342
+
343
+ # If z_list is x + i*eta, use eta to set an automatic activity threshold.
344
+ if active_imag_eps is None:
345
+ eta0 = float(abs(z_list[0].imag))
346
+ active_imag_eps = 50.0 * eta0 if eta0 > 0.0 else 1e-10
347
+ active_imag_eps = float(active_imag_eps)
348
+
349
+ # --------------------------------------
350
+ # TEST
351
+ # def solve_with_choice(iz, w_seed):
352
+ # # Neighbor-seeded candidate (spatial continuity)
353
+ # w_a, ok_a = fd_solve_w(
354
+ # z_list[iz], t, a_coeffs, w_seed,
355
+ # max_iter=max_iter, tol=tol, armijo=armijo,
356
+ # min_lam=min_lam, w_min=w_min
357
+ # )
358
+ #
359
+ # # Time-seeded candidate (time continuation)
360
+ # w_b, ok_b = fd_solve_w(
361
+ # z_list[iz], t, a_coeffs, w_prev[iz],
362
+ # max_iter=max_iter, tol=tol, armijo=armijo,
363
+ # min_lam=min_lam, w_min=w_min
364
+ # )
365
+ #
366
+ # if ok_a and ok_b:
367
+ # da = abs(w_a - w_prev[iz])
368
+ # db = abs(w_b - w_prev[iz])
369
+ #
370
+ # # Reject neighbor result if it drifted too far in one step
371
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
372
+ # return w_b, True
373
+ #
374
+ # return (w_a, True) if (da <= db) else (w_b, True)
375
+ #
376
+ # if ok_a:
377
+ # da = abs(w_a - w_prev[iz])
378
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
379
+ # return w_b, True
380
+ # return w_a, True
381
+ #
382
+ # if ok_b:
383
+ # return w_b, True
384
+ #
385
+ # return w_a, False
386
+ # ----------------------------------------
387
+ # TEST
388
+ def solve_with_choice(iz, w_seed):
389
+ # candidate roots at this (t,z)
390
+ cands = fd_candidates_w(z_list[iz], t, a_coeffs, w_min=w_min)
391
+
392
+ # ---------------------
393
+ # TEST
394
+ if iz in (0, nz//2, nz-1):
395
+ ims = [float(w.imag) for w in cands]
396
+ print(f" iz={iz} ncand={len(cands)} Im(cands) min/med/max="
397
+ f"{(min(ims) if ims else None)}/"
398
+ f"{(numpy.median(ims) if ims else None)}/"
399
+ f"{(max(ims) if ims else None)}")
400
+ # ---------------------
401
+
402
+ if len(cands) == 0:
403
+ # fallback to your existing single-root solver
404
+ w, success = fd_solve_w(
405
+ z_list[iz], t, a_coeffs, w_prev[iz],
406
+ max_iter=max_iter, tol=tol, armijo=armijo,
407
+ min_lam=min_lam, w_min=w_min
408
+ )
409
+ return w, success
410
+
411
+ # cost = spatial continuity + time continuity (tune weights if needed)
412
+ w_time = w_prev[iz]
413
+ w_space = w_seed
414
+ best = None
415
+ best_cost = None
416
+
417
+ for w in cands:
418
+ # TEST
419
+ # cost = abs(w - w_space) + 0.25 * abs(w - w_time)
420
+ # TEST
421
+ # prefer continuity, but also prefer larger Im(w) to stay on the bulk branch
422
+ cost = abs(w - w_space) + 0.25 * abs(w - w_time) - 5.0 * w.imag
423
+ # --------------
424
+
425
+ if (best_cost is None) or (cost < best_cost):
426
+ best = w
427
+ best_cost = cost
428
+
429
+ return best, True
430
+ # ----------------------------------------
431
+
432
+ for it in range(1, nt):
433
+ t0 = float(t_grid[it - 1])
434
+ t1 = float(t_grid[it])
435
+ dt = t1 - t0
436
+ if dt <= 0.0:
437
+ raise ValueError("t_grid must be strictly increasing.")
438
+
439
+ # Substep in time to keep continuation safe.
440
+ n_sub = int(numpy.ceil(dt / float(dt_max)))
441
+ if n_sub < 1:
442
+ n_sub = 1
443
+
444
+ for ks in range(1, n_sub + 1):
445
+ t = t0 + dt * (ks / float(n_sub))
446
+
447
+ w_row = numpy.empty(nz, dtype=complex)
448
+ ok_row = numpy.zeros(nz, dtype=bool)
449
+
450
+ if not sweep:
451
+ # Independent solves: can miss edge activation in multi-bulk
452
+ # problems.
453
+ for iz in range(nz):
454
+ w, success = fd_solve_w(
455
+ z_list[iz], t, a_coeffs, w_prev[iz],
456
+ max_iter=max_iter, tol=tol, armijo=armijo,
457
+ min_lam=min_lam, w_min=w_min
458
+ )
459
+ w_row[iz] = w
460
+ ok_row[iz] = success
461
+
462
+ w_prev = w_row
463
+ continue
464
+
465
+ # Define "active" region from previous time: inside bulks
466
+ # Im(w_prev) is O(1), outside bulks Im(w_prev) is ~O(eta). Dilate
467
+ # by sweep_pad to allow edges to move.
468
+
469
+ # ------------------------------
470
+ # TEST
471
+ # active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
472
+ # active_pad = active.copy()
473
+ # if sweep_pad > 0 and numpy.any(active):
474
+ # idx = numpy.flatnonzero(active)
475
+ # for i in idx:
476
+ # lo = 0 if (i - sweep_pad) < 0 else (i - sweep_pad)
477
+ # hi = \
478
+ # nz if (i + sweep_pad + 1) > nz else (i + sweep_pad + 1)
479
+ # active_pad[lo:hi] = True
480
+ # ------------------------------
481
+ # TEST
482
+ active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
483
+
484
+ # Split active indices into contiguous blocks (bulks)
485
+ pad_label = -numpy.ones(nz, dtype=numpy.int64) # bulk id per index
486
+ active_pad = numpy.zeros(nz, dtype=bool)
487
+
488
+ idx = numpy.flatnonzero(active)
489
+ if idx.size > 0:
490
+ cuts = numpy.where(numpy.diff(idx) > 1)[0]
491
+ blocks = numpy.split(idx, cuts + 1)
492
+
493
+ # Build padded intervals + centers
494
+ centers = []
495
+ pads = []
496
+ for b in blocks:
497
+ centers.append(int((b[0] + b[-1]) // 2))
498
+ lo = int(max(0, b[0] - sweep_pad))
499
+ hi = int(min(nz - 1, b[-1] + sweep_pad))
500
+ pads.append((lo, hi))
501
+
502
+ # Union of padded regions
503
+ for lo, hi in pads:
504
+ active_pad[lo:hi + 1] = True
505
+
506
+ # Assign each padded index to the nearest bulk center (no overlap label)
507
+ idx_u = numpy.flatnonzero(active_pad)
508
+ c = numpy.asarray(centers, dtype=numpy.int64)
509
+ dist = numpy.abs(idx_u[:, None] - c[None, :])
510
+ winner = numpy.argmin(dist, axis=1).astype(numpy.int64)
511
+ pad_label[idx_u] = winner
512
+ # ------------------------------
513
+
514
+ # ------------------------------
515
+ # TEST
516
+ def _ranges(idxs):
517
+ if idxs.size == 0:
518
+ return []
519
+ cuts = numpy.where(numpy.diff(idxs) > 1)[0]
520
+ blocks = numpy.split(idxs, cuts + 1)
521
+ return [(int(b[0]), int(b[-1])) for b in blocks]
522
+
523
+ print(" pad_label>=0 ranges:", _ranges(numpy.flatnonzero(pad_label >= 0)))
524
+ print(" overlap(-2) ranges:", _ranges(numpy.flatnonzero(pad_label == -2)))
525
+ # ------------------------------
526
+
527
+
528
+
529
+ # ----------------------------------------------
530
+
531
+ # TEST
532
+ # eta = float(abs(z_list[0].imag))
533
+ #
534
+ # # Barrier: points that look like "gap" (tiny Im(w_prev))
535
+ # barrier_eps = 10.0 * eta # try 5*eta or 10*eta
536
+ # barrier = (numpy.abs(numpy.imag(w_prev)) <= barrier_eps)
537
+
538
+
539
+
540
+ # TEST
541
+ # -------------------------
542
+ # --- diagnostics ---
543
+ active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
544
+ idx_active = numpy.flatnonzero(active)
545
+ idx_pad = numpy.flatnonzero(active_pad)
546
+
547
+ def _ranges(idxs):
548
+ if idxs.size == 0:
549
+ return []
550
+ cuts = numpy.where(numpy.diff(idxs) > 1)[0]
551
+ blocks = numpy.split(idxs, cuts + 1)
552
+ return [(b[0], b[-1]) for b in blocks]
553
+
554
+ print(f"[t={t:.6g}] eta={z_list[0].imag:.2e} active_eps={active_imag_eps:.2e} "
555
+ f"active_n={idx_active.size}/{nz} pad_n={idx_pad.size}/{nz} "
556
+ f"active_ranges={_ranges(idx_active)} pad_ranges={_ranges(idx_pad)}")
557
+
558
+ # Track the physical “gap” region around between bulks by looking at low Im(w_prev)
559
+ gap = numpy.abs(numpy.imag(w_prev)) <= 5.0 * z_list[0].imag
560
+ ig = numpy.flatnonzero(gap)
561
+ if ig.size > 0:
562
+ print(f" gap_ranges(Im<=5eta)={_ranges(ig)} "
563
+ f"Im(w) min/med/max = {numpy.min(w_prev.imag):.3e}/"
564
+ f"{numpy.median(w_prev.imag):.3e}/{numpy.max(w_prev.imag):.3e}")
565
+
566
+ # ------------------
567
+
568
+
569
+
570
+ # Left-to-right: use neighbor seed only within padded active
571
+ # regions, so we don't propagate a branch across the gap between
572
+ # bulks.
573
+ for iz in range(nz):
574
+ if iz == 0:
575
+ w_seed = w_prev[iz]
576
+ else:
577
+ # TEST
578
+ # if active_pad[iz] and active_pad[iz - 1]:
579
+ # w_seed = w_row[iz - 1]
580
+ # else:
581
+ # w_seed = w_prev[iz]
582
+ # TEST
583
+ # if (active_pad[iz] and active_pad[iz - 1] and
584
+ # (not barrier[iz]) and (not barrier[iz - 1])):
585
+ # w_seed = w_row[iz - 1]
586
+ # else:
587
+ # w_seed = w_prev[iz]
588
+ # ----------------------
589
+ # TEST
590
+ # if (active_pad[iz] and active_pad[iz - 1] and
591
+ # (pad_label[iz] == pad_label[iz - 1]) and
592
+ # (pad_label[iz] >= 0)):
593
+ # -----------------
594
+ # TEST
595
+ if (active_pad[iz] and active_pad[iz - 1] and
596
+ (pad_label[iz] == pad_label[iz - 1]) and
597
+ (pad_label[iz] >= 0)):
598
+ w_seed = w_row[iz - 1]
599
+ else:
600
+ w_seed = w_prev[iz]
601
+ # ----------------------
602
+
603
+
604
+ w_row[iz], ok_row[iz] = solve_with_choice(iz, w_seed)
605
+
606
+ # Right-to-left refinement: helps stabilize left edges of bulks.
607
+ for iz in range(nz - 2, -1, -1):
608
+ # TEST
609
+ # if active_pad[iz] and active_pad[iz + 1]:
610
+ # TEST
611
+ # if (active_pad[iz] and active_pad[iz + 1] and
612
+ # (not barrier[iz]) and (not barrier[iz + 1])):
613
+ # TEST
614
+ # if (active_pad[iz] and active_pad[iz + 1] and
615
+ # (pad_label[iz] == pad_label[iz + 1]) and
616
+ # (pad_label[iz] >= 0)):
617
+ # TEST
618
+ if (active_pad[iz] and active_pad[iz + 1] and
619
+ (pad_label[iz] == pad_label[iz + 1]) and
620
+ (pad_label[iz] >= 0)):
621
+
622
+ w_seed = w_row[iz + 1]
623
+ w_new, ok_new = solve_with_choice(iz, w_seed)
624
+ if ok_new:
625
+ # Keep the more time-consistent solution.
626
+ if (not ok_row[iz]) or (abs(w_new - w_prev[iz]) <
627
+ abs(w_row[iz] - w_prev[iz])):
628
+ w_row[iz] = w_new
629
+ ok_row[iz] = True
630
+
631
+
632
+
633
+ # TEST
634
+ print(f'solved_ok={ok_row.sum()}/{nz} (this substep)')
635
+
636
+ w_prev = w_row
637
+
638
+ W[it, :] = w_prev
639
+ ok[it, :] = ok_row
640
+
641
+ return W, ok