freealg 0.7.12__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.
@@ -0,0 +1,320 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026, Siavash Ameli
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # SPDX-FileType: SOURCE
4
+ #
5
+ # Robust Stieltjes branch evaluation for algebraic P(z,m)=0.
6
+ #
7
+ # This version is tailored for empirical polynomial fits where spurious
8
+ # small-Im roots can appear outside the true support and create fake bulks
9
+ # when using rho = Im m(x+i*eta)/pi.
10
+ #
11
+ # Core idea: pick the physical branch by combining
12
+ # (i) Herglotz half-plane constraint,
13
+ # (ii) asymptotic constraint z*m ~ -1,
14
+ # (iii) 1D continuity along x (Viterbi),
15
+ # and DO NOT globally reward large |Im(m)| (which can fabricate density).
16
+
17
+ import numpy
18
+
19
+ __all__ = ["StieltjesPoly"]
20
+
21
+
22
+ # =========================
23
+ # Poly -> roots in m for z
24
+ # =========================
25
+
26
+ def _poly_m_coeffs(a_coeffs, z):
27
+ """Return coefficients b_j for \sum_j b_j m^j = 0 at fixed z.
28
+
29
+ a_coeffs[i,j] is coeff of z^i m^j.
30
+ Returns b of length (deg_m+1) with b[j] = \sum_i a[i,j] z^i.
31
+ """
32
+ a = numpy.asarray(a_coeffs, dtype=numpy.complex128)
33
+ deg_z = a.shape[0] - 1
34
+ deg_m = a.shape[1] - 1
35
+
36
+ # Horner in z for each m-power j
37
+ z = complex(z)
38
+ b = numpy.zeros((deg_m + 1,), dtype=numpy.complex128)
39
+ # b[j] = a[0,j] + a[1,j] z + ... + a[deg_z,j] z^{deg_z}
40
+ # do Horner: (((a[deg_z,j] z + a[deg_z-1,j]) z + ...) z + a[0,j])
41
+ for j in range(deg_m + 1):
42
+ acc = 0.0 + 0.0j
43
+ for i in range(deg_z, -1, -1):
44
+ acc = acc * z + a[i, j]
45
+ b[j] = acc
46
+
47
+ return b
48
+
49
+
50
+ def _roots_m(a_coeffs, z):
51
+ """All algebraic roots in m at fixed z."""
52
+ b = _poly_m_coeffs(a_coeffs, z)
53
+
54
+ # Drop leading zeros in highest power to keep numpy.roots stable
55
+ # numpy.roots expects highest degree first.
56
+ coeffs = b.copy()
57
+ # find highest nonzero index
58
+ nz = numpy.flatnonzero(numpy.abs(coeffs) > 0.0)
59
+ if nz.size == 0:
60
+ return numpy.array([], dtype=numpy.complex128)
61
+ j_max = int(nz.max())
62
+ coeffs = coeffs[: j_max + 1]
63
+
64
+ # reverse to highest-first
65
+ return numpy.roots(coeffs[::-1])
66
+
67
+
68
+ # ==============================
69
+ # Physical root selection scalar
70
+ # ==============================
71
+
72
+ def _pick_physical_root_scalar(z, roots, target=None, tol_im=1e-12):
73
+ """Pick the physical root among candidates for a single z.
74
+
75
+ Rules:
76
+ 1) Prefer roots with sign(Im m) = sign(Im z) (Herglotz).
77
+ 2) Break ties by asymptotic closeness to -1/z.
78
+ 3) If target provided, also enforce continuity by closeness to target.
79
+
80
+ Returns complex root.
81
+ """
82
+ z = complex(z)
83
+ roots = numpy.asarray(roots, dtype=numpy.complex128).ravel()
84
+ if roots.size == 0:
85
+ return numpy.nan + 1j * numpy.nan
86
+
87
+ s = numpy.sign(z.imag)
88
+ if s == 0.0:
89
+ s = 1.0
90
+
91
+ im_s = numpy.imag(roots) * s
92
+ cand = roots[im_s > -tol_im]
93
+ if cand.size == 0:
94
+ cand = roots
95
+
96
+ # asymptotic target
97
+ m_asym = -1.0 / z
98
+
99
+ if target is None:
100
+ score = numpy.abs(cand - m_asym)
101
+ else:
102
+ t = complex(target)
103
+ # continuity dominates, asymptotic helps in ambiguous regions
104
+ score = numpy.abs(cand - t) + 0.15 * numpy.abs(cand - m_asym)
105
+
106
+ return cand[int(numpy.argmin(score))]
107
+
108
+
109
+ # =====================
110
+ # Viterbi along a line
111
+ # =====================
112
+
113
+ def _viterbi_track(z_list, roots_list, mL, mR, *,
114
+ lam_space=1.0,
115
+ lam_edge=20.0,
116
+ lam_asym=0.25,
117
+ lam_time=0.0,
118
+ # Hinge penalty against "tiny-im" traps ONLY
119
+ lam_tiny_im=0.0,
120
+ tiny_im=1e-7,
121
+ tol_im=1e-12,
122
+ m_prev=None):
123
+ """Choose one root per z via dynamic programming.
124
+
125
+ z_list: (nz,)
126
+ roots_list: list of arrays of candidate roots, each (k_i,)
127
+ mL,mR: boundary anchors (complex)
128
+ m_prev: optional previous-time chosen path, shape (nz,)
129
+
130
+ Returns m_path (nz,) and ok (nz,) boolean indicating finite.
131
+ """
132
+ z_list = numpy.asarray(z_list, dtype=numpy.complex128).ravel()
133
+ nz = z_list.size
134
+
135
+ # Determine a fixed K by padding with NaNs (max roots)
136
+ K = max((r.size for r in roots_list), default=0)
137
+ if K == 0:
138
+ return (numpy.full((nz,), numpy.nan + 1j * numpy.nan, dtype=numpy.complex128),
139
+ numpy.zeros((nz,), dtype=bool))
140
+
141
+ R = numpy.full((nz, K), numpy.nan + 1j * numpy.nan, dtype=numpy.complex128)
142
+ for i, r in enumerate(roots_list):
143
+ if r.size:
144
+ R[i, : r.size] = r
145
+
146
+ # feasible mask: finite and Herglotz half-plane (soft)
147
+ s = numpy.sign(z_list.imag)
148
+ s[s == 0.0] = 1.0
149
+ IM = (numpy.imag(R) * s[:, None])
150
+ feasible = numpy.isfinite(R) & (IM > -tol_im)
151
+
152
+ # unary cost
153
+ unary = numpy.full((nz, K), numpy.inf, dtype=numpy.float64)
154
+ # continuity to asymptotic (-1/z) discourages fake branches outside support
155
+ m_asym = -1.0 / z_list
156
+
157
+ for i in range(nz):
158
+ zi = z_list[i]
159
+ mi = m_asym[i]
160
+ for k in range(K):
161
+ if not feasible[i, k]:
162
+ continue
163
+ w = R[i, k]
164
+ c = 0.0
165
+
166
+ # asymptotic penalty (small weight)
167
+ if lam_asym != 0.0:
168
+ c += float(lam_asym) * float(numpy.abs(zi * w + 1.0))
169
+
170
+ # optional hinge against tiny imag (ONLY below threshold)
171
+ if lam_tiny_im != 0.0:
172
+ im = abs(w.imag)
173
+ floor = max(float(tiny_im), 0.25 * abs(zi.imag))
174
+ if im < floor:
175
+ c += float(lam_tiny_im) * float(((floor / max(im, 1e-16)) - 1.0) ** 2)
176
+
177
+ # optional time consistency
178
+ if (lam_time != 0.0) and (m_prev is not None) and numpy.isfinite(m_prev[i]):
179
+ c += float(lam_time) * float(numpy.abs(w - m_prev[i]))
180
+
181
+ unary[i, k] = c
182
+
183
+ # boundary anchors
184
+ if numpy.isfinite(mL):
185
+ unary[0, :] += float(lam_edge) * numpy.abs(R[0, :] - mL)
186
+ if numpy.isfinite(mR):
187
+ unary[-1, :] += float(lam_edge) * numpy.abs(R[-1, :] - mR)
188
+
189
+ # pairwise cost
190
+ dp = numpy.full((nz, K), numpy.inf, dtype=numpy.float64)
191
+ prev = numpy.full((nz, K), -1, dtype=numpy.int64)
192
+
193
+ dp[0, :] = unary[0, :]
194
+
195
+ for i in range(1, nz):
196
+ for k in range(K):
197
+ if not numpy.isfinite(unary[i, k]):
198
+ continue
199
+ # transition from any j
200
+ best_val = numpy.inf
201
+ best_j = -1
202
+ wk = R[i, k]
203
+ for j in range(K):
204
+ if not numpy.isfinite(dp[i - 1, j]):
205
+ continue
206
+ wj = R[i - 1, j]
207
+ if not numpy.isfinite(wj):
208
+ continue
209
+ val = dp[i - 1, j] + float(lam_space) * float(numpy.abs(wk - wj))
210
+ if val < best_val:
211
+ best_val = val
212
+ best_j = j
213
+ if best_j >= 0:
214
+ dp[i, k] = best_val + unary[i, k]
215
+ prev[i, k] = best_j
216
+
217
+ # backtrack
218
+ k_end = int(numpy.argmin(dp[-1, :]))
219
+ m_path = numpy.full((nz,), numpy.nan + 1j * numpy.nan, dtype=numpy.complex128)
220
+ if not numpy.isfinite(dp[-1, k_end]):
221
+ return m_path, numpy.zeros((nz,), dtype=bool)
222
+
223
+ k = k_end
224
+ for i in range(nz - 1, -1, -1):
225
+ m_path[i] = R[i, k]
226
+ k = prev[i, k]
227
+ if (i > 0) and (k < 0):
228
+ # cannot continue
229
+ break
230
+
231
+ ok = numpy.isfinite(m_path)
232
+ return m_path, ok
233
+
234
+
235
+ # ============
236
+ # StieltjesPoly
237
+ # ============
238
+
239
+ class StieltjesPoly(object):
240
+ """Callable m(z) for P(z,m)=0 using robust branch selection."""
241
+
242
+ def __init__(self, a_coeffs, *,
243
+ viterbi_opt=None):
244
+ self.a_coeffs = numpy.asarray(a_coeffs, dtype=numpy.complex128)
245
+ self.viterbi_opt = dict(viterbi_opt or {})
246
+
247
+ # ----------
248
+ # scalar eval
249
+ # ----------
250
+
251
+ def evaluate_scalar(self, z, target=None):
252
+ r = _roots_m(self.a_coeffs, z)
253
+ return _pick_physical_root_scalar(z, r, target=target)
254
+
255
+ # ---------------
256
+ # vectorized eval
257
+ # ---------------
258
+
259
+ def __call__(self, z):
260
+ z = numpy.asarray(z, dtype=numpy.complex128)
261
+ scalar = (z.ndim == 0)
262
+ if scalar:
263
+ z = z.reshape((1,))
264
+
265
+ # If 1D, do Viterbi tracking in the given order
266
+ if z.ndim == 1:
267
+ z_list = z.ravel()
268
+
269
+ # roots for each point
270
+ roots_list = [_roots_m(self.a_coeffs, zi) for zi in z_list]
271
+
272
+ # boundary anchors via scalar selection
273
+ mL = self.evaluate_scalar(z_list[0])
274
+ mR = self.evaluate_scalar(z_list[-1])
275
+
276
+ opt = {
277
+ "lam_space": 1.0,
278
+ "lam_edge": 20.0,
279
+ "lam_asym": 0.25,
280
+ "lam_time": 0.0,
281
+ "lam_tiny_im": 0.0,
282
+ "tiny_im": 1e-7,
283
+ "tol_im": 1e-12,
284
+ }
285
+ opt.update(self.viterbi_opt)
286
+
287
+ m_path, ok = _viterbi_track(
288
+ z_list,
289
+ roots_list,
290
+ mL,
291
+ mR,
292
+ lam_space=opt["lam_space"],
293
+ lam_edge=opt["lam_edge"],
294
+ lam_asym=opt["lam_asym"],
295
+ lam_time=opt["lam_time"],
296
+ lam_tiny_im=opt["lam_tiny_im"],
297
+ tiny_im=opt["tiny_im"],
298
+ tol_im=opt["tol_im"],
299
+ m_prev=None,
300
+ )
301
+
302
+ # fallback pointwise for any failures
303
+ if not numpy.all(ok):
304
+ out = m_path.copy()
305
+ for i in numpy.flatnonzero(~ok):
306
+ out[i] = self.evaluate_scalar(z_list[i], target=None)
307
+ m_path = out
308
+
309
+ out = m_path.reshape(z.shape)
310
+ return out.reshape(()) if scalar else out
311
+
312
+ # For nd>1: evaluate pointwise with asymptotic tie-break
313
+ out = numpy.empty(z.size, dtype=numpy.complex128)
314
+ zf = z.ravel()
315
+ prev = None
316
+ for i in range(zf.size):
317
+ out[i] = self.evaluate_scalar(zf[i], target=prev)
318
+ prev = out[i]
319
+ out = out.reshape(z.shape)
320
+ return out.reshape(()) if scalar else out
@@ -0,0 +1,185 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026, Siavash Ameli
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # SPDX-FileType: SOURCE
4
+ #
5
+ # Robust Stieltjes branch evaluation for algebraic P(z,m)=0 using
6
+ # global 1D dynamic programming (Viterbi) along a complex line.
7
+
8
+ import numpy
9
+
10
+
11
+ def _poly_coeffs_in_m(a_coeffs, z):
12
+ a = a_coeffs
13
+ dz = a.shape[0] - 1
14
+ s = a.shape[1] - 1
15
+ zp = numpy.array([z**i for i in range(dz + 1)], dtype=numpy.complex128)
16
+ coeff_m = numpy.empty(s + 1, dtype=numpy.complex128)
17
+ for j in range(s + 1):
18
+ coeff_m[j] = numpy.dot(a[:, j], zp)
19
+ return coeff_m
20
+
21
+
22
+ def _roots_m(a_coeffs, z):
23
+ coeff_m = _poly_coeffs_in_m(a_coeffs, z)
24
+ c = coeff_m[::-1]
25
+ while c.size > 1 and numpy.abs(c[0]) == 0:
26
+ c = c[1:]
27
+ if c.size <= 1:
28
+ return numpy.array([], dtype=numpy.complex128)
29
+ return numpy.roots(c)
30
+
31
+
32
+ def _pick_anchor(z, roots, tol_im, lam_asym):
33
+ if roots.size == 0:
34
+ return numpy.nan + 1j * numpy.nan
35
+ sgn = 1.0 if numpy.imag(z) >= 0 else -1.0
36
+ ok = (sgn * numpy.imag(roots) > tol_im)
37
+ if numpy.any(ok):
38
+ cand = roots[ok]
39
+ else:
40
+ cand = roots
41
+ cost = lam_asym * numpy.abs(z * cand + 1.0)
42
+ return cand[int(numpy.argmin(cost))]
43
+
44
+
45
+ def _viterbi_1d(z_list, roots_all, *, lam_space, lam_asym,
46
+ lam_tiny_im, tiny_im, tol_im):
47
+ n, s = roots_all.shape
48
+ big = 1.0e300
49
+
50
+ cost0 = numpy.zeros((n, s), dtype=float)
51
+ back = numpy.zeros((n, s), dtype=numpy.int64)
52
+ dp = numpy.full((n, s), big, dtype=float)
53
+
54
+ for k in range(n):
55
+ z = z_list[k]
56
+ r = roots_all[k]
57
+ sgn = 1.0 if numpy.imag(z) >= 0 else -1.0
58
+
59
+ ok = (sgn * numpy.imag(r) > tol_im)
60
+ cost0[k, ~ok] += big * 1.0e-6
61
+
62
+ if lam_tiny_im != 0.0 and tiny_im is not None:
63
+ imabs = numpy.abs(numpy.imag(r))
64
+ hing = numpy.maximum(0.0, float(tiny_im) - imabs)
65
+ cost0[k] += lam_tiny_im * hing
66
+
67
+ if lam_asym != 0.0:
68
+ cost0[k] += lam_asym * numpy.abs(z * r + 1.0)
69
+
70
+ m0 = _pick_anchor(z_list[0], roots_all[0], tol_im, lam_asym)
71
+ mN = _pick_anchor(z_list[-1], roots_all[-1], tol_im, lam_asym)
72
+
73
+ init = cost0[0] + lam_space * numpy.abs(roots_all[0] - m0)
74
+ dp[0] = init
75
+
76
+ for k in range(1, n):
77
+ r = roots_all[k]
78
+ rp = roots_all[k - 1]
79
+ for j in range(s):
80
+ trans = dp[k - 1] + lam_space * numpy.abs(r[j] - rp)
81
+ idx = int(numpy.argmin(trans))
82
+ dp[k, j] = trans[idx] + cost0[k, j]
83
+ back[k, j] = idx
84
+
85
+ last = dp[-1] + lam_space * numpy.abs(roots_all[-1] - mN)
86
+ jn = int(numpy.argmin(last))
87
+
88
+ path = numpy.empty(n, dtype=numpy.complex128)
89
+ for k in range(n - 1, -1, -1):
90
+ path[k] = roots_all[k, jn]
91
+ if k > 0:
92
+ jn = int(back[k, jn])
93
+ return path
94
+
95
+
96
+ class StieltjesPoly(object):
97
+ """Callable m(z) for P(z,m)=0 using robust branch selection."""
98
+
99
+ def __init__(self, a_coeffs, *, viterbi_opt=None):
100
+ self.a_coeffs = numpy.asarray(a_coeffs, dtype=numpy.complex128)
101
+ self.viterbi_opt = dict(viterbi_opt or {})
102
+
103
+ def evaluate_scalar(self, z, target=None):
104
+ r = _roots_m(self.a_coeffs, z)
105
+ if r.size == 0:
106
+ return numpy.nan + 1j * numpy.nan
107
+ tol_im = float(self.viterbi_opt.get("tol_im", 1e-14))
108
+ lam_asym = float(self.viterbi_opt.get("lam_asym", 1.0))
109
+ sgn = 1.0 if numpy.imag(z) >= 0 else -1.0
110
+ ok = (sgn * numpy.imag(r) > tol_im)
111
+ cand = r[ok] if numpy.any(ok) else r
112
+ cost = lam_asym * numpy.abs(z * cand + 1.0)
113
+ if target is not None and numpy.isfinite(target):
114
+ lam_space = float(self.viterbi_opt.get("lam_space", 1.0))
115
+ cost = cost + lam_space * numpy.abs(cand - target)
116
+ return cand[int(numpy.argmin(cost))]
117
+
118
+ def __call__(self, z):
119
+ z = numpy.asarray(z, dtype=numpy.complex128)
120
+ scalar = (z.ndim == 0)
121
+ if scalar:
122
+ z = z.reshape((1,))
123
+
124
+ if z.ndim == 1 and z.size >= 2:
125
+ z_list = z.ravel()
126
+ s = self.a_coeffs.shape[1] - 1
127
+ roots_all = numpy.empty((z_list.size, s), dtype=numpy.complex128)
128
+ ok_all = numpy.ones(z_list.size, dtype=bool)
129
+ for k in range(z_list.size):
130
+ r = _roots_m(self.a_coeffs, z_list[k])
131
+ if r.size != s:
132
+ ok_all[k] = False
133
+ if r.size == 0:
134
+ roots_all[k] = numpy.nan + 1j * numpy.nan
135
+ elif r.size < s:
136
+ rr = numpy.empty(s, dtype=numpy.complex128)
137
+ rr[:] = numpy.nan + 1j * numpy.nan
138
+ rr[:r.size] = r
139
+ roots_all[k] = rr
140
+ else:
141
+ roots_all[k] = r[:s]
142
+ else:
143
+ roots_all[k] = r
144
+
145
+ opt = {
146
+ "lam_space": 1.0,
147
+ "lam_asym": 1.0,
148
+ "lam_tiny_im": 200.0,
149
+ "tiny_im": None,
150
+ "tol_im": 1e-14,
151
+ }
152
+ opt.update(self.viterbi_opt)
153
+
154
+ if opt["tiny_im"] is None:
155
+ opt["tiny_im"] = 0.5 * numpy.abs(numpy.imag(z_list[0]))
156
+
157
+ m_path = _viterbi_1d(
158
+ z_list, roots_all,
159
+ lam_space=float(opt["lam_space"]),
160
+ lam_asym=float(opt["lam_asym"]),
161
+ lam_tiny_im=float(opt["lam_tiny_im"]),
162
+ tiny_im=float(opt["tiny_im"]),
163
+ tol_im=float(opt["tol_im"]),
164
+ )
165
+
166
+ if not numpy.all(ok_all):
167
+ out = m_path.copy()
168
+ prev = None
169
+ for i in range(z_list.size):
170
+ if not ok_all[i] or not numpy.isfinite(out[i]):
171
+ out[i] = self.evaluate_scalar(z_list[i], target=prev)
172
+ prev = out[i]
173
+ m_path = out
174
+
175
+ out = m_path.reshape(z.shape)
176
+ return out.reshape(()) if scalar else out
177
+
178
+ out = numpy.empty(z.size, dtype=numpy.complex128)
179
+ zf = z.ravel()
180
+ prev = None
181
+ for i in range(zf.size):
182
+ out[i] = self.evaluate_scalar(zf[i], target=prev)
183
+ prev = out[i]
184
+ out = out.reshape(z.shape)
185
+ return out.reshape(()) if scalar else out
@@ -248,7 +248,6 @@ class Moments(object):
248
248
  a_n = self.coeffs(n)
249
249
  k = numpy.arange(n, dtype=float)
250
250
  return float(numpy.dot(a_n, numpy.exp(k * t)))
251
-
252
251
 
253
252
 
254
253
  # ===========================