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.
@@ -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)