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/__init__.py +69 -0
- bornsim/_archives.py +172 -0
- bornsim/_result_plotting.py +454 -0
- bornsim/_result_validation.py +89 -0
- bornsim/_validation.py +10 -0
- bornsim/_version.py +1 -0
- bornsim/_volume_plotting.py +395 -0
- bornsim/angular_data.py +338 -0
- bornsim/api.py +26 -0
- bornsim/directions.py +99 -0
- bornsim/ensemble.py +253 -0
- bornsim/ensemble_sampling.py +75 -0
- bornsim/geometry.py +716 -0
- bornsim/green.py +161 -0
- bornsim/grid.py +98 -0
- bornsim/material.py +46 -0
- bornsim/media.py +359 -0
- bornsim/model.py +276 -0
- bornsim/results.py +717 -0
- bornsim/rotation.py +68 -0
- bornsim/sampling.py +231 -0
- bornsim/series.py +300 -0
- bornsim/solver.py +561 -0
- bornsim/source.py +57 -0
- bornsim/units.py +81 -0
- bornsim/volume.py +319 -0
- bornsim-0.2.6.dist-info/METADATA +529 -0
- bornsim-0.2.6.dist-info/RECORD +31 -0
- bornsim-0.2.6.dist-info/WHEEL +5 -0
- bornsim-0.2.6.dist-info/licenses/LICENSE +21 -0
- bornsim-0.2.6.dist-info/top_level.txt +1 -0
bornsim/angular_data.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Consistent unitful directional scattering data for solves and cuts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .units import Quantity, validate_units, ureg
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, kw_only=True, repr=False)
|
|
9
|
+
class AngularData:
|
|
10
|
+
"""Group coordinates, amplitudes, intensities and normalization.
|
|
11
|
+
|
|
12
|
+
Full angular data have shape (order, polar angle, azimuth). Cuts and
|
|
13
|
+
explicitly averaged curves have shape (order, observation). Directions
|
|
14
|
+
have the same observation axes and a final Cartesian axis of size three.
|
|
15
|
+
Amplitudes add incident-polarization and vector axes of sizes two and
|
|
16
|
+
three. Ensemble amplitudes are absent: intensities, not amplitudes, are
|
|
17
|
+
averaged across realizations. Arrays are copied, unitful and read-only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
differential: Quantity
|
|
21
|
+
directions: Quantity | None = None
|
|
22
|
+
amplitudes: Quantity | None = None
|
|
23
|
+
angles: Quantity | None = None
|
|
24
|
+
azimuths: Quantity | None = None
|
|
25
|
+
term_differential: Quantity | None = None
|
|
26
|
+
stderr: Quantity | None = None
|
|
27
|
+
azimuth_stderr: Quantity | None = None
|
|
28
|
+
mu_s: Quantity | None = None
|
|
29
|
+
sample_volume: Quantity | None = None
|
|
30
|
+
kind: str = "volume"
|
|
31
|
+
azimuth_averaged: bool = False
|
|
32
|
+
meridian_azimuth: Quantity | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
units = {
|
|
36
|
+
"differential": "1 / meter / steradian",
|
|
37
|
+
"directions": "dimensionless",
|
|
38
|
+
"amplitudes": "meter",
|
|
39
|
+
"angles": "radian",
|
|
40
|
+
"azimuths": "radian",
|
|
41
|
+
"term_differential": "1 / meter / steradian",
|
|
42
|
+
"stderr": "1 / meter / steradian",
|
|
43
|
+
"azimuth_stderr": "1 / meter / steradian",
|
|
44
|
+
"mu_s": "1 / meter",
|
|
45
|
+
"sample_volume": "meter**3",
|
|
46
|
+
"meridian_azimuth": "radian",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for name, unit in units.items():
|
|
50
|
+
value = getattr(self, name)
|
|
51
|
+
|
|
52
|
+
if value is not None:
|
|
53
|
+
if unit == "dimensionless" and not isinstance(value, Quantity):
|
|
54
|
+
value = np.array(value, copy=True) * ureg.dimensionless
|
|
55
|
+
|
|
56
|
+
validate_units(
|
|
57
|
+
value,
|
|
58
|
+
unit=unit,
|
|
59
|
+
name=name,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not np.issubdtype(np.asarray(value.magnitude).dtype, np.number):
|
|
63
|
+
raise ValueError(f"{name} must contain numeric values.")
|
|
64
|
+
|
|
65
|
+
quantity = np.array(value.magnitude, copy=True) * value.units
|
|
66
|
+
|
|
67
|
+
quantity.magnitude.setflags(write=False)
|
|
68
|
+
|
|
69
|
+
object.__setattr__(self, name, quantity)
|
|
70
|
+
|
|
71
|
+
shape = self.differential.shape
|
|
72
|
+
|
|
73
|
+
if len(shape) not in (2, 3) or 0 in shape:
|
|
74
|
+
raise ValueError("differential must have nonempty order and observation axes.")
|
|
75
|
+
|
|
76
|
+
if self.directions is not None and self.directions.shape != (*shape[1:], 3):
|
|
77
|
+
raise ValueError("directions must match the observation axes.")
|
|
78
|
+
|
|
79
|
+
if self.amplitudes is not None and self.amplitudes.shape != (*shape, 2, 3):
|
|
80
|
+
raise ValueError("amplitudes must have shape matching the order and observation axes.")
|
|
81
|
+
|
|
82
|
+
self._validate_values()
|
|
83
|
+
|
|
84
|
+
if self.directions is None and not self.azimuth_averaged:
|
|
85
|
+
theta = getattr(self, "angles").to("radian").magnitude
|
|
86
|
+
|
|
87
|
+
if self.azimuths is not None:
|
|
88
|
+
theta, phi = np.meshgrid(theta, self.azimuths.to("radian").magnitude, indexing="ij")
|
|
89
|
+
else:
|
|
90
|
+
phi = np.zeros_like(theta)
|
|
91
|
+
|
|
92
|
+
directions = np.stack([np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi), np.cos(theta)], axis=-1)
|
|
93
|
+
|
|
94
|
+
quantity = directions * ureg.dimensionless
|
|
95
|
+
|
|
96
|
+
quantity.magnitude.setflags(write=False)
|
|
97
|
+
|
|
98
|
+
object.__setattr__(self, "directions", quantity)
|
|
99
|
+
|
|
100
|
+
def _validate_values(self):
|
|
101
|
+
shape = self.differential.shape
|
|
102
|
+
|
|
103
|
+
shapes = {
|
|
104
|
+
"angles": (shape[1],),
|
|
105
|
+
"azimuths": (shape[2],) if len(shape) == 3 else (),
|
|
106
|
+
"term_differential": shape,
|
|
107
|
+
"stderr": shape,
|
|
108
|
+
"azimuth_stderr": shape[:2],
|
|
109
|
+
"mu_s": (shape[0],),
|
|
110
|
+
"sample_volume": (),
|
|
111
|
+
"meridian_azimuth": (),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if shape[0] > 12:
|
|
115
|
+
raise ValueError("differential must contain at most 12 Born orders.")
|
|
116
|
+
|
|
117
|
+
if self.kind == "analytical" and shape[0] != 1:
|
|
118
|
+
raise ValueError("analytical differential must contain exactly one order.")
|
|
119
|
+
|
|
120
|
+
if self.kind not in ("volume", "ensemble", "analytical") or not isinstance(self.azimuth_averaged, bool):
|
|
121
|
+
raise ValueError("kind or azimuth_averaged is invalid.")
|
|
122
|
+
|
|
123
|
+
if len(shape) == 3 and (self.angles is None or self.azimuths is None or self.azimuth_averaged):
|
|
124
|
+
raise ValueError("Full angular data require angles and azimuths without averaging.")
|
|
125
|
+
|
|
126
|
+
if len(shape) == 2 and self.azimuths is not None:
|
|
127
|
+
raise ValueError("azimuths require full angular data.")
|
|
128
|
+
|
|
129
|
+
if self.angles is None and self.directions is None:
|
|
130
|
+
raise ValueError("angles or directions must identify observations.")
|
|
131
|
+
|
|
132
|
+
for name in ("differential", "directions", "amplitudes", *shapes):
|
|
133
|
+
quantity = getattr(self, name)
|
|
134
|
+
|
|
135
|
+
if quantity is None:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
values = quantity.magnitude
|
|
139
|
+
|
|
140
|
+
if name in shapes and values.shape != shapes[name]:
|
|
141
|
+
raise ValueError(f"{name} must have shape {shapes[name]}.")
|
|
142
|
+
|
|
143
|
+
real_required = name != "amplitudes"
|
|
144
|
+
|
|
145
|
+
if not np.issubdtype(values.dtype, np.number) or (real_required and np.iscomplexobj(values)):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"{name} must contain numeric {'real' if real_required else 'complex or real'} values."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
unknown_error = (
|
|
151
|
+
name in ("stderr", "azimuth_stderr") and self.kind == "ensemble" and np.all(np.isnan(values))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not unknown_error and np.any(~np.isfinite(values)):
|
|
155
|
+
raise ValueError(f"{name} must contain finite values.")
|
|
156
|
+
|
|
157
|
+
nonnegative = name in ("differential", "term_differential", "stderr", "azimuth_stderr", "mu_s")
|
|
158
|
+
|
|
159
|
+
if nonnegative and np.any(values < 0):
|
|
160
|
+
raise ValueError(f"{name} must be nonnegative.")
|
|
161
|
+
|
|
162
|
+
invalid_directions = self.directions is not None and not np.allclose(
|
|
163
|
+
np.linalg.norm(self.directions.magnitude, axis=-1), 1, rtol=0, atol=1e-10
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if invalid_directions:
|
|
167
|
+
raise ValueError("directions must contain unit vectors.")
|
|
168
|
+
|
|
169
|
+
if self.kind != "volume" and self.angles is None:
|
|
170
|
+
raise ValueError("analytical and ensemble results require angles.")
|
|
171
|
+
|
|
172
|
+
if self.directions is not None and self.angles is not None:
|
|
173
|
+
expected_cosine = np.cos(self.angles).magnitude
|
|
174
|
+
|
|
175
|
+
if len(shape) == 3:
|
|
176
|
+
expected_cosine = expected_cosine[:, None]
|
|
177
|
+
|
|
178
|
+
if not np.allclose(self.directions.magnitude[..., 2], expected_cosine, rtol=0, atol=1e-10):
|
|
179
|
+
raise ValueError("angles must match the polar angles of directions.")
|
|
180
|
+
|
|
181
|
+
if self.angles is not None and np.any((self.angles < 0) | (self.angles > np.pi * ureg.radian)):
|
|
182
|
+
raise ValueError("angles must lie between zero and pi.")
|
|
183
|
+
|
|
184
|
+
if self.azimuths is not None:
|
|
185
|
+
phi = self.azimuths.to("radian").magnitude
|
|
186
|
+
|
|
187
|
+
invalid_phi = not 4 <= len(phi) <= 32 or not np.allclose(
|
|
188
|
+
phi, np.arange(len(phi)) * 2 * np.pi / len(phi), atol=1e-12, rtol=0
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if invalid_phi:
|
|
192
|
+
raise ValueError("azimuths must uniformly cover [0, 2*pi), starting at zero.")
|
|
193
|
+
|
|
194
|
+
if self.sample_volume is not None and (self.kind == "analytical" or self.sample_volume.magnitude <= 0):
|
|
195
|
+
raise ValueError("sample_volume must describe a positive finite-sample volume.")
|
|
196
|
+
|
|
197
|
+
unnormalized_cut = (
|
|
198
|
+
self.kind == "volume" and len(shape) == 2 and not self.azimuth_averaged and self.meridian_azimuth is None
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if unnormalized_cut and self.mu_s is not None:
|
|
202
|
+
raise ValueError("integrated coefficients are unavailable for a single-volume angular cut.")
|
|
203
|
+
|
|
204
|
+
if self.azimuth_averaged and (self.directions is not None or self.amplitudes is not None):
|
|
205
|
+
raise ValueError("Azimuth-averaged data have no single directions or coherent amplitudes.")
|
|
206
|
+
|
|
207
|
+
if self.kind != "volume" and self.amplitudes is not None:
|
|
208
|
+
raise ValueError("amplitudes are only available for a single volume.")
|
|
209
|
+
|
|
210
|
+
if self.kind != "ensemble" and (self.stderr is not None or self.azimuth_stderr is not None):
|
|
211
|
+
raise ValueError("stderr and azimuth_stderr are only available for an ensemble.")
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def phase_function(self):
|
|
215
|
+
"""Density per steradian, normalized by the full solid-angle integral."""
|
|
216
|
+
|
|
217
|
+
if self.mu_s is None or np.any(~np.isfinite(self.mu_s.magnitude)) or np.any(self.mu_s.magnitude <= 0):
|
|
218
|
+
raise ValueError("Phase normalization requires positive, finite integrated mu_s.")
|
|
219
|
+
|
|
220
|
+
axes = (len(self.mu_s),) + (1,) * (self.differential.ndim - 1)
|
|
221
|
+
|
|
222
|
+
return (self.differential / self.mu_s.reshape(axes)).to("1 / steradian")
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def directional_phase_function(self):
|
|
226
|
+
"""Compatibility alias for full directional phase densities."""
|
|
227
|
+
|
|
228
|
+
if self.differential.ndim != 3:
|
|
229
|
+
raise ValueError("Full directional phase data are unavailable.")
|
|
230
|
+
|
|
231
|
+
return self.phase_function
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def differential_cross_section(self):
|
|
235
|
+
"""Finite-sample differential cross section in square metres per sr."""
|
|
236
|
+
|
|
237
|
+
if self.sample_volume is None:
|
|
238
|
+
raise ValueError("sample_volume is unavailable for this angular data.")
|
|
239
|
+
|
|
240
|
+
return (self.differential * self.sample_volume).to("meter**2 / steradian")
|
|
241
|
+
|
|
242
|
+
def azimuth_average(self):
|
|
243
|
+
"""Average intensities explicitly; never average coherent amplitudes."""
|
|
244
|
+
|
|
245
|
+
if self.azimuths is None:
|
|
246
|
+
raise ValueError("Azimuth averaging requires a full angular grid.")
|
|
247
|
+
|
|
248
|
+
return AngularData(
|
|
249
|
+
differential=self.differential.mean(axis=-1),
|
|
250
|
+
angles=self.angles,
|
|
251
|
+
term_differential=None if self.term_differential is None else self.term_differential.mean(axis=-1),
|
|
252
|
+
stderr=self.azimuth_stderr,
|
|
253
|
+
mu_s=self.mu_s,
|
|
254
|
+
sample_volume=self.sample_volume,
|
|
255
|
+
kind=self.kind,
|
|
256
|
+
azimuth_averaged=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def meridian(self, *, azimuth, method="exact"):
|
|
260
|
+
"""Select a sampled meridian using an angle, preserving normalization.
|
|
261
|
+
|
|
262
|
+
Azimuth requires explicit angular units and is periodic modulo 2*pi. exact requires
|
|
263
|
+
a sampled azimuth within 1e-10 radians; nearest explicitly selects the
|
|
264
|
+
closest sample. No interpolation or azimuth averaging is performed.
|
|
265
|
+
meridian_azimuth records the actual selected angle.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
if self.azimuths is None:
|
|
269
|
+
raise ValueError("Meridian selection requires a full angular grid.")
|
|
270
|
+
|
|
271
|
+
if method not in ("exact", "nearest"):
|
|
272
|
+
raise ValueError("method must be exact or nearest.")
|
|
273
|
+
|
|
274
|
+
validate_units(
|
|
275
|
+
azimuth,
|
|
276
|
+
unit="radian",
|
|
277
|
+
name="azimuth",
|
|
278
|
+
scalar=True,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
requested = azimuth.to("radian").magnitude
|
|
282
|
+
|
|
283
|
+
if not np.isfinite(requested):
|
|
284
|
+
raise ValueError("azimuth must be finite.")
|
|
285
|
+
|
|
286
|
+
phi = self.azimuths.to("radian").magnitude
|
|
287
|
+
|
|
288
|
+
distance = np.abs((phi - requested + np.pi) % (2 * np.pi) - np.pi)
|
|
289
|
+
|
|
290
|
+
index = int(np.argmin(distance))
|
|
291
|
+
|
|
292
|
+
if method == "exact" and distance[index] > 1e-10:
|
|
293
|
+
raise ValueError("azimuth is not sampled; use method='nearest' to select the closest meridian.")
|
|
294
|
+
|
|
295
|
+
return AngularData(
|
|
296
|
+
differential=self.differential[..., index],
|
|
297
|
+
angles=self.angles,
|
|
298
|
+
directions=getattr(self, "directions")[:, index],
|
|
299
|
+
amplitudes=None if self.amplitudes is None else self.amplitudes[:, :, index],
|
|
300
|
+
term_differential=None if self.term_differential is None else self.term_differential[..., index],
|
|
301
|
+
stderr=None if self.stderr is None else self.stderr[..., index],
|
|
302
|
+
mu_s=self.mu_s,
|
|
303
|
+
sample_volume=self.sample_volume,
|
|
304
|
+
kind=self.kind,
|
|
305
|
+
meridian_azimuth=phi[index] * ureg.radian,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def plot(self, *, terms=False, log_y=False, title=None, azimuth=0):
|
|
309
|
+
"""Plot one sampled meridian, or an explicitly averaged curve."""
|
|
310
|
+
|
|
311
|
+
from ._result_plotting import _ResultPlotter
|
|
312
|
+
|
|
313
|
+
return _ResultPlotter(result=self).plot(
|
|
314
|
+
terms=terms,
|
|
315
|
+
log_y=log_y,
|
|
316
|
+
title=title,
|
|
317
|
+
azimuth=azimuth,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def plot_phase_function(self, *, view="angular", order=None, log_y=False, azimuth=0, backend=None):
|
|
321
|
+
"""Plot normalized directional densities with the same Result interface."""
|
|
322
|
+
|
|
323
|
+
from ._result_plotting import _ResultPlotter
|
|
324
|
+
|
|
325
|
+
return _ResultPlotter(result=self).plot_phase_function(
|
|
326
|
+
view=view,
|
|
327
|
+
order=order,
|
|
328
|
+
log_y=log_y,
|
|
329
|
+
azimuth=azimuth,
|
|
330
|
+
backend=backend,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def __repr__(self):
|
|
334
|
+
return (
|
|
335
|
+
f"AngularData(shape={self.differential.shape}, intensity_unit=m^-1 sr^-1, "
|
|
336
|
+
f"amplitudes={'available' if self.amplitudes is not None else 'absent'}, "
|
|
337
|
+
f"normalized={self.mu_s is not None}, azimuth_averaged={self.azimuth_averaged})"
|
|
338
|
+
)
|
bornsim/api.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Convenience imports for the BornSim object API.
|
|
2
|
+
|
|
3
|
+
Implementations live in source.py, solver.py, and results.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .source import Source
|
|
7
|
+
from .solver import Solver
|
|
8
|
+
from .results import Result
|
|
9
|
+
from .grid import Grid
|
|
10
|
+
from .directions import Directions
|
|
11
|
+
from .sampling import AngularSampling
|
|
12
|
+
from .angular_data import AngularData
|
|
13
|
+
from .material import Material
|
|
14
|
+
from .ensemble_sampling import EnsembleSampling
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Directions",
|
|
18
|
+
"Material",
|
|
19
|
+
"EnsembleSampling",
|
|
20
|
+
"Source",
|
|
21
|
+
"Solver",
|
|
22
|
+
"Result",
|
|
23
|
+
"Grid",
|
|
24
|
+
"AngularSampling",
|
|
25
|
+
"AngularData",
|
|
26
|
+
]
|
bornsim/directions.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Validated observation directions for numerical scattering."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
from .units import Quantity, _dimensionless, validate_units, ureg
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, kw_only=True, init=False, eq=False, repr=False)
|
|
11
|
+
class Directions:
|
|
12
|
+
"""Define an ordered collection of Cartesian unit observation vectors.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
vectors : array_like or Quantity
|
|
17
|
+
Explicit dimensionless vectors of shape (n, 3), with 1 to 16384 rows.
|
|
18
|
+
Every vector must be finite and have unit length. Vectors are copied
|
|
19
|
+
into an immutable array; they are never normalized automatically.
|
|
20
|
+
|
|
21
|
+
Notes
|
|
22
|
+
-----
|
|
23
|
+
Coordinates use the global x, y, z axes. The incident wave propagates
|
|
24
|
+
along positive z. Vector order is preserved in scattered amplitudes.
|
|
25
|
+
Use ``vectors`` to access the NumPy array and :meth:`from_angles` to
|
|
26
|
+
construct vectors from explicitly unit-bearing spherical coordinates.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
vectors: NDArray[np.float64]
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, vectors: NDArray[np.float64] | Sequence[Sequence[float]] | Quantity) -> None:
|
|
32
|
+
coordinate_values = vectors.magnitude if isinstance(vectors, Quantity) else vectors
|
|
33
|
+
|
|
34
|
+
if np.iscomplexobj(coordinate_values):
|
|
35
|
+
raise ValueError("directions.vectors must contain real Cartesian coordinates.")
|
|
36
|
+
|
|
37
|
+
vectors = _dimensionless(value=vectors, name="directions.vectors")
|
|
38
|
+
|
|
39
|
+
if vectors.ndim != 2 or vectors.shape[1] != 3 or not 1 <= len(vectors) <= 16384:
|
|
40
|
+
raise ValueError("directions.vectors must contain 1–16384 unit 3-vectors.")
|
|
41
|
+
|
|
42
|
+
invalid_vectors = not np.all(np.isfinite(vectors)) or not np.allclose(
|
|
43
|
+
np.linalg.norm(vectors, axis=1), 1, atol=1e-10, rtol=0
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if invalid_vectors:
|
|
47
|
+
raise ValueError("directions.vectors must be finite unit vectors.")
|
|
48
|
+
|
|
49
|
+
# Immutable backing storage prevents changing a cached solver configuration.
|
|
50
|
+
immutable_vectors = np.frombuffer(vectors.tobytes(), dtype=np.float64).reshape(vectors.shape)
|
|
51
|
+
|
|
52
|
+
object.__setattr__(self, "vectors", immutable_vectors)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_angles(cls, *, polar_angles: Quantity, azimuth_angles: Quantity) -> "Directions":
|
|
56
|
+
"""Convert paired or broadcastable spherical angles into unit vectors.
|
|
57
|
+
|
|
58
|
+
Polar angles are measured from positive z and lie in [0, pi]. Azimuth
|
|
59
|
+
angles are measured from positive x toward positive y and are periodic.
|
|
60
|
+
Both inputs require explicit angular units. Broadcast coordinates are
|
|
61
|
+
flattened in row-major order, preserving polar/azimuth grid ordering.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
validate_units(
|
|
65
|
+
polar_angles,
|
|
66
|
+
unit="radian",
|
|
67
|
+
name="polar_angles",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
validate_units(
|
|
71
|
+
azimuth_angles,
|
|
72
|
+
unit="radian",
|
|
73
|
+
name="azimuth_angles",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
invalid_polar_angles = np.any(~np.isfinite(polar_angles)) or np.any(
|
|
77
|
+
(polar_angles < 0) | (polar_angles > np.pi * ureg.radian)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if invalid_polar_angles:
|
|
81
|
+
raise ValueError("polar_angles must be finite and between zero and pi.")
|
|
82
|
+
|
|
83
|
+
if np.any(~np.isfinite(azimuth_angles)):
|
|
84
|
+
raise ValueError("azimuth_angles must be finite.")
|
|
85
|
+
|
|
86
|
+
# Trigonometric quantities become dimensionless Cartesian coordinates.
|
|
87
|
+
cartesian_components = np.broadcast_arrays(
|
|
88
|
+
(np.sin(polar_angles) * np.cos(azimuth_angles)).magnitude,
|
|
89
|
+
(np.sin(polar_angles) * np.sin(azimuth_angles)).magnitude,
|
|
90
|
+
np.cos(polar_angles).magnitude,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return cls(vectors=np.stack(cartesian_components, axis=-1).reshape(-1, 3))
|
|
94
|
+
|
|
95
|
+
def __len__(self) -> int:
|
|
96
|
+
return len(self.vectors)
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"Directions(n_vectors={len(self)})"
|