vectorwaves 1.0.0__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.
- vectorwaves/__init__.py +88 -0
- vectorwaves/backends/__init__.py +0 -0
- vectorwaves/backends/cupy_backend.py +197 -0
- vectorwaves/backends/numba_backend.py +271 -0
- vectorwaves/backends/numpy_backend.py +193 -0
- vectorwaves/beam_stuff.py +623 -0
- vectorwaves/config_stuff.py +747 -0
- vectorwaves/engine_stuff.py +379 -0
- vectorwaves/py.typed +0 -0
- vectorwaves/singularities.py +617 -0
- vectorwaves/spectra.py +341 -0
- vectorwaves/utils.py +130 -0
- vectorwaves/version.py +5 -0
- vectorwaves-1.0.0.dist-info/METADATA +114 -0
- vectorwaves-1.0.0.dist-info/RECORD +18 -0
- vectorwaves-1.0.0.dist-info/WHEEL +5 -0
- vectorwaves-1.0.0.dist-info/licenses/LICENSE +21 -0
- vectorwaves-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Topological Singularity Detection
|
|
3
|
+
==================================
|
|
4
|
+
|
|
5
|
+
Find and analyze polarization singularities in 3D electromagnetic fields.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np, warnings
|
|
9
|
+
from .engine_stuff import FieldEngine
|
|
10
|
+
|
|
11
|
+
class SingularityFinder:
|
|
12
|
+
"""
|
|
13
|
+
Locates and traces electric field singularities in 3D space.
|
|
14
|
+
|
|
15
|
+
Works with a physics_engine to find points where field properties
|
|
16
|
+
become degenerate (C-points, C^T-points, L^T-points) and trace their evolution
|
|
17
|
+
through space as lines or loops.
|
|
18
|
+
|
|
19
|
+
Time t=0 by default as polarization is time independent in monochromatic fields.
|
|
20
|
+
Use self.t to change this behaviour.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
physics_engine : FieldEngine
|
|
25
|
+
An initialized FieldEngine capable of evaluating points in space.
|
|
26
|
+
|
|
27
|
+
Example
|
|
28
|
+
-------
|
|
29
|
+
```python
|
|
30
|
+
engine = FieldEngine(beam, cfg)
|
|
31
|
+
fields = engine.compute_on_op(z=0.0)
|
|
32
|
+
|
|
33
|
+
finder = SingularityFinder(engine)
|
|
34
|
+
pts = finder.find_stokes_C_points(z_value=0.0, E_grid=fields.E)
|
|
35
|
+
lines = finder.trace_stokes_C_lines(pts, ds=0.05)
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
def __init__(self, physics_engine: FieldEngine):
|
|
39
|
+
self.engine = physics_engine
|
|
40
|
+
|
|
41
|
+
# Read the engine's active backend to issue helpful warnings
|
|
42
|
+
self.backend_name = self.engine.backend_name
|
|
43
|
+
if self.backend_name == 'numpy':
|
|
44
|
+
warnings.warn(
|
|
45
|
+
"Singularity finder is using the 'numpy' backend. "
|
|
46
|
+
"Use 'numba' or 'cupy64' for a significant speed boost.",
|
|
47
|
+
RuntimeWarning
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
elif self.backend_name == 'cupy32':
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"cupy32 backend is not supported: convergence heuristics assume float64 precision. "
|
|
53
|
+
"Use 'cupy64'."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.x = physics_engine.x
|
|
57
|
+
self.y = physics_engine.y
|
|
58
|
+
self.t = 0
|
|
59
|
+
self.x_min, self.x_max = min(self.x), max(self.x)
|
|
60
|
+
self.y_min, self.y_max = min(self.y), max(self.y)
|
|
61
|
+
|
|
62
|
+
# ==========================================
|
|
63
|
+
# --- Internal Math Helpers (Batched) ---
|
|
64
|
+
# ==========================================
|
|
65
|
+
|
|
66
|
+
def _zero_cross_mask(self, field):
|
|
67
|
+
"""Finds 2x2 cells on a grid where the field crosses zero."""
|
|
68
|
+
fs = np.sign(field).astype(np.int8)
|
|
69
|
+
cell_max = np.maximum(fs[:-1, :-1], fs[:-1, 1:])
|
|
70
|
+
np.maximum(cell_max, fs[1:, :-1], out=cell_max)
|
|
71
|
+
np.maximum(cell_max, fs[1:, 1:], out=cell_max)
|
|
72
|
+
|
|
73
|
+
cell_min = np.minimum(fs[:-1, :-1], fs[:-1, 1:])
|
|
74
|
+
np.minimum(cell_min, fs[1:, :-1], out=cell_min)
|
|
75
|
+
np.minimum(cell_min, fs[1:, 1:], out=cell_min)
|
|
76
|
+
|
|
77
|
+
return (cell_max > 0) & (cell_min < 0)
|
|
78
|
+
|
|
79
|
+
def _batched_plane_fit_2eq(self, candidate_coords, data1, data2):
|
|
80
|
+
"""
|
|
81
|
+
Vectorized least-squares plane fit for 2 equations on N cells.
|
|
82
|
+
Solves for the sub-pixel zero-crossing coordinates (dx, dy).
|
|
83
|
+
"""
|
|
84
|
+
if len(candidate_coords) == 0: return np.array([]), np.array([]), np.array([], dtype=bool)
|
|
85
|
+
|
|
86
|
+
y_idx, x_idx = candidate_coords[:, 0], candidate_coords[:, 1]
|
|
87
|
+
|
|
88
|
+
# Extract the 4 corners of each candidate cell. Shape: (4, N)
|
|
89
|
+
q1 = np.array([data1[y_idx, x_idx], data1[y_idx, x_idx+1], data1[y_idx+1, x_idx], data1[y_idx+1, x_idx+1]])
|
|
90
|
+
q2 = np.array([data2[y_idx, x_idx], data2[y_idx, x_idx+1], data2[y_idx+1, x_idx], data2[y_idx+1, x_idx+1]])
|
|
91
|
+
|
|
92
|
+
# Pseudoinverse for local cell coordinates [[0,0], [0,1], [1,0], [1,1]]
|
|
93
|
+
A = np.array([[0,0,1], [0,1,1], [1,0,1], [1,1,1]], dtype=float)
|
|
94
|
+
A_pinv = np.linalg.pinv(A)
|
|
95
|
+
|
|
96
|
+
# Fit planes: p = [coeff_y, coeff_x, intercept]. Shape: (3, N)
|
|
97
|
+
p1 = A_pinv @ q1
|
|
98
|
+
p2 = A_pinv @ q2
|
|
99
|
+
|
|
100
|
+
# Setup Cramer's rule for A * [dy, dx]^T = b
|
|
101
|
+
a11, a12 = p1[1], p1[0] # dx, dy for eq 1
|
|
102
|
+
a21, a22 = p2[1], p2[0] # dx, dy for eq 2
|
|
103
|
+
b1, b2 = -p1[2], -p2[2] # -intercepts
|
|
104
|
+
|
|
105
|
+
det = a11*a22 - a12*a21
|
|
106
|
+
valid = np.abs(det) > 1e-14
|
|
107
|
+
|
|
108
|
+
dx = np.where(valid, ( a22*b1 - a12*b2) / det, -1)
|
|
109
|
+
dy = np.where(valid, (-a21*b1 + a11*b2) / det, -1)
|
|
110
|
+
|
|
111
|
+
return dx, dy, valid & (dx >= 0) & (dx < 1) & (dy >= 0) & (dy < 1)
|
|
112
|
+
|
|
113
|
+
def _batched_plane_fit_3eq(self, candidate_coords, data1, data2, data3):
|
|
114
|
+
"""
|
|
115
|
+
Vectorized least-squares plane fit solving normal equations for 3 equations on N cells.
|
|
116
|
+
Used for overdetermined systems like L-points (codimension 2 but 3 components).
|
|
117
|
+
"""
|
|
118
|
+
if len(candidate_coords) == 0: return np.array([]), np.array([]), np.array([], dtype=bool)
|
|
119
|
+
|
|
120
|
+
y_idx, x_idx = candidate_coords[:, 0], candidate_coords[:, 1]
|
|
121
|
+
|
|
122
|
+
q1 = np.array([data1[y_idx, x_idx], data1[y_idx, x_idx+1], data1[y_idx+1, x_idx], data1[y_idx+1, x_idx+1]])
|
|
123
|
+
q2 = np.array([data2[y_idx, x_idx], data2[y_idx, x_idx+1], data2[y_idx+1, x_idx], data2[y_idx+1, x_idx+1]])
|
|
124
|
+
q3 = np.array([data3[y_idx, x_idx], data3[y_idx, x_idx+1], data3[y_idx+1, x_idx], data3[y_idx+1, x_idx+1]])
|
|
125
|
+
|
|
126
|
+
A_pinv = np.linalg.pinv(np.array([[0,0,1], [0,1,1], [1,0,1], [1,1,1]], dtype=float))
|
|
127
|
+
|
|
128
|
+
p1, p2, p3 = A_pinv @ q1, A_pinv @ q2, A_pinv @ q3
|
|
129
|
+
|
|
130
|
+
# Design matrix elements across all N points
|
|
131
|
+
A_y = np.array([p1[0], p2[0], p3[0]]) # Shape: (3, N)
|
|
132
|
+
A_x = np.array([p1[1], p2[1], p3[1]])
|
|
133
|
+
b = np.array([-p1[2], -p2[2], -p3[2]])
|
|
134
|
+
|
|
135
|
+
# Normal equations: A.T @ A * delta = A.T @ b
|
|
136
|
+
AtA_00 = np.sum(A_y * A_y, axis=0)
|
|
137
|
+
AtA_11 = np.sum(A_x * A_x, axis=0)
|
|
138
|
+
AtA_01 = np.sum(A_y * A_x, axis=0)
|
|
139
|
+
Atb_0 = np.sum(A_y * b, axis=0)
|
|
140
|
+
Atb_1 = np.sum(A_x * b, axis=0)
|
|
141
|
+
|
|
142
|
+
det = AtA_00*AtA_11 - AtA_01*AtA_01
|
|
143
|
+
valid = np.abs(det) > 1e-14
|
|
144
|
+
|
|
145
|
+
dy = np.where(valid, ( AtA_11*Atb_0 - AtA_01*Atb_1) / det, -1)
|
|
146
|
+
dx = np.where(valid, (-AtA_01*Atb_0 + AtA_00*Atb_1) / det, -1)
|
|
147
|
+
|
|
148
|
+
return dx, dy, valid & (dx >= 0) & (dx < 1) & (dy >= 0) & (dy < 1)
|
|
149
|
+
|
|
150
|
+
def _batched_newton_raphson_2d(self, value_and_corrector, x0, y0, z_value, max_iter=10, tol=1e-6, value_tol=1e-6):
|
|
151
|
+
"""
|
|
152
|
+
Batched Newton-Raphson evaluating all active points simultaneously via compute_cloud.
|
|
153
|
+
Drops points as they converge to save computation.
|
|
154
|
+
"""
|
|
155
|
+
x, y = x0.copy(), y0.copy()
|
|
156
|
+
z = np.full_like(x, z_value)
|
|
157
|
+
active = np.ones_like(x, dtype=bool)
|
|
158
|
+
|
|
159
|
+
for _ in range(max_iter):
|
|
160
|
+
if not np.any(active): break
|
|
161
|
+
|
|
162
|
+
# Query the engine for the currently active subset of points
|
|
163
|
+
fields = self.engine.compute_cloud(x[active], y[active], z[active], t=self.t, need_b=False)
|
|
164
|
+
|
|
165
|
+
# Get values (M, 2) and Jacobian matrices (M, 2, 2) for active points
|
|
166
|
+
v, C = value_and_corrector(fields.E, fields.jacobian_E)
|
|
167
|
+
|
|
168
|
+
# Exact 2x2 matrix inversion via Cramer's rule for batch (M,)
|
|
169
|
+
det = C[:,0,0]*C[:,1,1] - C[:,0,1]*C[:,1,0]
|
|
170
|
+
valid_det = np.abs(det) > 1e-14
|
|
171
|
+
inv_det = np.where(valid_det, 1.0/det, 0.0)
|
|
172
|
+
|
|
173
|
+
dx = ( C[:,1,1]*v[:,0] - C[:,0,1]*v[:,1]) * inv_det
|
|
174
|
+
dy = (-C[:,1,0]*v[:,0] + C[:,0,0]*v[:,1]) * inv_det
|
|
175
|
+
|
|
176
|
+
x[active] -= dx
|
|
177
|
+
y[active] -= dy
|
|
178
|
+
|
|
179
|
+
# Check convergence for this batch
|
|
180
|
+
just_converged = (np.hypot(dx, dy) < tol) & valid_det
|
|
181
|
+
active_indices = np.where(active)[0]
|
|
182
|
+
|
|
183
|
+
# Turn off points that either converged or became singular
|
|
184
|
+
active[active_indices[just_converged]] = False
|
|
185
|
+
active[active_indices[~valid_det]] = False
|
|
186
|
+
|
|
187
|
+
# Final residual check across ALL points
|
|
188
|
+
fields = self.engine.compute_cloud(x, y, z, t=self.t, need_b=False)
|
|
189
|
+
final_v, _ = value_and_corrector(fields.E, fields.jacobian_E)
|
|
190
|
+
return x, y, np.linalg.norm(final_v, axis=1) < value_tol
|
|
191
|
+
|
|
192
|
+
def _find_singularities_template(self, z_value, candidate_coords, dx, dy, success, value_and_corrector_func, max_iter, tol, value_tol):
|
|
193
|
+
"""Standardizes the prediction -> correction -> bound-checking pipeline."""
|
|
194
|
+
y_idx, x_idx = candidate_coords[success, 0], candidate_coords[success, 1]
|
|
195
|
+
if len(x_idx) == 0: return np.array([]), np.array([]), np.array([]), np.array([]), np.array([], dtype=bool)
|
|
196
|
+
|
|
197
|
+
cont_x = self.x[x_idx] + dx[success] * (self.x[x_idx+1] - self.x[x_idx])
|
|
198
|
+
cont_y = self.y[y_idx] + dy[success] * (self.y[y_idx+1] - self.y[y_idx])
|
|
199
|
+
|
|
200
|
+
final_x, final_y, confident = self._batched_newton_raphson_2d(
|
|
201
|
+
value_and_corrector_func, cont_x, cont_y, z_value, max_iter, tol, value_tol
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
in_bounds = (final_x >= self.x_min) & (final_x <= self.x_max) & (final_y >= self.y_min) & (final_y <= self.y_max)
|
|
205
|
+
return cont_x, cont_y, final_x, final_y, confident & in_bounds
|
|
206
|
+
|
|
207
|
+
# ==========================================
|
|
208
|
+
# --- 2D Point Finding Methods ---
|
|
209
|
+
# ==========================================
|
|
210
|
+
|
|
211
|
+
def _stokes_and_grads_from_EJ(self, E, J):
|
|
212
|
+
"""
|
|
213
|
+
Calculates Stokes parameters and their spatial gradients.
|
|
214
|
+
Works seamlessly for both single points and batched clouds (N,) arrays!
|
|
215
|
+
"""
|
|
216
|
+
Ex, Ey = E[0], E[1]
|
|
217
|
+
Ex_x, Ex_y, Ex_z = J[0]
|
|
218
|
+
Ey_x, Ey_y, Ey_z = J[1]
|
|
219
|
+
|
|
220
|
+
S0 = abs(Ex)**2 + abs(Ey)**2
|
|
221
|
+
S1 = abs(Ex)**2 - abs(Ey)**2
|
|
222
|
+
S2 = 2 * np.real(Ex * np.conj(Ey))
|
|
223
|
+
S3 = 2 * np.imag(Ex * np.conj(Ey))
|
|
224
|
+
|
|
225
|
+
S0_x = 2 * np.real(Ex_x * np.conj(Ex) + Ey_x * np.conj(Ey))
|
|
226
|
+
S0_y = 2 * np.real(Ex_y * np.conj(Ex) + Ey_y * np.conj(Ey))
|
|
227
|
+
S0_z = 2 * np.real(Ex_z * np.conj(Ex) + Ey_z * np.conj(Ey))
|
|
228
|
+
|
|
229
|
+
S1_x = 2 * np.real(Ex_x * np.conj(Ex)) - 2 * np.real(Ey_x * np.conj(Ey))
|
|
230
|
+
S1_y = 2 * np.real(Ex_y * np.conj(Ex)) - 2 * np.real(Ey_y * np.conj(Ey))
|
|
231
|
+
S1_z = 2 * np.real(Ex_z * np.conj(Ex)) - 2 * np.real(Ey_z * np.conj(Ey))
|
|
232
|
+
|
|
233
|
+
S2_x = 2 * np.real(Ex_x * np.conj(Ey) + Ex * np.conj(Ey_x))
|
|
234
|
+
S2_y = 2 * np.real(Ex_y * np.conj(Ey) + Ex * np.conj(Ey_y))
|
|
235
|
+
S2_z = 2 * np.real(Ex_z * np.conj(Ey) + Ex * np.conj(Ey_z))
|
|
236
|
+
|
|
237
|
+
return ((S0, S1, S2, S3), np.array([S0_x, S0_y, S0_z]), np.array([S1_x, S1_y, S1_z]), np.array([S2_x, S2_y, S2_z]))
|
|
238
|
+
|
|
239
|
+
def _stokes_c_point_value_and_corrector(self, E, J):
|
|
240
|
+
"""Returns normalized S1, S2 and their 2x2 Jacobian for the XY plane."""
|
|
241
|
+
(S0, S1, S2, _), grad_S0, grad_S1, grad_S2 = self._stokes_and_grads_from_EJ(E, J)
|
|
242
|
+
S0_safe = np.where(S0 > 1e-12, S0, 1.0)
|
|
243
|
+
|
|
244
|
+
f_sp = np.stack([S1, S2], axis=-1) / S0_safe[:, None]
|
|
245
|
+
J_sp_0 = (S0 * grad_S1[0] - S1 * grad_S0[0]) / S0_safe**2
|
|
246
|
+
J_sp_1 = (S0 * grad_S1[1] - S1 * grad_S0[1]) / S0_safe**2
|
|
247
|
+
J_sp_2 = (S0 * grad_S2[0] - S2 * grad_S0[0]) / S0_safe**2
|
|
248
|
+
J_sp_3 = (S0 * grad_S2[1] - S2 * grad_S0[1]) / S0_safe**2
|
|
249
|
+
|
|
250
|
+
# Build batched Jacobian: Shape (N, 2, 2)
|
|
251
|
+
C = np.stack([np.stack([J_sp_0, J_sp_1], axis=-1), np.stack([J_sp_2, J_sp_3], axis=-1)], axis=1)
|
|
252
|
+
return f_sp, C
|
|
253
|
+
|
|
254
|
+
def find_stokes_C_points(self, z_value, E_grid, max_iter=10, pos_tol=1e-6, value_tol=1e-6):
|
|
255
|
+
"""
|
|
256
|
+
Finds Stokes C-points, where transverse polarization (2D) is purely circular (s1=s2=0).
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
z_value : float
|
|
261
|
+
z-coordinate of the observation plane.
|
|
262
|
+
E_grid : np.ndarray
|
|
263
|
+
Electric field array (3, ny, nx) evaluated at `z_value` from the engine.
|
|
264
|
+
max_iter : int, optional
|
|
265
|
+
Newton-Raphson iterations per candidate.
|
|
266
|
+
pos_tol : float, optional
|
|
267
|
+
Position convergence tolerance.
|
|
268
|
+
value_tol : float, optional
|
|
269
|
+
Residual tolerance for sqrt(s1^2 + s2^2).
|
|
270
|
+
|
|
271
|
+
Returns
|
|
272
|
+
-------
|
|
273
|
+
list of dict
|
|
274
|
+
List of dictionaries for each found singularity. Keys include:
|
|
275
|
+
- 'position': (x, y, z) tuple of the refined root.
|
|
276
|
+
- 'guess': (x, y, z) tuple of the initial plane-fit guess.
|
|
277
|
+
- 'type': Morphological classification ('Star', 'Lemon', 'Monstar').
|
|
278
|
+
- 'intensity': S0 value at the point.
|
|
279
|
+
- 'handedness': Sign of S3 at the point (Right or Left circular).
|
|
280
|
+
- 'confident': Boolean indicating if the solver fully converged.
|
|
281
|
+
"""
|
|
282
|
+
Ex, Ey, _ = E_grid
|
|
283
|
+
S0 = abs(Ex)**2 + abs(Ey)**2
|
|
284
|
+
S0_safe = np.where(S0 == 0, 1.0, S0)
|
|
285
|
+
s1, s2 = (abs(Ex)**2 - abs(Ey)**2) / S0_safe, (2 * np.real(Ex * np.conj(Ey))) / S0_safe
|
|
286
|
+
|
|
287
|
+
mask = self._zero_cross_mask(s1) & self._zero_cross_mask(s2)
|
|
288
|
+
coords = np.argwhere(mask)
|
|
289
|
+
dx, dy, success = self._batched_plane_fit_2eq(coords, s1, s2)
|
|
290
|
+
|
|
291
|
+
g_x, g_y, f_x, f_y, conf = self._find_singularities_template(
|
|
292
|
+
z_value, coords, dx, dy, success, self._stokes_c_point_value_and_corrector, max_iter, pos_tol, value_tol
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
found_points = []
|
|
296
|
+
if len(f_x) > 0:
|
|
297
|
+
# Recompute topological properties for the refined points in one batch
|
|
298
|
+
fields = self.engine.compute_cloud(f_x, f_y, np.full_like(f_x, z_value), t=self.t, need_b=False)
|
|
299
|
+
all_S, _, S1_derivs, S2_derivs = self._stokes_and_grads_from_EJ(fields.E, fields.jacobian_E)
|
|
300
|
+
S0_safe_cloud = np.where(all_S[0] > 1e-12, all_S[0], 1.0)
|
|
301
|
+
|
|
302
|
+
S1_x, S1_y = S1_derivs[0]/S0_safe_cloud, S1_derivs[1]/S0_safe_cloud
|
|
303
|
+
S2_x, S2_y = S2_derivs[0]/S0_safe_cloud, S2_derivs[1]/S0_safe_cloud
|
|
304
|
+
D_I = S1_x * S2_y - S1_y * S2_x
|
|
305
|
+
|
|
306
|
+
# Non-linear discriminant for Lemon vs Monstar
|
|
307
|
+
NL_disc = ((2*S1_y + S2_x)**2 - 3*S2_y*(2*S1_x - S2_y)) * ((2*S1_x - S2_y)**2 + 3*S2_x*(2*S1_y + S2_x)) \
|
|
308
|
+
- (2*S1_x*S1_y + S1_x*S2_x - S1_y*S2_y + 4*S2_x*S2_y)**2
|
|
309
|
+
|
|
310
|
+
for i in range(len(f_x)):
|
|
311
|
+
c_type = 'Star' if D_I[i] < 0 else ('Lemon' if NL_disc[i] < 0 else 'Monstar')
|
|
312
|
+
found_points.append({
|
|
313
|
+
'position': (f_x[i], f_y[i], z_value), 'guess': (g_x[i], g_y[i], z_value),
|
|
314
|
+
'type': c_type, 'intensity': float(all_S[0][i]), 'handedness': float(np.sign(all_S[3][i])),
|
|
315
|
+
'confident': bool(conf[i])
|
|
316
|
+
})
|
|
317
|
+
return found_points
|
|
318
|
+
|
|
319
|
+
def _C_T_points_v_and_c(self, E, J):
|
|
320
|
+
"""Returns value and Jacobian for True 3D Circular polarization (E dot E = 0)."""
|
|
321
|
+
E2 = np.sum(E*E, axis=0)
|
|
322
|
+
dE2_dx, dE2_dy = 2 * np.sum(E * J[:, 0, :], axis=0), 2 * np.sum(E * J[:, 1, :], axis=0)
|
|
323
|
+
|
|
324
|
+
f_cp = np.stack([np.real(E2), np.imag(E2)], axis=-1)
|
|
325
|
+
C = np.stack([np.stack([np.real(dE2_dx), np.real(dE2_dy)], axis=-1),
|
|
326
|
+
np.stack([np.imag(dE2_dx), np.imag(dE2_dy)], axis=-1)], axis=1)
|
|
327
|
+
return f_cp, C
|
|
328
|
+
|
|
329
|
+
def find_C_T_points(self, z_value, E_grid, max_iter=10, pos_tol=1e-6, value_tol=1e-6):
|
|
330
|
+
"""
|
|
331
|
+
Finds C^T points where true 3D circular polarization occurs (E·E = 0).
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
z_value : float
|
|
336
|
+
z-coordinate of the observation plane.
|
|
337
|
+
E_grid : np.ndarray
|
|
338
|
+
Electric field array (3, ny, nx) evaluated at `z_value` from the engine.
|
|
339
|
+
max_iter : int, optional
|
|
340
|
+
Newton-Raphson iterations per candidate.
|
|
341
|
+
pos_tol : float, optional
|
|
342
|
+
Position convergence tolerance.
|
|
343
|
+
value_tol : float, optional
|
|
344
|
+
Residual tolerance for E·E.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
list of dict
|
|
349
|
+
List containing 'position', 'guess', and 'confident' status for each point.
|
|
350
|
+
"""
|
|
351
|
+
E2 = np.sum(E_grid**2, axis=0)
|
|
352
|
+
re_E2, im_E2 = np.real(E2), np.imag(E2)
|
|
353
|
+
|
|
354
|
+
coords = np.argwhere(self._zero_cross_mask(re_E2) & self._zero_cross_mask(im_E2))
|
|
355
|
+
dx, dy, success = self._batched_plane_fit_2eq(coords, re_E2, im_E2)
|
|
356
|
+
|
|
357
|
+
g_x, g_y, f_x, f_y, conf = self._find_singularities_template(
|
|
358
|
+
z_value, coords, dx, dy, success, self._C_T_points_v_and_c, max_iter, pos_tol, value_tol
|
|
359
|
+
)
|
|
360
|
+
return [{'position': (f_x[i], f_y[i], z_value), 'guess': (g_x[i], g_y[i], z_value), 'confident': bool(conf[i])}
|
|
361
|
+
for i in range(len(f_x))]
|
|
362
|
+
|
|
363
|
+
def _L_T_points_v_and_c(self, E, J):
|
|
364
|
+
"""
|
|
365
|
+
Computes the minimization step for Vector L-points where N = Re(E) x Im(E) = 0.
|
|
366
|
+
Returns the Normal Equations (J.T @ J and J.T @ val) compatible with generic Newton solver.
|
|
367
|
+
"""
|
|
368
|
+
ReE, ImE = np.real(E), np.imag(E)
|
|
369
|
+
n_vec = np.cross(ReE, ImE, axis=0)
|
|
370
|
+
|
|
371
|
+
dn_dx = np.cross(np.real(J[:, 0, :]), ImE, axis=0) + np.cross(ReE, np.imag(J[:, 0, :]), axis=0)
|
|
372
|
+
dn_dy = np.cross(np.real(J[:, 1, :]), ImE, axis=0) + np.cross(ReE, np.imag(J[:, 1, :]), axis=0)
|
|
373
|
+
|
|
374
|
+
C00, C11 = np.sum(dn_dx * dn_dx, axis=0), np.sum(dn_dy * dn_dy, axis=0)
|
|
375
|
+
C01 = np.sum(dn_dx * dn_dy, axis=0)
|
|
376
|
+
|
|
377
|
+
C = np.stack([np.stack([C00, C01], axis=-1), np.stack([C01, C11], axis=-1)], axis=1)
|
|
378
|
+
v = np.stack([np.sum(dn_dx * n_vec, axis=0), np.sum(dn_dy * n_vec, axis=0)], axis=-1)
|
|
379
|
+
return v, C
|
|
380
|
+
|
|
381
|
+
def find_L_T_points(self, z_value, E_grid, max_iter=10, pos_tol=1e-6, value_tol=1e-6):
|
|
382
|
+
"""
|
|
383
|
+
Finds L^T points where true 3D linear polarization occurs (Re(E) x Im(E) = 0).
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
z_value : float
|
|
388
|
+
z-coordinate of the observation plane.
|
|
389
|
+
E_grid : np.ndarray
|
|
390
|
+
Electric field array (3, ny, nx) evaluated at `z_value` from the engine.
|
|
391
|
+
max_iter : int, optional
|
|
392
|
+
Newton-Raphson iterations per candidate.
|
|
393
|
+
pos_tol : float, optional
|
|
394
|
+
Position convergence tolerance.
|
|
395
|
+
value_tol : float, optional
|
|
396
|
+
Residual tolerance for the normal vector magnitude.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
list of dict
|
|
401
|
+
List containing 'position', 'guess', and 'confident' status for each point.
|
|
402
|
+
"""
|
|
403
|
+
N_e = np.cross(np.real(E_grid), np.imag(E_grid), axis=0)
|
|
404
|
+
coords = np.argwhere(
|
|
405
|
+
self._zero_cross_mask(N_e[0]) & self._zero_cross_mask(N_e[1]) & self._zero_cross_mask(N_e[2])
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
dx, dy, success = self._batched_plane_fit_3eq(coords, N_e[0], N_e[1], N_e[2])
|
|
409
|
+
g_x, g_y, f_x, f_y, conf = self._find_singularities_template(
|
|
410
|
+
z_value, coords, dx, dy, success, self._L_T_points_v_and_c, max_iter, pos_tol, value_tol
|
|
411
|
+
)
|
|
412
|
+
return [{'position': (f_x[i], f_y[i], z_value), 'guess': (g_x[i], g_y[i], z_value), 'confident': bool(conf[i])}
|
|
413
|
+
for i in range(len(f_x))]
|
|
414
|
+
|
|
415
|
+
# ==========================================
|
|
416
|
+
# --- 3D Batched Line Tracing Methods ---
|
|
417
|
+
# ==========================================
|
|
418
|
+
|
|
419
|
+
def _batched_trace_lines(self, starting_points, val_jac_func, ds, max_steps, max_iter, value_tol):
|
|
420
|
+
"""
|
|
421
|
+
Generic routine to trace N lines simultaneously in 3D.
|
|
422
|
+
Uses a Predictor-Corrector method:
|
|
423
|
+
1. Predictor: Steps along the curve's tangent vector (Gradient 1 x Gradient 2).
|
|
424
|
+
2. Corrector: Refines the predicted point back onto the zero-curve via Minimum-Norm
|
|
425
|
+
pseudoinverse updates (underdetermined 2x3 Newton-Raphson).
|
|
426
|
+
"""
|
|
427
|
+
pts = [p['position'] if isinstance(p, dict) else p for p in starting_points]
|
|
428
|
+
if not pts: return []
|
|
429
|
+
|
|
430
|
+
xyz = np.array(pts, dtype=float)
|
|
431
|
+
N_lines = len(xyz)
|
|
432
|
+
trajectories = [[xyz[i].copy()] for i in range(N_lines)]
|
|
433
|
+
|
|
434
|
+
# Track which lines are still successfully being traced
|
|
435
|
+
active = np.ones(N_lines, dtype=bool)
|
|
436
|
+
|
|
437
|
+
for _ in range(max_steps):
|
|
438
|
+
if not np.any(active): break
|
|
439
|
+
|
|
440
|
+
# --- 1. Predictor step (All Active Lines) ---
|
|
441
|
+
fields = self.engine.compute_cloud(xyz[active, 0], xyz[active, 1], xyz[active, 2], t=self.t, need_b=False)
|
|
442
|
+
vals, J = val_jac_func(fields.E, fields.jacobian_E) # J is (M, 2, 3) where M is num active lines
|
|
443
|
+
|
|
444
|
+
# Tangent vector is cross product of the gradients of the two conditions
|
|
445
|
+
tangent = np.cross(J[:, 0, :], J[:, 1, :])
|
|
446
|
+
norm_t = np.linalg.norm(tangent, axis=1)
|
|
447
|
+
valid_t = norm_t > 1e-12
|
|
448
|
+
tangent[valid_t] = tangent[valid_t] / norm_t[valid_t, None]
|
|
449
|
+
|
|
450
|
+
pred_xyz = np.zeros_like(xyz[active])
|
|
451
|
+
pred_xyz[valid_t] = xyz[active][valid_t] + tangent[valid_t] * ds
|
|
452
|
+
|
|
453
|
+
active_indices = np.where(active)[0]
|
|
454
|
+
active[active_indices[~valid_t]] = False # Kill flat tangent lines (likely topological anomalies or bounds)
|
|
455
|
+
|
|
456
|
+
# --- 2. Corrector step (Sub-loop to relax onto line) ---
|
|
457
|
+
corr_xyz = pred_xyz[valid_t]
|
|
458
|
+
corr_active = np.ones(len(corr_xyz), dtype=bool)
|
|
459
|
+
corr_success = np.zeros(len(corr_xyz), dtype=bool)
|
|
460
|
+
|
|
461
|
+
for _ in range(max_iter):
|
|
462
|
+
if not np.any(corr_active): break
|
|
463
|
+
c_fields = self.engine.compute_cloud(corr_xyz[corr_active, 0], corr_xyz[corr_active, 1], corr_xyz[corr_active, 2], t=self.t, need_b=False)
|
|
464
|
+
c_v, c_J = val_jac_func(c_fields.E, c_fields.jacobian_E)
|
|
465
|
+
|
|
466
|
+
# Check convergence
|
|
467
|
+
c_converged = np.linalg.norm(c_v, axis=1) < value_tol
|
|
468
|
+
just_converged = c_converged & corr_active
|
|
469
|
+
corr_success[just_converged] = True
|
|
470
|
+
corr_active[just_converged] = False
|
|
471
|
+
|
|
472
|
+
not_conv = ~c_converged
|
|
473
|
+
if np.any(not_conv):
|
|
474
|
+
# Minimum Norm Pseudoinverse: Delta = J.T * (J * J.T)^-1 * vals
|
|
475
|
+
nc_J, nc_v = c_J[not_conv], c_v[not_conv]
|
|
476
|
+
JJT_00, JJT_11 = np.sum(nc_J[:,0,:]**2, axis=1), np.sum(nc_J[:,1,:]**2, axis=1)
|
|
477
|
+
JJT_01 = np.sum(nc_J[:,0,:] * nc_J[:,1,:], axis=1)
|
|
478
|
+
|
|
479
|
+
det = JJT_00*JJT_11 - JJT_01**2
|
|
480
|
+
vdet = np.abs(det) > 1e-14
|
|
481
|
+
inv_det = np.where(vdet, 1.0/det, 0.0)
|
|
482
|
+
|
|
483
|
+
# Solve (J * J.T) * lambda = vals
|
|
484
|
+
lam_0 = ( JJT_11*nc_v[:,0] - JJT_01*nc_v[:,1]) * inv_det
|
|
485
|
+
lam_1 = (-JJT_01*nc_v[:,0] + JJT_00*nc_v[:,1]) * inv_det
|
|
486
|
+
|
|
487
|
+
# Update X = X - J.T * lambda
|
|
488
|
+
update_idx = np.where(corr_active)[0][not_conv]
|
|
489
|
+
corr_xyz[update_idx, 0] -= nc_J[:,0,0]*lam_0 + nc_J[:,1,0]*lam_1
|
|
490
|
+
corr_xyz[update_idx, 1] -= nc_J[:,0,1]*lam_0 + nc_J[:,1,1]*lam_1
|
|
491
|
+
corr_xyz[update_idx, 2] -= nc_J[:,0,2]*lam_0 + nc_J[:,1,2]*lam_1
|
|
492
|
+
|
|
493
|
+
corr_active[update_idx[~vdet]] = False # Kill singular lines
|
|
494
|
+
|
|
495
|
+
# --- 3. Apply successful steps to global trajectory tracker ---
|
|
496
|
+
valid_active_indices = active_indices[valid_t]
|
|
497
|
+
active[valid_active_indices[~corr_success]] = False # Kill globally if Corrector failed
|
|
498
|
+
|
|
499
|
+
success_local = np.where(corr_success)[0]
|
|
500
|
+
success_global = valid_active_indices[success_local]
|
|
501
|
+
xyz[success_global] = corr_xyz[success_local]
|
|
502
|
+
|
|
503
|
+
for local_i, global_i in zip(success_local, success_global):
|
|
504
|
+
trajectories[global_i].append(corr_xyz[local_i].copy())
|
|
505
|
+
|
|
506
|
+
return [np.array(t) for t in trajectories]
|
|
507
|
+
|
|
508
|
+
def _stokes_C_val_jac_3d(self, E, J):
|
|
509
|
+
"""Returns normalized [S1, S2] and full 2x3 Jacobian for tracing Stokes C-lines."""
|
|
510
|
+
(S0, S1, S2, _), grad_S0, grad_S1, grad_S2 = self._stokes_and_grads_from_EJ(E, J)
|
|
511
|
+
S0_safe = np.where(S0 > 1e-12, S0, 1.0)
|
|
512
|
+
vals = np.stack([S1, S2], axis=-1) / S0_safe[:, None]
|
|
513
|
+
jac = np.stack([(S0 * grad_S1 - S1 * grad_S0).T / S0_safe**2,
|
|
514
|
+
(S0 * grad_S2 - S2 * grad_S0).T / S0_safe**2], axis=1)
|
|
515
|
+
return vals, jac
|
|
516
|
+
|
|
517
|
+
def trace_stokes_C_lines(self, starting_points, ds=0.05, max_steps=500, max_iter=10, value_tol=1e-6):
|
|
518
|
+
"""
|
|
519
|
+
Traces Stokes C-lines in 3D (curves where s1 = s2 = 0) originating from seed points.
|
|
520
|
+
|
|
521
|
+
Parameters
|
|
522
|
+
----------
|
|
523
|
+
starting_points : list of dict or list of tuple
|
|
524
|
+
Seed points. Can be the dictionaries returned by `find_stokes_C_points()`
|
|
525
|
+
or raw (x,y,z) coordinate tuples.
|
|
526
|
+
ds : float, optional
|
|
527
|
+
Step size spatial parameter for tracing along the tangent.
|
|
528
|
+
max_steps : int, optional
|
|
529
|
+
Maximum number of tracing steps per line before giving up/stopping.
|
|
530
|
+
max_iter : int, optional
|
|
531
|
+
Corrector (Newton) iterations allowed per spatial step.
|
|
532
|
+
value_tol : float, optional
|
|
533
|
+
Residual tolerance for the line condition (sqrt(s1^2 + s2^2)).
|
|
534
|
+
|
|
535
|
+
Returns
|
|
536
|
+
-------
|
|
537
|
+
list of np.ndarray
|
|
538
|
+
List containing a trajectory array of shape (N_steps, 3) for each seed point.
|
|
539
|
+
"""
|
|
540
|
+
return self._batched_trace_lines(starting_points, self._stokes_C_val_jac_3d, ds, max_steps, max_iter, value_tol)
|
|
541
|
+
|
|
542
|
+
def _C_T_val_jac_3d(self, E, J):
|
|
543
|
+
"""Returns [Re(E^2), Im(E^2)] and 2x3 Jacobian for Vector C^T lines."""
|
|
544
|
+
E2 = np.sum(E*E, axis=0)
|
|
545
|
+
dE2_dx, dE2_dy, dE2_dz = 2 * np.sum(E * J[:, 0, :], axis=0), 2 * np.sum(E * J[:, 1, :], axis=0), 2 * np.sum(E * J[:, 2, :], axis=0)
|
|
546
|
+
return np.stack([np.real(E2), np.imag(E2)], axis=-1), \
|
|
547
|
+
np.stack([np.stack([np.real(dE2_dx), np.real(dE2_dy), np.real(dE2_dz)], axis=-1),
|
|
548
|
+
np.stack([np.imag(dE2_dx), np.imag(dE2_dy), np.imag(dE2_dz)], axis=-1)], axis=1)
|
|
549
|
+
|
|
550
|
+
def trace_C_T_lines(self, starting_points, ds=0.05, max_steps=500, max_iter=10, value_tol=1e-6):
|
|
551
|
+
"""
|
|
552
|
+
Traces C^T lines in 3D (curves where E·E = 0) originating from seed points.
|
|
553
|
+
|
|
554
|
+
Parameters
|
|
555
|
+
----------
|
|
556
|
+
starting_points : list of dict or list of tuple
|
|
557
|
+
Seed points. Can be dictionaries from `find_C_T_points()` or (x,y,z) tuples.
|
|
558
|
+
ds : float, optional
|
|
559
|
+
Step size spatial parameter for tracing along the tangent.
|
|
560
|
+
max_steps : int, optional
|
|
561
|
+
Maximum number of tracing steps per line.
|
|
562
|
+
max_iter : int, optional
|
|
563
|
+
Corrector (Newton) iterations allowed per spatial step.
|
|
564
|
+
value_tol : float, optional
|
|
565
|
+
Residual tolerance for the line condition (abs(E·E)).
|
|
566
|
+
|
|
567
|
+
Returns
|
|
568
|
+
-------
|
|
569
|
+
list of np.ndarray
|
|
570
|
+
List containing a trajectory array of shape (N_steps, 3) for each seed point.
|
|
571
|
+
"""
|
|
572
|
+
return self._batched_trace_lines(starting_points, self._C_T_val_jac_3d, ds, max_steps, max_iter, value_tol)
|
|
573
|
+
|
|
574
|
+
def _L_T_val_jac_3d(self, E, J):
|
|
575
|
+
"""
|
|
576
|
+
Returns values and 2x3 Jacobian for Vector L-Lines (True Linear Polarization).
|
|
577
|
+
|
|
578
|
+
Math Note:
|
|
579
|
+
The condition N = Re(E) x Im(E) = 0 is codimension 2.
|
|
580
|
+
The components of N are dependent (N is orthogonal to E). Therefore, vanishing
|
|
581
|
+
of any two components implies vanishing of the third.
|
|
582
|
+
We solve for the intersection of N_y = 0 and N_z = 0 to trace the curve.
|
|
583
|
+
"""
|
|
584
|
+
ReE, ImE = np.real(E), np.imag(E)
|
|
585
|
+
n_vec = np.cross(ReE, ImE, axis=0)
|
|
586
|
+
|
|
587
|
+
dn_dx = np.cross(np.real(J[:, 0, :]), ImE, axis=0) + np.cross(ReE, np.imag(J[:, 0, :]), axis=0)
|
|
588
|
+
dn_dy = np.cross(np.real(J[:, 1, :]), ImE, axis=0) + np.cross(ReE, np.imag(J[:, 1, :]), axis=0)
|
|
589
|
+
dn_dz = np.cross(np.real(J[:, 2, :]), ImE, axis=0) + np.cross(ReE, np.imag(J[:, 2, :]), axis=0)
|
|
590
|
+
|
|
591
|
+
return np.stack([n_vec[1], n_vec[2]], axis=-1), \
|
|
592
|
+
np.stack([np.stack([dn_dx[1], dn_dy[1], dn_dz[1]], axis=-1),
|
|
593
|
+
np.stack([dn_dx[2], dn_dy[2], dn_dz[2]], axis=-1)], axis=1)
|
|
594
|
+
|
|
595
|
+
def trace_L_lines(self, starting_points, ds=0.05, max_steps=500, max_iter=10, value_tol=1e-6):
|
|
596
|
+
"""
|
|
597
|
+
Traces L^T lines in 3D (curves where Re(E) x Im(E) = 0) originating from seed points.
|
|
598
|
+
|
|
599
|
+
Parameters
|
|
600
|
+
----------
|
|
601
|
+
starting_points : list of dict or list of tuple
|
|
602
|
+
Seed points. Can be dictionaries from `find_L_T_points()` or (x,y,z) tuples.
|
|
603
|
+
ds : float, optional
|
|
604
|
+
Step size spatial parameter for tracing along the tangent.
|
|
605
|
+
max_steps : int, optional
|
|
606
|
+
Maximum number of tracing steps per line.
|
|
607
|
+
max_iter : int, optional
|
|
608
|
+
Corrector (Newton) iterations allowed per spatial step.
|
|
609
|
+
value_tol : float, optional
|
|
610
|
+
Residual tolerance for the line condition (abs(N_y) and abs(N_z)).
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
list of np.ndarray
|
|
615
|
+
List containing a trajectory array of shape (N_steps, 3) for each seed point.
|
|
616
|
+
"""
|
|
617
|
+
return self._batched_trace_lines(starting_points, self._L_T_val_jac_3d, ds, max_steps, max_iter, value_tol)
|