freealg 0.7.11__py3-none-any.whl → 0.7.14__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 (36) hide show
  1. freealg/__init__.py +2 -2
  2. freealg/__version__.py +1 -1
  3. freealg/_algebraic_form/__init__.py +2 -1
  4. freealg/_algebraic_form/_constraints.py +53 -12
  5. freealg/_algebraic_form/_cusp.py +357 -0
  6. freealg/_algebraic_form/_cusp_wrap.py +268 -0
  7. freealg/_algebraic_form/_decompress.py +330 -381
  8. freealg/_algebraic_form/_decompress2.py +120 -0
  9. freealg/_algebraic_form/_decompress4.py +739 -0
  10. freealg/_algebraic_form/_decompress5.py +738 -0
  11. freealg/_algebraic_form/_decompress6.py +492 -0
  12. freealg/_algebraic_form/_decompress7.py +355 -0
  13. freealg/_algebraic_form/_decompress8.py +369 -0
  14. freealg/_algebraic_form/_decompress9.py +363 -0
  15. freealg/_algebraic_form/_decompress_new.py +431 -0
  16. freealg/_algebraic_form/_decompress_new_2.py +1631 -0
  17. freealg/_algebraic_form/_decompress_util.py +172 -0
  18. freealg/_algebraic_form/_edge.py +46 -68
  19. freealg/_algebraic_form/_homotopy.py +62 -30
  20. freealg/_algebraic_form/_homotopy2.py +289 -0
  21. freealg/_algebraic_form/_homotopy3.py +215 -0
  22. freealg/_algebraic_form/_homotopy4.py +320 -0
  23. freealg/_algebraic_form/_homotopy5.py +185 -0
  24. freealg/_algebraic_form/_moments.py +43 -57
  25. freealg/_algebraic_form/_support.py +132 -177
  26. freealg/_algebraic_form/algebraic_form.py +163 -30
  27. freealg/distributions/__init__.py +3 -1
  28. freealg/distributions/_compound_poisson.py +464 -0
  29. freealg/distributions/_deformed_marchenko_pastur.py +51 -0
  30. freealg/distributions/_deformed_wigner.py +44 -0
  31. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/METADATA +2 -1
  32. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/RECORD +36 -20
  33. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/WHEEL +1 -1
  34. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/licenses/AUTHORS.txt +0 -0
  35. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/licenses/LICENSE.txt +0 -0
  36. {freealg-0.7.11.dist-info → freealg-0.7.14.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1631 @@
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__ = ['build_time_grid', 'decompress_newton_old', 'decompress_newton']
18
+
19
+
20
+ # ===============
21
+ # build time grid
22
+ # ===============
23
+
24
+ def build_time_grid(sizes, n0, min_n_times=0):
25
+ """
26
+ sizes: list/array of requested matrix sizes (e.g. [2000,3000,4000,8000])
27
+ n0: initial size (self.n)
28
+ min_n_times: minimum number of time points to run Newton sweep on
29
+
30
+ Returns
31
+ -------
32
+ t_all: sorted time grid to run solver on
33
+ idx_req: indices of requested times inside t_all (same order as sizes)
34
+ """
35
+
36
+ sizes = numpy.asarray(sizes, dtype=float)
37
+ alpha = sizes / float(n0)
38
+ t_req = numpy.log(alpha)
39
+
40
+ # Always include t=0 and T=max(t_req)
41
+ T = float(numpy.max(t_req)) if t_req.size else 0.0
42
+ base = numpy.unique(numpy.r_[0.0, t_req, T])
43
+ t_all = numpy.sort(base)
44
+
45
+ # Add points only if needed: split largest gaps
46
+ N = int(min_n_times) if min_n_times is not None else 0
47
+ while t_all.size < N and t_all.size >= 2:
48
+ gaps = numpy.diff(t_all)
49
+ k = int(numpy.argmax(gaps))
50
+ mid = 0.5 * (t_all[k] + t_all[k+1])
51
+ t_all = numpy.sort(numpy.unique(numpy.r_[t_all, mid]))
52
+
53
+ # Map each requested time to an index in t_all (stable, no float drama)
54
+ # (t_req values came from same construction, so they should match exactly;
55
+ # still: use searchsorted + assert)
56
+ idx_req = numpy.searchsorted(t_all, t_req)
57
+ # optional sanity:
58
+ # assert numpy.allclose(t_all[idx_req], t_req, rtol=0, atol=0)
59
+
60
+ return t_all, idx_req
61
+
62
+
63
+ # ===============
64
+ # eval P partials
65
+ # ===============
66
+
67
+ def eval_P_partials(z, m, a_coeffs):
68
+ """
69
+ Evaluate P(z,m) and its partial derivatives dP/dz and dP/dm.
70
+
71
+ This assumes P is represented by `a_coeffs` in the monomial basis
72
+
73
+ P(z, m) = sum_{j=0..s} a_j(z) * m^j,
74
+ a_j(z) = sum_{i=0..deg_z} a_coeffs[i, j] * z^i.
75
+
76
+ The function returns P, dP/dz, dP/dm with broadcasting over z and m.
77
+
78
+ Parameters
79
+ ----------
80
+ z : complex or array_like of complex
81
+ First argument to P.
82
+ m : complex or array_like of complex
83
+ Second argument to P. Must be broadcast-compatible with `z`.
84
+ a_coeffs : ndarray, shape (deg_z+1, s+1)
85
+ Coefficient matrix for P in the monomial basis.
86
+
87
+ Returns
88
+ -------
89
+ P : complex or ndarray of complex
90
+ Value P(z,m).
91
+ Pz : complex or ndarray of complex
92
+ Partial derivative dP/dz evaluated at (z,m).
93
+ Pm : complex or ndarray of complex
94
+ Partial derivative dP/dm evaluated at (z,m).
95
+
96
+ Notes
97
+ -----
98
+ For scalar (z,m), this uses Horner evaluation for a_j(z) and then Horner
99
+ in m. For array inputs, it uses precomputed power tables via `_powers` for
100
+ simplicity.
101
+
102
+ Examples
103
+ --------
104
+ .. code-block:: python
105
+
106
+ P, Pz, Pm = eval_P_partials(1.0 + 1j, 0.2 + 0.3j, a_coeffs)
107
+ """
108
+
109
+ z = numpy.asarray(z, dtype=complex)
110
+ m = numpy.asarray(m, dtype=complex)
111
+
112
+ deg_z = int(a_coeffs.shape[0] - 1)
113
+ s = int(a_coeffs.shape[1] - 1)
114
+
115
+ if (z.ndim == 0) and (m.ndim == 0):
116
+ zz = complex(z)
117
+ mm = complex(m)
118
+
119
+ a = numpy.empty(s + 1, dtype=complex)
120
+ ap = numpy.empty(s + 1, dtype=complex)
121
+
122
+ for j in range(s + 1):
123
+ c = a_coeffs[:, j]
124
+
125
+ val = 0.0 + 0.0j
126
+ for i in range(deg_z, -1, -1):
127
+ val = val * zz + c[i]
128
+ a[j] = val
129
+
130
+ dval = 0.0 + 0.0j
131
+ for i in range(deg_z, 0, -1):
132
+ dval = dval * zz + (i * c[i])
133
+ ap[j] = dval
134
+
135
+ p = a[s]
136
+ pm = 0.0 + 0.0j
137
+ for j in range(s - 1, -1, -1):
138
+ pm = pm * mm + p
139
+ p = p * mm + a[j]
140
+
141
+ pz = ap[s]
142
+ for j in range(s - 1, -1, -1):
143
+ pz = pz * mm + ap[j]
144
+
145
+ return p, pz, pm
146
+
147
+ shp = numpy.broadcast(z, m).shape
148
+ zz = numpy.broadcast_to(z, shp).ravel()
149
+ mm = numpy.broadcast_to(m, shp).ravel()
150
+
151
+ zp = powers(zz, deg_z)
152
+ mp = powers(mm, s)
153
+
154
+ dzp = numpy.zeros_like(zp)
155
+ for i in range(1, deg_z + 1):
156
+ dzp[:, i] = i * zp[:, i - 1]
157
+
158
+ P = numpy.zeros(zz.size, dtype=complex)
159
+ Pz = numpy.zeros(zz.size, dtype=complex)
160
+ Pm = numpy.zeros(zz.size, dtype=complex)
161
+
162
+ for j in range(s + 1):
163
+ aj = zp @ a_coeffs[:, j]
164
+ P += aj * mp[:, j]
165
+
166
+ ajp = dzp @ a_coeffs[:, j]
167
+ Pz += ajp * mp[:, j]
168
+
169
+ if j >= 1:
170
+ Pm += (j * aj) * mp[:, j - 1]
171
+
172
+ return P.reshape(shp), Pz.reshape(shp), Pm.reshape(shp)
173
+
174
+
175
+ # ==========
176
+ # fd solve w
177
+ # ==========
178
+
179
+ # def fd_solve_w(z, t, a_coeffs, w_init, max_iter=50, tol=1e-12,
180
+ # armijo=1e-4, min_lam=1e-6, w_min=1e-14):
181
+ # """
182
+ # Solve for w = m(t,z) from the implicit FD equation using damped Newton.
183
+ #
184
+ # We solve in w the equation
185
+ #
186
+ # F(w) = P(z + alpha/w, tau*w) = 0,
187
+ #
188
+ # where tau = exp(t) and alpha = 1 - 1/tau.
189
+ #
190
+ # A backtracking (Armijo) line search is used to stabilize Newton updates.
191
+ # When Im(z) > 0, the iterate is constrained to remain in the upper
192
+ # half-plane (Im(w) > 0), enforcing the Herglotz branch.
193
+ #
194
+ # Parameters
195
+ # ----------
196
+ # z : complex
197
+ # Query point in the complex plane.
198
+ # t : float
199
+ # Time parameter (tau = exp(t)).
200
+ # a_coeffs : ndarray
201
+ # Coefficients defining P(zeta,y) in the monomial basis.
202
+ # w_init : complex
203
+ # Initial guess for w.
204
+ # max_iter : int, optional
205
+ # Maximum number of Newton iterations.
206
+ # tol : float, optional
207
+ # Residual tolerance on |F(w)|.
208
+ # armijo : float, optional
209
+ # Armijo parameter for backtracking sufficient decrease.
210
+ # min_lam : float, optional
211
+ # Minimum damping factor allowed in backtracking.
212
+ # w_min : float, optional
213
+ # Minimum |w| allowed to avoid singularity in z + alpha/w.
214
+ #
215
+ # Returns
216
+ # -------
217
+ # w : complex
218
+ # The computed solution (last iterate if not successful).
219
+ # success : bool
220
+ # True if convergence criteria were met, False otherwise.
221
+ #
222
+ # Notes
223
+ # -----
224
+ # This function does not choose the correct branch globally by itself; it
225
+ # relies on a good initialization strategy (e.g. time continuation and/or
226
+ # x-sweeps) to avoid converging to a different valid root of the implicit
227
+ # equation.
228
+ #
229
+ # Examples
230
+ # --------
231
+ # .. code-block:: python
232
+ #
233
+ # w, ok = fd_solve_w(
234
+ # z=0.5 + 1e-6j, t=2.0, a_coeffs=a_coeffs, w_init=m1_fn(0.5 + 1e-6j),
235
+ # max_iter=50, tol=1e-12
236
+ # )
237
+ # """
238
+ #
239
+ # z = complex(z)
240
+ # w = complex(w_init)
241
+ #
242
+ # tau = float(numpy.exp(t))
243
+ # alpha = 1.0 - 1.0 / tau
244
+ #
245
+ # want_pos_imag = (z.imag > 0.0)
246
+ #
247
+ # for _ in range(max_iter):
248
+ # if not numpy.isfinite(w.real) or not numpy.isfinite(w.imag):
249
+ # return w, False
250
+ # if abs(w) < w_min:
251
+ # return w, False
252
+ # if want_pos_imag and (w.imag <= 0.0):
253
+ # return w, False
254
+ #
255
+ # zeta = z + alpha / w
256
+ # y = tau * w
257
+ #
258
+ # F, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
259
+ # F = complex(F)
260
+ # Pz = complex(Pz)
261
+ # Py = complex(Py)
262
+ #
263
+ # if abs(F) <= tol:
264
+ # return w, True
265
+ #
266
+ # dF = (-alpha / (w * w)) * Pz + tau * Py
267
+ # if dF == 0.0:
268
+ # return w, False
269
+ #
270
+ # step = -F / dF
271
+ #
272
+ # lam = 1.0
273
+ # F_abs = abs(F)
274
+ # ok = False
275
+ #
276
+ # while lam >= min_lam:
277
+ # w_new = w + lam * step
278
+ # if abs(w_new) < w_min:
279
+ # lam *= 0.5
280
+ # continue
281
+ # if want_pos_imag and (w_new.imag <= 0.0):
282
+ # lam *= 0.5
283
+ # continue
284
+ #
285
+ # zeta_new = z + alpha / w_new
286
+ # y_new = tau * w_new
287
+ #
288
+ # F_new = eval_P_partials(zeta_new, y_new, a_coeffs)[0]
289
+ # F_new = complex(F_new)
290
+ #
291
+ # if abs(F_new) <= (1.0 - armijo * lam) * F_abs:
292
+ # w = w_new
293
+ # ok = True
294
+ # break
295
+ #
296
+ # lam *= 0.5
297
+ #
298
+ # if not ok:
299
+ # return w, False
300
+ #
301
+ # F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
302
+ # return w, (abs(F_end) <= 10.0 * tol)
303
+
304
+ # def fd_solve_w(z, t, a_coeffs, w_init, max_iter=50, tol=1e-12,
305
+ # armijo=1e-4, min_lam=1e-6, w_min=1e-14):
306
+ # """
307
+ # Solve for w = m(t,z) from the implicit FD equation using damped Newton.
308
+ #
309
+ # We solve in w the equation
310
+ #
311
+ # F(w) = P(z + alpha/w, tau*w) = 0,
312
+ #
313
+ # where tau = exp(t) and alpha = 1 - 1/tau.
314
+ #
315
+ # A backtracking (Armijo) line search is used to stabilize Newton updates.
316
+ # When Im(z) > 0, the iterate is constrained to remain in the upper
317
+ # half-plane (Im(w) > 0), enforcing the Herglotz branch.
318
+ #
319
+ # Parameters
320
+ # ----------
321
+ # z : complex
322
+ # Query point in the complex plane.
323
+ # t : float
324
+ # Time parameter (tau = exp(t)).
325
+ # a_coeffs : ndarray
326
+ # Coefficients defining P(zeta,y) in the monomial basis.
327
+ # w_init : complex
328
+ # Initial guess for w.
329
+ # max_iter : int, optional
330
+ # Maximum number of Newton iterations.
331
+ # tol : float, optional
332
+ # Residual tolerance on |F(w)|.
333
+ # armijo : float, optional
334
+ # Armijo parameter for backtracking sufficient decrease.
335
+ # min_lam : float, optional
336
+ # Minimum damping factor allowed in backtracking.
337
+ # w_min : float, optional
338
+ # Minimum |w| allowed to avoid singularity in z + alpha/w.
339
+ #
340
+ # Returns
341
+ # -------
342
+ # w : complex
343
+ # The computed solution (last iterate if not successful).
344
+ # success : bool
345
+ # True if convergence criteria were met, False otherwise.
346
+ #
347
+ # Notes
348
+ # -----
349
+ # This function does not choose the correct branch globally by itself; it
350
+ # relies on a good initialization strategy (e.g. time continuation and/or
351
+ # x-sweeps) to avoid converging to a different valid root of the implicit
352
+ # equation.
353
+ #
354
+ # Examples
355
+ # --------
356
+ # .. code-block:: python
357
+ #
358
+ # w, ok = fd_solve_w(
359
+ # z=0.5 + 1e-6j, t=2.0, a_coeffs=a_coeffs, w_init=m1_fn(0.5 + 1e-6j),
360
+ # max_iter=50, tol=1e-12
361
+ # )
362
+ # """
363
+ #
364
+ # z = complex(z)
365
+ # w = complex(w_init)
366
+ #
367
+ # tau = float(numpy.exp(t))
368
+ # alpha = 1.0 - 1.0 / tau
369
+ #
370
+ # want_pos_imag = (z.imag > 0.0)
371
+ #
372
+ # for _ in range(max_iter):
373
+ #
374
+ # # ----------------
375
+ #
376
+ # # if not numpy.isfinite(w.real) or not numpy.isfinite(w.imag):
377
+ # # return w, False
378
+ # # if abs(w) < w_min:
379
+ # # return w, False
380
+ # # if want_pos_imag and (w.imag <= 0.0):
381
+ # # return w, False
382
+ # #
383
+ # # zeta = z + alpha / w
384
+ # # y = tau * w
385
+ # #
386
+ # # F, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
387
+ # # F = complex(F)
388
+ # # Pz = complex(Pz)
389
+ # # Py = complex(Py)
390
+ # #
391
+ # # if abs(F) <= tol:
392
+ # # return w, True
393
+ # #
394
+ # # dF = (-alpha / (w * w)) * Pz + tau * Py
395
+ # # if dF == 0.0:
396
+ # # return w, False
397
+ # #
398
+ # # step = -F / dF
399
+ # #
400
+ # # lam = 1.0
401
+ # # F_abs = abs(F)
402
+ # # ok = False
403
+ # #
404
+ # # while lam >= min_lam:
405
+ # # w_new = w + lam * step
406
+ # # if abs(w_new) < w_min:
407
+ # # lam *= 0.5
408
+ # # continue
409
+ # # if want_pos_imag and (w_new.imag <= 0.0):
410
+ # # lam *= 0.5
411
+ # # continue
412
+ # #
413
+ # # zeta_new = z + alpha / w_new
414
+ # # y_new = tau * w_new
415
+ # #
416
+ # # F_new = eval_P_partials(zeta_new, y_new, a_coeffs)[0]
417
+ # # F_new = complex(F_new)
418
+ # #
419
+ # # if abs(F_new) <= (1.0 - armijo * lam) * F_abs:
420
+ # # w = w_new
421
+ # # ok = True
422
+ # # break
423
+ # #
424
+ # # lam *= 0.5
425
+ # #
426
+ # # if not ok:
427
+ # # return w, False
428
+ #
429
+ # # ---------------
430
+ #
431
+ # # TEST
432
+ #
433
+ # # -------------------------
434
+ # # Polynomial root selection
435
+ # # -------------------------
436
+ # # We solve: P(z + alpha/w, tau*w) = 0.
437
+ # # Let y = tau*w. Then alpha/w = alpha*tau/y = (tau - 1)/y.
438
+ # # So we solve in y:
439
+ # # P(z + beta/y, y) = 0, beta = tau - 1.
440
+ # # Multiply by y^deg_z to clear denominators and get a polynomial in y.
441
+ #
442
+ # a = numpy.asarray(a_coeffs, dtype=numpy.complex128)
443
+ # deg_z = a.shape[0] - 1
444
+ # deg_m = a.shape[1] - 1
445
+ #
446
+ # beta = tau - 1.0
447
+ #
448
+ # # poly_y[p] stores coeff of y^p after clearing denominators
449
+ # poly_y = numpy.zeros(deg_z + deg_m + 1, dtype=numpy.complex128)
450
+ #
451
+ # # Build polynomial: sum_{i,j} a[i,j] (z + beta/y)^i y^j * y^{deg_z}
452
+ # # Expand (z + beta/y)^i = sum_{k=0}^i C(i,k) z^{i-k} (beta/y)^k
453
+ # # Term contributes to power p = deg_z + j - k.
454
+ # from math import comb
455
+ # for i in range(deg_z + 1):
456
+ # for j in range(deg_m + 1):
457
+ # aij = a[i, j]
458
+ # if aij == 0:
459
+ # continue
460
+ # for k in range(i + 1):
461
+ # p = deg_z + j - k
462
+ # poly_y[p] += aij * comb(i, k) * (z ** (i - k)) * (beta ** k)
463
+ #
464
+ # # numpy.roots expects highest degree first
465
+ # coeffs = poly_y[::-1]
466
+ #
467
+ # # If leading coefficients are ~0, trim (rare but safe)
468
+ # nz_lead = numpy.flatnonzero(numpy.abs(coeffs) > 0)
469
+ # if nz_lead.size == 0:
470
+ # return w, False
471
+ # coeffs = coeffs[nz_lead[0]:]
472
+ #
473
+ # roots_y = numpy.roots(coeffs)
474
+ #
475
+ # # Pick root with Im(w)>0 (if z in upper half-plane), closest to time seed
476
+ # y_seed = tau * w_init
477
+ # best = None
478
+ # best_score = None
479
+ #
480
+ # for y in roots_y:
481
+ # if not numpy.isfinite(y.real) or not numpy.isfinite(y.imag):
482
+ # continue
483
+ #
484
+ # w_cand = y / tau
485
+ #
486
+ # if want_pos_imag and (w_cand.imag <= 0.0):
487
+ # continue
488
+ #
489
+ # if abs(w_cand) < w_min:
490
+ # continue
491
+ #
492
+ # # score: stick to time continuation
493
+ # score = abs(y - y_seed)
494
+ #
495
+ # if (best_score is None) or (score < best_score):
496
+ # best = w_cand
497
+ # best_score = score
498
+ #
499
+ # if best is None:
500
+ # return w, False
501
+ #
502
+ # w = complex(best)
503
+ #
504
+ # # final residual check
505
+ # F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
506
+ # return w, (abs(F_end) <= 1e3 * tol)
507
+ #
508
+ # # -------------------
509
+ #
510
+ #
511
+ # F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
512
+ # return w, (abs(F_end) <= 10.0 * tol)
513
+
514
+ def fd_solve_w(z, t, a_coeffs, w_init, max_iter=50, tol=1e-12,
515
+ armijo=1e-4, min_lam=1e-6, w_min=1e-14):
516
+ """
517
+ Damped Newton solve for w from F_t(z,w)=P(z+alpha/w, tau*w)=0.
518
+
519
+ Convention: m(z)=∫ rho(x)/(x-z) dx, so for z in C^+ we want Im(w)>0.
520
+ """
521
+ z = complex(z)
522
+ w = complex(w_init)
523
+
524
+ tau = float(numpy.exp(t))
525
+ alpha = 1.0 - 1.0 / tau
526
+
527
+ want_pos_imag = (z.imag > 0.0)
528
+
529
+ # quick validity check on init
530
+ if (not numpy.isfinite(w.real)) or (not numpy.isfinite(w.imag)):
531
+ return w, False
532
+ if abs(w) < w_min:
533
+ return w, False
534
+ if want_pos_imag and (w.imag <= 0.0):
535
+ # nudge into upper half-plane (do NOT flip sign; just perturb)
536
+ w = complex(w.real, max(1e-15, abs(w.imag)))
537
+
538
+ for _ in range(max_iter):
539
+
540
+ if (not numpy.isfinite(w.real)) or (not numpy.isfinite(w.imag)):
541
+ return w, False
542
+ if abs(w) < w_min:
543
+ return w, False
544
+ if want_pos_imag and (w.imag <= 0.0):
545
+ return w, False
546
+
547
+ zeta = z + alpha / w
548
+ y = tau * w
549
+
550
+ F, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
551
+ F = complex(F)
552
+ Pz = complex(Pz)
553
+ Py = complex(Py)
554
+
555
+ F_abs = abs(F)
556
+ if F_abs <= tol:
557
+ return w, True
558
+
559
+ dF = (-alpha / (w * w)) * Pz + tau * Py
560
+ dF = complex(dF)
561
+ if dF == 0.0 or (not numpy.isfinite(dF.real)) or (not numpy.isfinite(dF.imag)):
562
+ return w, False
563
+
564
+ step = -F / dF
565
+
566
+ # backtracking on |F| decrease
567
+ lam = 1.0
568
+ ok = False
569
+ while lam >= min_lam:
570
+ w_new = w + lam * step
571
+
572
+ if (not numpy.isfinite(w_new.real)) or (not numpy.isfinite(w_new.imag)):
573
+ lam *= 0.5
574
+ continue
575
+ if abs(w_new) < w_min:
576
+ lam *= 0.5
577
+ continue
578
+ if want_pos_imag and (w_new.imag <= 0.0):
579
+ lam *= 0.5
580
+ continue
581
+
582
+ F_new = eval_P_partials(z + alpha / w_new, tau * w_new, a_coeffs)[0]
583
+ F_new = complex(F_new)
584
+
585
+ # Armijo-like sufficient decrease on residual norm
586
+ if abs(F_new) <= (1.0 - armijo * lam) * F_abs:
587
+ w = w_new
588
+ ok = True
589
+ break
590
+
591
+ lam *= 0.5
592
+
593
+ if not ok:
594
+ return w, False
595
+
596
+ # if max_iter hit, accept only if residual is reasonably small
597
+ F_end = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
598
+ F_end = complex(F_end)
599
+ return w, (abs(F_end) <= 10.0 * tol)
600
+
601
+
602
+
603
+ # ============
604
+ # NEW FUNCTION
605
+ # ============
606
+
607
+ def fd_candidates_w(z, t, a_coeffs, w_min=1e-14):
608
+ """
609
+ Return candidate roots w solving P(z + alpha/w, tau*w)=0 with Im(w)>0 (if Im(z)>0).
610
+ """
611
+ z = complex(z)
612
+ tau = float(numpy.exp(t))
613
+ alpha = 1.0 - 1.0 / tau
614
+ want_pos_imag = (z.imag > 0.0)
615
+
616
+ a = numpy.asarray(a_coeffs, dtype=numpy.complex128)
617
+ deg_z = a.shape[0] - 1
618
+ deg_m = a.shape[1] - 1
619
+
620
+ beta = tau - 1.0 # since alpha/w = (tau-1)/(tau*w) = beta / y with y=tau*w
621
+
622
+ poly_y = numpy.zeros(deg_z + deg_m + 1, dtype=numpy.complex128)
623
+
624
+ from math import comb
625
+ for i in range(deg_z + 1):
626
+ for j in range(deg_m + 1):
627
+ aij = a[i, j]
628
+ if aij == 0:
629
+ continue
630
+ for k in range(i + 1):
631
+ p = deg_z + j - k
632
+ poly_y[p] += aij * comb(i, k) * (z ** (i - k)) * (beta ** k)
633
+
634
+ coeffs = poly_y[::-1]
635
+ nz_lead = numpy.flatnonzero(numpy.abs(coeffs) > 0)
636
+ if nz_lead.size == 0:
637
+ return []
638
+
639
+ coeffs = coeffs[nz_lead[0]:]
640
+ roots_y = numpy.roots(coeffs)
641
+
642
+ cands = []
643
+ for y in roots_y:
644
+ if not numpy.isfinite(y.real) or not numpy.isfinite(y.imag):
645
+ continue
646
+ w = y / tau
647
+ if abs(w) < w_min:
648
+ continue
649
+ if want_pos_imag and (w.imag <= 0.0):
650
+ continue
651
+ # residual filter (optional but helps)
652
+ # -------------
653
+ # TEST
654
+ # F = eval_P_partials(z + alpha / w, tau * w, a_coeffs)[0]
655
+ # if abs(F) < 1e-6:
656
+ # cands.append(complex(w))
657
+ # ---------------
658
+ # TEST
659
+ cands.append(complex(w))
660
+ # ------------------
661
+
662
+ return cands
663
+
664
+
665
+ # =====================
666
+ # decompress newton old
667
+ # =====================
668
+
669
+ # def decompress_newton_old(z_list, t_grid, a_coeffs, w0_list=None,
670
+ # dt_max=0.1, sweep=True, time_rel_tol=5.0,
671
+ # max_iter=50, tol=1e-12, armijo=1e-4,
672
+ # min_lam=1e-6, w_min=1e-14):
673
+ # """
674
+ # Evolve w = m(t,z) on a fixed z grid and time grid using FD.
675
+ #
676
+ # Parameters
677
+ # ----------
678
+ # z_list : array_like of complex
679
+ # Query points z (typically x + 1j*eta with eta > 0).
680
+ # t_grid : array_like of float
681
+ # Strictly increasing time grid.
682
+ # a_coeffs : ndarray
683
+ # Coefficients defining P(zeta,y) in the monomial basis used by eval_P.
684
+ # w0_list : array_like of complex
685
+ # Initial values at t_grid[0] (typically m0(z_list) on the physical
686
+ # branch).
687
+ # dt_max : float, optional
688
+ # Maximum internal time step. Larger dt is handled by substepping.
689
+ # sweep : bool, optional
690
+ # If True, use spatial continuation (neighbor seeding) plus a
691
+ # time-consistency check to prevent branch collapse. If False, solve
692
+ # each z independently from the previous-time seed (faster but may
693
+ # branch-switch for small eta).
694
+ # time_rel_tol : float, optional
695
+ # When sweep=True, if the neighbor-seeded solution differs from the
696
+ # previous-time value w_prev by more than time_rel_tol*(1+|w_prev|), we
697
+ # also solve using the previous-time seed and select the closer one.
698
+ # max_iter : int, optional
699
+ # Maximum Newton iterations in fd_solve_w.
700
+ # tol : float, optional
701
+ # Residual tolerance in fd_solve_w.
702
+ # armijo : float, optional
703
+ # Armijo parameter for backtracking in fd_solve_w.
704
+ # min_lam : float, optional
705
+ # Minimum damping factor in fd_solve_w backtracking.
706
+ # w_min : float, optional
707
+ # Minimum |w| allowed to avoid singularity.
708
+ #
709
+ # Returns
710
+ # -------
711
+ # W : ndarray, shape (len(t_grid), len(z_list))
712
+ # Evolved values w(t,z).
713
+ # ok : ndarray of bool, same shape as W
714
+ # Convergence flags from the final accepted solve at each point.
715
+ #
716
+ # Notes
717
+ # -----
718
+ # For very small eta, the implicit FD equation can have multiple roots in the
719
+ # upper half-plane. The sweep option is a branch-selection mechanism. The
720
+ # time-consistency check is critical at large t to avoid propagating a
721
+ # nearly-real spurious root across x.
722
+ #
723
+ # Examples
724
+ # --------
725
+ # .. code-block:: python
726
+ #
727
+ # x = numpy.linspace(-0.5, 2.5, 2000)
728
+ # eta = 1e-6
729
+ # z_query = x + 1j*eta
730
+ # w0_list = m1_fn(z_query)
731
+ #
732
+ # t_grid = numpy.linspace(0.0, 4.0, 2)
733
+ # W, ok = fd_evolve_on_grid(
734
+ # z_query, t_grid, a_coeffs, w0_list=w0_list,
735
+ # dt_max=0.1, sweep=True, time_rel_tol=5.0,
736
+ # max_iter=50, tol=1e-12, armijo=1e-4, min_lam=1e-6, w_min=1e-14
737
+ # )
738
+ # rho = W.imag / numpy.pi
739
+ # """
740
+ # z_list = numpy.asarray(z_list, dtype=complex).ravel()
741
+ # t_grid = numpy.asarray(t_grid, dtype=float).ravel()
742
+ # nt = t_grid.size
743
+ # nz = z_list.size
744
+ #
745
+ # W = numpy.empty((nt, nz), dtype=complex)
746
+ # ok = numpy.zeros((nt, nz), dtype=bool)
747
+ #
748
+ # if w0_list is None:
749
+ # raise ValueError(
750
+ # "w0_list must be provided (e.g. m1_fn(z_list) at t=0).")
751
+ # w_prev = numpy.asarray(w0_list, dtype=complex).ravel()
752
+ # if w_prev.size != nz:
753
+ # raise ValueError("w0_list must have same size as z_list.")
754
+ #
755
+ # W[0, :] = w_prev
756
+ # ok[0, :] = True
757
+ #
758
+ # sweep = bool(sweep)
759
+ # time_rel_tol = float(time_rel_tol)
760
+ #
761
+ # for it in range(1, nt):
762
+ # t0 = float(t_grid[it - 1])
763
+ # t1 = float(t_grid[it])
764
+ # dt = t1 - t0
765
+ # if dt <= 0.0:
766
+ # raise ValueError("t_grid must be strictly increasing.")
767
+ #
768
+ # # Internal substepping makes time-continuity a strong selector.
769
+ # n_sub = int(numpy.ceil(dt / float(dt_max)))
770
+ # if n_sub < 1:
771
+ # n_sub = 1
772
+ #
773
+ # for ks in range(1, n_sub + 1):
774
+ # t = t0 + dt * (ks / float(n_sub))
775
+ #
776
+ # w_row = numpy.empty(nz, dtype=complex)
777
+ # ok_row = numpy.zeros(nz, dtype=bool)
778
+ #
779
+ # if not sweep:
780
+ # # Independent solves: each point uses previous-time seed only.
781
+ # for iz in range(nz):
782
+ # w, success = fd_solve_w(
783
+ # z_list[iz], t, a_coeffs, w_prev[iz],
784
+ # max_iter=max_iter, tol=tol, armijo=armijo,
785
+ # min_lam=min_lam, w_min=w_min
786
+ # )
787
+ # w_row[iz] = w
788
+ # ok_row[iz] = success
789
+ #
790
+ # w_prev = w_row
791
+ # continue
792
+ #
793
+ # # Center-out sweep seed: pick where previous-time Im is largest.
794
+ # i0 = int(numpy.argmax(numpy.abs(numpy.imag(w_prev))))
795
+ #
796
+ # w0, ok0 = fd_solve_w(
797
+ # z_list[i0], t, a_coeffs, w_prev[i0],
798
+ # max_iter=max_iter, tol=tol, armijo=armijo,
799
+ # min_lam=min_lam, w_min=w_min
800
+ # )
801
+ # w_row[i0] = w0
802
+ # ok_row[i0] = ok0
803
+ #
804
+ # # -----------------
805
+ # # slove with choice
806
+ # # -----------------
807
+ #
808
+ # def solve_with_choice(iz, w_neighbor):
809
+ # # First try neighbor-seeded Newton (spatial continuity).
810
+ # w_a, ok_a = fd_solve_w(
811
+ # z_list[iz], t, a_coeffs, w_neighbor,
812
+ # max_iter=max_iter, tol=tol, armijo=armijo,
813
+ # min_lam=min_lam, w_min=w_min
814
+ # )
815
+ #
816
+ # # Always keep a time-consistent fallback candidate.
817
+ # w_b, ok_b = fd_solve_w(
818
+ # z_list[iz], t, a_coeffs, w_prev[iz],
819
+ # max_iter=max_iter, tol=tol, armijo=armijo,
820
+ # min_lam=min_lam, w_min=w_min
821
+ # )
822
+ #
823
+ # if ok_a and ok_b:
824
+ # # Prefer the root closer to previous-time value (time
825
+ # # continuation).
826
+ # da = abs(w_a - w_prev[iz])
827
+ # db = abs(w_b - w_prev[iz])
828
+ #
829
+ # # If neighbor result is wildly off, reject it.
830
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
831
+ # return w_b, True
832
+ #
833
+ # return (w_a, True) if (da <= db) else (w_b, True)
834
+ #
835
+ # if ok_a:
836
+ # # If only neighbor succeeded, still guard against extreme
837
+ # # drift.
838
+ # da = abs(w_a - w_prev[iz])
839
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
840
+ # return w_b, True
841
+ # return w_a, True
842
+ #
843
+ # if ok_b:
844
+ # return w_b, True
845
+ #
846
+ # return w_a, False
847
+ #
848
+ # # Sweep right
849
+ # for iz in range(i0 + 1, nz):
850
+ # w_row[iz], ok_row[iz] = solve_with_choice(iz, w_row[iz - 1])
851
+ #
852
+ # # Sweep left
853
+ # for iz in range(i0 - 1, -1, -1):
854
+ # w_row[iz], ok_row[iz] = solve_with_choice(iz, w_row[iz + 1])
855
+ #
856
+ # w_prev = w_row
857
+ #
858
+ # W[it, :] = w_prev
859
+ # ok[it, :] = ok_row
860
+ #
861
+ # return W, ok
862
+
863
+
864
+ # =================
865
+ # decompress newton
866
+ # =================
867
+
868
+ # def decompress_newton(z_list, t_grid, a_coeffs, w0_list=None,
869
+ # dt_max=0.1, sweep=True, time_rel_tol=5.0,
870
+ # active_imag_eps=None, sweep_pad=20,
871
+ # max_iter=50, tol=1e-12, armijo=1e-4,
872
+ # min_lam=1e-6, w_min=1e-14):
873
+ # """
874
+ # Evolve w = m(t,z) on a fixed z grid and time grid using FD.
875
+ #
876
+ # Parameters
877
+ # ----------
878
+ # z_list : array_like of complex
879
+ # Query points z (typically x + 1j*eta with eta > 0), ordered along x.
880
+ # t_grid : array_like of float
881
+ # Strictly increasing time grid.
882
+ # a_coeffs : ndarray
883
+ # Coefficients defining P(zeta,y) in the monomial basis.
884
+ # w0_list : array_like of complex
885
+ # Initial values at t_grid[0] (typically m0(z_list) on the physical
886
+ # branch).
887
+ # dt_max : float, optional
888
+ # Maximum internal time step. Larger dt is handled by substepping.
889
+ # sweep : bool, optional
890
+ # If True, enforce spatial continuity within active (bulk) regions and
891
+ # allow edge activation via padding. If False, solve each z independently
892
+ # from previous-time seeds (may fail to "activate" new support near
893
+ # edges).
894
+ # time_rel_tol : float, optional
895
+ # When sweep=True, reject neighbor-propagated solutions that drift too
896
+ # far from the previous-time value, using a time-consistent fallback.
897
+ # active_imag_eps : float or None, optional
898
+ # Threshold on |Im(w_prev)| to define active/bulk indices. If None, it is
899
+ # set to 50*Im(z_list[0]) (works well when z_list=x+i*eta).
900
+ # sweep_pad : int, optional
901
+ # Number of indices used to dilate the active region. This is crucial for
902
+ # multi-bulk laws so that edges can move and points just outside a bulk
903
+ # can be initialized from the interior.
904
+ # max_iter, tol, armijo, min_lam, w_min : optional
905
+ # Newton/backtracking controls passed to fd_solve_w.
906
+ #
907
+ # Returns
908
+ # -------
909
+ # W : ndarray, shape (len(t_grid), len(z_list))
910
+ # Evolved values w(t,z).
911
+ # ok : ndarray of bool, same shape as W
912
+ # Convergence flags from the accepted solve at each point.
913
+ # """
914
+ #
915
+ # z_list = numpy.asarray(z_list, dtype=complex).ravel()
916
+ # t_grid = numpy.asarray(t_grid, dtype=float).ravel()
917
+ # nt = t_grid.size
918
+ # nz = z_list.size
919
+ #
920
+ # W = numpy.empty((nt, nz), dtype=complex)
921
+ # ok = numpy.zeros((nt, nz), dtype=bool)
922
+ #
923
+ # if w0_list is None:
924
+ # raise ValueError(
925
+ # "w0_list must be provided (e.g. m1_fn(z_list) at t=0).")
926
+ # w_prev = numpy.asarray(w0_list, dtype=complex).ravel()
927
+ # if w_prev.size != nz:
928
+ # raise ValueError("w0_list must have same size as z_list.")
929
+ #
930
+ # W[0, :] = w_prev
931
+ # ok[0, :] = True
932
+ #
933
+ # sweep = bool(sweep)
934
+ # time_rel_tol = float(time_rel_tol)
935
+ # sweep_pad = int(sweep_pad)
936
+ #
937
+ # # If z_list is x + i*eta, use eta to set an automatic activity threshold.
938
+ # if active_imag_eps is None:
939
+ # eta0 = float(abs(z_list[0].imag))
940
+ # active_imag_eps = 50.0 * eta0 if eta0 > 0.0 else 1e-10
941
+ # active_imag_eps = float(active_imag_eps)
942
+ #
943
+ # # --------------------------------------
944
+ # # TEST
945
+ # # def solve_with_choice(iz, w_seed):
946
+ # # # Neighbor-seeded candidate (spatial continuity)
947
+ # # w_a, ok_a = fd_solve_w(
948
+ # # z_list[iz], t, a_coeffs, w_seed,
949
+ # # max_iter=max_iter, tol=tol, armijo=armijo,
950
+ # # min_lam=min_lam, w_min=w_min
951
+ # # )
952
+ # #
953
+ # # # Time-seeded candidate (time continuation)
954
+ # # w_b, ok_b = fd_solve_w(
955
+ # # z_list[iz], t, a_coeffs, w_prev[iz],
956
+ # # max_iter=max_iter, tol=tol, armijo=armijo,
957
+ # # min_lam=min_lam, w_min=w_min
958
+ # # )
959
+ # #
960
+ # # if ok_a and ok_b:
961
+ # # da = abs(w_a - w_prev[iz])
962
+ # # db = abs(w_b - w_prev[iz])
963
+ # #
964
+ # # # Reject neighbor result if it drifted too far in one step
965
+ # # if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
966
+ # # return w_b, True
967
+ # #
968
+ # # return (w_a, True) if (da <= db) else (w_b, True)
969
+ # #
970
+ # # if ok_a:
971
+ # # da = abs(w_a - w_prev[iz])
972
+ # # if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
973
+ # # return w_b, True
974
+ # # return w_a, True
975
+ # #
976
+ # # if ok_b:
977
+ # # return w_b, True
978
+ # #
979
+ # # return w_a, False
980
+ # # ----------------------------------------
981
+ # # TEST
982
+ # # def solve_with_choice(iz, w_seed):
983
+ # # # candidate roots at this (t,z)
984
+ # # cands = fd_candidates_w(z_list[iz], t, a_coeffs, w_min=w_min)
985
+ # #
986
+ # # # ---------------------
987
+ # # # TEST
988
+ # # if iz in (0, nz//2, nz-1):
989
+ # # ims = [float(w.imag) for w in cands]
990
+ # # print(f" iz={iz} ncand={len(cands)} Im(cands) min/med/max="
991
+ # # f"{(min(ims) if ims else None)}/"
992
+ # # f"{(numpy.median(ims) if ims else None)}/"
993
+ # # f"{(max(ims) if ims else None)}")
994
+ # # # ---------------------
995
+ # #
996
+ # # if len(cands) == 0:
997
+ # # # fallback to your existing single-root solver
998
+ # # w, success = fd_solve_w(
999
+ # # z_list[iz], t, a_coeffs, w_prev[iz],
1000
+ # # max_iter=max_iter, tol=tol, armijo=armijo,
1001
+ # # min_lam=min_lam, w_min=w_min
1002
+ # # )
1003
+ # # return w, success
1004
+ # #
1005
+ # # # cost = spatial continuity + time continuity (tune weights if needed)
1006
+ # # w_time = w_prev[iz]
1007
+ # # w_space = w_seed
1008
+ # # best = None
1009
+ # # best_cost = None
1010
+ # #
1011
+ # # for w in cands:
1012
+ # # # TEST
1013
+ # # # cost = abs(w - w_space) + 0.25 * abs(w - w_time)
1014
+ # # # TEST
1015
+ # # # prefer continuity, but also prefer larger Im(w) to stay on the bulk branch
1016
+ # # cost = abs(w - w_space) + 0.25 * abs(w - w_time) - 5.0 * w.imag
1017
+ # # # --------------
1018
+ # #
1019
+ # # if (best_cost is None) or (cost < best_cost):
1020
+ # # best = w
1021
+ # # best_cost = cost
1022
+ # #
1023
+ # # return best, True
1024
+ # # ----------------------------------------
1025
+ # # TEST
1026
+ # def solve_with_choice(iz, w_neighbor):
1027
+ # # Neighbor-seeded Newton (spatial continuity).
1028
+ # w_a, ok_a = fd_solve_w(
1029
+ # z_list[iz], t, a_coeffs, w_neighbor,
1030
+ # max_iter=max_iter, tol=tol, armijo=armijo,
1031
+ # min_lam=min_lam, w_min=w_min
1032
+ # )
1033
+ #
1034
+ # # Time-seeded Newton (time continuity).
1035
+ # w_b, ok_b = fd_solve_w(
1036
+ # z_list[iz], t, a_coeffs, w_prev[iz],
1037
+ # max_iter=max_iter, tol=tol, armijo=armijo,
1038
+ # min_lam=min_lam, w_min=w_min
1039
+ # )
1040
+ #
1041
+ # z_here = z_list[iz]
1042
+ # w_asymp = -1.0 / z_here # mass=1 Stieltjes asymptote
1043
+ #
1044
+ # def score(w):
1045
+ # # prefer time continuity + correct asymptote (stronger for large |z|)
1046
+ # return (
1047
+ # abs(w - w_prev[iz])
1048
+ # + 0.2 * abs(z_here) * abs(w - w_asymp)
1049
+ # )
1050
+ #
1051
+ # if ok_a and ok_b:
1052
+ # # hard reject neighbor result if it jumped in time
1053
+ # da = abs(w_a - w_prev[iz])
1054
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])):
1055
+ # return w_b, True
1056
+ #
1057
+ # return (w_a, True) if (score(w_a) <= score(w_b)) else (w_b, True)
1058
+ #
1059
+ # if ok_a:
1060
+ # # if only neighbor succeeded, still reject if it jumped badly
1061
+ # da = abs(w_a - w_prev[iz])
1062
+ # if da > time_rel_tol * (1.0 + abs(w_prev[iz])) and ok_b:
1063
+ # return w_b, True
1064
+ # return w_a, True
1065
+ #
1066
+ # if ok_b:
1067
+ # return w_b, True
1068
+ #
1069
+ # return w_a, False
1070
+ # # ----------------------------------------
1071
+ #
1072
+ # for it in range(1, nt):
1073
+ # t0 = float(t_grid[it - 1])
1074
+ # t1 = float(t_grid[it])
1075
+ # dt = t1 - t0
1076
+ # if dt <= 0.0:
1077
+ # raise ValueError("t_grid must be strictly increasing.")
1078
+ #
1079
+ # # Substep in time to keep continuation safe.
1080
+ # n_sub = int(numpy.ceil(dt / float(dt_max)))
1081
+ # if n_sub < 1:
1082
+ # n_sub = 1
1083
+ #
1084
+ # for ks in range(1, n_sub + 1):
1085
+ # t = t0 + dt * (ks / float(n_sub))
1086
+ #
1087
+ # w_row = numpy.empty(nz, dtype=complex)
1088
+ # ok_row = numpy.zeros(nz, dtype=bool)
1089
+ #
1090
+ # if not sweep:
1091
+ # # Independent solves: can miss edge activation in multi-bulk
1092
+ # # problems.
1093
+ # for iz in range(nz):
1094
+ # w, success = fd_solve_w(
1095
+ # z_list[iz], t, a_coeffs, w_prev[iz],
1096
+ # max_iter=max_iter, tol=tol, armijo=armijo,
1097
+ # min_lam=min_lam, w_min=w_min
1098
+ # )
1099
+ # w_row[iz] = w
1100
+ # ok_row[iz] = success
1101
+ #
1102
+ # w_prev = w_row
1103
+ # continue
1104
+ #
1105
+ # # Define "active" region from previous time: inside bulks
1106
+ # # Im(w_prev) is O(1), outside bulks Im(w_prev) is ~O(eta). Dilate
1107
+ # # by sweep_pad to allow edges to move.
1108
+ #
1109
+ # # ------------------------------
1110
+ # # TEST
1111
+ # # active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
1112
+ # # active_pad = active.copy()
1113
+ # # if sweep_pad > 0 and numpy.any(active):
1114
+ # # idx = numpy.flatnonzero(active)
1115
+ # # for i in idx:
1116
+ # # lo = 0 if (i - sweep_pad) < 0 else (i - sweep_pad)
1117
+ # # hi = \
1118
+ # # nz if (i + sweep_pad + 1) > nz else (i + sweep_pad + 1)
1119
+ # # active_pad[lo:hi] = True
1120
+ # # ------------------------------
1121
+ # # TEST
1122
+ # active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
1123
+ #
1124
+ # # Split active indices into contiguous blocks (bulks)
1125
+ # pad_label = -numpy.ones(nz, dtype=numpy.int64) # bulk id per index
1126
+ # active_pad = numpy.zeros(nz, dtype=bool)
1127
+ #
1128
+ # idx = numpy.flatnonzero(active)
1129
+ # if idx.size > 0:
1130
+ # cuts = numpy.where(numpy.diff(idx) > 1)[0]
1131
+ # blocks = numpy.split(idx, cuts + 1)
1132
+ #
1133
+ # # Build padded intervals + centers
1134
+ # centers = []
1135
+ # pads = []
1136
+ # for b in blocks:
1137
+ # centers.append(int((b[0] + b[-1]) // 2))
1138
+ # lo = int(max(0, b[0] - sweep_pad))
1139
+ # hi = int(min(nz - 1, b[-1] + sweep_pad))
1140
+ # pads.append((lo, hi))
1141
+ #
1142
+ # # Union of padded regions
1143
+ # for lo, hi in pads:
1144
+ # active_pad[lo:hi + 1] = True
1145
+ #
1146
+ # # Assign each padded index to the nearest bulk center (no overlap label)
1147
+ # idx_u = numpy.flatnonzero(active_pad)
1148
+ # c = numpy.asarray(centers, dtype=numpy.int64)
1149
+ # dist = numpy.abs(idx_u[:, None] - c[None, :])
1150
+ # winner = numpy.argmin(dist, axis=1).astype(numpy.int64)
1151
+ # pad_label[idx_u] = winner
1152
+ # # ------------------------------
1153
+ #
1154
+ # # ------------------------------
1155
+ # # TEST
1156
+ # def _ranges(idxs):
1157
+ # if idxs.size == 0:
1158
+ # return []
1159
+ # cuts = numpy.where(numpy.diff(idxs) > 1)[0]
1160
+ # blocks = numpy.split(idxs, cuts + 1)
1161
+ # return [(int(b[0]), int(b[-1])) for b in blocks]
1162
+ #
1163
+ # # print(" pad_label>=0 ranges:", _ranges(numpy.flatnonzero(pad_label >= 0)))
1164
+ # # print(" overlap(-2) ranges:", _ranges(numpy.flatnonzero(pad_label == -2)))
1165
+ # # ------------------------------
1166
+ #
1167
+ #
1168
+ #
1169
+ # # ----------------------------------------------
1170
+ #
1171
+ # # TEST
1172
+ # # eta = float(abs(z_list[0].imag))
1173
+ # #
1174
+ # # # Barrier: points that look like "gap" (tiny Im(w_prev))
1175
+ # # barrier_eps = 10.0 * eta # try 5*eta or 10*eta
1176
+ # # barrier = (numpy.abs(numpy.imag(w_prev)) <= barrier_eps)
1177
+ #
1178
+ #
1179
+ #
1180
+ # # TEST
1181
+ # # -------------------------
1182
+ # # --- diagnostics ---
1183
+ # active = (numpy.abs(numpy.imag(w_prev)) > active_imag_eps)
1184
+ # idx_active = numpy.flatnonzero(active)
1185
+ # idx_pad = numpy.flatnonzero(active_pad)
1186
+ #
1187
+ # def _ranges(idxs):
1188
+ # if idxs.size == 0:
1189
+ # return []
1190
+ # cuts = numpy.where(numpy.diff(idxs) > 1)[0]
1191
+ # blocks = numpy.split(idxs, cuts + 1)
1192
+ # return [(b[0], b[-1]) for b in blocks]
1193
+ #
1194
+ # # print(f"[t={t:.6g}] eta={z_list[0].imag:.2e} active_eps={active_imag_eps:.2e} "
1195
+ # # f"active_n={idx_active.size}/{nz} pad_n={idx_pad.size}/{nz} "
1196
+ # # f"active_ranges={_ranges(idx_active)} pad_ranges={_ranges(idx_pad)}")
1197
+ #
1198
+ # # Track the physical “gap” region around between bulks by looking at low Im(w_prev)
1199
+ # gap = numpy.abs(numpy.imag(w_prev)) <= 5.0 * z_list[0].imag
1200
+ # ig = numpy.flatnonzero(gap)
1201
+ # # if ig.size > 0:
1202
+ # # print(f" gap_ranges(Im<=5eta)={_ranges(ig)} "
1203
+ # # f"Im(w) min/med/max = {numpy.min(w_prev.imag):.3e}/"
1204
+ # # f"{numpy.median(w_prev.imag):.3e}/{numpy.max(w_prev.imag):.3e}")
1205
+ #
1206
+ # # ------------------
1207
+ #
1208
+ #
1209
+ #
1210
+ # # Left-to-right: use neighbor seed only within padded active
1211
+ # # regions, so we don't propagate a branch across the gap between
1212
+ # # bulks.
1213
+ # for iz in range(nz):
1214
+ # if iz == 0:
1215
+ # w_seed = w_prev[iz]
1216
+ # else:
1217
+ # # TEST
1218
+ # # if active_pad[iz] and active_pad[iz - 1]:
1219
+ # # w_seed = w_row[iz - 1]
1220
+ # # else:
1221
+ # # w_seed = w_prev[iz]
1222
+ # # TEST
1223
+ # # if (active_pad[iz] and active_pad[iz - 1] and
1224
+ # # (not barrier[iz]) and (not barrier[iz - 1])):
1225
+ # # w_seed = w_row[iz - 1]
1226
+ # # else:
1227
+ # # w_seed = w_prev[iz]
1228
+ # # ----------------------
1229
+ # # TEST
1230
+ # # if (active_pad[iz] and active_pad[iz - 1] and
1231
+ # # (pad_label[iz] == pad_label[iz - 1]) and
1232
+ # # (pad_label[iz] >= 0)):
1233
+ # # -----------------
1234
+ # # TEST
1235
+ # if (active_pad[iz] and active_pad[iz - 1] and
1236
+ # (pad_label[iz] == pad_label[iz - 1]) and
1237
+ # (pad_label[iz] >= 0)):
1238
+ # w_seed = w_row[iz - 1]
1239
+ # else:
1240
+ # w_seed = w_prev[iz]
1241
+ # # ----------------------
1242
+ #
1243
+ #
1244
+ # w_row[iz], ok_row[iz] = solve_with_choice(iz, w_seed)
1245
+ #
1246
+ # # Right-to-left refinement: helps stabilize left edges of bulks.
1247
+ # for iz in range(nz - 2, -1, -1):
1248
+ # # TEST
1249
+ # # if active_pad[iz] and active_pad[iz + 1]:
1250
+ # # TEST
1251
+ # # if (active_pad[iz] and active_pad[iz + 1] and
1252
+ # # (not barrier[iz]) and (not barrier[iz + 1])):
1253
+ # # TEST
1254
+ # # if (active_pad[iz] and active_pad[iz + 1] and
1255
+ # # (pad_label[iz] == pad_label[iz + 1]) and
1256
+ # # (pad_label[iz] >= 0)):
1257
+ # # TEST
1258
+ # if (active_pad[iz] and active_pad[iz + 1] and
1259
+ # (pad_label[iz] == pad_label[iz + 1]) and
1260
+ # (pad_label[iz] >= 0)):
1261
+ #
1262
+ # w_seed = w_row[iz + 1]
1263
+ # w_new, ok_new = solve_with_choice(iz, w_seed)
1264
+ # if ok_new:
1265
+ # # Keep the more time-consistent solution.
1266
+ # if (not ok_row[iz]) or (abs(w_new - w_prev[iz]) <
1267
+ # abs(w_row[iz] - w_prev[iz])):
1268
+ # w_row[iz] = w_new
1269
+ # ok_row[iz] = True
1270
+ #
1271
+ #
1272
+ #
1273
+ # # TEST
1274
+ # # print(f'solved_ok={ok_row.sum()}/{nz} (this substep)')
1275
+ #
1276
+ # w_prev = w_row
1277
+ #
1278
+ # W[it, :] = w_prev
1279
+ # ok[it, :] = ok_row
1280
+ #
1281
+ # return W, ok
1282
+
1283
+
1284
+ def eval_row_by_z_homotopy(
1285
+ t,
1286
+ z_targets,
1287
+ w_seed_targets,
1288
+ R,
1289
+ a_coeffs,
1290
+ w_anchor,
1291
+ *,
1292
+ steps=80,
1293
+ max_iter=50,
1294
+ tol=1e-12,
1295
+ armijo=1e-4,
1296
+ min_lam=1e-6,
1297
+ w_min=1e-14,
1298
+ ):
1299
+ """
1300
+ Evaluate w(t,z) on z_targets in C^+ by z-homotopy from z0=iR,
1301
+ but anchored at the TRUE w(t,z0)=w_anchor (computed separately).
1302
+
1303
+ Path is 2-segment:
1304
+ z0=iR -> x+iR -> x+i*eta
1305
+ """
1306
+ import numpy
1307
+
1308
+ z_targets = numpy.asarray(z_targets, dtype=numpy.complex128)
1309
+ w_seed_targets = numpy.asarray(w_seed_targets, dtype=numpy.complex128)
1310
+
1311
+ steps = int(steps)
1312
+ if steps < 2:
1313
+ steps = 2
1314
+
1315
+ z0 = 1j * float(R)
1316
+ eta_floor = float(abs(z_targets[0].imag))
1317
+ if eta_floor <= 0.0:
1318
+ eta_floor = 1e-6
1319
+
1320
+ w_out = numpy.empty(z_targets.size, dtype=numpy.complex128)
1321
+ ok_out = numpy.zeros(z_targets.size, dtype=bool)
1322
+
1323
+ def _pick(cands, z, w_ref):
1324
+ # Filter Herglotz for your convention: Im(w)>0 on C^+
1325
+ cpos = [u for u in cands if u.imag > 0.0]
1326
+ if cpos:
1327
+ cands = cpos
1328
+
1329
+ # Continuity + asymptotic-at-infinity preference (mass=1): w*z ~ -1
1330
+ # This is CRITICAL to avoid choosing the wrong Herglotz-looking sheet.
1331
+ best = None
1332
+ best_cost = None
1333
+ for u in cands:
1334
+ cost = abs(u - w_ref) + 1.0 * abs(u * z + 1.0)
1335
+ if (best_cost is None) or (cost < best_cost):
1336
+ best = u
1337
+ best_cost = cost
1338
+ return best
1339
+
1340
+ for k in range(z_targets.size):
1341
+ zT = z_targets[k]
1342
+ xT = float(zT.real)
1343
+
1344
+ zA = complex(xT, float(R)) # horizontal leg endpoint
1345
+ zB = complex(xT, eta_floor) # final point (vertical down)
1346
+
1347
+ w = w_anchor
1348
+ ok = True
1349
+
1350
+ # ---- segment 1: z0 -> zA (horizontal at imag=R) ----
1351
+ for j in range(1, steps + 1):
1352
+ s = j / float(steps)
1353
+ z = z0 + s * (zA - z0)
1354
+
1355
+ w_new, ok_new = fd_solve_w(
1356
+ z, t, a_coeffs, w,
1357
+ max_iter=max_iter, tol=tol, armijo=armijo,
1358
+ min_lam=min_lam, w_min=w_min
1359
+ )
1360
+
1361
+ if not ok_new:
1362
+ cands = fd_candidates_w(z, t, a_coeffs, w_min=w_min)
1363
+ if cands:
1364
+ w_new = _pick(cands, z, w)
1365
+ ok_new = (w_new is not None)
1366
+
1367
+ if not ok_new:
1368
+ ok = False
1369
+ break
1370
+ w = w_new
1371
+
1372
+ # ---- segment 2: zA -> zB (vertical down at fixed real=xT) ----
1373
+ if ok:
1374
+ for j in range(1, steps + 1):
1375
+ s = j / float(steps)
1376
+ z = zA + s * (zB - zA)
1377
+
1378
+ w_new, ok_new = fd_solve_w(
1379
+ z, t, a_coeffs, w,
1380
+ max_iter=max_iter, tol=tol, armijo=armijo,
1381
+ min_lam=min_lam, w_min=w_min
1382
+ )
1383
+
1384
+ if not ok_new:
1385
+ cands = fd_candidates_w(z, t, a_coeffs, w_min=w_min)
1386
+ if cands:
1387
+ w_new = _pick(cands, z, w)
1388
+ ok_new = (w_new is not None)
1389
+
1390
+ if not ok_new:
1391
+ ok = False
1392
+ break
1393
+ w = w_new
1394
+
1395
+ w_out[k] = w
1396
+
1397
+ if not ok:
1398
+ # fallback at zT: prefer continuity to the provided per-z time seed
1399
+ cands = fd_candidates_w(zT, t, a_coeffs, w_min=w_min)
1400
+ if cands:
1401
+ w_out[k] = _pick(cands, zT, w_seed_targets[k])
1402
+ ok_out[k] = (w_out[k] is not None)
1403
+ if not ok_out[k]:
1404
+ w_out[k] = w_seed_targets[k]
1405
+ else:
1406
+ w_out[k] = w_seed_targets[k]
1407
+ ok_out[k] = False
1408
+ else:
1409
+ ok_out[k] = True
1410
+
1411
+ return w_out, ok_out
1412
+ def decompress_newton(
1413
+ z_list,
1414
+ t_grid,
1415
+ a_coeffs,
1416
+ w0_list=None,
1417
+ *,
1418
+ R=400.0,
1419
+ z_hom_steps=160,
1420
+ eta_track=1e-3, # IMPORTANT: track branches at this safe height
1421
+ eta_steps=40, # vertical homotopy steps down to target imag
1422
+ max_iter=50,
1423
+ tol=1e-12,
1424
+ armijo=1e-4,
1425
+ min_lam=1e-6,
1426
+ w_min=1e-14,
1427
+ **_unused_kwargs,
1428
+ ):
1429
+ """
1430
+ Robust FD solver:
1431
+ (A) For each t, compute w(x+i*eta_track) by z-homotopy from z0=iR.
1432
+ (B) For each x, descend vertically from eta_track to target imag (typically 1e-5)
1433
+ using continuation in eta (vertical homotopy).
1434
+ This prevents multi-bulk cutoffs caused by trying to track directly at tiny eta.
1435
+ """
1436
+ import numpy
1437
+
1438
+ z_list = numpy.asarray(z_list, dtype=numpy.complex128)
1439
+ t_grid = numpy.asarray(t_grid, dtype=float)
1440
+ nz = z_list.size
1441
+ nt = t_grid.size
1442
+
1443
+ if numpy.any(numpy.diff(t_grid) <= 0.0):
1444
+ raise ValueError("t_grid must be strictly increasing.")
1445
+
1446
+ x_list = z_list.real
1447
+ eta_target = float(z_list.imag.max()) # your z_query uses constant imag
1448
+ if eta_target <= 0.0:
1449
+ raise ValueError("This solver assumes z_list is in C^+ (imag>0).")
1450
+
1451
+ eta_track = float(max(eta_track, 10.0 * eta_target)) # ensure track height is above target
1452
+
1453
+ # -----------------
1454
+ # damped Newton solve
1455
+ # -----------------
1456
+ def solve_w_newton(z, t, w_init):
1457
+ z = complex(z)
1458
+ w = complex(w_init)
1459
+
1460
+ tau = float(numpy.exp(t))
1461
+ alpha = 1.0 - 1.0 / tau
1462
+
1463
+ # Herglotz for your convention: Im(w)>0 for z in C^+
1464
+ if w.imag <= 0.0:
1465
+ w = complex(w.real, max(1e-15, abs(w.imag)))
1466
+
1467
+ for _ in range(max_iter):
1468
+ if (not numpy.isfinite(w.real)) or (not numpy.isfinite(w.imag)):
1469
+ return w, False
1470
+ if abs(w) < w_min:
1471
+ return w, False
1472
+ if w.imag <= 0.0:
1473
+ return w, False
1474
+
1475
+ zeta = z + alpha / w
1476
+ y = tau * w
1477
+ F, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
1478
+ F = complex(F)
1479
+ if abs(F) <= tol:
1480
+ return w, True
1481
+
1482
+ dF = (-alpha / (w * w)) * complex(Pz) + tau * complex(Py)
1483
+ if (dF == 0.0) or (not numpy.isfinite(dF.real)) or (not numpy.isfinite(dF.imag)):
1484
+ return w, False
1485
+
1486
+ step = -F / dF
1487
+ F_abs = abs(F)
1488
+
1489
+ lam = 1.0
1490
+ ok = False
1491
+ while lam >= min_lam:
1492
+ w_new = w + lam * step
1493
+ if (not numpy.isfinite(w_new.real)) or (not numpy.isfinite(w_new.imag)):
1494
+ lam *= 0.5
1495
+ continue
1496
+ if abs(w_new) < w_min:
1497
+ lam *= 0.5
1498
+ continue
1499
+ if w_new.imag <= 0.0:
1500
+ lam *= 0.5
1501
+ continue
1502
+
1503
+ zeta_new = z + alpha / w_new
1504
+ y_new = tau * w_new
1505
+ F_new = complex(eval_P_partials(zeta_new, y_new, a_coeffs)[0])
1506
+
1507
+ if abs(F_new) <= (1.0 - armijo * lam) * F_abs:
1508
+ w = w_new
1509
+ ok = True
1510
+ break
1511
+ lam *= 0.5
1512
+
1513
+ if not ok:
1514
+ return w, False
1515
+
1516
+ # accept if residual not crazy
1517
+ zeta = z + alpha / w
1518
+ y = tau * w
1519
+ F_end = complex(eval_P_partials(zeta, y, a_coeffs)[0])
1520
+ return w, (abs(F_end) <= 1e3 * tol)
1521
+
1522
+ # -----------------------
1523
+ # (A) z-homotopy at safe eta
1524
+ # -----------------------
1525
+ def row_by_z_homotopy_at_eta(t, w_anchor_prev):
1526
+ z0 = 1j * float(R)
1527
+
1528
+ # anchor solve at far point (use previous anchor in time)
1529
+ if w_anchor_prev is None:
1530
+ w0_seed = -1.0 / z0
1531
+ else:
1532
+ w0_seed = w_anchor_prev
1533
+
1534
+ w_anchor, ok_anchor = solve_w_newton(z0, t, w0_seed)
1535
+ if not ok_anchor:
1536
+ return None, None, False
1537
+
1538
+ w_row = numpy.empty(nz, dtype=numpy.complex128)
1539
+ ok_row = numpy.ones(nz, dtype=bool)
1540
+
1541
+ for iz in range(nz):
1542
+ zt = complex(x_list[iz], eta_track)
1543
+ dz = zt - z0
1544
+ w = w_anchor
1545
+
1546
+ for k in range(1, int(z_hom_steps) + 1):
1547
+ s = k / float(z_hom_steps)
1548
+ z = z0 + s * dz
1549
+
1550
+ # enforce imag floor at eta_track (never go near the real axis here)
1551
+ if z.imag < eta_track:
1552
+ z = complex(z.real, eta_track)
1553
+
1554
+ w, ok = solve_w_newton(z, t, w)
1555
+ if not ok:
1556
+ ok_row[iz] = False
1557
+ break
1558
+
1559
+ w_row[iz] = w if ok_row[iz] else (numpy.nan + 1j * numpy.nan)
1560
+
1561
+ return w_anchor, w_row, bool(ok_row.all())
1562
+
1563
+ # -----------------------
1564
+ # (B) vertical homotopy: eta_track -> eta_target
1565
+ # -----------------------
1566
+ def descend_in_eta(t, w_track_row):
1567
+ w_out = numpy.empty(nz, dtype=numpy.complex128)
1568
+ ok_out = numpy.ones(nz, dtype=bool)
1569
+
1570
+ if eta_steps <= 0 or eta_track <= eta_target:
1571
+ # no descent requested
1572
+ for iz in range(nz):
1573
+ # one final polish at target imag
1574
+ zt = complex(x_list[iz], eta_target)
1575
+ w, ok = solve_w_newton(zt, t, w_track_row[iz])
1576
+ w_out[iz] = w
1577
+ ok_out[iz] = ok
1578
+ return w_out, bool(ok_out.all())
1579
+
1580
+ for iz in range(nz):
1581
+ w = w_track_row[iz]
1582
+ ok = True
1583
+
1584
+ # linear schedule in imag
1585
+ for k in range(1, int(eta_steps) + 1):
1586
+ eta = eta_track + (eta_target - eta_track) * (k / float(eta_steps))
1587
+ z = complex(x_list[iz], eta)
1588
+ w, ok = solve_w_newton(z, t, w)
1589
+ if not ok:
1590
+ break
1591
+
1592
+ w_out[iz] = w
1593
+ ok_out[iz] = ok
1594
+
1595
+ return w_out, bool(ok_out.all())
1596
+
1597
+ # -----------------------
1598
+ # main time loop
1599
+ # -----------------------
1600
+ if w0_list is None:
1601
+ w_prev = -1.0 / z_list
1602
+ else:
1603
+ w_prev = numpy.asarray(w0_list, dtype=numpy.complex128).copy()
1604
+
1605
+ W = numpy.empty((nt, nz), dtype=numpy.complex128)
1606
+ OK = numpy.zeros((nt, nz), dtype=bool)
1607
+
1608
+ W[0, :] = w_prev
1609
+ OK[0, :] = True
1610
+
1611
+ w_anchor_prev = None
1612
+
1613
+ for it in range(1, nt):
1614
+ t = float(t_grid[it])
1615
+
1616
+ w_anchor_prev, w_track_row, ok_track = row_by_z_homotopy_at_eta(t, w_anchor_prev)
1617
+ if (not ok_track) or (w_track_row is None):
1618
+ # fallback: keep previous time
1619
+ W[it, :] = w_prev
1620
+ OK[it, :] = False
1621
+ continue
1622
+
1623
+ w_row, ok_row = descend_in_eta(t, w_track_row)
1624
+
1625
+ W[it, :] = w_row
1626
+ OK[it, :] = ok_row
1627
+ w_prev = w_row
1628
+
1629
+ return W, OK
1630
+
1631
+