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,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)