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,431 @@
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
+ # Imports
11
+ # =======
12
+
13
+ import numpy
14
+ from ._continuation_algebraic import powers
15
+
16
+ __all__ = ['build_time_grid', 'decompress_newton']
17
+
18
+
19
+ # ===============
20
+ # build time grid
21
+ # ===============
22
+
23
+ def build_time_grid(sizes, n0, min_n_times=0):
24
+ """
25
+ Build a monotone time grid t for sizes/n0 = exp(t).
26
+
27
+ Returns
28
+ -------
29
+ t_all : ndarray
30
+ Sorted time grid (includes t=0 and all requested times).
31
+ idx_req : ndarray
32
+ Indices into t_all for the requested times (same order as sizes).
33
+ """
34
+ sizes = numpy.asarray(sizes, dtype=float)
35
+ alpha = sizes / float(n0)
36
+ t_req = numpy.log(alpha)
37
+
38
+ T = float(numpy.max(t_req)) if t_req.size else 0.0
39
+ base = numpy.unique(numpy.r_[0.0, t_req, T])
40
+ t_all = numpy.sort(base)
41
+
42
+ N = int(min_n_times) if min_n_times is not None else 0
43
+ while t_all.size < N and t_all.size >= 2:
44
+ gaps = numpy.diff(t_all)
45
+ k = int(numpy.argmax(gaps))
46
+ mid = 0.5 * (t_all[k] + t_all[k + 1])
47
+ t_all = numpy.sort(numpy.unique(numpy.r_[t_all, mid]))
48
+
49
+ idx_req = numpy.searchsorted(t_all, t_req)
50
+ return t_all, idx_req
51
+
52
+
53
+ # ===============
54
+ # eval P partials
55
+ # ===============
56
+
57
+ def eval_P_partials(z, m, a_coeffs):
58
+ """
59
+ Evaluate P(z,m) and partials dP/dz, dP/dm.
60
+
61
+ P is represented by a_coeffs in the monomial basis:
62
+ P(z,m) = sum_{j=0..s} a_j(z) m^j
63
+ a_j(z) = sum_{i=0..deg_z} a_coeffs[i,j] z^i
64
+ """
65
+ z = numpy.asarray(z, dtype=complex)
66
+ m = numpy.asarray(m, dtype=complex)
67
+
68
+ deg_z = int(a_coeffs.shape[0] - 1)
69
+ s = int(a_coeffs.shape[1] - 1)
70
+
71
+ # Scalar fast path
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
+ # Horner in m
93
+ p = a[s]
94
+ pm = 0.0 + 0.0j
95
+ for j in range(s - 1, -1, -1):
96
+ pm = pm * mm + p
97
+ p = p * mm + a[j]
98
+
99
+ pz = ap[s]
100
+ for j in range(s - 1, -1, -1):
101
+ pz = pz * mm + ap[j]
102
+
103
+ return p, pz, pm
104
+
105
+ # Vector path
106
+ shp = numpy.broadcast(z, m).shape
107
+ zz = numpy.broadcast_to(z, shp).ravel()
108
+ mm = numpy.broadcast_to(m, shp).ravel()
109
+
110
+ zp = powers(zz, deg_z)
111
+ mp = powers(mm, s)
112
+
113
+ dzp = numpy.zeros_like(zp)
114
+ for i in range(1, deg_z + 1):
115
+ dzp[:, i] = i * zp[:, i - 1]
116
+
117
+ P = numpy.zeros(zz.size, dtype=complex)
118
+ Pz = numpy.zeros(zz.size, dtype=complex)
119
+ Pm = numpy.zeros(zz.size, dtype=complex)
120
+
121
+ for j in range(s + 1):
122
+ aj = zp @ a_coeffs[:, j]
123
+ P += aj * mp[:, j]
124
+
125
+ ajp = dzp @ a_coeffs[:, j]
126
+ Pz += ajp * mp[:, j]
127
+
128
+ if j >= 1:
129
+ Pm += (j * aj) * mp[:, j - 1]
130
+
131
+ return P.reshape(shp), Pz.reshape(shp), Pm.reshape(shp)
132
+
133
+
134
+ # =========================
135
+ # Newton for one (z,t) pair
136
+ # =========================
137
+
138
+ def _newton_one(z, t, a_coeffs, w0, max_iter=50, tol=1e-12,
139
+ armijo=1e-4, min_lam=1e-6, w_min=1e-14):
140
+ """
141
+ Solve F_t(z,w)=0 at a single (t,z) by damped Newton.
142
+
143
+ F_t(z,w) := P(z + (1 - e^{-t})/w, e^{t} w) = 0.
144
+ """
145
+ z = complex(z)
146
+ w = complex(w0)
147
+
148
+ want_upper = (z.imag > 0.0)
149
+
150
+ tau = numpy.exp(float(t))
151
+ beta = 1.0 - 1.0 / tau # = 1 - e^{-t}
152
+
153
+ def eval_F_and_dF(w_val):
154
+ if (not numpy.isfinite(w_val)) or (abs(w_val) <= w_min):
155
+ return numpy.nan + 1j * numpy.nan, numpy.nan + 1j * numpy.nan
156
+ zeta = z + beta / w_val
157
+ y = tau * w_val
158
+ P, Pz, Py = eval_P_partials(zeta, y, a_coeffs)
159
+ dF = (-beta / (w_val * w_val)) * Pz + tau * Py
160
+ return P, dF
161
+
162
+ F, dF = eval_F_and_dF(w)
163
+ if not numpy.isfinite(F):
164
+ return w, False
165
+
166
+ for _ in range(int(max_iter)):
167
+ if abs(F) <= tol:
168
+ ok = numpy.isfinite(w)
169
+ if want_upper:
170
+ ok = ok and (w.imag > 0.0)
171
+ return w, bool(ok)
172
+
173
+ if (not numpy.isfinite(dF)) or (abs(dF) == 0.0):
174
+ break
175
+
176
+ step = -F / dF
177
+ if not numpy.isfinite(step):
178
+ break
179
+
180
+ lam = 1.0
181
+ absF = abs(F)
182
+ w_new = w
183
+ F_new = F
184
+ dF_new = dF
185
+
186
+ while lam >= float(min_lam):
187
+ cand = w + lam * step
188
+
189
+ if (not numpy.isfinite(cand)) or (abs(cand) <= w_min):
190
+ lam *= 0.5
191
+ continue
192
+ if want_upper and (cand.imag <= 0.0):
193
+ lam *= 0.5
194
+ continue
195
+
196
+ Fcand, dFcand = eval_F_and_dF(cand)
197
+ if not numpy.isfinite(Fcand):
198
+ lam *= 0.5
199
+ continue
200
+
201
+ if armijo is None:
202
+ ok_dec = (abs(Fcand) < absF)
203
+ else:
204
+ c = float(armijo)
205
+ ok_dec = (abs(Fcand) <= (1.0 - c * lam) * absF)
206
+
207
+ if ok_dec:
208
+ w_new = cand
209
+ F_new = Fcand
210
+ dF_new = dFcand
211
+ break
212
+
213
+ lam *= 0.5
214
+
215
+ if lam < float(min_lam):
216
+ break
217
+
218
+ if abs(w_new - w) <= tol * max(1.0, abs(w)):
219
+ w = w_new
220
+ F = F_new
221
+ ok = numpy.isfinite(w)
222
+ if want_upper:
223
+ ok = ok and (w.imag > 0.0)
224
+ return w, bool(ok)
225
+
226
+ w = w_new
227
+ F = F_new
228
+ dF = dF_new
229
+
230
+ ok = numpy.isfinite(w)
231
+ if want_upper:
232
+ ok = ok and (w.imag > 0.0)
233
+ return w, False
234
+
235
+
236
+ # =====================
237
+ # Main Newton evolution
238
+ # =====================
239
+
240
+ def decompress_newton(
241
+ z_query,
242
+ t_all,
243
+ a_coeffs,
244
+ w0_list=None,
245
+ max_iter=50,
246
+ tol=1e-12,
247
+ armijo=1e-4,
248
+ min_lam=1e-6,
249
+ w_min=1e-14,
250
+ sweep=False,
251
+ R=None,
252
+ z_hom_steps=0,
253
+ homotopy_tol=None,
254
+ **_ignored,
255
+ ):
256
+ """
257
+ Evolve the physical Stieltjes branch under free decompression.
258
+
259
+ If R and z_hom_steps are provided (as in your notebook), the solver uses a
260
+ straight-line z-homotopy from a far anchor point z_anchor = i R to each
261
+ query point z, at every time slice. This is much more reliable than a sweep
262
+ across the real grid for multi-cut densities.
263
+
264
+ Returns
265
+ -------
266
+ W : ndarray, shape (len(t_all), len(z_query))
267
+ ok : ndarray, same shape, boolean
268
+ """
269
+ zq = numpy.asarray(z_query, dtype=complex).ravel()
270
+ t_all = numpy.asarray(t_all, dtype=float).ravel()
271
+ if t_all.size == 0:
272
+ raise ValueError('t_all must be non-empty.')
273
+
274
+ # Sort t if needed
275
+ if numpy.any(numpy.diff(t_all) < 0.0):
276
+ order = numpy.argsort(t_all)
277
+ t_all = t_all[order]
278
+ else:
279
+ order = None
280
+
281
+ n_t = t_all.size
282
+ n_z = zq.size
283
+ W = numpy.empty((n_t, n_z), dtype=complex)
284
+ ok = numpy.zeros((n_t, n_z), dtype=bool)
285
+
286
+ if w0_list is None:
287
+ w_init = -1.0 / zq
288
+ else:
289
+ w_init = numpy.asarray(w0_list, dtype=complex).ravel()
290
+ if w_init.size != n_z:
291
+ raise ValueError('w0_list must have same length as z_query.')
292
+
293
+ W[0, :] = w_init
294
+ ok[0, :] = numpy.isfinite(w_init)
295
+ pos = (zq.imag > 0.0)
296
+ ok[0, pos] &= (W[0, pos].imag > 0.0)
297
+
298
+ use_homotopy = (R is not None) and (int(z_hom_steps) > 0)
299
+ if homotopy_tol is None:
300
+ homotopy_tol = tol
301
+
302
+ if use_homotopy:
303
+ Rf = float(R)
304
+ if not numpy.isfinite(Rf) or (Rf <= 0.0):
305
+ raise ValueError('R must be a positive finite number.')
306
+ z_anchor = 1j * Rf
307
+ max_dist = float(numpy.max(numpy.abs(zq - z_anchor)))
308
+ if max_dist == 0.0:
309
+ max_dist = 1.0
310
+
311
+ # Initial anchor solve at first time
312
+ w_anchor_prev, ok_anchor_prev = _newton_one(
313
+ z_anchor, float(t_all[0]), a_coeffs, -1.0 / z_anchor,
314
+ max_iter=max_iter, tol=homotopy_tol,
315
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
316
+ if not ok_anchor_prev:
317
+ w_anchor_prev = -1.0 / z_anchor
318
+
319
+ def _track_from_anchor(z_target, t, w_seed):
320
+ z_target = complex(z_target)
321
+ if z_target == z_anchor:
322
+ return complex(w_seed), True
323
+
324
+ n_steps_max = int(z_hom_steps)
325
+ n_steps = int(max(
326
+ 4,
327
+ numpy.ceil(n_steps_max * abs(z_target - z_anchor) / max_dist)
328
+ ))
329
+
330
+ s = 0.0
331
+ ds = 1.0 / float(n_steps)
332
+ w_curr = complex(w_seed)
333
+ refine = 0
334
+ refine_max = 6
335
+
336
+ while s < 1.0 - 1e-15:
337
+ s_next = min(1.0, s + ds)
338
+ z_next = z_anchor + (z_target - z_anchor) * s_next
339
+
340
+ w_next, ok_next = _newton_one(
341
+ z_next, t, a_coeffs, w_curr,
342
+ max_iter=max_iter, tol=homotopy_tol,
343
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
344
+
345
+ if ok_next:
346
+ w_curr = w_next
347
+ s = s_next
348
+ ds = min(ds * 1.5, 1.0 - s)
349
+ refine = 0
350
+ else:
351
+ ds *= 0.5
352
+ refine += 1
353
+ if refine > refine_max or ds < 1e-8:
354
+ return w_curr, False
355
+
356
+ return w_curr, True
357
+
358
+ start_k = 1
359
+ if not numpy.isclose(t_all[0], 0.0):
360
+ start_k = 0
361
+
362
+ for k in range(start_k, n_t):
363
+ t = float(t_all[k])
364
+
365
+ w_prev_time = W[k - 1, :] if k > 0 else w_init
366
+ Wk = numpy.empty(n_z, dtype=complex)
367
+ okk = numpy.zeros(n_z, dtype=bool)
368
+
369
+ if use_homotopy:
370
+ # Continue anchor in time
371
+ w_anchor, ok_anchor = _newton_one(
372
+ z_anchor, t, a_coeffs, w_anchor_prev,
373
+ max_iter=max_iter, tol=homotopy_tol,
374
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
375
+ if ok_anchor:
376
+ w_anchor_prev = w_anchor
377
+ else:
378
+ w_anchor = w_anchor_prev
379
+
380
+ # Track each z from the anchor
381
+ for j in range(n_z):
382
+ w_h, ok_h = _track_from_anchor(zq[j], t, w_anchor)
383
+ if ok_h:
384
+ Wk[j] = w_h
385
+ okk[j] = True
386
+ continue
387
+
388
+ # Fallback: direct Newton from previous time
389
+ w_d, ok_d = _newton_one(
390
+ zq[j], t, a_coeffs, w_prev_time[j],
391
+ max_iter=max_iter, tol=tol,
392
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
393
+ if ok_d:
394
+ Wk[j] = w_d
395
+ okk[j] = True
396
+ else:
397
+ Wk[j] = w_h
398
+ okk[j] = False
399
+
400
+ elif sweep:
401
+ for j in range(n_z):
402
+ if j == 0:
403
+ w0 = w_prev_time[j]
404
+ else:
405
+ w0 = Wk[j - 1] if okk[j - 1] else w_prev_time[j]
406
+ w_sol, ok_sol = _newton_one(
407
+ zq[j], t, a_coeffs, w0,
408
+ max_iter=max_iter, tol=tol,
409
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
410
+ Wk[j] = w_sol
411
+ okk[j] = ok_sol
412
+
413
+ else:
414
+ for j in range(n_z):
415
+ w_sol, ok_sol = _newton_one(
416
+ zq[j], t, a_coeffs, w_prev_time[j],
417
+ max_iter=max_iter, tol=tol,
418
+ armijo=armijo, min_lam=min_lam, w_min=w_min)
419
+ Wk[j] = w_sol
420
+ okk[j] = ok_sol
421
+
422
+ W[k, :] = Wk
423
+ ok[k, :] = okk
424
+
425
+ if order is not None:
426
+ inv = numpy.empty_like(order)
427
+ inv[order] = numpy.arange(order.size)
428
+ W = W[inv, :]
429
+ ok = ok[inv, :]
430
+
431
+ return W, ok