freealg 0.6.3__py3-none-any.whl → 0.7.1__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 (48) hide show
  1. freealg/__init__.py +8 -7
  2. freealg/__version__.py +1 -1
  3. freealg/_algebraic_form/__init__.py +11 -0
  4. freealg/_algebraic_form/_continuation_algebraic.py +503 -0
  5. freealg/_algebraic_form/_decompress.py +648 -0
  6. freealg/_algebraic_form/_edge.py +352 -0
  7. freealg/_algebraic_form/_sheets_util.py +145 -0
  8. freealg/_algebraic_form/algebraic_form.py +987 -0
  9. freealg/_freeform/__init__.py +16 -0
  10. freealg/_freeform/_density_util.py +243 -0
  11. freealg/{_linalg.py → _freeform/_linalg.py} +1 -1
  12. freealg/{freeform.py → _freeform/freeform.py} +2 -1
  13. freealg/_geometric_form/__init__.py +13 -0
  14. freealg/_geometric_form/_continuation_genus0.py +175 -0
  15. freealg/_geometric_form/_continuation_genus1.py +275 -0
  16. freealg/_geometric_form/_elliptic_functions.py +174 -0
  17. freealg/_geometric_form/_sphere_maps.py +63 -0
  18. freealg/_geometric_form/_torus_maps.py +118 -0
  19. freealg/_geometric_form/geometric_form.py +1094 -0
  20. freealg/_util.py +1 -228
  21. freealg/distributions/__init__.py +5 -1
  22. freealg/distributions/_chiral_block.py +440 -0
  23. freealg/distributions/_deformed_marchenko_pastur.py +617 -0
  24. freealg/distributions/_deformed_wigner.py +312 -0
  25. freealg/distributions/_kesten_mckay.py +2 -2
  26. freealg/distributions/_marchenko_pastur.py +199 -82
  27. freealg/distributions/_meixner.py +2 -2
  28. freealg/distributions/_wachter.py +2 -2
  29. freealg/distributions/_wigner.py +2 -2
  30. freealg/visualization/__init__.py +12 -0
  31. freealg/visualization/_glue_util.py +32 -0
  32. freealg/visualization/_rgb_hsv.py +125 -0
  33. {freealg-0.6.3.dist-info → freealg-0.7.1.dist-info}/METADATA +1 -1
  34. freealg-0.7.1.dist-info/RECORD +47 -0
  35. freealg-0.6.3.dist-info/RECORD +0 -26
  36. /freealg/{_chebyshev.py → _freeform/_chebyshev.py} +0 -0
  37. /freealg/{_damp.py → _freeform/_damp.py} +0 -0
  38. /freealg/{_decompress.py → _freeform/_decompress.py} +0 -0
  39. /freealg/{_jacobi.py → _freeform/_jacobi.py} +0 -0
  40. /freealg/{_pade.py → _freeform/_pade.py} +0 -0
  41. /freealg/{_plot_util.py → _freeform/_plot_util.py} +0 -0
  42. /freealg/{_sample.py → _freeform/_sample.py} +0 -0
  43. /freealg/{_series.py → _freeform/_series.py} +0 -0
  44. /freealg/{_support.py → _freeform/_support.py} +0 -0
  45. {freealg-0.6.3.dist-info → freealg-0.7.1.dist-info}/WHEEL +0 -0
  46. {freealg-0.6.3.dist-info → freealg-0.7.1.dist-info}/licenses/AUTHORS.txt +0 -0
  47. {freealg-0.6.3.dist-info → freealg-0.7.1.dist-info}/licenses/LICENSE.txt +0 -0
  48. {freealg-0.6.3.dist-info → freealg-0.7.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,648 @@
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 powers
16
+
17
+ __all__ = ['decompress_newton_old', 'decompress_newton']
18
+
19
+
20
+ # ===============
21
+ # eval P partials
22
+ # ===============
23
+
24
+ def eval_P_partials(z, m, a_coeffs):
25
+ """
26
+ Evaluate P(z,m) and its partial derivatives dP/dz and dP/dm.
27
+
28
+ This assumes P is represented by `a_coeffs` in the monomial basis
29
+
30
+ P(z, m) = sum_{j=0..s} a_j(z) * m^j,
31
+ a_j(z) = sum_{i=0..deg_z} a_coeffs[i, j] * z^i.
32
+
33
+ The function returns P, dP/dz, dP/dm with broadcasting over z and m.
34
+
35
+ Parameters
36
+ ----------
37
+ z : complex or array_like of complex
38
+ First argument to P.
39
+ m : complex or array_like of complex
40
+ Second argument to P. Must be broadcast-compatible with `z`.
41
+ a_coeffs : ndarray, shape (deg_z+1, s+1)
42
+ Coefficient matrix for P in the monomial basis.
43
+
44
+ Returns
45
+ -------
46
+ P : complex or ndarray of complex
47
+ Value P(z,m).
48
+ Pz : complex or ndarray of complex
49
+ Partial derivative dP/dz evaluated at (z,m).
50
+ Pm : complex or ndarray of complex
51
+ Partial derivative dP/dm evaluated at (z,m).
52
+
53
+ Notes
54
+ -----
55
+ For scalar (z,m), this uses Horner evaluation for a_j(z) and then Horner
56
+ in m. For array inputs, it uses precomputed power tables via `_powers` for
57
+ simplicity.
58
+
59
+ Examples
60
+ --------
61
+ .. code-block:: python
62
+
63
+ P, Pz, Pm = eval_P_partials(1.0 + 1j, 0.2 + 0.3j, a_coeffs)
64
+ """
65
+
66
+ z = numpy.asarray(z, dtype=complex)
67
+ m = numpy.asarray(m, dtype=complex)
68
+
69
+ deg_z = int(a_coeffs.shape[0] - 1)
70
+ s = int(a_coeffs.shape[1] - 1)
71
+
72
+ if (z.ndim == 0) and (m.ndim == 0):
73
+ zz = complex(z)
74
+ mm = complex(m)
75
+
76
+ a = numpy.empty(s + 1, dtype=complex)
77
+ ap = numpy.empty(s + 1, dtype=complex)
78
+
79
+ for j in range(s + 1):
80
+ c = a_coeffs[:, j]
81
+
82
+ val = 0.0 + 0.0j
83
+ for i in range(deg_z, -1, -1):
84
+ val = val * zz + c[i]
85
+ a[j] = val
86
+
87
+ dval = 0.0 + 0.0j
88
+ for i in range(deg_z, 0, -1):
89
+ dval = dval * zz + (i * c[i])
90
+ ap[j] = dval
91
+
92
+ p = a[s]
93
+ pm = 0.0 + 0.0j
94
+ for j in range(s - 1, -1, -1):
95
+ pm = pm * mm + p
96
+ p = p * mm + a[j]
97
+
98
+ pz = ap[s]
99
+ for j in range(s - 1, -1, -1):
100
+ pz = pz * mm + ap[j]
101
+
102
+ return p, pz, pm
103
+
104
+ shp = numpy.broadcast(z, m).shape
105
+ zz = numpy.broadcast_to(z, shp).ravel()
106
+ mm = numpy.broadcast_to(m, shp).ravel()
107
+
108
+ zp = powers(zz, deg_z)
109
+ mp = powers(mm, s)
110
+
111
+ dzp = numpy.zeros_like(zp)
112
+ for i in range(1, deg_z + 1):
113
+ dzp[:, i] = i * zp[:, i - 1]
114
+
115
+ P = numpy.zeros(zz.size, dtype=complex)
116
+ Pz = numpy.zeros(zz.size, dtype=complex)
117
+ Pm = numpy.zeros(zz.size, dtype=complex)
118
+
119
+ for j in range(s + 1):
120
+ aj = zp @ a_coeffs[:, j]
121
+ P += aj * mp[:, j]
122
+
123
+ ajp = dzp @ a_coeffs[:, j]
124
+ Pz += ajp * mp[:, j]
125
+
126
+ if j >= 1:
127
+ Pm += (j * aj) * mp[:, j - 1]
128
+
129
+ return P.reshape(shp), Pz.reshape(shp), Pm.reshape(shp)
130
+
131
+
132
+ # ==========
133
+ # fd solve w
134
+ # ==========
135
+
136
+ def fd_solve_w(z, t, a_coeffs, w_init, max_iter=50, tol=1e-12,
137
+ armijo=1e-4, min_lam=1e-6, w_min=1e-14):
138
+ """
139
+ Solve for w = m(t,z) from the implicit FD equation using damped Newton.
140
+
141
+ We solve in w the equation
142
+
143
+ F(w) = P(z + alpha/w, tau*w) = 0,
144
+
145
+ where tau = exp(t) and alpha = 1 - 1/tau.
146
+
147
+ A backtracking (Armijo) line search is used to stabilize Newton updates.
148
+ When Im(z) > 0, the iterate is constrained to remain in the upper
149
+ half-plane (Im(w) > 0), enforcing the Herglotz branch.
150
+
151
+ Parameters
152
+ ----------
153
+ z : complex
154
+ Query point in the complex plane.
155
+ t : float
156
+ Time parameter (tau = exp(t)).
157
+ a_coeffs : ndarray
158
+ Coefficients defining P(zeta,y) in the monomial basis.
159
+ w_init : complex
160
+ Initial guess for w.
161
+ max_iter : int, optional
162
+ Maximum number of Newton iterations.
163
+ tol : float, optional
164
+ Residual tolerance on |F(w)|.
165
+ armijo : float, optional
166
+ Armijo parameter for backtracking sufficient decrease.
167
+ min_lam : float, optional
168
+ Minimum damping factor allowed in backtracking.
169
+ w_min : float, optional
170
+ Minimum |w| allowed to avoid singularity in z + alpha/w.
171
+
172
+ Returns
173
+ -------
174
+ w : complex
175
+ The computed solution (last iterate if not successful).
176
+ success : bool
177
+ True if convergence criteria were met, False otherwise.
178
+
179
+ Notes
180
+ -----
181
+ This function does not choose the correct branch globally by itself; it
182
+ relies on a good initialization strategy (e.g. time continuation and/or
183
+ x-sweeps) to avoid converging to a different valid root of the implicit
184
+ equation.
185
+
186
+ Examples
187
+ --------
188
+ .. code-block:: python
189
+
190
+ w, ok = fd_solve_w(
191
+ z=0.5 + 1e-6j, t=2.0, a_coeffs=a_coeffs, w_init=m1_fn(0.5 + 1e-6j),
192
+ max_iter=50, tol=1e-12
193
+ )
194
+ """
195
+
196
+ z = complex(z)
197
+ w = complex(w_init)
198
+
199
+ tau = float(numpy.exp(t))
200
+ alpha = 1.0 - 1.0 / tau
201
+
202
+ want_pos_imag = (z.imag > 0.0)
203
+
204
+ for _ in range(max_iter):
205
+ if not numpy.isfinite(w.real) or not numpy.isfinite(w.imag):
206
+ return w, False
207
+ if abs(w) < w_min:
208
+ return w, False
209
+ if want_pos_imag and (w.imag <= 0.0):
210
+ return w, False
211
+
212
+ zeta = z + alpha / w
213
+ y = tau * w
214
+
215
+ F, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
216
+ F = complex(F)
217
+ Pz = complex(Pz)
218
+ Py = complex(Py)
219
+
220
+ if abs(F) <= tol:
221
+ return w, True
222
+
223
+ dF = (-alpha / (w * w)) * Pz + tau * Py
224
+ if dF == 0.0:
225
+ return w, False
226
+
227
+ step = -F / dF
228
+
229
+ lam = 1.0
230
+ F_abs = abs(F)
231
+ ok = False
232
+
233
+ while lam >= min_lam:
234
+ w_new = w + lam * step
235
+ if abs(w_new) < w_min:
236
+ lam *= 0.5
237
+ continue
238
+ if want_pos_imag and (w_new.imag <= 0.0):
239
+ lam *= 0.5
240
+ continue
241
+
242
+ zeta_new = z + alpha / w_new
243
+ y_new = tau * w_new
244
+
245
+ F_new = eval_P_partials(zeta_new, y_new, a_coeffs)[0]
246
+ F_new = complex(F_new)
247
+
248
+ if abs(F_new) <= (1.0 - armijo * lam) * F_abs:
249
+ w = w_new
250
+ ok = True
251
+ break
252
+
253
+ lam *= 0.5
254
+
255
+ if not ok:
256
+ return w, False
257
+
258
+ F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
259
+ return w, (abs(F_end) <= 10.0 * tol)
260
+
261
+
262
+ # =====================
263
+ # decompress newton old
264
+ # =====================
265
+
266
+ def decompress_newton_old(z_list, t_grid, a_coeffs, w0_list=None,
267
+ dt_max=0.1, sweep=True, time_rel_tol=5.0,
268
+ max_iter=50, tol=1e-12, armijo=1e-4,
269
+ min_lam=1e-6, w_min=1e-14):
270
+ """
271
+ Evolve w = m(t,z) on a fixed z grid and time grid using FD.
272
+
273
+ Parameters
274
+ ----------
275
+ z_list : array_like of complex
276
+ Query points z (typically x + 1j*eta with eta > 0).
277
+ t_grid : array_like of float
278
+ Strictly increasing time grid.
279
+ a_coeffs : ndarray
280
+ Coefficients defining P(zeta,y) in the monomial basis used by eval_P.
281
+ w0_list : array_like of complex
282
+ Initial values at t_grid[0] (typically m0(z_list) on the physical
283
+ branch).
284
+ dt_max : float, optional
285
+ Maximum internal time step. Larger dt is handled by substepping.
286
+ sweep : bool, optional
287
+ If True, use spatial continuation (neighbor seeding) plus a
288
+ time-consistency check to prevent branch collapse. If False, solve
289
+ each z independently from the previous-time seed (faster but may
290
+ branch-switch for small eta).
291
+ time_rel_tol : float, optional
292
+ When sweep=True, if the neighbor-seeded solution differs from the
293
+ previous-time value w_prev by more than time_rel_tol*(1+|w_prev|), we
294
+ also solve using the previous-time seed and select the closer one.
295
+ max_iter : int, optional
296
+ Maximum Newton iterations in fd_solve_w.
297
+ tol : float, optional
298
+ Residual tolerance in fd_solve_w.
299
+ armijo : float, optional
300
+ Armijo parameter for backtracking in fd_solve_w.
301
+ min_lam : float, optional
302
+ Minimum damping factor in fd_solve_w backtracking.
303
+ w_min : float, optional
304
+ Minimum |w| allowed to avoid singularity.
305
+
306
+ Returns
307
+ -------
308
+ W : ndarray, shape (len(t_grid), len(z_list))
309
+ Evolved values w(t,z).
310
+ ok : ndarray of bool, same shape as W
311
+ Convergence flags from the final accepted solve at each point.
312
+
313
+ Notes
314
+ -----
315
+ For very small eta, the implicit FD equation can have multiple roots in the
316
+ upper half-plane. The sweep option is a branch-selection mechanism. The
317
+ time-consistency check is critical at large t to avoid propagating a
318
+ nearly-real spurious root across x.
319
+
320
+ Examples
321
+ --------
322
+ .. code-block:: python
323
+
324
+ x = numpy.linspace(-0.5, 2.5, 2000)
325
+ eta = 1e-6
326
+ z_query = x + 1j*eta
327
+ w0_list = m1_fn(z_query)
328
+
329
+ t_grid = numpy.linspace(0.0, 4.0, 2)
330
+ W, ok = fd_evolve_on_grid(
331
+ z_query, t_grid, a_coeffs, w0_list=w0_list,
332
+ dt_max=0.1, sweep=True, time_rel_tol=5.0,
333
+ max_iter=50, tol=1e-12, armijo=1e-4, min_lam=1e-6, w_min=1e-14
334
+ )
335
+ rho = W.imag / numpy.pi
336
+ """
337
+ z_list = numpy.asarray(z_list, dtype=complex).ravel()
338
+ t_grid = numpy.asarray(t_grid, dtype=float).ravel()
339
+ nt = t_grid.size
340
+ nz = z_list.size
341
+
342
+ W = numpy.empty((nt, nz), dtype=complex)
343
+ ok = numpy.zeros((nt, nz), dtype=bool)
344
+
345
+ if w0_list is None:
346
+ raise ValueError(
347
+ "w0_list must be provided (e.g. m1_fn(z_list) at t=0).")
348
+ w_prev = numpy.asarray(w0_list, dtype=complex).ravel()
349
+ if w_prev.size != nz:
350
+ raise ValueError("w0_list must have same size as z_list.")
351
+
352
+ W[0, :] = w_prev
353
+ ok[0, :] = True
354
+
355
+ sweep = bool(sweep)
356
+ time_rel_tol = float(time_rel_tol)
357
+
358
+ for it in range(1, nt):
359
+ t0 = float(t_grid[it - 1])
360
+ t1 = float(t_grid[it])
361
+ dt = t1 - t0
362
+ if dt <= 0.0:
363
+ raise ValueError("t_grid must be strictly increasing.")
364
+
365
+ # Internal substepping makes time-continuity a strong selector.
366
+ n_sub = int(numpy.ceil(dt / float(dt_max)))
367
+ if n_sub < 1:
368
+ n_sub = 1
369
+
370
+ for ks in range(1, n_sub + 1):
371
+ t = t0 + dt * (ks / float(n_sub))
372
+
373
+ w_row = numpy.empty(nz, dtype=complex)
374
+ ok_row = numpy.zeros(nz, dtype=bool)
375
+
376
+ if not sweep:
377
+ # Independent solves: each point uses previous-time seed only.
378
+ for iz in range(nz):
379
+ w, success = fd_solve_w(
380
+ z_list[iz], t, a_coeffs, w_prev[iz],
381
+ max_iter=max_iter, tol=tol, armijo=armijo,
382
+ min_lam=min_lam, w_min=w_min
383
+ )
384
+ w_row[iz] = w
385
+ ok_row[iz] = success
386
+
387
+ w_prev = w_row
388
+ continue
389
+
390
+ # Center-out sweep seed: pick where previous-time Im is largest.
391
+ i0 = int(numpy.argmax(numpy.abs(numpy.imag(w_prev))))
392
+
393
+ w0, ok0 = fd_solve_w(
394
+ z_list[i0], t, a_coeffs, w_prev[i0],
395
+ max_iter=max_iter, tol=tol, armijo=armijo,
396
+ min_lam=min_lam, w_min=w_min
397
+ )
398
+ w_row[i0] = w0
399
+ ok_row[i0] = ok0
400
+
401
+ def solve_with_choice(iz, w_neighbor):
402
+ # First try neighbor-seeded Newton (spatial continuity).
403
+ w_a, ok_a = fd_solve_w(
404
+ z_list[iz], t, a_coeffs, w_neighbor,
405
+ max_iter=max_iter, tol=tol, armijo=armijo,
406
+ min_lam=min_lam, w_min=w_min
407
+ )
408
+
409
+ # Always keep a time-consistent fallback candidate.
410
+ w_b, ok_b = fd_solve_w(
411
+ z_list[iz], t, a_coeffs, w_prev[iz],
412
+ max_iter=max_iter, tol=tol, armijo=armijo,
413
+ min_lam=min_lam, w_min=w_min
414
+ )
415
+
416
+ if ok_a and ok_b:
417
+ # Prefer the root closer to previous-time value (time
418
+ # continuation).
419
+ da = abs(w_a - w_prev[iz])
420
+ db = abs(w_b - w_prev[iz])
421
+
422
+ # If neighbor result is wildly off, reject it.
423
+ if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
424
+ return w_b, True
425
+
426
+ return (w_a, True) if (da <= db) else (w_b, True)
427
+
428
+ if ok_a:
429
+ # If only neighbor succeeded, still guard against extreme
430
+ # drift.
431
+ da = abs(w_a - w_prev[iz])
432
+ if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
433
+ return w_b, True
434
+ return w_a, True
435
+
436
+ if ok_b:
437
+ return w_b, True
438
+
439
+ return w_a, False
440
+
441
+ # Sweep right
442
+ for iz in range(i0 + 1, nz):
443
+ w_row[iz], ok_row[iz] = solve_with_choice(iz, w_row[iz - 1])
444
+
445
+ # Sweep left
446
+ for iz in range(i0 - 1, -1, -1):
447
+ w_row[iz], ok_row[iz] = solve_with_choice(iz, w_row[iz + 1])
448
+
449
+ w_prev = w_row
450
+
451
+ W[it, :] = w_prev
452
+ ok[it, :] = ok_row
453
+
454
+ return W, ok
455
+
456
+
457
+ # =================
458
+ # decompress newton
459
+ # =================
460
+
461
+ def decompress_newton(z_list, t_grid, a_coeffs, w0_list=None,
462
+ dt_max=0.1, sweep=True, time_rel_tol=5.0,
463
+ active_imag_eps=None, sweep_pad=20,
464
+ max_iter=50, tol=1e-12, armijo=1e-4,
465
+ min_lam=1e-6, w_min=1e-14):
466
+ """
467
+ Evolve w = m(t,z) on a fixed z grid and time grid using FD.
468
+
469
+ Parameters
470
+ ----------
471
+ z_list : array_like of complex
472
+ Query points z (typically x + 1j*eta with eta > 0), ordered along x.
473
+ t_grid : array_like of float
474
+ Strictly increasing time grid.
475
+ a_coeffs : ndarray
476
+ Coefficients defining P(zeta,y) in the monomial basis.
477
+ w0_list : array_like of complex
478
+ Initial values at t_grid[0] (typically m0(z_list) on the physical
479
+ branch).
480
+ dt_max : float, optional
481
+ Maximum internal time step. Larger dt is handled by substepping.
482
+ sweep : bool, optional
483
+ If True, enforce spatial continuity within active (bulk) regions and
484
+ allow edge activation via padding. If False, solve each z independently
485
+ from previous-time seeds (may fail to "activate" new support near
486
+ edges).
487
+ time_rel_tol : float, optional
488
+ When sweep=True, reject neighbor-propagated solutions that drift too
489
+ far from the previous-time value, using a time-consistent fallback.
490
+ active_imag_eps : float or None, optional
491
+ Threshold on |Im(w_prev)| to define active/bulk indices. If None, it is
492
+ set to 50*Im(z_list[0]) (works well when z_list=x+i*eta).
493
+ sweep_pad : int, optional
494
+ Number of indices used to dilate the active region. This is crucial for
495
+ multi-bulk laws so that edges can move and points just outside a bulk
496
+ can be initialized from the interior.
497
+ max_iter, tol, armijo, min_lam, w_min : optional
498
+ Newton/backtracking controls passed to fd_solve_w.
499
+
500
+ Returns
501
+ -------
502
+ W : ndarray, shape (len(t_grid), len(z_list))
503
+ Evolved values w(t,z).
504
+ ok : ndarray of bool, same shape as W
505
+ Convergence flags from the accepted solve at each point.
506
+ """
507
+ z_list = numpy.asarray(z_list, dtype=complex).ravel()
508
+ t_grid = numpy.asarray(t_grid, dtype=float).ravel()
509
+ nt = t_grid.size
510
+ nz = z_list.size
511
+
512
+ W = numpy.empty((nt, nz), dtype=complex)
513
+ ok = numpy.zeros((nt, nz), dtype=bool)
514
+
515
+ if w0_list is None:
516
+ raise ValueError(
517
+ "w0_list must be provided (e.g. m1_fn(z_list) at t=0).")
518
+ w_prev = numpy.asarray(w0_list, dtype=complex).ravel()
519
+ if w_prev.size != nz:
520
+ raise ValueError("w0_list must have same size as z_list.")
521
+
522
+ W[0, :] = w_prev
523
+ ok[0, :] = True
524
+
525
+ sweep = bool(sweep)
526
+ time_rel_tol = float(time_rel_tol)
527
+ sweep_pad = int(sweep_pad)
528
+
529
+ # If z_list is x + i*eta, use eta to set an automatic activity threshold.
530
+ if active_imag_eps is None:
531
+ eta0 = float(abs(z_list[0].imag))
532
+ active_imag_eps = 50.0 * eta0 if eta0 > 0.0 else 1e-10
533
+ active_imag_eps = float(active_imag_eps)
534
+
535
+ def solve_with_choice(iz, w_seed):
536
+ # Neighbor-seeded candidate (spatial continuity)
537
+ w_a, ok_a = fd_solve_w(
538
+ z_list[iz], t, a_coeffs, w_seed,
539
+ max_iter=max_iter, tol=tol, armijo=armijo,
540
+ min_lam=min_lam, w_min=w_min
541
+ )
542
+
543
+ # Time-seeded candidate (time continuation)
544
+ w_b, ok_b = fd_solve_w(
545
+ z_list[iz], t, a_coeffs, w_prev[iz],
546
+ max_iter=max_iter, tol=tol, armijo=armijo,
547
+ min_lam=min_lam, w_min=w_min
548
+ )
549
+
550
+ if ok_a and ok_b:
551
+ da = abs(w_a - w_prev[iz])
552
+ db = abs(w_b - w_prev[iz])
553
+
554
+ # Reject neighbor result if it drifted too far in one step
555
+ if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
556
+ return w_b, True
557
+
558
+ return (w_a, True) if (da <= db) else (w_b, True)
559
+
560
+ if ok_a:
561
+ da = abs(w_a - w_prev[iz])
562
+ if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
563
+ return w_b, True
564
+ return w_a, True
565
+
566
+ if ok_b:
567
+ return w_b, True
568
+
569
+ return w_a, False
570
+
571
+ for it in range(1, nt):
572
+ t0 = float(t_grid[it - 1])
573
+ t1 = float(t_grid[it])
574
+ dt = t1 - t0
575
+ if dt <= 0.0:
576
+ raise ValueError("t_grid must be strictly increasing.")
577
+
578
+ # Substep in time to keep continuation safe.
579
+ n_sub = int(numpy.ceil(dt / float(dt_max)))
580
+ if n_sub < 1:
581
+ n_sub = 1
582
+
583
+ for ks in range(1, n_sub + 1):
584
+ t = t0 + dt * (ks / float(n_sub))
585
+
586
+ w_row = numpy.empty(nz, dtype=complex)
587
+ ok_row = numpy.zeros(nz, dtype=bool)
588
+
589
+ if not sweep:
590
+ # Independent solves: can miss edge activation in multi-bulk
591
+ # problems.
592
+ for iz in range(nz):
593
+ w, success = fd_solve_w(
594
+ z_list[iz], t, a_coeffs, w_prev[iz],
595
+ max_iter=max_iter, tol=tol, armijo=armijo,
596
+ min_lam=min_lam, w_min=w_min
597
+ )
598
+ w_row[iz] = w
599
+ ok_row[iz] = success
600
+
601
+ w_prev = w_row
602
+ continue
603
+
604
+ # Define "active" region from previous time: inside bulks
605
+ # Im(w_prev) is O(1), outside bulks Im(w_prev) is ~O(eta). Dilate
606
+ # by sweep_pad to allow edges to move.
607
+ active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
608
+ active_pad = active.copy()
609
+ if sweep_pad > 0 and numpy.any(active):
610
+ idx = numpy.flatnonzero(active)
611
+ for i in idx:
612
+ lo = 0 if (i - sweep_pad) < 0 else (i - sweep_pad)
613
+ hi = \
614
+ nz if (i + sweep_pad + 1) > nz else (i + sweep_pad + 1)
615
+ active_pad[lo:hi] = True
616
+
617
+ # Left-to-right: use neighbor seed only within padded active
618
+ # regions, so we don't propagate a branch across the gap between
619
+ # bulks.
620
+ for iz in range(nz):
621
+ if iz == 0:
622
+ w_seed = w_prev[iz]
623
+ else:
624
+ if active_pad[iz] and active_pad[iz - 1]:
625
+ w_seed = w_row[iz - 1]
626
+ else:
627
+ w_seed = w_prev[iz]
628
+
629
+ w_row[iz], ok_row[iz] = solve_with_choice(iz, w_seed)
630
+
631
+ # Right-to-left refinement: helps stabilize left edges of bulks.
632
+ for iz in range(nz - 2, -1, -1):
633
+ if active_pad[iz] and active_pad[iz + 1]:
634
+ w_seed = w_row[iz + 1]
635
+ w_new, ok_new = solve_with_choice(iz, w_seed)
636
+ if ok_new:
637
+ # Keep the more time-consistent solution.
638
+ if (not ok_row[iz]) or (abs(w_new - w_prev[iz]) <
639
+ abs(w_row[iz] - w_prev[iz])):
640
+ w_row[iz] = w_new
641
+ ok_row[iz] = True
642
+
643
+ w_prev = w_row
644
+
645
+ W[it, :] = w_prev
646
+ ok[it, :] = ok_row
647
+
648
+ return W, ok