freealg 0.1.11__py3-none-any.whl → 0.7.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. freealg/__init__.py +8 -2
  2. freealg/__version__.py +1 -1
  3. freealg/_algebraic_form/__init__.py +12 -0
  4. freealg/_algebraic_form/_branch_points.py +288 -0
  5. freealg/_algebraic_form/_constraints.py +139 -0
  6. freealg/_algebraic_form/_continuation_algebraic.py +706 -0
  7. freealg/_algebraic_form/_decompress.py +641 -0
  8. freealg/_algebraic_form/_decompress2.py +204 -0
  9. freealg/_algebraic_form/_edge.py +330 -0
  10. freealg/_algebraic_form/_homotopy.py +323 -0
  11. freealg/_algebraic_form/_moments.py +448 -0
  12. freealg/_algebraic_form/_sheets_util.py +145 -0
  13. freealg/_algebraic_form/_support.py +309 -0
  14. freealg/_algebraic_form/algebraic_form.py +1232 -0
  15. freealg/_free_form/__init__.py +16 -0
  16. freealg/{_chebyshev.py → _free_form/_chebyshev.py} +75 -43
  17. freealg/_free_form/_decompress.py +993 -0
  18. freealg/_free_form/_density_util.py +243 -0
  19. freealg/_free_form/_jacobi.py +359 -0
  20. freealg/_free_form/_linalg.py +508 -0
  21. freealg/{_pade.py → _free_form/_pade.py} +42 -208
  22. freealg/{_plot_util.py → _free_form/_plot_util.py} +37 -22
  23. freealg/{_sample.py → _free_form/_sample.py} +58 -22
  24. freealg/_free_form/_series.py +454 -0
  25. freealg/_free_form/_support.py +214 -0
  26. freealg/_free_form/free_form.py +1362 -0
  27. freealg/_geometric_form/__init__.py +13 -0
  28. freealg/_geometric_form/_continuation_genus0.py +175 -0
  29. freealg/_geometric_form/_continuation_genus1.py +275 -0
  30. freealg/_geometric_form/_elliptic_functions.py +174 -0
  31. freealg/_geometric_form/_sphere_maps.py +63 -0
  32. freealg/_geometric_form/_torus_maps.py +118 -0
  33. freealg/_geometric_form/geometric_form.py +1094 -0
  34. freealg/_util.py +56 -110
  35. freealg/distributions/__init__.py +7 -1
  36. freealg/distributions/_chiral_block.py +494 -0
  37. freealg/distributions/_deformed_marchenko_pastur.py +726 -0
  38. freealg/distributions/_deformed_wigner.py +386 -0
  39. freealg/distributions/_kesten_mckay.py +29 -15
  40. freealg/distributions/_marchenko_pastur.py +224 -95
  41. freealg/distributions/_meixner.py +47 -37
  42. freealg/distributions/_wachter.py +29 -17
  43. freealg/distributions/_wigner.py +27 -14
  44. freealg/visualization/__init__.py +12 -0
  45. freealg/visualization/_glue_util.py +32 -0
  46. freealg/visualization/_rgb_hsv.py +125 -0
  47. freealg-0.7.12.dist-info/METADATA +172 -0
  48. freealg-0.7.12.dist-info/RECORD +53 -0
  49. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/WHEEL +1 -1
  50. freealg/_decompress.py +0 -180
  51. freealg/_jacobi.py +0 -218
  52. freealg/_support.py +0 -85
  53. freealg/freeform.py +0 -967
  54. freealg-0.1.11.dist-info/METADATA +0 -140
  55. freealg-0.1.11.dist-info/RECORD +0 -24
  56. /freealg/{_damp.py → _free_form/_damp.py} +0 -0
  57. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/AUTHORS.txt +0 -0
  58. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/LICENSE.txt +0 -0
  59. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,13 @@
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
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+ from .geometric_form import GeometricForm
10
+ # from ._elliptic_functions import ellipj
11
+ # from ._continuation_genus1 import mobius_z
12
+
13
+ __all__ = ['GeometricForm']
@@ -0,0 +1,175 @@
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
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+
16
+ __all__ = ['joukowski_z', 'joukowski_w', 'fit_pade', 'eval_pade',
17
+ 'generate_pade']
18
+
19
+
20
+ # ===========
21
+ # joukowski z
22
+ # ===========
23
+
24
+ def joukowski_z(w, a, b):
25
+
26
+ c = 0.5 * (a + b)
27
+ d = 0.5 * (b - a)
28
+ z = c + 0.5 * d * (w + 1.0 / w)
29
+
30
+ return z
31
+
32
+
33
+ # ===========
34
+ # joukowski w
35
+ # ===========
36
+
37
+ def joukowski_w(z, a, b):
38
+
39
+ c = 0.5 * (a + b)
40
+ d = 0.5 * (b - a)
41
+ xi = (z - c) / d
42
+ s = numpy.sqrt(xi * xi - 1.0)
43
+
44
+ # Stabilize sqrt branch: make s have same sign as xi (helps continuity)
45
+ s = numpy.where(numpy.real(xi) < 0.0, -s, s)
46
+
47
+ w1 = xi + s
48
+ w2 = xi - s
49
+ w = numpy.where(numpy.abs(w1) >= 1.0, w1, w2)
50
+
51
+ return w
52
+
53
+
54
+ # ========
55
+ # fit pade
56
+ # ========
57
+
58
+ def fit_pade(w, m1, deg_p=12, deg_q=12, ridge_lambda=0.0):
59
+ """
60
+ Fit m1 on w-plane using Pade rational approximation.
61
+ """
62
+
63
+ n_samples = m1.size
64
+
65
+ wp = numpy.ones((n_samples, deg_p + 1), dtype=complex)
66
+ for k in range(1, deg_p + 1):
67
+ wp[:, k] = wp[:, k - 1] * w
68
+
69
+ wq = numpy.ones((n_samples, deg_q + 1), dtype=complex)
70
+ for k in range(1, deg_q + 1):
71
+ wq[:, k] = wq[:, k - 1] * w
72
+
73
+ A = numpy.hstack([wp, -m1[:, None] * wq[:, 1:]])
74
+ bvec = m1
75
+
76
+ # Scale columns for better conditioning on LS
77
+ s = numpy.linalg.norm(A, axis=0)
78
+ s[s == 0] = 1.0
79
+ As = A / s[None, :]
80
+
81
+ if ridge_lambda is None:
82
+ ridge_lambda = 0.0
83
+
84
+ if ridge_lambda > 0.0:
85
+ # Scale ridge by average diagonal magnitude of AhA
86
+ # Since columns of As have unit norm, this is typically ~1.
87
+ alpha = ridge_lambda
88
+
89
+ # Solving augmented least square
90
+ n_coef = As.shape[1]
91
+ A_aug = numpy.vstack([As, numpy.sqrt(alpha) * numpy.eye(
92
+ n_coef, dtype=complex)])
93
+ b_aug = numpy.concatenate([bvec, numpy.zeros(n_coef, dtype=complex)])
94
+
95
+ coef, _, _, _ = numpy.linalg.lstsq(A_aug, b_aug, rcond=None)
96
+ else:
97
+ coef, _, _, _ = numpy.linalg.lstsq(As, bvec, rcond=None)
98
+
99
+ coef = coef / s
100
+
101
+ p = coef[:deg_p + 1]
102
+ q = numpy.zeros(deg_q + 1, dtype=complex)
103
+ q[0] = 1.0
104
+ q[1:] = coef[deg_p + 1:]
105
+
106
+ return p, q
107
+
108
+
109
+ # =========
110
+ # eval pade
111
+ # =========
112
+
113
+ def eval_pade(w, p, q):
114
+
115
+ num = numpy.zeros_like(w, dtype=complex)
116
+ den = numpy.zeros_like(w, dtype=complex)
117
+
118
+ for k in range(len(p) - 1, -1, -1):
119
+ num = num * w + p[k]
120
+
121
+ for k in range(len(q) - 1, -1, -1):
122
+ den = den * w + q[k]
123
+
124
+ return num / den
125
+
126
+
127
+ # =============
128
+ # generate pade
129
+ # =============
130
+
131
+ def generate_pade(m1_fn, a, b, deg_p=12, deg_q=12, n_samples=4096, r=1.2,
132
+ n_r=1, r_min=None, ridge_lambda=0.0):
133
+
134
+ if r_min is None:
135
+ r_min = 1.0 + 0.05 * (r - 1.0) if r > 1.0 else 1.0
136
+
137
+ if n_r is None or n_r < 1:
138
+ n_r = 1
139
+
140
+ if n_samples % 2 != 0:
141
+ raise ValueError('n_samples should be even.')
142
+
143
+ if n_r == 1:
144
+ rs = numpy.array([r], dtype=float)
145
+ else:
146
+ rs = numpy.linspace(r_min, r, n_r)
147
+
148
+ W_list = []
149
+ M_list = []
150
+
151
+ n_half = n_samples // 2
152
+
153
+ for r_i in rs:
154
+
155
+ # Generate sample points along theta
156
+ theta = numpy.pi * (numpy.arange(n_half) + 0.5) / n_half
157
+ w = r_i * numpy.exp(1j * theta)
158
+ z = joukowski_z(w, a, b)
159
+ m1 = m1_fn(z)
160
+
161
+ W_list.append(w)
162
+ M_list.append(m1)
163
+
164
+ # Add conjugate points which enforces Schwarz reflection
165
+ W_list.append(numpy.conjugate(w))
166
+ M_list.append(numpy.conjugate(m1))
167
+
168
+ w_all = numpy.concatenate(W_list)
169
+ m1_all = numpy.concatenate(M_list)
170
+
171
+ # Fit on the sample data from m1
172
+ p, q = fit_pade(w_all, m1_all, deg_p=deg_p, deg_q=deg_q,
173
+ ridge_lambda=ridge_lambda)
174
+
175
+ return p, q
@@ -0,0 +1,275 @@
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
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+ import scipy
16
+ from ._elliptic_functions import ellipj
17
+
18
+ __all__ = ['mobius_X', 'mobius_z', 'mobius_lambda', 'legendre_Y',
19
+ 'fit_rational_xy', '_poly_eval', 'eval_rational_xy',
20
+ 'eval_rational_xy_select', 'generate_rational_xy']
21
+
22
+
23
+ # =============
24
+ # sqrt pos imag
25
+ # =============
26
+
27
+ def sqrt_pos_imag(z):
28
+ """
29
+ Square root on a branch cut with always positive imaginary part.
30
+ """
31
+
32
+ sq = numpy.sqrt(z)
33
+ sq = numpy.where(sq.imag < 0, -sq, sq)
34
+
35
+ return sq
36
+
37
+
38
+ # ========
39
+ # mobius X
40
+ # ========
41
+
42
+ def mobius_X(z, a1, b1, a2, b2):
43
+
44
+ A = (a2 - b2) / (a2 - a1)
45
+ return A * (z - a1) / (z - b2)
46
+
47
+
48
+ # ========
49
+ # mobius z
50
+ # ========
51
+
52
+ def mobius_z(X, a1, b1, a2, b2):
53
+
54
+ A = (a2 - b2) / (a2 - a1)
55
+ return (X * b2 - A * a1) / (X - A)
56
+
57
+
58
+ # =============
59
+ # mobius lambda
60
+ # =============
61
+
62
+ def mobius_lambda(a1, b1, a2, b2):
63
+
64
+ num = (b1 - a1) * (b2 - a2)
65
+ den = (a2 - a1) * (b2 - b1)
66
+
67
+ return num / den
68
+
69
+
70
+ # ==========
71
+ # legendre Y
72
+ # ==========
73
+
74
+ def legendre_Y(X, lam):
75
+
76
+ D = X * (1.0 - X) * (X - lam)
77
+ Y = sqrt_pos_imag(D)
78
+
79
+ flip = numpy.real(X) > 1.0
80
+ Y = numpy.where(flip, -Y, Y)
81
+
82
+ return Y
83
+
84
+
85
+ # ===============
86
+ # fit rational xy
87
+ # ===============
88
+
89
+ def fit_rational_xy(X, Y, F, deg_p0=12, deg_p1=12, deg_q=12, ridge_lambda=0.0,
90
+ weights=None):
91
+
92
+ n = F.size
93
+
94
+ max_deg = max(deg_p0, deg_p1, deg_q)
95
+ xp = numpy.ones((n, max_deg + 1), dtype=complex)
96
+ for k in range(1, max_deg + 1):
97
+ xp[:, k] = xp[:, k - 1] * X
98
+
99
+ A0 = xp[:, :deg_p0 + 1]
100
+ A1 = (Y[:, None] * xp[:, :deg_p1 + 1])
101
+ Aq = (-F[:, None] * xp[:, 1:deg_q + 1])
102
+
103
+ A = numpy.hstack([A0, A1, Aq])
104
+ bvec = F
105
+
106
+ if weights is not None:
107
+ w = numpy.sqrt(numpy.maximum(weights, 0.0))
108
+ A = A * w[:, None]
109
+ bvec = bvec * w
110
+
111
+ s = numpy.linalg.norm(A, axis=0)
112
+ s[s == 0] = 1.0
113
+ As = A / s[None, :]
114
+
115
+ if ridge_lambda is None:
116
+ ridge_lambda = 0.0
117
+
118
+ if ridge_lambda > 0.0:
119
+ alpha = ridge_lambda
120
+ n_coef = As.shape[1]
121
+ A_aug = numpy.vstack(
122
+ [As, numpy.sqrt(alpha) * numpy.eye(n_coef, dtype=complex)])
123
+ b_aug = numpy.concatenate([bvec, numpy.zeros(n_coef, dtype=complex)])
124
+ coef, _, _, _ = numpy.linalg.lstsq(A_aug, b_aug, rcond=None)
125
+ else:
126
+ coef, _, _, _ = numpy.linalg.lstsq(As, bvec, rcond=None)
127
+
128
+ coef = coef / s
129
+
130
+ i0 = deg_p0 + 1
131
+ i1 = i0 + (deg_p1 + 1)
132
+
133
+ p0 = coef[:i0]
134
+ p1 = coef[i0:i1]
135
+
136
+ q = numpy.zeros(deg_q + 1, dtype=complex)
137
+ q[0] = 1.0
138
+ q[1:] = coef[i1:]
139
+
140
+ return p0, p1, q
141
+
142
+
143
+ # =========
144
+ # poly eval
145
+ # =========
146
+
147
+ def _poly_eval(x, c):
148
+
149
+ y = numpy.zeros_like(x, dtype=complex)
150
+ for k in range(len(c) - 1, -1, -1):
151
+ y = y * x + c[k]
152
+
153
+ return y
154
+
155
+
156
+ # ================
157
+ # eval rational xy
158
+ # ================
159
+
160
+ def eval_rational_xy(X, Y, p0, p1, q, alt_branch):
161
+ num0 = _poly_eval(X, p0)
162
+ num1 = _poly_eval(X, p1)
163
+ den = _poly_eval(X, q)
164
+
165
+ m_plus = (num0 + Y * num1) / den
166
+ m_minus = (num0 - Y * num1) / den
167
+
168
+ if alt_branch:
169
+ return m_minus
170
+ return m_plus
171
+
172
+
173
+ # =======================
174
+ # eval rational xy select
175
+ # =======================
176
+
177
+ def eval_rational_xy_select(z, X, Y, p0, p1, q, alt_branch):
178
+
179
+ m_plus = eval_rational_xy(X, Y, p0, p1, q, alt_branch=False)
180
+ m_minus = eval_rational_xy(X, Y, p0, p1, q, alt_branch=True)
181
+
182
+ mask_p = numpy.imag(z) >= 0.0
183
+
184
+ pick_plus_p = (numpy.imag(m_plus) >= 0.0)
185
+ pick_plus_m = (numpy.imag(m_plus) <= 0.0)
186
+
187
+ pick_plus = numpy.where(mask_p, pick_plus_p, pick_plus_m)
188
+
189
+ m1 = numpy.where(pick_plus, m_plus, m_minus)
190
+ m2 = numpy.where(pick_plus, m_minus, m_plus)
191
+
192
+ if alt_branch:
193
+ return m2
194
+ return m1
195
+
196
+
197
+ # ====================
198
+ # generate rational xy
199
+ # ====================
200
+
201
+ def generate_rational_xy(m1_fn, a1, b1, a2, b2, deg_p0=12, deg_p1=12, deg_q=12,
202
+ n_samples=4096, r=0.25, n_r=1, r_min=None,
203
+ ridge_lambda=0.0):
204
+
205
+ if n_samples % 2 != 0:
206
+ raise ValueError('n_samples should be even.')
207
+
208
+ if n_r is None or n_r < 1:
209
+ n_r = 1
210
+
211
+ lam = mobius_lambda(a1, b1, a2, b2)
212
+
213
+ r = float(r)
214
+ if r > 0.6:
215
+ r = 0.30
216
+
217
+ if r_min is None:
218
+ r_min = 0.25 * r
219
+
220
+ if n_r == 1:
221
+ vs = numpy.array([r], dtype=float)
222
+ else:
223
+ vs = numpy.linspace(r_min, r, n_r)
224
+
225
+ K = float(scipy.special.ellipk(lam))
226
+ Kp = float(scipy.special.ellipk(1.0 - lam))
227
+
228
+ omega1 = 2.0 * K
229
+ shift2 = 1j * Kp
230
+
231
+ n_half = n_samples // 2
232
+ t = (numpy.arange(n_half) + 0.5) / n_half
233
+ u_re = omega1 * t
234
+
235
+ X_list = []
236
+ Y_list = []
237
+ F_list = []
238
+
239
+ for v in vs:
240
+ for sh in (0.0, shift2):
241
+
242
+ u = u_re + sh + 1j * v
243
+
244
+ sn, cn, dn, _ = ellipj(u, lam)
245
+
246
+ X = lam * (sn * sn)
247
+
248
+ Y = legendre_Y(X, lam)
249
+ Y_ref = 1j * lam * (sn * cn * dn)
250
+ if sh != 0.0:
251
+ Y_ref = -Y_ref
252
+
253
+ flip = numpy.real(Y * numpy.conjugate(Y_ref)) < 0.0
254
+ Y = numpy.where(flip, -Y, Y)
255
+
256
+ z = mobius_z(X, a1, b1, a2, b2)
257
+ f = m1_fn(z)
258
+
259
+ X_list.append(X)
260
+ Y_list.append(Y)
261
+ F_list.append(f)
262
+
263
+ X_list.append(numpy.conjugate(X))
264
+ Y_list.append(numpy.conjugate(Y))
265
+ F_list.append(numpy.conjugate(f))
266
+
267
+ X_all = numpy.concatenate(X_list)
268
+ Y_all = numpy.concatenate(Y_list)
269
+ f_all = numpy.concatenate(F_list)
270
+
271
+ p0, p1, q = fit_rational_xy(X_all, Y_all, f_all, deg_p0=deg_p0,
272
+ deg_p1=deg_p1, deg_q=deg_q,
273
+ ridge_lambda=ridge_lambda)
274
+
275
+ return p0, p1, q, lam
@@ -0,0 +1,174 @@
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
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ """
11
+ The Jacobi elliptic functions sn, cn, and dn are implemented in:
12
+
13
+ 1. scipy.special: but they do not accept complex inputs
14
+ 2. mpmath: but they do not have vectorization (very slow)
15
+
16
+ Because of these, we implemented sn, cn, and dn ourselves. There is no need to
17
+ implement the elliptic K function, since scipy.special already has it (and
18
+ vectorized) and we only need that for real inputs, not complex inputs. So, no
19
+ need to re-implement the scipy.special.ellipk function.
20
+ """
21
+
22
+
23
+ # =======
24
+ # Imports
25
+ # =======
26
+
27
+ import numpy
28
+ from scipy import special
29
+
30
+ __all__ = ['ellipj']
31
+
32
+
33
+ # ===========
34
+ # jacobi nome
35
+ # ===========
36
+
37
+ def _jacobi_nome(m):
38
+ """
39
+ Compute the Jacobi nome q = exp(-pi K(1-m)/K(m)) and the complete elliptic
40
+ integrals K(m), K(1-m).
41
+ """
42
+
43
+ m = float(m)
44
+ K = special.ellipk(m)
45
+ Kp = special.ellipk(1.0 - m)
46
+ q = numpy.exp(-numpy.pi * Kp / K)
47
+
48
+ return q, K, Kp
49
+
50
+
51
+ # =======
52
+ # theta 1
53
+ # =======
54
+
55
+ def _theta1(v, q, n_terms):
56
+ """
57
+ Jacobi theta_1(v,q) via truncated Fourier series (vectorized for complex
58
+ v).
59
+ """
60
+
61
+ v = numpy.asarray(v, dtype=complex)
62
+ n = numpy.arange(n_terms, dtype=float)
63
+ a = 2.0 * n + 1.0
64
+ logq = numpy.log(q)
65
+ w = numpy.exp(((n + 0.5) ** 2) * logq) * ((-1.0) ** n)
66
+ s = numpy.sin(a[:, None] * v[None, :])
67
+
68
+ return 2.0 * (w[:, None] * s).sum(axis=0)
69
+
70
+
71
+ # =======
72
+ # theta 2
73
+ # =======
74
+
75
+ def _theta2(v, q, n_terms):
76
+ """
77
+ Jacobi theta_2(v,q) via truncated Fourier series (vectorized for complex
78
+ v).
79
+ """
80
+
81
+ v = numpy.asarray(v, dtype=complex)
82
+ n = numpy.arange(n_terms, dtype=float)
83
+ a = 2.0 * n + 1.0
84
+ logq = numpy.log(q)
85
+ w = numpy.exp(((n + 0.5) ** 2) * logq)
86
+ c = numpy.cos(a[:, None] * v[None, :])
87
+
88
+ return 2.0 * (w[:, None] * c).sum(axis=0)
89
+
90
+
91
+ # =======
92
+ # theta 3
93
+ # =======
94
+
95
+ def _theta3(v, q, n_terms):
96
+ """
97
+ Jacobi theta_3(v,q) via truncated Fourier series (vectorized for complex
98
+ v).
99
+ """
100
+
101
+ v = numpy.asarray(v, dtype=complex)
102
+ n = numpy.arange(1, n_terms + 1, dtype=float)
103
+ logq = numpy.log(q)
104
+ w = numpy.exp((n ** 2) * logq)
105
+ c = numpy.cos((2.0 * n)[:, None] * v[None, :])
106
+
107
+ return 1.0 + 2.0 * (w[:, None] * c).sum(axis=0)
108
+
109
+
110
+ # =======
111
+ # theta 4
112
+ # =======
113
+
114
+ def _theta4(v, q, n_terms):
115
+ """
116
+ Jacobi theta_4(v,q) via truncated Fourier series (vectorized for complex
117
+ v).
118
+ """
119
+
120
+ v = numpy.asarray(v, dtype=complex)
121
+ n = numpy.arange(1, n_terms + 1, dtype=float)
122
+ logq = numpy.log(q)
123
+ w = numpy.exp((n ** 2) * logq) * ((-1.0) ** n)
124
+ c = numpy.cos((2.0 * n)[:, None] * v[None, :])
125
+
126
+ return 1.0 + 2.0 * (w[:, None] * c).sum(axis=0)
127
+
128
+
129
+ # ======
130
+ # ellipj
131
+ # ======
132
+
133
+ def ellipj(u, m, n_terms=None):
134
+ """
135
+ Vectorized Jacobi elliptic sn, cn, dn for complex u and real m in (0,1),
136
+ computed from theta functions.
137
+ """
138
+
139
+ u = numpy.asarray(u, dtype=complex)
140
+ q, K, Kp = _jacobi_nome(m)
141
+
142
+ if n_terms is None:
143
+ if q < 1e-6:
144
+ n_terms = 16
145
+ elif q < 1e-3:
146
+ n_terms = 24
147
+ elif q < 1e-2:
148
+ n_terms = 32
149
+ elif q < 5e-2:
150
+ n_terms = 48
151
+ else:
152
+ n_terms = 80
153
+
154
+ v = (numpy.pi / (2.0 * K)) * u
155
+ v_flat = v.ravel()
156
+
157
+ th1 = _theta1(v_flat, q, n_terms)
158
+ th2 = _theta2(v_flat, q, n_terms)
159
+ th3 = _theta3(v_flat, q, n_terms)
160
+ th4 = _theta4(v_flat, q, n_terms)
161
+
162
+ th2_0 = _theta2(numpy.array([0.0], dtype=complex), q, n_terms)[0]
163
+ th3_0 = _theta3(numpy.array([0.0], dtype=complex), q, n_terms)[0]
164
+ th4_0 = _theta4(numpy.array([0.0], dtype=complex), q, n_terms)[0]
165
+
166
+ sn = (th3_0 * th1) / (th2_0 * th4)
167
+ cn = (th4_0 * th2) / (th2_0 * th4)
168
+ dn = (th4_0 * th3) / (th3_0 * th4)
169
+
170
+ sn = sn.reshape(v.shape)
171
+ cn = cn.reshape(v.shape)
172
+ dn = dn.reshape(v.shape)
173
+
174
+ return sn, cn, dn, q
@@ -0,0 +1,63 @@
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
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+ from ._continuation_genus0 import joukowski_z
16
+
17
+ __all__ = ['make_sphere_grid', 'eval_on_sphere']
18
+
19
+
20
+ # ===========================
21
+ # stereographic w from sphere
22
+ # ===========================
23
+
24
+ def stereographic_w_from_sphere(X, Y, Z):
25
+
26
+ den = 1.0 - Z
27
+ w = (X + 1j * Y) / den
28
+ return w
29
+
30
+
31
+ # ================
32
+ # make sphere grid
33
+ # ================
34
+
35
+ def make_sphere_grid(n_theta=100, n_phi=50):
36
+
37
+ theta = numpy.linspace(0.0, 2.0 * numpy.pi, n_theta, endpoint=False)
38
+ u = numpy.linspace(0.0, 1.0, n_phi)
39
+ phi = numpy.pi * (u * u)
40
+ TH, PH = numpy.meshgrid(theta, phi)
41
+ X = numpy.sin(PH) * numpy.cos(TH)
42
+ Y = numpy.sin(PH) * numpy.sin(TH)
43
+ Z = numpy.cos(PH)
44
+
45
+ return X, Y, Z
46
+
47
+
48
+ # ==============
49
+ # eval on sphere
50
+ # ==============
51
+
52
+ def eval_on_sphere(X, Y, Z, a, b, m1_fn, m2_fn, z_eps=1e-9):
53
+
54
+ Zc = numpy.minimum(Z, 1.0 - 1e-12)
55
+ w = stereographic_w_from_sphere(X, Y, Zc)
56
+ z = joukowski_z(w, a, b)
57
+ z = z + 1j * z_eps
58
+ m1 = m1_fn(z)
59
+ m2 = m2_fn(z)
60
+ out = numpy.array(m2, copy=True)
61
+ out[numpy.abs(w) <= 1.0] = m1[numpy.abs(w) <= 1.0]
62
+
63
+ return out