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,747 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration & Experiment Schema
|
|
3
|
+
=================================
|
|
4
|
+
|
|
5
|
+
The foundation of the VectorWaves pipeline. This module defines the strict data
|
|
6
|
+
structures (`Config`) used to describe physical parameters of beam,
|
|
7
|
+
and observation plane geometry.
|
|
8
|
+
|
|
9
|
+
It ensures physical validity via type checking and provides serialization
|
|
10
|
+
(JSON) for experiment reproducibility.
|
|
11
|
+
|
|
12
|
+
Pipeline Context:
|
|
13
|
+
1. Config (config_stuff.py) -> **START HERE**. Define physics.
|
|
14
|
+
2. Beam (beam_stuff.py) -> Pass Config to BeamMaker.
|
|
15
|
+
3. Engine (engine_stuff.py) -> Use Beam + Config for field evaluation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import warnings
|
|
21
|
+
import numpy as np
|
|
22
|
+
from dataclasses import dataclass, field, is_dataclass, fields
|
|
23
|
+
from typing import Tuple, List, Dict, Any, Optional, Literal, Callable, Union, Type
|
|
24
|
+
from .spectra import KSpaceSpectra, PolychromaticSpectra
|
|
25
|
+
from .version import current_version
|
|
26
|
+
|
|
27
|
+
# =========================================================================
|
|
28
|
+
# VALIDATION HELPERS
|
|
29
|
+
# =========================================================================
|
|
30
|
+
|
|
31
|
+
def _check_scalar(val: Any, name: str, dtype: Type, allow_complex: bool = False) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Strictly validates a scalar value against a specific type and ensures finiteness.
|
|
34
|
+
"""
|
|
35
|
+
if not allow_complex and isinstance(val, (complex, np.complex128, np.complex64)):
|
|
36
|
+
raise TypeError(f"Config Error ['{name}']: Expected {dtype.__name__}, got complex '{val}'")
|
|
37
|
+
|
|
38
|
+
if dtype is int and not isinstance(val, (int, np.integer)):
|
|
39
|
+
raise TypeError(f"Config Error ['{name}']: Expected strict integer, got {type(val).__name__} '{val}'")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
casted = dtype(val)
|
|
43
|
+
except (ValueError, TypeError):
|
|
44
|
+
raise TypeError(f"Config Error ['{name}']: Cannot convert {type(val).__name__} to {dtype.__name__}")
|
|
45
|
+
|
|
46
|
+
if isinstance(casted, (float, np.floating)) and not np.isfinite(casted):
|
|
47
|
+
raise ValueError(f"Config Error ['{name}']: Must be a finite number, got '{casted}'")
|
|
48
|
+
|
|
49
|
+
return casted
|
|
50
|
+
|
|
51
|
+
def _coerce_tuple(val: Any, length: int, name: str, dtype: Type = float) -> Tuple:
|
|
52
|
+
"""
|
|
53
|
+
Validates sequences, allows list->tuple, but enforces dtype strictly.
|
|
54
|
+
"""
|
|
55
|
+
if not hasattr(val, "__iter__") or isinstance(val, (str, bytes)):
|
|
56
|
+
raise TypeError(f"Config Error ['{name}']: Expected a sequence, got {type(val).__name__}")
|
|
57
|
+
|
|
58
|
+
val_list = list(val)
|
|
59
|
+
if len(val_list) != length:
|
|
60
|
+
raise ValueError(f"Config Error ['{name}']: Expected {length} elements, got {len(val_list)}")
|
|
61
|
+
return tuple(_check_scalar(x, f"{name}[{i}]", dtype, allow_complex=(dtype is complex)) for i, x in enumerate(val_list))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# =========================================================================
|
|
65
|
+
# SAVING/LOADING CONFIGS
|
|
66
|
+
# =========================================================================
|
|
67
|
+
|
|
68
|
+
class SerializableConfig:
|
|
69
|
+
"""Base class providing JSON serialization and generic config features."""
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
out = {}
|
|
72
|
+
if self.__class__.__name__ == "Config":
|
|
73
|
+
out["__version__"] = current_version
|
|
74
|
+
for f in fields(self):
|
|
75
|
+
value = getattr(self, f.name)
|
|
76
|
+
out[f.name] = self._serialize_value(value)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
def _serialize_value(self, val):
|
|
80
|
+
if val is None: return None
|
|
81
|
+
elif is_dataclass(val): return val.to_dict()
|
|
82
|
+
elif isinstance(val, (complex, np.complex64, np.complex128)):
|
|
83
|
+
return {"__complex__": True, "real": val.real, "imag": val.imag}
|
|
84
|
+
elif callable(val): return self._serialize_callable(val)
|
|
85
|
+
elif isinstance(val, (list, tuple, np.ndarray)):
|
|
86
|
+
return[self._serialize_value(i) for i in (val.tolist() if isinstance(val, np.ndarray) else val)]
|
|
87
|
+
elif isinstance(val, dict): return {k: self._serialize_value(v) for k, v in val.items()}
|
|
88
|
+
elif isinstance(val, np.generic): return val.item()
|
|
89
|
+
return val
|
|
90
|
+
|
|
91
|
+
def _serialize_callable(self, func):
|
|
92
|
+
if func is None: return None
|
|
93
|
+
for cls in [KSpaceSpectra, PolychromaticSpectra]:
|
|
94
|
+
for attr_name in dir(cls):
|
|
95
|
+
if attr_name.startswith("__"): continue
|
|
96
|
+
attr = getattr(cls, attr_name)
|
|
97
|
+
if attr is func:
|
|
98
|
+
return {
|
|
99
|
+
"__callable__": True,
|
|
100
|
+
"type": "builtin",
|
|
101
|
+
"class": cls.__name__,
|
|
102
|
+
"name": attr_name
|
|
103
|
+
}
|
|
104
|
+
return {"__callable__": True, "type": "custom", "name": func.__name__}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, data: Dict[str, Any]):
|
|
108
|
+
if "__version__" in data:
|
|
109
|
+
version = tuple(data.pop("__version__"))
|
|
110
|
+
if version != current_version: data = cls._migrate(data, version)
|
|
111
|
+
valid_fields = {f.name for f in fields(cls)}
|
|
112
|
+
incoming_fields = set(data.keys())
|
|
113
|
+
unknown = incoming_fields - valid_fields
|
|
114
|
+
if unknown: raise ValueError(f"Unknown configuration fields for {cls.__name__}: {unknown}")
|
|
115
|
+
|
|
116
|
+
sub_classes = {
|
|
117
|
+
'op': OpConfig, 'source': SourceConfig, 'randomize': RandomizeConfig,
|
|
118
|
+
'k_space': KSpaceConfig, 'polychromatic': PolychromaticConfig
|
|
119
|
+
}
|
|
120
|
+
init_args = {}
|
|
121
|
+
for k, v in data.items():
|
|
122
|
+
if v is None: init_args[k] = None
|
|
123
|
+
elif k in sub_classes and isinstance(v, dict):
|
|
124
|
+
init_args[k] = sub_classes[k].from_dict(v)
|
|
125
|
+
else: init_args[k] = cls._deserialize_value(v)
|
|
126
|
+
return cls(**init_args)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _migrate(data: Dict[str, Any], version: int) -> Dict[str, Any]:
|
|
130
|
+
raise ValueError(f"Unsupported config version: {version}")
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def _deserialize_value(cls, val):
|
|
134
|
+
if val is None: return None
|
|
135
|
+
if isinstance(val, dict):
|
|
136
|
+
if val.get("__complex__"): return complex(val["real"], val["imag"])
|
|
137
|
+
if val.get("__callable__"): return cls._deserialize_callable(val)
|
|
138
|
+
return {k: cls._deserialize_value(v) for k, v in val.items()}
|
|
139
|
+
elif isinstance(val, list): return [cls._deserialize_value(item) for item in val]
|
|
140
|
+
return val
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def custom_callable_fail(*args, **kwargs):
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"Custom callable(s) not initialized properly! You must redefine it before execution."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _deserialize_callable(data):
|
|
150
|
+
if data.get("type") == "custom":
|
|
151
|
+
warnings.warn(
|
|
152
|
+
f"Custom callable '{data.get('name')}' found in json. Redefine before execution.",
|
|
153
|
+
UserWarning
|
|
154
|
+
)
|
|
155
|
+
return SerializableConfig.custom_callable_fail
|
|
156
|
+
|
|
157
|
+
target_name = data.get("name")
|
|
158
|
+
target_class = data.get("class")
|
|
159
|
+
|
|
160
|
+
# Routing based on explicit class name
|
|
161
|
+
if target_class == "KSpaceSpectra" and hasattr(KSpaceSpectra, target_name):
|
|
162
|
+
return getattr(KSpaceSpectra, target_name)
|
|
163
|
+
if target_class == "PolychromaticSpectra" and hasattr(PolychromaticSpectra, target_name):
|
|
164
|
+
return getattr(PolychromaticSpectra, target_name)
|
|
165
|
+
|
|
166
|
+
raise RuntimeError("Deserializing callable failed.")
|
|
167
|
+
|
|
168
|
+
def save(self, filename: str):
|
|
169
|
+
if not filename.endswith(".json"): raise ValueError("filename must end with '.json'")
|
|
170
|
+
with open(filename, 'w') as f: json.dump(self.to_dict(), f, indent=4)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def load(cls, filename: str):
|
|
174
|
+
with open(filename, 'r') as f: data = json.load(f)
|
|
175
|
+
return cls.from_dict(data)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# =========================================================================
|
|
179
|
+
# CONFIGURATION CLASSES
|
|
180
|
+
# =========================================================================
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class OpConfig(SerializableConfig):
|
|
184
|
+
"""
|
|
185
|
+
Observation Plane Configuration.
|
|
186
|
+
|
|
187
|
+
Defines the spatial grid where the optical field is evaluated. Access this
|
|
188
|
+
via `config.op`.
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
`config.op.size = (10.0, 10.0)`
|
|
192
|
+
`config.op.spacing = 0.05`
|
|
193
|
+
|
|
194
|
+
Attributes
|
|
195
|
+
----------
|
|
196
|
+
spacing : float
|
|
197
|
+
Grid pixel spacing. Must be strictly > 0.
|
|
198
|
+
size : tuple[float, float]
|
|
199
|
+
Physical size of the rectangular plane (width, height).
|
|
200
|
+
center : tuple[float, float]
|
|
201
|
+
Center position (x, y) of the plane relative to the global origin (0,0).
|
|
202
|
+
"""
|
|
203
|
+
spacing: float = 0.05
|
|
204
|
+
size: Tuple[float, float] = (10.0, 10.0)
|
|
205
|
+
center: Tuple[float, float] = (0.0, 0.0)
|
|
206
|
+
|
|
207
|
+
def validate(self):
|
|
208
|
+
self.spacing = _check_scalar(self.spacing, "op.spacing", float)
|
|
209
|
+
if self.spacing <= 0: raise ValueError("op.spacing must be > 0")
|
|
210
|
+
self.size = _coerce_tuple(self.size, 2, "op.size", float)
|
|
211
|
+
if any(d <= 0 for d in self.size): raise ValueError("op.size must be positive")
|
|
212
|
+
self.center = _coerce_tuple(self.center, 2, "op.center", float)
|
|
213
|
+
|
|
214
|
+
def __post_init__(self):
|
|
215
|
+
self.validate()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(slots=True)
|
|
219
|
+
class RandomizeConfig(SerializableConfig):
|
|
220
|
+
"""
|
|
221
|
+
Stochastic Process Configuration.
|
|
222
|
+
|
|
223
|
+
Controls which aspects of the light field are randomized during generation
|
|
224
|
+
to simulate partially coherent beams, diffuse light, or specific speckle stats.
|
|
225
|
+
Access this via `config.source.randomize`.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
To simulate fully coherent deterministic light:
|
|
229
|
+
`config.source.randomize.off()`
|
|
230
|
+
|
|
231
|
+
Attributes
|
|
232
|
+
----------
|
|
233
|
+
seed : int
|
|
234
|
+
Seed for the random number generator ensuring reproducible fields.
|
|
235
|
+
|
|
236
|
+
phase_max : float
|
|
237
|
+
Applies a random phase offset phi to each mode
|
|
238
|
+
drawn from Uniform(-phase_max, +phase_max).
|
|
239
|
+
This controls the degree of phase randomness:
|
|
240
|
+
- phase_max = 0 for fully deterministic phase
|
|
241
|
+
- 0 < phase_max < pi for partially randomized phase
|
|
242
|
+
- phase_max = pi for fully randomized phase
|
|
243
|
+
Must lie in [0, pi].
|
|
244
|
+
|
|
245
|
+
pol_rot_max : float
|
|
246
|
+
Maximum random rotation angle applied to the polarization vector
|
|
247
|
+
drawn from Uniform(-pol_rot_max, +pol_rot_max)
|
|
248
|
+
Controls the spread of polarization orientations:
|
|
249
|
+
- pol_rot_max = 0 for deterministic polarization orientation
|
|
250
|
+
- 0 < pol_rot_max < pi for partially randomized orientation
|
|
251
|
+
- pol_rot_max = pi for fully randomized orientation
|
|
252
|
+
Must lie in [0, pi].
|
|
253
|
+
|
|
254
|
+
Ignored if `pol_state=True`.
|
|
255
|
+
|
|
256
|
+
pol_state : bool
|
|
257
|
+
If True, samples fully random polarization states from the Poincaré sphere.
|
|
258
|
+
Overrides `pol_rot_max`.
|
|
259
|
+
|
|
260
|
+
amplitude : bool
|
|
261
|
+
If True, applies a complex random normal factor to the mode amplitude.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
seed: int = 24459
|
|
265
|
+
pol_rot_max: float = np.pi
|
|
266
|
+
phase_max : float = np.pi
|
|
267
|
+
pol_state : bool = False
|
|
268
|
+
amplitude : bool = True
|
|
269
|
+
|
|
270
|
+
def validate(self):
|
|
271
|
+
for f in["pol_state", "amplitude"]:
|
|
272
|
+
val = getattr(self, f)
|
|
273
|
+
if not isinstance(val, (bool, np.bool_)):
|
|
274
|
+
raise TypeError(f"Config Error ['randomize.{f}']: Expected bool, got {type(val).__name__}")
|
|
275
|
+
self.seed = _check_scalar(self.seed, "randomize.seed", int)
|
|
276
|
+
|
|
277
|
+
self.phase_max = _check_scalar(self.phase_max, "randomize.phase_max", float)
|
|
278
|
+
if not 0 <= self.phase_max <= np.pi:
|
|
279
|
+
raise ValueError("Random phase_max must be in [0, pi].")
|
|
280
|
+
self.pol_rot_max = _check_scalar(self.pol_rot_max, "randomize.pol_rot_max", float)
|
|
281
|
+
if not 0 <= self.pol_rot_max <= np.pi:
|
|
282
|
+
raise ValueError("Random pol_rot_max must be in [0, pi].")
|
|
283
|
+
|
|
284
|
+
if self.pol_state and self.pol_rot_max > 0:
|
|
285
|
+
warnings.warn(
|
|
286
|
+
"'pol_state' is True and 'pol_rot_max' is present. 'pol_state' overrides 'pol_rot_max'.",
|
|
287
|
+
UserWarning
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def off(self) -> "RandomizeConfig":
|
|
291
|
+
"""Turns off all randomness, making the beam perfectly coherent and deterministic."""
|
|
292
|
+
self.pol_rot_max = 0
|
|
293
|
+
self.phase_max = 0
|
|
294
|
+
self.pol_state = False
|
|
295
|
+
self.amplitude = False
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
def __post_init__(self):
|
|
299
|
+
self.validate()
|
|
300
|
+
|
|
301
|
+
@dataclass(slots=True)
|
|
302
|
+
class KSpaceConfig(SerializableConfig):
|
|
303
|
+
"""
|
|
304
|
+
k-Space Spatial Profile Configuration.
|
|
305
|
+
|
|
306
|
+
Controls how plane wave modes are weighted depending on their wavevector.
|
|
307
|
+
Access this via `config.source.k_space`.
|
|
308
|
+
|
|
309
|
+
Use fluent methods to modify the distribution:
|
|
310
|
+
`config.source.k_space.gaussian(sigma_k_perp=2.0)`
|
|
311
|
+
`config.source.k_space.laguerre_gauss(p=0, l=1)`
|
|
312
|
+
|
|
313
|
+
Attributes
|
|
314
|
+
----------
|
|
315
|
+
profile : Callable
|
|
316
|
+
The active profile function (from `KSpaceSpectra` or custom).
|
|
317
|
+
params : Dict[str, Any]
|
|
318
|
+
Keyword arguments dynamically passed into the `profile` callable.
|
|
319
|
+
vectorised : bool
|
|
320
|
+
If True, indicates the callable supports numpy array vectorization.
|
|
321
|
+
"""
|
|
322
|
+
profile: Callable = field(default=KSpaceSpectra.gaussian)
|
|
323
|
+
params: Dict[str, Any] = field(default_factory=lambda: {"sigma_k_perp": 1.5})
|
|
324
|
+
vectorised: bool = True
|
|
325
|
+
|
|
326
|
+
def uniform(self) -> "KSpaceConfig":
|
|
327
|
+
"""
|
|
328
|
+
Sets k-space profile to a flat angular spectrum (Uniform k-space).
|
|
329
|
+
Weights all plane waves equally.
|
|
330
|
+
"""
|
|
331
|
+
self.profile = KSpaceSpectra.uniform
|
|
332
|
+
self.params = {}
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def gaussian(self, sigma_k_perp: float = 1.5) -> "KSpaceConfig":
|
|
336
|
+
"""
|
|
337
|
+
Sets k-space profile to a Gaussian envelope.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
sigma_k_perp : float, default=1.5
|
|
342
|
+
The standard deviation of the Gaussian envelope in k-space.
|
|
343
|
+
Inversely proportional to the real-space beam waist.
|
|
344
|
+
"""
|
|
345
|
+
self.profile = KSpaceSpectra.gaussian
|
|
346
|
+
self.params = {'sigma_k_perp': sigma_k_perp}
|
|
347
|
+
self.vectorised = True
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
def tophat(self, k_perp_max: float = 1.0) -> "KSpaceConfig":
|
|
351
|
+
"""
|
|
352
|
+
Sets k-space profile to a sharp Top-Hat cutoff, producing an Airy disk
|
|
353
|
+
in real space.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
k_perp_max : float, default=1.0
|
|
358
|
+
The maximum transverse spatial frequency allowed.
|
|
359
|
+
"""
|
|
360
|
+
self.profile = KSpaceSpectra.tophat
|
|
361
|
+
self.params = {'k_perp_max': k_perp_max}
|
|
362
|
+
self.vectorised = True
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
def laguerre_gauss(self, *, p: int = 0, l: int = 0, sigma_k_perp: float = 0.5) -> "KSpaceConfig":
|
|
366
|
+
"""
|
|
367
|
+
Sets k-space profile to a Laguerre-Gauss mode carrying
|
|
368
|
+
Orbital Angular Momentum.
|
|
369
|
+
|
|
370
|
+
Parameters
|
|
371
|
+
----------
|
|
372
|
+
p : int, default=0
|
|
373
|
+
Radial index (p >= 0). Determines the number of radial rings.
|
|
374
|
+
l : int, default=0
|
|
375
|
+
Azimuthal index (topological charge). Determines the OAM.
|
|
376
|
+
sigma_k_perp : float, default=0.5
|
|
377
|
+
Transverse scaling parameter in k-space.
|
|
378
|
+
"""
|
|
379
|
+
if p < 0: raise ValueError("LG index p must be >= 0")
|
|
380
|
+
self.profile = KSpaceSpectra.laguerre_gauss
|
|
381
|
+
self.params = {'p': p, 'l': l, 'sigma_k_perp': sigma_k_perp}
|
|
382
|
+
self.vectorised = True
|
|
383
|
+
return self
|
|
384
|
+
|
|
385
|
+
def hermite_gauss(self, *, l: int = 0, m: int = 0, sigma_k_perp: float = 0.5) -> "KSpaceConfig":
|
|
386
|
+
"""
|
|
387
|
+
Sets k-space profile to a higher-order Hermite-Gauss transverse mode.
|
|
388
|
+
|
|
389
|
+
Parameters
|
|
390
|
+
----------
|
|
391
|
+
l : int, default=0
|
|
392
|
+
Transverse mode index in the x-direction (l >= 0).
|
|
393
|
+
m : int, default=0
|
|
394
|
+
Transverse mode index in the y-direction (m >= 0).
|
|
395
|
+
sigma_k_perp : float, default=0.5
|
|
396
|
+
Transverse scaling parameter in k-space.
|
|
397
|
+
"""
|
|
398
|
+
if l < 0 or m < 0: raise ValueError("HG indices l, m must be >= 0")
|
|
399
|
+
self.profile = KSpaceSpectra.hermite_gauss
|
|
400
|
+
self.params = {'l': l, 'm': m, 'sigma_k_perp': sigma_k_perp}
|
|
401
|
+
self.vectorised = True
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
def bessel_gauss(self, *, theta_deg: float = 10.0, sigma_theta: float = 0.05, l: int = 0) -> "KSpaceConfig":
|
|
405
|
+
"""
|
|
406
|
+
Sets k-space profile to a Bessel-Gauss mode, creating a non-diffracting core.
|
|
407
|
+
|
|
408
|
+
Parameters
|
|
409
|
+
----------
|
|
410
|
+
theta_deg : float, default=10.0
|
|
411
|
+
Cone opening angle (in degrees) of the Bessel beam in k-space.
|
|
412
|
+
sigma_theta : float, default=0.05
|
|
413
|
+
Angular thickness (in radians) of the Gaussian ring.
|
|
414
|
+
l : int, default=0
|
|
415
|
+
Topological charge. If l != 0, creates a Higher-Order Bessel beam.
|
|
416
|
+
"""
|
|
417
|
+
if theta_deg > 70:
|
|
418
|
+
warnings.warn(
|
|
419
|
+
"Large Bessel cone angles interact strangely with hemisphere clipping.",
|
|
420
|
+
UserWarning
|
|
421
|
+
)
|
|
422
|
+
self.profile = KSpaceSpectra.bessel_gauss
|
|
423
|
+
self.params = {'theta_0': np.radians(theta_deg), 'sigma_theta': sigma_theta, 'l': l}
|
|
424
|
+
self.vectorised = True
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def custom(self, fn: Callable, vectorised: bool = False, **params) -> "KSpaceConfig":
|
|
428
|
+
"""
|
|
429
|
+
Sets a user-defined custom k-space profile function.
|
|
430
|
+
|
|
431
|
+
Parameters
|
|
432
|
+
----------
|
|
433
|
+
fn : Callable
|
|
434
|
+
Function signature: fn(k: np.ndarray, **params) -> complex/np.ndarray
|
|
435
|
+
vectorised : bool, default=False
|
|
436
|
+
If True, `fn` must accept a (3, N) array and return an (N,) array.
|
|
437
|
+
Else: fn must accept a (3,) array and return a complex number.
|
|
438
|
+
**params : Any
|
|
439
|
+
Keyword arguments passed directly into `fn` during evaluation.
|
|
440
|
+
|
|
441
|
+
Notes
|
|
442
|
+
-----
|
|
443
|
+
The provided function `fn` will be tested with a dummy wavevector
|
|
444
|
+
array (np.random.randn(3, 2) if vectorised else np.random.randn(3))
|
|
445
|
+
to validate its return type and shape.
|
|
446
|
+
"""
|
|
447
|
+
self.profile = fn
|
|
448
|
+
self.params = params
|
|
449
|
+
self.vectorised = vectorised
|
|
450
|
+
self.validate()
|
|
451
|
+
return self
|
|
452
|
+
|
|
453
|
+
def validate(self):
|
|
454
|
+
if getattr(self.profile, '__name__', '') == 'custom_callable_fail': return
|
|
455
|
+
try:
|
|
456
|
+
test_k = np.random.randn(3, 2) if self.vectorised else np.random.randn(3)
|
|
457
|
+
res = self.profile(test_k, **self.params)
|
|
458
|
+
if self.vectorised and (not hasattr(res, "__len__") or len(res) != 2):
|
|
459
|
+
raise ValueError("Vectorised function must return an array matching input shape.")
|
|
460
|
+
elif not self.vectorised and not np.isscalar(res) and not isinstance(res, complex):
|
|
461
|
+
raise ValueError("Non-vectorised function must return a scalar.")
|
|
462
|
+
except Exception as e:
|
|
463
|
+
raise RuntimeError(f"K space profile failed validation: {e}")
|
|
464
|
+
|
|
465
|
+
def __post_init__(self):
|
|
466
|
+
self.validate()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@dataclass(slots=True)
|
|
470
|
+
class PolychromaticConfig(SerializableConfig):
|
|
471
|
+
"""
|
|
472
|
+
Polychromatic Envelope Configuration.
|
|
473
|
+
|
|
474
|
+
Controls the spectral (wavelength) distribution of the beam. Access this
|
|
475
|
+
via `config.source.polychromatic`.
|
|
476
|
+
|
|
477
|
+
Use fluent methods to modify the distribution:
|
|
478
|
+
`config.source.polychromatic.gaussian(center=8.0, sigma=0.5)`
|
|
479
|
+
|
|
480
|
+
Attributes
|
|
481
|
+
----------
|
|
482
|
+
profile : Callable
|
|
483
|
+
The active profile function (from `PolychromaticSpectra` or custom).
|
|
484
|
+
params : Dict[str, Any]
|
|
485
|
+
Keyword arguments passed into the `profile` callable.
|
|
486
|
+
"""
|
|
487
|
+
profile: Callable = field(default=PolychromaticSpectra.uniform)
|
|
488
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
489
|
+
|
|
490
|
+
def uniform(self) -> "PolychromaticConfig":
|
|
491
|
+
"""
|
|
492
|
+
Sets a flat spectral envelope (all wavelengths weighted equally).
|
|
493
|
+
"""
|
|
494
|
+
self.profile = PolychromaticSpectra.uniform
|
|
495
|
+
self.params = {}
|
|
496
|
+
return self
|
|
497
|
+
|
|
498
|
+
def gaussian(self, center: float = 8.0, sigma: float = 0.5) -> "PolychromaticConfig":
|
|
499
|
+
"""
|
|
500
|
+
Sets a Gaussian spectral weight distribution.
|
|
501
|
+
|
|
502
|
+
Parameters
|
|
503
|
+
----------
|
|
504
|
+
center : float, default=8.0
|
|
505
|
+
The central wavelength (mean).
|
|
506
|
+
sigma : float, default=0.5
|
|
507
|
+
The spectral bandwidth (standard deviation).
|
|
508
|
+
"""
|
|
509
|
+
self.profile = PolychromaticSpectra.gaussian
|
|
510
|
+
self.params = {'center': center, 'sigma': sigma}
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
def lorentzian(self, center: float = 8.0, gamma: float = 0.5) -> "PolychromaticConfig":
|
|
514
|
+
"""
|
|
515
|
+
Sets a Lorentzian spectral weight distribution.
|
|
516
|
+
|
|
517
|
+
Parameters
|
|
518
|
+
----------
|
|
519
|
+
center : float, default=8.0
|
|
520
|
+
The central resonance wavelength.
|
|
521
|
+
gamma : float, default=0.5
|
|
522
|
+
The Half-Width at Half-Maximum of the spectrum.
|
|
523
|
+
"""
|
|
524
|
+
self.profile = PolychromaticSpectra.lorentzian
|
|
525
|
+
self.params = {'center': center, 'gamma': gamma}
|
|
526
|
+
return self
|
|
527
|
+
|
|
528
|
+
def tophat(self, center: float = 8.0, width: float = 1.0) -> "PolychromaticConfig":
|
|
529
|
+
"""
|
|
530
|
+
Sets a Top-Hat bandpass spectral envelope.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
center : float, default=8.0
|
|
535
|
+
The central wavelength of the bandpass.
|
|
536
|
+
width : float, default=1.0
|
|
537
|
+
The total spectral width of the bandpass.
|
|
538
|
+
"""
|
|
539
|
+
self.profile = PolychromaticSpectra.tophat
|
|
540
|
+
self.params = {'center': center, 'width': width}
|
|
541
|
+
return self
|
|
542
|
+
|
|
543
|
+
def custom(self, fn: Callable, **params) -> "PolychromaticConfig":
|
|
544
|
+
"""
|
|
545
|
+
Sets a user-defined custom polychromatic envelope.
|
|
546
|
+
|
|
547
|
+
Parameters
|
|
548
|
+
----------
|
|
549
|
+
fn : Callable
|
|
550
|
+
Function signature: fn(wavelength: float, **params) -> float
|
|
551
|
+
**params : Any
|
|
552
|
+
Keyword arguments passed directly into `fn` during evaluation.
|
|
553
|
+
|
|
554
|
+
Notes
|
|
555
|
+
-----
|
|
556
|
+
The provided function `fn` will be tested with a dummy wavelength
|
|
557
|
+
(8.0) to validate that it returns a finite scalar.
|
|
558
|
+
"""
|
|
559
|
+
self.profile = fn
|
|
560
|
+
self.params = params
|
|
561
|
+
self.validate()
|
|
562
|
+
return self
|
|
563
|
+
|
|
564
|
+
def validate(self):
|
|
565
|
+
if getattr(self.profile, '__name__', '').endswith('fail'): return
|
|
566
|
+
try:
|
|
567
|
+
result = self.profile(8.0, **self.params)
|
|
568
|
+
if not np.isscalar(result): raise ValueError(f"Polychromatic envelope must return a scalar. Got {type(result)}.")
|
|
569
|
+
if not np.isfinite(result): raise ValueError(f"Polychromatic envelope returned non-finite value: {result}")
|
|
570
|
+
except Exception as e:
|
|
571
|
+
raise RuntimeError(f"Polychromatic profile failed validation: {e}\nEnsure signature is: fn(wavelength: float, **params) -> float") from e
|
|
572
|
+
|
|
573
|
+
def __post_init__(self):
|
|
574
|
+
self.validate()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@dataclass(slots=True)
|
|
578
|
+
class SourceConfig(SerializableConfig):
|
|
579
|
+
"""
|
|
580
|
+
Light Source Configuration.
|
|
581
|
+
|
|
582
|
+
Aggregates all physical parameters defining the initial state of the light
|
|
583
|
+
field, including nested properties like spatial modes and polarization.
|
|
584
|
+
Access this via `config.source`.
|
|
585
|
+
|
|
586
|
+
Example:
|
|
587
|
+
`config.source.intensity_scale = 1e3`
|
|
588
|
+
`config.source.wavelength = 0.532`
|
|
589
|
+
`config.source.k_space.gaussian(sigma_k_perp=2.0)`
|
|
590
|
+
`config.source.randomize.off()`
|
|
591
|
+
|
|
592
|
+
Attributes
|
|
593
|
+
----------
|
|
594
|
+
k_space : KSpaceConfig
|
|
595
|
+
Sub-configuration dictating the spatial distribution of momentum (k-vectors).
|
|
596
|
+
polychromatic : PolychromaticConfig
|
|
597
|
+
Sub-configuration dictating the spectral/wavelength envelope.
|
|
598
|
+
randomize : RandomizeConfig
|
|
599
|
+
Sub-configuration dictating random noise/coherence features.
|
|
600
|
+
intensity_scale : float
|
|
601
|
+
Scalar multiplier for total field intensity. Must be > 0.
|
|
602
|
+
wavelength : Union[float, List[float]]
|
|
603
|
+
The physical wavelength(s) of the simulation.
|
|
604
|
+
pol_vect : Tuple[complex, complex]
|
|
605
|
+
Jones vector (E1, E2) defining base polarization, normalized upon init.
|
|
606
|
+
beam_axis : Tuple[float, float, float]
|
|
607
|
+
Average propagation direction vector (kx, ky, kz), normalized upon init.
|
|
608
|
+
num_modes : int
|
|
609
|
+
Number of plane wave modes used for the angular spectrum sum.
|
|
610
|
+
theta_max : float
|
|
611
|
+
Maximum half-cone angle (radians) restricting the generated wavevectors.
|
|
612
|
+
"""
|
|
613
|
+
k_space: KSpaceConfig = field(default_factory=KSpaceConfig)
|
|
614
|
+
polychromatic: PolychromaticConfig = field(default_factory=PolychromaticConfig)
|
|
615
|
+
randomize: RandomizeConfig = field(default_factory=RandomizeConfig)
|
|
616
|
+
|
|
617
|
+
intensity_scale: float = 1e3
|
|
618
|
+
wavelength: Union[float, List[float]] = 1.0
|
|
619
|
+
pol_vect: Tuple[complex, complex] = (1+0j, 0+0j)
|
|
620
|
+
beam_axis: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
|
621
|
+
|
|
622
|
+
num_modes: int = 6000
|
|
623
|
+
theta_max: float = np.pi/2
|
|
624
|
+
|
|
625
|
+
def validate(self):
|
|
626
|
+
self.intensity_scale = _check_scalar(self.intensity_scale, "source.intensity_scale", float)
|
|
627
|
+
if self.intensity_scale <= 0: raise ValueError("source.intensity_scale must be > 0")
|
|
628
|
+
|
|
629
|
+
self.num_modes = _check_scalar(self.num_modes, "source.num_modes", int)
|
|
630
|
+
if self.num_modes <= 0: raise ValueError("source.num_modes must be > 0")
|
|
631
|
+
|
|
632
|
+
if np.isscalar(self.wavelength):
|
|
633
|
+
self.wavelength = _check_scalar(self.wavelength, "source.wavelength", float)
|
|
634
|
+
if self.wavelength <= 0: raise ValueError("Wavelength must be > 0")
|
|
635
|
+
else:
|
|
636
|
+
if isinstance(self.wavelength, (np.ndarray, list, tuple)):
|
|
637
|
+
self.wavelength =[_check_scalar(w, "source.wavelength", float) for w in self.wavelength]
|
|
638
|
+
if any(w <= 0 for w in self.wavelength): raise ValueError("All wavelengths must be > 0")
|
|
639
|
+
else:
|
|
640
|
+
raise TypeError(f"Expected float or list of floats for wavelength, got {type(self.wavelength)}")
|
|
641
|
+
|
|
642
|
+
self.pol_vect = _coerce_tuple(self.pol_vect, 2, "source.pol_vect", dtype=complex)
|
|
643
|
+
norm = np.linalg.norm(self.pol_vect)
|
|
644
|
+
if norm < 1e-13: raise ValueError('pol_vect must have reasonable norm.')
|
|
645
|
+
self.pol_vect = (self.pol_vect[0]/norm, self.pol_vect[1]/norm)
|
|
646
|
+
|
|
647
|
+
axis = _coerce_tuple(self.beam_axis, 3, "source.beam_axis", dtype=float)
|
|
648
|
+
norm = np.linalg.norm(axis)
|
|
649
|
+
if norm < 1e-13: raise ValueError("beam_axis must have reasonable norm")
|
|
650
|
+
self.beam_axis = (axis[0]/norm, axis[1]/norm, axis[2]/norm)
|
|
651
|
+
|
|
652
|
+
self.theta_max = _check_scalar(self.theta_max, "source.theta_max", float)
|
|
653
|
+
if not 0 < self.theta_max <= np.pi:
|
|
654
|
+
raise ValueError("For fibonacci sampling, maximum angle must be in (0, pi).")
|
|
655
|
+
|
|
656
|
+
self.k_space.validate()
|
|
657
|
+
self.polychromatic.validate()
|
|
658
|
+
self.randomize.validate()
|
|
659
|
+
|
|
660
|
+
def __post_init__(self):
|
|
661
|
+
self.validate()
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@dataclass(slots=True)
|
|
665
|
+
class Config(SerializableConfig):
|
|
666
|
+
"""
|
|
667
|
+
Main VectorWaves Configuration.
|
|
668
|
+
|
|
669
|
+
This is the root tree for initializing an experiment. It manages the global
|
|
670
|
+
backend, the observation plane settings, and the complex light source properties.
|
|
671
|
+
|
|
672
|
+
Example:
|
|
673
|
+
`config = get_config()`
|
|
674
|
+
`config.op.size = (20.0, 20.0)`
|
|
675
|
+
`config.source.k_space.gaussian(sigma_k_perp=1.0)`
|
|
676
|
+
`config.source.randomize.off()`
|
|
677
|
+
|
|
678
|
+
Attributes
|
|
679
|
+
----------
|
|
680
|
+
backend : Literal["auto", "numpy", "numba", "cupy", "cupy64"]
|
|
681
|
+
Computational backend to execute evaluations. Defaults to "auto".
|
|
682
|
+
All backends perform exactly the same physical superposition of plane waves,
|
|
683
|
+
but scale differently based on hardware:
|
|
684
|
+
- "numpy" : Vectorized CPU fallback (highest RAM usage, best for small sets).
|
|
685
|
+
- "numba" : JIT compiled parallel CPU loops (low RAM usage, fast CPU).
|
|
686
|
+
- "cupy32" : Single-precision GPU execution (fastest for massive computations).
|
|
687
|
+
- "cupy64" : Double-precision GPU execution (slower than cupy, but exact).
|
|
688
|
+
- "auto" : Defaults to cupy64 if available, else numba, else numpy.
|
|
689
|
+
op : OpConfig
|
|
690
|
+
Settings for the Observation Plane (grid size, center, resolution).
|
|
691
|
+
source : SourceConfig
|
|
692
|
+
Settings for the Light Source (beam axis, wavelength, spatial/spectral profiles).
|
|
693
|
+
verbose : bool
|
|
694
|
+
If True, prints execution details and deep warnings.
|
|
695
|
+
"""
|
|
696
|
+
backend: Literal["auto", "numpy", "numba", "cupy32", "cupy64"] = "auto"
|
|
697
|
+
op: OpConfig = field(default_factory=OpConfig)
|
|
698
|
+
source: SourceConfig = field(default_factory=SourceConfig)
|
|
699
|
+
verbose: bool = False
|
|
700
|
+
|
|
701
|
+
def validate(self):
|
|
702
|
+
self.op.validate()
|
|
703
|
+
self.source.validate()
|
|
704
|
+
|
|
705
|
+
self.backend = str(self.backend).lower()
|
|
706
|
+
allowed = ["numba", "numpy", "auto", "cupy32", "cupy64"]
|
|
707
|
+
if self.backend not in allowed:
|
|
708
|
+
raise ValueError(f"Invalid backend: {self.backend}. Must be one of {allowed}")
|
|
709
|
+
|
|
710
|
+
wls = np.atleast_1d(self.source.wavelength)
|
|
711
|
+
min_wl = np.min(wls)
|
|
712
|
+
if self.op.spacing > min_wl / 2:
|
|
713
|
+
warnings.warn(
|
|
714
|
+
f"Spatial aliasing detected. Grid spacing ({self.op.spacing}) "
|
|
715
|
+
f"is larger than half the minimum wavelength ({min_wl/2:.4f}).",
|
|
716
|
+
UserWarning
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
def __post_init__(self):
|
|
720
|
+
self.validate()
|
|
721
|
+
|
|
722
|
+
def get_config() -> Config:
|
|
723
|
+
"""
|
|
724
|
+
Factory function to create a default configuration.
|
|
725
|
+
|
|
726
|
+
Returns
|
|
727
|
+
-------
|
|
728
|
+
Config
|
|
729
|
+
Initialized with standard defaults.
|
|
730
|
+
"""
|
|
731
|
+
return Config()
|
|
732
|
+
|
|
733
|
+
def load_config(filename: str) -> Config:
|
|
734
|
+
"""
|
|
735
|
+
Load a VectorWaves configuration from a JSON file.
|
|
736
|
+
|
|
737
|
+
Parameters
|
|
738
|
+
----------
|
|
739
|
+
filename : str
|
|
740
|
+
Path to the saved JSON configuration file.
|
|
741
|
+
|
|
742
|
+
Returns
|
|
743
|
+
-------
|
|
744
|
+
Config
|
|
745
|
+
The deserialized configuration object.
|
|
746
|
+
"""
|
|
747
|
+
return get_config().load(filename)
|