BornSim 0.2.6__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.
bornsim/solver.py ADDED
@@ -0,0 +1,561 @@
1
+ """Solver configuration and analytical, volume, and ensemble calculations."""
2
+
3
+ from dataclasses import dataclass, field
4
+ import hashlib
5
+ import numpy as np
6
+ from ._version import __version__
7
+ from .media import Medium
8
+ from .model import AnalyticalMedium, angular_scattering, optical_properties
9
+ from .results import Result
10
+ from ._validation import _integer
11
+ from .volume import Volume
12
+ from .series import BornSeries
13
+ from .ensemble import ensemble_scattering
14
+ from .grid import Grid
15
+ from .directions import Directions
16
+ from .sampling import AngularSampling
17
+ from .ensemble_sampling import EnsembleSampling
18
+ from .source import Source
19
+ from .units import Quantity, validate_units, ureg
20
+
21
+
22
+ def _provenance(*, order, **settings):
23
+ return {
24
+ "bornsim_version": __version__,
25
+ "numpy_version": np.__version__,
26
+ "order": int(order),
27
+ "dielectric_contrast": "2 * background_refractive_index * delta_refractive_index",
28
+ "green_self_cell": "equal-volume sphere with longitudinal contact term",
29
+ **settings,
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True, kw_only=True)
34
+ class Solver:
35
+ """Configure analytical and finite-volume Born scattering calculations.
36
+
37
+ Parameters
38
+ ----------
39
+ source : Source
40
+ Incident vacuum wavelength and unpolarized illumination.
41
+ sampling : AngularSampling, optional
42
+ Shared output angles and solid-angle quadrature. Defaults to
43
+ AngularSampling(). Used for full-volume solves and ensembles.
44
+ order : int, optional
45
+ Highest cumulative numerical Born order, from 1 to 12. Default is 3.
46
+ Analytical calculations always use first order.
47
+ quadrature_order : int, optional
48
+ Gauss-Legendre order for analytical integrated coefficients. Must be
49
+ at least 16; default is 256. Numerical integration uses sampling's
50
+ polar_samples and azimuth_samples instead.
51
+
52
+ Attributes
53
+ ----------
54
+ source : Source
55
+ Incident source shared by all calculations.
56
+ order : int
57
+ Highest numerical Born order.
58
+ quadrature_order : int
59
+ Analytical integration order.
60
+
61
+ Raises
62
+ ------
63
+ TypeError
64
+ If ``source`` is not a Source.
65
+ ValueError
66
+ If either order parameter is outside its supported integer range.
67
+
68
+ Notes
69
+ -----
70
+ Numerical calculations retain the linearized dielectric contrast
71
+ ``2 * n0 * delta_n``. Green-tensor self interactions use an equal-volume
72
+ sphere including the longitudinal contact term. Increasing Born order
73
+ does not restore the omitted quadratic constitutive term. Decreasing
74
+ successive terms do not establish universal Born convergence.
75
+
76
+ Examples
77
+ --------
78
+ >>> from bornsim import AnalyticalMedium, Solver, Source
79
+ ...
80
+ >>> from bornsim.units import ureg
81
+ ...
82
+ >>> solver = Solver(
83
+ ... source=Source(
84
+ ... wavelength=633e-9 * ureg.meter,
85
+ ... ),
86
+ ... order=3,
87
+ ... )
88
+ >>> result = solver.solve(
89
+ ... target=AnalyticalMedium(
90
+ ... background_refractive_index=1.33,
91
+ ... refractive_index_std=0.01,
92
+ ... correlation_length=100e-9 * ureg.meter,
93
+ ... correlation="gaussian",
94
+ ... )
95
+ ... )
96
+ >>> result.differential.shape
97
+ (1, 121, 8)
98
+ """
99
+
100
+ source: Source
101
+ sampling: AngularSampling = field(default_factory=AngularSampling)
102
+ order: int = 3
103
+ quadrature_order: int = 256
104
+
105
+ def __post_init__(self):
106
+ if not isinstance(self.sampling, AngularSampling):
107
+ raise TypeError("sampling must be an AngularSampling.")
108
+
109
+ if not isinstance(self.source, Source):
110
+ raise TypeError("source must be a Source.")
111
+
112
+ _integer(
113
+ value=self.order,
114
+ name="order",
115
+ low=1,
116
+ high=12,
117
+ )
118
+
119
+ invalid_quadrature_order = (
120
+ isinstance(self.quadrature_order, bool)
121
+ or not isinstance(self.quadrature_order, int)
122
+ or self.quadrature_order < 16
123
+ )
124
+
125
+ if invalid_quadrature_order:
126
+ raise ValueError("quadrature_order must be an integer of at least 16.")
127
+
128
+ def __repr__(self):
129
+ wavelength = getattr(self.source, "wavelength").to("nanometer")
130
+
131
+ return f"Solver(wavelength={wavelength.magnitude:g} nm, order={self.order}, sampling={self.sampling!r})"
132
+
133
+ def solve(
134
+ self,
135
+ *,
136
+ target: Volume | AnalyticalMedium,
137
+ sampling: AngularSampling | None = None,
138
+ angles: Quantity | None = None,
139
+ directions: Directions | None = None,
140
+ ) -> Result:
141
+ """Compute a full angular scattering distribution with normalization.
142
+
143
+ Parameters
144
+ ----------
145
+ target : Volume or AnalyticalMedium
146
+ Fixed numerical sample or analytical first-order statistics.
147
+ sampling : AngularSampling, optional
148
+ Override the solver's shared angular settings for this call.
149
+ angles : array_like or Quantity, optional
150
+ Deprecated analytical output-angle keyword. Use AngularSampling.
151
+ Numerical angular cuts use solve_cut instead.
152
+ directions : Directions, optional
153
+ Rejected here; arbitrary numerical observations use solve_cut.
154
+
155
+ Returns
156
+ -------
157
+ result : Result
158
+ Directional intensities and normalized phase densities, with
159
+ integrated coefficients. Numerical amplitudes are retained.
160
+ Coefficients for finite samples are cross sections divided by
161
+ voxel-box volume, not intrinsic infinite-medium properties.
162
+ """
163
+
164
+ if not isinstance(target, (AnalyticalMedium, Volume)):
165
+ raise TypeError("target must be an AnalyticalMedium or Volume.")
166
+
167
+ if directions is not None or (isinstance(target, Volume) and angles is not None):
168
+ raise ValueError("Use solve_cut for explicit angles or directions; solve always computes full scattering.")
169
+
170
+ if sampling is not None and angles is not None:
171
+ raise ValueError("Supply sampling or analytical angles, not both.")
172
+
173
+ sampling = self.sampling if sampling is None else AngularSampling._resolve(sampling=sampling)
174
+
175
+ if isinstance(target, Volume):
176
+ return self._solve_volume(volume=target, sampling=sampling)
177
+
178
+ if angles is not None:
179
+ validate_units(
180
+ angles,
181
+ unit="radian",
182
+ name="angles",
183
+ )
184
+
185
+ theta = sampling.angles.copy() if angles is None else angles.copy()
186
+
187
+ invalid_angles = (
188
+ theta.ndim != 1
189
+ or theta.size == 0
190
+ or np.any(~np.isfinite(theta))
191
+ or np.any((theta < 0) | (theta > np.pi * ureg.radian))
192
+ )
193
+
194
+ if invalid_angles:
195
+ raise ValueError("angles must be a nonempty 1D array of finite angles between 0 and pi.")
196
+
197
+ if angles is not None:
198
+ import warnings
199
+
200
+ warnings.warn("Use AngularSampling for analytical output angles.", DeprecationWarning, stacklevel=2)
201
+
202
+ wavelength = self.source.wavelength
203
+
204
+ differential = angular_scattering(
205
+ medium=target,
206
+ wavelength=wavelength,
207
+ theta=theta,
208
+ )
209
+
210
+ coefficients = optical_properties(
211
+ medium=target,
212
+ wavelength=wavelength,
213
+ quadrature_order=self.quadrature_order,
214
+ )
215
+
216
+ directional = np.broadcast_to(differential[None, :, None], (1, len(theta), sampling.azimuth_samples)).copy()
217
+
218
+ return Result(
219
+ source=self.source,
220
+ kind="analytical",
221
+ angles=theta if theta is not None else None,
222
+ azimuths=sampling.azimuths,
223
+ differential=directional,
224
+ mu_s=coefficients["mu_s"].reshape(1),
225
+ g=np.array([np.nan if coefficients["g"] is None else coefficients["g"]]),
226
+ mu_s_prime=coefficients["mu_s_prime"].reshape(1),
227
+ provenance=_provenance(
228
+ order=1,
229
+ medium=target.metadata,
230
+ quadrature_order=self.quadrature_order,
231
+ sampling={**sampling.metadata, "angles_rad": theta.to("radian").magnitude.tolist()},
232
+ coefficient_scope="infinite-medium",
233
+ ),
234
+ )
235
+
236
+ def solve_cut(
237
+ self,
238
+ *,
239
+ target: Volume,
240
+ angles: Quantity | None = None,
241
+ directions: Directions | None = None,
242
+ ) -> Result:
243
+ """Compute an unnormalized angular cut through a fixed Volume.
244
+
245
+ Supply polar angles on the x-z meridian or a Directions configuration,
246
+ but not both. Explicit angular units are required. Default angles span 0 to pi
247
+ with 121 samples. Cut amplitudes and intensities retain every requested
248
+ direction. A cut alone cannot determine a solid-angle integral;
249
+ integrated coefficients and normalized phase functions are unavailable.
250
+ """
251
+
252
+ if not isinstance(target, Volume):
253
+ raise TypeError("target must be a Volume.")
254
+
255
+ if angles is not None and directions is not None:
256
+ raise ValueError("Supply angles or directions, not both.")
257
+
258
+ if angles is not None:
259
+ validate_units(
260
+ angles,
261
+ unit="radian",
262
+ name="angles",
263
+ )
264
+
265
+ theta = None
266
+
267
+ if directions is None:
268
+ theta = np.linspace(0, np.pi, 121) * ureg.radian if angles is None else angles.copy()
269
+
270
+ invalid_angles = (
271
+ theta.ndim != 1
272
+ or theta.size == 0
273
+ or np.any(~np.isfinite(theta))
274
+ or np.any((theta < 0) | (theta > np.pi * ureg.radian))
275
+ )
276
+
277
+ if invalid_angles:
278
+ raise ValueError("angles must be a nonempty 1D array of finite angles between 0 and pi.")
279
+
280
+ if directions is None:
281
+ assert theta is not None
282
+
283
+ directions = Directions.from_angles(
284
+ polar_angles=theta,
285
+ azimuth_angles=0 * ureg.radian,
286
+ )
287
+
288
+ engine = BornSeries(
289
+ grid=target.grid,
290
+ background_refractive_index=target.background_refractive_index,
291
+ wavelength=self.source.wavelength,
292
+ directions=directions,
293
+ order=self.order,
294
+ )
295
+
296
+ result = engine.solve(volume=target)
297
+
298
+ return Result(
299
+ source=self.source,
300
+ kind="volume",
301
+ sample_volume=target.volume,
302
+ angles=theta if theta is not None else None,
303
+ directions=result.directions,
304
+ differential=result.differential * (1 / ureg.meter / ureg.steradian),
305
+ amplitudes=result.amplitudes * ureg.meter,
306
+ term_differential=result.term_differential * (1 / ureg.meter / ureg.steradian),
307
+ field_norms=result.field_norms,
308
+ warnings=result.warnings,
309
+ provenance=_provenance(
310
+ order=self.order,
311
+ grid={
312
+ "shape": list(target.delta_refractive_index.shape),
313
+ "spacing_m": float(target.spacing.to("meter").magnitude),
314
+ "background_refractive_index": target.background_refractive_index,
315
+ "delta_refractive_index_sha256": hashlib.sha256(
316
+ np.asarray(target.delta_refractive_index, dtype="<f8").tobytes(order="C")
317
+ ).hexdigest(),
318
+ },
319
+ medium=None if target.medium is None else target.medium.metadata,
320
+ seed=target.seed,
321
+ coefficient_scope="finite-sample",
322
+ ),
323
+ )
324
+
325
+ def _solve_volume(self, *, volume, sampling):
326
+ sampling.check_work(grid=volume.grid, order=self.order)
327
+
328
+ engine = BornSeries(
329
+ grid=volume.grid,
330
+ background_refractive_index=volume.background_refractive_index,
331
+ wavelength=self.source.wavelength,
332
+ directions=sampling.directions,
333
+ order=self.order,
334
+ )
335
+
336
+ born = engine.solve(volume=volume)
337
+
338
+ sampled = sampling.summarize(result=born)
339
+
340
+ integrated = sampled["integrals"]
341
+
342
+ mu = integrated[:, 0]
343
+
344
+ g = np.divide(integrated[:, 1], mu, out=np.full_like(mu, np.nan), where=mu != 0)
345
+
346
+ return Result(
347
+ source=self.source,
348
+ kind="volume",
349
+ sample_volume=volume.volume,
350
+ angles=sampling.angles,
351
+ azimuths=sampling.azimuths,
352
+ differential=sampled["directional_differential"] * (1 / ureg.meter / ureg.steradian),
353
+ amplitudes=sampled["directional_amplitudes"] * ureg.meter,
354
+ term_differential=sampled["directional_terms"] * (1 / ureg.meter / ureg.steradian),
355
+ mu_s=mu * (1 / ureg.meter),
356
+ g=g,
357
+ mu_s_prime=integrated[:, 2] * (1 / ureg.meter),
358
+ field_norms=born.field_norms,
359
+ warnings=born.warnings,
360
+ provenance=_provenance(
361
+ order=self.order,
362
+ grid={
363
+ **volume.grid.metadata,
364
+ "background_refractive_index": volume.background_refractive_index,
365
+ "delta_refractive_index_sha256": hashlib.sha256(
366
+ np.asarray(volume.delta_refractive_index, dtype="<f8").tobytes(order="C")
367
+ ).hexdigest(),
368
+ },
369
+ sampling=sampling.metadata,
370
+ medium=None if volume.medium is None else volume.medium.metadata,
371
+ seed=volume.seed,
372
+ coefficient_scope="finite-sample",
373
+ ),
374
+ )
375
+
376
+ def ensemble(
377
+ self,
378
+ *,
379
+ medium: Medium,
380
+ grid=None,
381
+ sampling=None,
382
+ shape=None,
383
+ spacing=None,
384
+ ensemble_sampling=None,
385
+ realizations=None,
386
+ seed=None,
387
+ angles=None,
388
+ azimuth_samples=None,
389
+ polar_samples=None,
390
+ ):
391
+ """Average sampled-volume intensities and integrate over solid angle.
392
+
393
+ Parameters
394
+ ----------
395
+ medium : Medium
396
+ Random medium or structured medium with a random background.
397
+ Deterministic structures use solve on their generated Volume.
398
+ grid : Grid, optional
399
+ Explicit spatial configuration for every realization. Cannot be
400
+ combined with shape or spacing.
401
+ sampling : AngularSampling, optional
402
+ Override shared angular settings. Defaults to the solver's sampling
403
+ when individual angular keywords are omitted. Cannot be combined
404
+ with angles, polar_samples, or azimuth_samples.
405
+ shape : tuple of int, optional
406
+ Three grid dimensions, each from 2 to 32. Required with spacing when grid is omitted.
407
+ spacing : Quantity, optional
408
+ Positive, finite cubic voxel spacing. Explicit length units are required;
409
+ must be supplied with shape when grid is omitted.
410
+ ensemble_sampling : EnsembleSampling, optional
411
+ Realization counts and ordered independent seeds. Defaults to
412
+ four consecutive seeds starting at zero.
413
+ realizations : int, optional
414
+ Deprecated; use EnsembleSampling.
415
+ Independent sample count, from 1 to 32. Default is 4.
416
+ seed : int, optional
417
+ Deprecated; use EnsembleSampling.
418
+ First sample seed, from 0 to 2**32 - 1. Default is 0. Samples use
419
+ consecutive seeds; the entire seed range must remain valid.
420
+ angles : array_like or Quantity, optional
421
+ One-dimensional plot angles in [0, pi], with 1 to 181 observations.
422
+ Angular values require explicit units. Default is 121 evenly spaced angles.
423
+ azimuth_samples : int, optional
424
+ Uniform azimuth samples for both curves and quadrature, from 4 to 32.
425
+ Default is 8.
426
+ polar_samples : int, optional
427
+ Gauss-Legendre nodes for integration, from 16 to 128. Default is 32.
428
+ These nodes are independent of the plot angles.
429
+
430
+ Returns
431
+ -------
432
+ result : Result
433
+ Directional cumulative and isolated-term intensities through
434
+ ``self.order``, standard errors, finite-sample effective coefficients,
435
+ per-realization field norms, and numerical diagnostics, all with units.
436
+ Directional intensities retain azimuth for 3D phase plotting.
437
+ Complex amplitudes are not retained in the ensemble result.
438
+
439
+ Raises
440
+ ------
441
+ TypeError
442
+ If ``medium`` is not a Medium.
443
+ ValueError
444
+ If a grid, sampling, seed, unit, or order constraint is violated,
445
+ the ensemble exceeds the synchronous work limit, or a generated
446
+ volume has nonpositive linearized permittivity.
447
+
448
+ See Also
449
+ --------
450
+ bornsim.ensemble.ensemble_scattering : Numeric SI ensemble function.
451
+
452
+ Notes
453
+ -----
454
+ Intensities are averaged after coherent summation within each realization;
455
+ random amplitudes are never averaged together. Standard errors describe
456
+ realization sampling, not spatial or angular discretization error. One
457
+ realization gives NaN errors. Numerical integrated coefficients are
458
+ finite-sample cross sections divided by volume. Check voxel refinement,
459
+ angular quadrature, sample size, and realization count independently.
460
+ Deterministic structures have no realization sampling and must use
461
+ solve. A single random realization reports unknown sampling errors
462
+ (NaN), rather than inferred uncertainty. Individual shape/spacing, angular and realization keywords remain
463
+ available with deprecation warnings.
464
+
465
+ Examples
466
+ --------
467
+ >>> from bornsim import RandomMedium, Solver, Source
468
+ >>> from bornsim.units import ureg
469
+ ...
470
+ ...
471
+ >>> ensemble_solver = Solver(
472
+ ... source=Source(
473
+ ... wavelength=633e-9 * ureg.meter,
474
+ ... ),
475
+ ... order=2,
476
+ ... )
477
+
478
+ ...
479
+ >>> result = ensemble_solver.ensemble(
480
+ ... medium=RandomMedium(
481
+ ... correlation="gaussian",
482
+ ... background_refractive_index=1.33,
483
+ ... refractive_index_std=0.01,
484
+ ... correlation_length=100e-9 * ureg.meter,
485
+ ... ),
486
+ ... shape=(2, 2, 2),
487
+ ... realizations=2,
488
+ ... seed=42,
489
+ ... angles=[0, 1] * ureg.radian,
490
+ ... azimuth_samples=4,
491
+ ... polar_samples=16,
492
+ ... spacing=50e-9 * ureg.meter,
493
+ ... )
494
+ >>> result.stderr.shape
495
+ (2, 2, 4)
496
+ """
497
+
498
+ if not isinstance(medium, Medium):
499
+ raise TypeError("medium must be a Medium.")
500
+
501
+ grid = Grid._resolve(
502
+ grid=grid,
503
+ shape=shape,
504
+ spacing=spacing,
505
+ )
506
+
507
+ if sampling is None and all(value is None for value in (angles, azimuth_samples, polar_samples)):
508
+ sampling = self.sampling
509
+
510
+ sampling = AngularSampling._resolve(
511
+ sampling=sampling,
512
+ angles=angles,
513
+ polar_samples=polar_samples,
514
+ azimuth_samples=azimuth_samples,
515
+ )
516
+
517
+ ensemble_sampling = EnsembleSampling._resolve(
518
+ ensemble_sampling=ensemble_sampling,
519
+ realizations=realizations,
520
+ seed=seed,
521
+ )
522
+
523
+ result = ensemble_scattering(
524
+ medium=medium,
525
+ wavelength=self.source.wavelength,
526
+ grid=grid,
527
+ sampling=sampling,
528
+ order=self.order,
529
+ ensemble_sampling=ensemble_sampling,
530
+ )
531
+
532
+ return Result(
533
+ source=self.source,
534
+ kind="ensemble",
535
+ sample_volume=grid.volume,
536
+ angles=result["angles"].copy(),
537
+ differential=result["directional_differential"] * (1 / ureg.meter / ureg.steradian),
538
+ azimuths=result["azimuths"],
539
+ term_differential=result["directional_terms"] * (1 / ureg.meter / ureg.steradian),
540
+ stderr=result["directional_stderr"] * (1 / ureg.meter / ureg.steradian),
541
+ azimuth_stderr=result["stderr"] * (1 / ureg.meter / ureg.steradian),
542
+ mu_s=result["mu_s"] * (1 / ureg.meter),
543
+ g=result["g"],
544
+ mu_s_prime=result["mu_s_prime"] * (1 / ureg.meter),
545
+ field_norms=result["field_norms"],
546
+ warnings=result["warnings"],
547
+ realizations=result["realizations"],
548
+ provenance=_provenance(
549
+ order=self.order,
550
+ medium=medium.metadata,
551
+ grid=grid.metadata,
552
+ sampling=sampling.metadata,
553
+ seed=ensemble_sampling.seed,
554
+ seeds=list(ensemble_sampling.seeds),
555
+ ensemble_sampling=ensemble_sampling.metadata,
556
+ realizations=result["realizations"],
557
+ polar_samples=sampling.polar_samples,
558
+ azimuth_samples=sampling.azimuth_samples,
559
+ coefficient_scope="finite-sample",
560
+ ),
561
+ )
bornsim/source.py ADDED
@@ -0,0 +1,57 @@
1
+ """Incident illumination for BornSim scattering calculations."""
2
+
3
+ from dataclasses import dataclass
4
+ import numpy as np
5
+ from .units import Quantity, validate_units
6
+
7
+
8
+ @dataclass(frozen=True, kw_only=True)
9
+ class Source:
10
+ """Define an unpolarized plane wave propagating along the positive z axis.
11
+
12
+ Parameters
13
+ ----------
14
+ wavelength : Quantity
15
+ Positive, finite vacuum wavelength. Explicit length units are required;
16
+ the supplied units are preserved. A wavelength must be supplied.
17
+
18
+ Attributes
19
+ ----------
20
+ wavelength : Quantity
21
+ Scalar vacuum wavelength with its supplied length units.
22
+
23
+ Raises
24
+ ------
25
+ ValueError
26
+ If the wavelength is nonpositive, nonfinite, nonscalar, or has units
27
+ incompatible with length.
28
+
29
+ Notes
30
+ -----
31
+ The transverse x and y incident polarizations have equal weights and are
32
+ averaged incoherently. Arbitrary incidence directions and polarized
33
+ illumination are not currently supported.
34
+
35
+ Examples
36
+ --------
37
+ >>> from bornsim import Source
38
+ >>> from bornsim.units import ureg
39
+ ...
40
+ ...
41
+ >>> source = Source(wavelength=633 * ureg.nanometer)
42
+ >>> source.wavelength.check("[length]")
43
+ True
44
+ """
45
+
46
+ wavelength: Quantity
47
+
48
+ def __post_init__(self) -> None:
49
+ validate_units(
50
+ self.wavelength,
51
+ unit="meter",
52
+ name="wavelength",
53
+ scalar=True,
54
+ )
55
+
56
+ if not np.isfinite(self.wavelength) or self.wavelength <= 0:
57
+ raise ValueError("wavelength must be finite and positive.")
bornsim/units.py ADDED
@@ -0,0 +1,81 @@
1
+ """Shared TypedUnit registry and explicit quantity validation.
2
+
3
+ Validation preserves the supplied units. Dimensional inputs require quantities;
4
+ dimensionless inputs may be bare numbers. Numerical kernels, plotting and
5
+ serialization explicitly select units when they need magnitudes.
6
+ """
7
+
8
+ from typing import Any, Literal, overload
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+ from TypedUnit import Angle, Dimensionless, Length, Quantity, RefractiveIndex, ureg
12
+
13
+ __all__ = ["ureg", "Quantity", "Length", "Angle", "Dimensionless", "RefractiveIndex", "validate_units"]
14
+
15
+
16
+ def validate_units(value: object, *, unit: str, name: str, scalar: bool = False) -> None:
17
+ """Check units and shape without converting or stripping the quantity."""
18
+
19
+ if not isinstance(value, Quantity):
20
+ raise ValueError(f"{name} requires an explicit quantity with units compatible with {unit}.")
21
+
22
+ if unit == "radian" and value.units == ureg.dimensionless:
23
+ raise ValueError(f"{name} requires explicit angular units, such as degree or radian.")
24
+
25
+ if not value.is_compatible_with(unit):
26
+ raise ValueError(f"{name} must have units compatible with {unit}.")
27
+
28
+ if scalar and np.ndim(value.magnitude) != 0:
29
+ raise ValueError(f"{name} must be a scalar.")
30
+
31
+
32
+ @overload
33
+ def _dimensionless(*, value: Any, name: str, scalar: Literal[True]) -> float: ...
34
+
35
+
36
+ @overload
37
+ def _dimensionless(*, value: Any, name: str, scalar: Literal[False] = False) -> NDArray[np.float64]: ...
38
+
39
+
40
+ def _dimensionless(*, value: Any, name: str, scalar: bool = False) -> float | NDArray[np.float64]:
41
+ """Validate dimensionless numbers, including scaled units such as percent."""
42
+
43
+ if isinstance(value, Quantity):
44
+ validate_units(
45
+ value,
46
+ unit="dimensionless",
47
+ name=name,
48
+ scalar=scalar,
49
+ )
50
+
51
+ value = value.to("dimensionless").magnitude
52
+
53
+ data = np.asarray(value, dtype=float)
54
+
55
+ if scalar:
56
+ if data.ndim != 0:
57
+ raise ValueError(f"{name} must be a scalar.")
58
+
59
+ return float(data)
60
+
61
+ return data
62
+
63
+
64
+ @overload
65
+ def _refractive_index_values(*, value: Any, name: str, scalar: Literal[True]) -> float: ...
66
+
67
+
68
+ @overload
69
+ def _refractive_index_values(*, value: Any, name: str, scalar: Literal[False] = False) -> NDArray[np.float64]: ...
70
+
71
+
72
+ def _refractive_index_values(*, value: Any, name: str, scalar: bool = False) -> float | NDArray[np.float64]:
73
+ """Require plain numerical refractive indices and fluctuations, without units."""
74
+
75
+ if isinstance(value, Quantity):
76
+ raise ValueError(f"{name} must be unitless; supply plain numbers without units.")
77
+
78
+ if scalar:
79
+ return _dimensionless(value=value, name=name, scalar=True)
80
+
81
+ return _dimensionless(value=value, name=name)