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,379 @@
1
+ """
2
+ Field Engine & Results
3
+ ======================
4
+
5
+ The final stage of the VectorWaves pipeline. This module provides the `FieldEngine`,
6
+ which orchestrates the spatial evaluation of electromagnetic fields by routing
7
+ precomputed `Beam` objects to hardware backends (NumPy, Numba or CuPy).
8
+
9
+ All computations are encapsulated in the `FieldResult` container, which provides
10
+ a unified interface for Electric field components, vector calculus operations (Curl,
11
+ Divergence), and the Jacobian tensor as well as the Magnetic field components.
12
+
13
+ Pipeline Context:
14
+ 1. Config (config_stuff.py) -> Define parameters.
15
+ 2. Beam (beam_stuff.py) -> Precompute spectrum/weights via BeamMaker.
16
+ 3. Engine (engine_stuff.py) -> Evaluate fields on points/grids via FieldEngine.
17
+ """
18
+
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import Optional, TYPE_CHECKING
22
+
23
+ import numpy as np
24
+
25
+ from .config_stuff import Config
26
+ from .beam_stuff import Beam
27
+
28
+ # Import backends
29
+ from .backends.numpy_backend import NumpyMethods
30
+ try:
31
+ from .backends.numba_backend import NumbaMethods, has_numba
32
+ except ImportError:
33
+ NumbaMethods = None
34
+ has_numba = False
35
+ try:
36
+ from .backends.cupy_backend import CupyMethods, has_cupy
37
+ except ImportError:
38
+ CupyMethods = None
39
+ has_cupy = False
40
+
41
+ # tqdm
42
+ if TYPE_CHECKING:
43
+ from tqdm import tqdm
44
+ else:
45
+ try:
46
+ from tqdm import tqdm
47
+ except ImportError:
48
+ class tqdm:
49
+ def __init__(self, *args, **kwargs): pass
50
+ def update(self, n): pass
51
+ def close(self): pass
52
+
53
+ @dataclass
54
+ class FieldResult:
55
+ """
56
+ Structured container for electromagnetic field data and its derivatives.
57
+
58
+ This class provides a unified interface for accessing field components,
59
+ vector calculus operations (Divergence, Curl), and the Jacobian tensor
60
+ regardless of whether the evaluation was at a point, a cloud, or a grid.
61
+
62
+ Shape Conventions
63
+ -----------------
64
+ Let `domain_shape` be the spatial dimensions of the evaluation:
65
+ - compute_point: ()
66
+ - compute_cloud: (N,)
67
+ - compute_grid: (Ny, Nx)
68
+
69
+ - E, B: (3, *domain_shape)
70
+ - jacobian_E: (3, 3, *domain_shape)
71
+ - div_E: (*domain_shape)
72
+ - curl_E: (3, *domain_shape)
73
+
74
+ Attributes
75
+ ----------
76
+ E : np.ndarray
77
+ The Electric field vector.
78
+ E[0, ...] is Ex, E[1, ...] is Ey, E[2, ...] is Ez.
79
+ """
80
+ E: np.ndarray
81
+ _B: Optional[np.ndarray] = None
82
+ _jacobian_E: Optional[np.ndarray] = None
83
+
84
+ def __repr__(self) -> str:
85
+ shape = self.E.shape[1:] if self.E.ndim > 1 else "Point"
86
+ has_b = self._B is not None
87
+ has_jac = self._jacobian_E is not None
88
+ return f"<FieldResult: Grid={shape}, B_computed={has_b}, Derivs_computed={has_jac}>"
89
+
90
+ @property
91
+ def B(self) -> np.ndarray:
92
+ """
93
+ The Magnetic field vector (3, *domain_shape).
94
+ Raises RuntimeError if 'need_b' was False during computation.
95
+ """
96
+ if self._B is None:
97
+ raise RuntimeError("Magnetic field (B) was not computed. Set need_b=True.")
98
+ return self._B
99
+
100
+ @property
101
+ def jacobian_E(self) -> np.ndarray:
102
+ """
103
+ The Jacobian matrix of the Electric field (3, 3, *domain_shape).
104
+ Raises RuntimeError if 'need_derivs' was False during computation.
105
+
106
+ Layout (Numerator Convention):
107
+ The first index (i) corresponds to the E-field component (Ex, Ey, Ez).
108
+ The second index (j) corresponds to the spatial derivative(d/dx, d/dy, d/dz).
109
+
110
+ result[i, j, ...] = dE_i / dx_j
111
+
112
+ Example:
113
+ >>> # Get dEy / dz
114
+ >>> dEy_dz = field.jacobian_E[1, 2]
115
+ """
116
+ if self._jacobian_E is None:
117
+ raise RuntimeError("Derivatives were not computed. Set 'need_derivs=True'.")
118
+ return self._jacobian_E
119
+
120
+ # --- Convenience Slice Accessors ---
121
+ @property
122
+ def dE_dx(self) -> np.ndarray: return self.jacobian_E[:, 0, ...]
123
+
124
+ @property
125
+ def dE_dy(self) -> np.ndarray: return self.jacobian_E[:, 1, ...]
126
+
127
+ @property
128
+ def dE_dz(self) -> np.ndarray: return self.jacobian_E[:, 2, ...]
129
+
130
+ # --- Derived Physical Quantities ---
131
+ @property
132
+ def div_E(self) -> np.ndarray:
133
+ """
134
+ Divergence: div(E)
135
+ Computed as the trace of the Electric field Jacobian.
136
+ """
137
+ return np.trace(self.jacobian_E, axis1=0, axis2=1)
138
+
139
+ @property
140
+ def curl_E(self) -> np.ndarray:
141
+ """
142
+ Curl: curl(E)
143
+ Computed from the anti-symmetric components of the Electric field Jacobian.
144
+ """
145
+ j = self.jacobian_E
146
+ return np.stack([
147
+ j[2, 1, ...] - j[1, 2, ...],
148
+ j[0, 2, ...] - j[2, 0, ...],
149
+ j[1, 0, ...] - j[0, 1, ...]
150
+ ], axis=0)
151
+
152
+ @property
153
+ def intensity_E(self) -> np.ndarray:
154
+ """Calculates intensity = |E|^2 over domain."""
155
+ return np.sum(np.abs(self.E)**2, axis=0)
156
+
157
+
158
+ class FieldEngine:
159
+ """
160
+ Main engine for computing spatial electromagnetic fields from a Beam.
161
+ """
162
+ def __init__(self, beam: Beam, config: Config):
163
+ self.beam = beam
164
+ self.config = config
165
+
166
+ self.backend_name = self.selector(self.config.backend)
167
+ if self.config.verbose:
168
+ print(f"--- FieldEngine Initialized (Backend: {self.backend_name}) ---")
169
+
170
+ center_x, center_y = self.config.op.center
171
+ w, h = self.config.op.size
172
+ dx = self.config.op.spacing
173
+
174
+ nx = int(w / dx)
175
+ ny = int(h / dx)
176
+
177
+ self.x = np.linspace(center_x - w/2, center_x + w/2, nx)
178
+ self.y = np.linspace(center_y - h/2, center_y + h/2, ny)
179
+ self.X, self.Y = np.meshgrid(self.x, self.y)
180
+
181
+ self.op_extent = [min(self.x), max(self.x), min(self.y), max(self.y)]
182
+
183
+ def selector(self, choice: str) -> str:
184
+ choice = choice.lower()
185
+ if choice == "auto":
186
+ if has_cupy: return "cupy64"
187
+ return "numba" if has_numba else "numpy"
188
+
189
+ # Define supported strings
190
+ valid_backends = ["numpy", "numba", "cupy32", "cupy64"]
191
+ if choice not in valid_backends:
192
+ raise ValueError(f"Backend '{choice}' not supported. Choose from {valid_backends}")
193
+
194
+ # Check availability
195
+ if choice.startswith("cupy") and not has_cupy:
196
+ raise ValueError("CuPy backend requested but cupy is not installed or no GPU found.")
197
+ if choice == "numba" and not has_numba:
198
+ raise ValueError("Numba backend requested but numba is not installed.")
199
+
200
+ return choice
201
+
202
+ def get_backend(self, backend_name: Optional[str] = None):
203
+ if backend_name is None:
204
+ return self.get_backend(self.backend_name)
205
+ name = self.selector(backend_name)
206
+
207
+ if name == "numpy":
208
+ return NumpyMethods(self.beam)
209
+
210
+ elif name == "numba" and NumbaMethods:
211
+ return NumbaMethods(self.beam)
212
+
213
+ elif name == "cupy32" and CupyMethods:
214
+ return CupyMethods(self.beam, use_single_precision=True)
215
+
216
+ elif name == "cupy64" and CupyMethods:
217
+ return CupyMethods(self.beam, use_single_precision=False)
218
+
219
+ raise RuntimeError(f"Backend '{name}' could not be constructed.")
220
+
221
+ def _wrap_results(self, E: np.ndarray, D: Optional[tuple], B: Optional[np.ndarray]) -> FieldResult:
222
+ """Helper to package raw backend output into FieldResult."""
223
+ E.flags.writeable = False
224
+ if B is not None: B.flags.writeable = False
225
+
226
+ jacobian_E = None
227
+ if D is not None and all(d is not None for d in D):
228
+ jacobian_E = np.stack(D, axis=1)
229
+ jacobian_E.flags.writeable = False
230
+
231
+ return FieldResult(E=E, _B=B, _jacobian_E=jacobian_E)
232
+
233
+ def compute_on_op(
234
+ self, z: float = 0.0, t: float = 0.0,
235
+ need_b: bool = True, need_derivs: bool = True,
236
+ backend_name: Optional[str] = None, progress_bar: bool = False
237
+ ) -> FieldResult:
238
+ """
239
+ Computes the electromagnetic field arrays on the Observation Plane (OP).
240
+
241
+ The observation plane dimensions and resolution are determined by the
242
+ `Config.op` settings passed during initialization.
243
+
244
+ Parameters
245
+ ----------
246
+ z : float
247
+ The longitudinal z-coordinate of the observation plane.
248
+ t : float
249
+ The time step for the field evaluation.
250
+ need_b : bool
251
+ If True, calculates and returns the Magnetic field (B).
252
+ need_derivs : bool
253
+ If True, calculates and returns spatial derivatives (Jacobian).
254
+ backend_name : str, optional
255
+ Override the default backend for this specific call.
256
+ progress_bar : bool, optional
257
+ Provides a tqdm progress bar if available.
258
+
259
+ Returns
260
+ -------
261
+ FieldResult
262
+ Object containing E (3, Ny, Nx) and optionally B and Jacobian.
263
+ """
264
+ if np.ndim(z) != 0 or np.ndim(t) != 0:
265
+ raise ValueError("compute_on_op requires 'z' and 't' to be scalars.")
266
+ backend = self.get_backend(backend_name)
267
+
268
+ if self.config.verbose:
269
+ print(f"OP Grid: {len(self.x)}x{len(self.y)} points | Spacing: {self.config.op.spacing} | Z: {z}")
270
+ t0 = time.time()
271
+
272
+ callback = None
273
+ if progress_bar:
274
+ pbar = tqdm(total=len(self.y), desc="OP Grid", unit="rows")
275
+ callback = lambda n: pbar.update(n)
276
+
277
+ E, D, B = backend.compute_grid(
278
+ self.x, self.y, z, t,
279
+ need_b=need_b, need_derivs=need_derivs,
280
+ progress_callback=callback,
281
+ )
282
+ if progress_bar:
283
+ pbar.close()
284
+
285
+ if self.config.verbose:
286
+ print(f"OP Computation complete in {time.time() - t0:.4f}s")
287
+
288
+ return self._wrap_results(E, D, B)
289
+
290
+ def compute_cloud(
291
+ self, x_arr: np.ndarray, y_arr: np.ndarray, z_arr: np.ndarray, t: float = 0.0,
292
+ need_b: bool = True, need_derivs: bool = True, backend_name: Optional[str] = None,
293
+ progress_bar: bool = False
294
+
295
+ ) -> FieldResult:
296
+ """
297
+ Field calculator for N arbitrary points (cloud).
298
+
299
+ Parameters
300
+ ----------
301
+ x_arr, y_arr, z_arr : np.ndarray
302
+ 1D arrays of shape (N,) specifying evaluation points.
303
+ t : float
304
+ Time coordinate.
305
+ need_b : bool
306
+ If True, includes Magnetic field.
307
+ need_derivs : bool
308
+ If True, includes spatial derivatives.
309
+ progress_bar : bool, optional
310
+ Provides a tqdm progress bar if available.
311
+
312
+ Returns
313
+ -------
314
+ FieldResult
315
+ Object containing E (3, N) and optional fields.
316
+ """
317
+
318
+ x_arr = np.atleast_1d(x_arr)
319
+ y_arr = np.atleast_1d(y_arr)
320
+ z_arr = np.atleast_1d(z_arr)
321
+
322
+ if x_arr.ndim != 1 or y_arr.ndim != 1 or z_arr.ndim != 1:
323
+ raise ValueError(
324
+ f"compute_cloud requires 1D arrays. Got ndims: "
325
+ f"x:{x_arr.ndim}, y:{y_arr.ndim}, z:{z_arr.ndim}"
326
+ )
327
+
328
+ if not (len(x_arr) == len(y_arr) == len(z_arr)):
329
+ raise ValueError(
330
+ f"compute_cloud requires arrays of identical length. "
331
+ f"Got lengths - x:{len(x_arr)}, y:{len(y_arr)}, z:{len(z_arr)}"
332
+ )
333
+
334
+ if np.ndim(t) != 0:
335
+ raise ValueError(f"Time 't' must be a scalar, got ndim={np.ndim(t)}")
336
+
337
+ callback = None
338
+ if progress_bar:
339
+ total = len(x_arr)
340
+ pbar = tqdm(total=total, desc="Point Cloud", unit="pts")
341
+ callback = lambda n: pbar.update(n)
342
+
343
+ backend = self.get_backend(backend_name)
344
+
345
+ E,D,B = backend.compute_cloud(
346
+ x_arr, y_arr, z_arr, t,
347
+ need_b=need_b, need_derivs=need_derivs,
348
+ progress_callback=callback,
349
+ )
350
+ if progress_bar:
351
+ pbar.close()
352
+
353
+ return self._wrap_results(E,D,B)
354
+
355
+ def compute_point(
356
+ self, x: float, y: float, z: float, t: float = 0.0,
357
+ need_b: bool = True, need_derivs: bool = True, backend_name: Optional[str] = None,
358
+ ) -> FieldResult:
359
+ """
360
+ Compute fields at a single exact spacetime point.
361
+
362
+ Returns
363
+ -------
364
+ FieldResult
365
+ Object containing E (3,) and optional fields.
366
+ """
367
+ if np.ndim(x) != 0 or np.ndim(y) != 0 or np.ndim(z) != 0 or np.ndim(t) != 0:
368
+ raise ValueError(
369
+ "compute_point requires pure scalars for x, y, z, and t. "
370
+ "If you want to compute multiple points, use compute_cloud."
371
+ )
372
+
373
+ backend = self.get_backend(backend_name)
374
+
375
+ E,D,B = backend.compute_point(
376
+ x, y, z, t, need_b=need_b,
377
+ need_derivs=need_derivs
378
+ )
379
+ return self._wrap_results(E,D,B)
vectorwaves/py.typed ADDED
File without changes