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.
@@ -0,0 +1,89 @@
1
+ """Shape, coordinate, uncertainty and physical-range validation for Result."""
2
+
3
+ import numpy as np
4
+ from ._validation import _integer
5
+
6
+
7
+ class _ResultValidator:
8
+ """Validate primary directional data and explicitly averaged curves."""
9
+
10
+ @staticmethod
11
+ def validate(*, result):
12
+ orders, observations = result.differential.shape[:2]
13
+
14
+ if result.kind == "ensemble":
15
+ result.realizations = _integer(value=result.realizations, name="realizations", low=1, high=32)
16
+ elif result.realizations is not None:
17
+ raise ValueError("realizations is only available for an ensemble.")
18
+
19
+ if result.kind != "volume" and result.directions is not None:
20
+ raise ValueError("analytical and ensemble results require angles and no directions.")
21
+
22
+ if result.differential.ndim == 3 and result.directions is not None:
23
+ raise ValueError("Full data require angles and azimuths without cut directions.")
24
+
25
+ shapes = {
26
+ "g": (orders,),
27
+ "mu_s_prime": (orders,),
28
+ "field_norms": (result.realizations, orders) if result.kind == "ensemble" else (orders,),
29
+ }
30
+
31
+ for name, shape in shapes.items():
32
+ quantity = getattr(result, name)
33
+
34
+ if quantity is None:
35
+ continue
36
+
37
+ values = quantity.magnitude
38
+
39
+ if values.shape != shape:
40
+ raise ValueError(f"{name} must have shape {shape}; got {values.shape}.")
41
+
42
+ if not np.issubdtype(values.dtype, np.number) or np.iscomplexobj(values):
43
+ raise ValueError(f"{name} must contain numeric real values.")
44
+
45
+ if np.any(np.isinf(values)) or (name != "g" and np.any(~np.isfinite(values))):
46
+ raise ValueError(f"{name} must be finite, except for documented unknown values.")
47
+
48
+ if name != "g" and np.any(values < 0):
49
+ raise ValueError(f"{name} must be nonnegative.")
50
+
51
+ if result.g is not None:
52
+ g = result.g.magnitude
53
+
54
+ if np.any(np.abs(g) > 1 + 1e-12):
55
+ raise ValueError("g must lie between -1 and 1.")
56
+
57
+ if result.mu_s is None and np.any(np.isnan(g)):
58
+ raise ValueError("NaN g requires a zero mu_s coefficient.")
59
+
60
+ if result.mu_s is not None and np.any(np.isnan(g) != (result.mu_s.magnitude == 0)):
61
+ raise ValueError("g must be NaN exactly where mu_s is zero.")
62
+
63
+ if all(value is not None for value in (result.mu_s, result.g, result.mu_s_prime)):
64
+ expected = result.mu_s * (1 - np.nan_to_num(result.g.to("dimensionless").magnitude))
65
+
66
+ if not np.allclose(result.mu_s_prime, expected, rtol=1e-10, atol=0):
67
+ raise ValueError("mu_s_prime must equal mu_s * (1 - g), with zero at zero scattering.")
68
+
69
+ for name in ("stderr", "azimuth_stderr"):
70
+ errors = getattr(result, name)
71
+
72
+ if result.realizations == 1 and errors is not None and not np.all(np.isnan(errors.magnitude)):
73
+ raise ValueError(f"{name} must be NaN for one realization.")
74
+
75
+ if result.realizations != 1 and errors is not None and np.any(~np.isfinite(errors.magnitude)):
76
+ raise ValueError(f"{name} must be finite for multiple realizations.")
77
+
78
+ incomplete_integrals = (
79
+ result.kind == "volume"
80
+ and result.differential.ndim == 2
81
+ and not result.azimuth_averaged
82
+ and any(value is not None for value in (result.mu_s, result.g, result.mu_s_prime))
83
+ )
84
+
85
+ if incomplete_integrals:
86
+ raise ValueError("integrated coefficients are unavailable for a single-volume angular cut.")
87
+
88
+ if result.kind == "analytical" and (result.field_norms is not None or result.term_differential is not None):
89
+ raise ValueError("field_norms and term_differential are only available for numerical results.")
bornsim/_validation.py ADDED
@@ -0,0 +1,10 @@
1
+ """Shared validation for bounded integer settings."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _integer(*, value, name, low, high):
7
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or not low <= value <= high:
8
+ raise ValueError(f"{name} must be an integer between {low} and {high}.")
9
+
10
+ return int(value)
bornsim/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = version = "0.2.6"
@@ -0,0 +1,395 @@
1
+ """Volume rendering behind the public Volume plotting methods.
2
+
3
+ All physical fields remain on Volume. Display conversions, color scales,
4
+ backend selection and figure creation belong to this internal renderer.
5
+ """
6
+
7
+ import numpy as np
8
+ from ._validation import _integer
9
+
10
+
11
+ class _VolumePlotter:
12
+ """Render one volume without changing its field, grid or SI units."""
13
+
14
+ def __init__(self, *, volume):
15
+ self._volume = volume
16
+
17
+ def _plot_field(self, *, field):
18
+ """Return the selected material field and display label."""
19
+
20
+ fields = {
21
+ "delta_refractive_index": (self._volume.delta_refractive_index, "Refractive-index fluctuation δn"),
22
+ "refractive_index": (
23
+ self._volume.background_refractive_index + self._volume.delta_refractive_index,
24
+ "Refractive index n",
25
+ ),
26
+ "permittivity": (
27
+ self._volume.background_refractive_index**2
28
+ + 2 * self._volume.background_refractive_index * self._volume.delta_refractive_index,
29
+ "Linearized relative permittivity",
30
+ ),
31
+ }
32
+
33
+ if field not in fields:
34
+ raise ValueError("field must be delta_refractive_index, refractive_index, or permittivity.")
35
+
36
+ return fields[field]
37
+
38
+ def plot_slice(self, *, normal="z", index=None, field="refractive_index", length_unit="nanometer", title=None):
39
+ """Render one voxel plane with full-volume color limits."""
40
+
41
+ from pint.errors import DimensionalityError, UndefinedUnitError
42
+ import matplotlib.pyplot as plt
43
+
44
+ if normal not in ("x", "y", "z"):
45
+ raise ValueError("normal must be x, y, or z.")
46
+
47
+ axis_index = "xyz".index(normal)
48
+
49
+ count = self._volume.delta_refractive_index.shape[axis_index]
50
+
51
+ selected = _integer(
52
+ value=count // 2 if index is None else index,
53
+ name="index",
54
+ low=0,
55
+ high=count - 1,
56
+ )
57
+
58
+ values, label = self._plot_field(field=field)
59
+
60
+ try:
61
+ spacing = self._volume.spacing.to(length_unit)
62
+ except (DimensionalityError, UndefinedUnitError, TypeError, ValueError) as error:
63
+ raise ValueError("length_unit must name a length unit.") from error
64
+
65
+ unit = f"{spacing.units:~}"
66
+
67
+ width = float(spacing.magnitude)
68
+
69
+ horizontal, vertical = [i for i in range(3) if i != axis_index]
70
+
71
+ half_width = self._volume.delta_refractive_index.shape[horizontal] * width / 2
72
+
73
+ half_height = self._volume.delta_refractive_index.shape[vertical] * width / 2
74
+
75
+ low, high = float(values.min()), float(values.max())
76
+
77
+ if field == "delta_refractive_index":
78
+ high = float(np.abs(values).max())
79
+
80
+ low = -high
81
+
82
+ if low == high:
83
+ padding = max(abs(low), 1.0) * 1e-6
84
+
85
+ low, high = low - padding, high + padding
86
+
87
+ figure, axis = plt.subplots(layout="constrained")
88
+
89
+ image = axis.imshow(
90
+ np.take(values, selected, axis=axis_index).T,
91
+ origin="lower",
92
+ extent=(-half_width, half_width, -half_height, half_height),
93
+ interpolation="nearest",
94
+ aspect="equal",
95
+ cmap="RdBu_r" if field == "delta_refractive_index" else "viridis",
96
+ vmin=low,
97
+ vmax=high,
98
+ )
99
+
100
+ position = (selected - (count - 1) / 2) * width
101
+
102
+ axis.set(
103
+ xlabel=f"{'xyz'[horizontal]} ({unit})",
104
+ ylabel=f"{'xyz'[vertical]} ({unit})",
105
+ title=title if title is not None else f"{label} at {normal} = {position:+g} {unit}",
106
+ )
107
+
108
+ figure.colorbar(image, ax=axis, label=label)
109
+
110
+ return figure
111
+
112
+ def plot_3d(
113
+ self,
114
+ *,
115
+ backend="plotly",
116
+ mode=None,
117
+ field="delta_refractive_index",
118
+ length_unit="nanometer",
119
+ surface_count=8,
120
+ opacity=None,
121
+ opacity_scale="uniform",
122
+ slice_indices=None,
123
+ ):
124
+ """Validate rendering options and draw the selected 3D backend."""
125
+
126
+ if backend not in ("matplotlib", "plotly"):
127
+ raise ValueError("backend must be matplotlib or plotly.")
128
+
129
+ if mode is None:
130
+ mode = "slices" if backend == "matplotlib" else "volume"
131
+
132
+ modes = ("slices", "voxels") if backend == "matplotlib" else ("volume", "isosurface", "slices")
133
+
134
+ if mode not in modes:
135
+ raise ValueError(f"{backend} mode must be one of {modes}.")
136
+
137
+ if opacity_scale not in ("uniform", "increasing"):
138
+ raise ValueError("opacity_scale must be uniform or increasing.")
139
+
140
+ if opacity_scale != "uniform" and (backend != "plotly" or mode != "volume"):
141
+ raise ValueError("increasing opacity_scale is only supported for Plotly volume mode.")
142
+
143
+ values, label = self._plot_field(field=field)
144
+
145
+ count = _integer(
146
+ value=surface_count,
147
+ name="surface_count",
148
+ low=1,
149
+ high=32,
150
+ )
151
+
152
+ if opacity is None:
153
+ opacity = 1.0 if backend == "matplotlib" else 0.15
154
+
155
+ if isinstance(opacity, (bool, np.bool_)) or not isinstance(opacity, (int, float, np.integer, np.floating)):
156
+ raise ValueError("opacity must be finite and in (0, 1].")
157
+
158
+ alpha = float(opacity)
159
+
160
+ if not np.isfinite(alpha) or not 0 < alpha <= 1:
161
+ raise ValueError("opacity must be finite and in (0, 1].")
162
+
163
+ if slice_indices is not None and mode != "slices":
164
+ raise ValueError("slice_indices are only supported for slices mode.")
165
+
166
+ indices = (
167
+ tuple(n // 2 for n in self._volume.delta_refractive_index.shape)
168
+ if slice_indices is None
169
+ else tuple(slice_indices)
170
+ )
171
+
172
+ if len(indices) != 3:
173
+ raise ValueError("slice_indices must contain three voxel indices.")
174
+
175
+ indices = tuple(
176
+ _integer(
177
+ value=value,
178
+ name="slice index",
179
+ low=0,
180
+ high=n - 1,
181
+ )
182
+ for value, n in zip(indices, self._volume.delta_refractive_index.shape)
183
+ )
184
+
185
+ from pint.errors import DimensionalityError, UndefinedUnitError
186
+
187
+ try:
188
+ positions = self._volume.positions.to(length_unit).magnitude
189
+ except (DimensionalityError, UndefinedUnitError, TypeError, ValueError) as error:
190
+ raise ValueError("length_unit must name a length unit.") from error
191
+
192
+ low, high = float(values.min()), float(values.max())
193
+
194
+ uniform = low == high
195
+
196
+ if uniform and backend == "plotly":
197
+ mode = "slices"
198
+
199
+ # Use a shared color domain for all slices; preserve zero for contrasts.
200
+ if field == "delta_refractive_index" and not uniform:
201
+ color_low, color_high = -float(np.abs(values).max()), float(np.abs(values).max())
202
+ else:
203
+ padding = max(abs(low), 1.0) * 1e-6 if uniform else 0
204
+
205
+ color_low, color_high = low - padding, high + padding
206
+
207
+ colorscale = "RdBu_r" if field == "delta_refractive_index" else "Viridis"
208
+
209
+ if backend == "matplotlib":
210
+ return self._plot_3d_matplotlib(
211
+ mode=mode,
212
+ values=values,
213
+ label=label,
214
+ positions=positions,
215
+ length_unit=length_unit,
216
+ indices=indices,
217
+ color_low=color_low,
218
+ color_high=color_high,
219
+ colormap="RdBu_r" if field == "delta_refractive_index" else "viridis",
220
+ opacity=alpha,
221
+ uniform=uniform,
222
+ )
223
+
224
+ import plotly.graph_objects as go
225
+
226
+ colorbar = {"title": label, **({"tickvals": [low]} if uniform else {})}
227
+
228
+ hover = (
229
+ f"x=%{{x:.4g}} {length_unit}<br>y=%{{y:.4g}} {length_unit}"
230
+ f"<br>z=%{{z:.4g}} {length_unit}<br>{label}=%{{value:.5g}}<extra></extra>"
231
+ )
232
+
233
+ if mode == "slices":
234
+ traces = []
235
+
236
+ for axis, index in enumerate(indices):
237
+ plane: list[slice | int] = [slice(None)] * 3
238
+
239
+ plane[axis] = index
240
+
241
+ coordinate = positions[tuple(plane)]
242
+
243
+ traces.append(
244
+ go.Surface(
245
+ x=coordinate[..., 0],
246
+ y=coordinate[..., 1],
247
+ z=coordinate[..., 2],
248
+ surfacecolor=values[tuple(plane)],
249
+ customdata=values[tuple(plane)],
250
+ text=[[f"{value:.5g}" for value in row] for row in values[tuple(plane)]],
251
+ cmin=color_low,
252
+ cmax=color_high,
253
+ colorscale=colorscale,
254
+ showscale=axis == 0,
255
+ colorbar=colorbar,
256
+ hovertemplate=hover.replace("%{value:.5g}", f"{low:.5g}" if uniform else "%{text}"),
257
+ name=f"{'xyz'[axis]} slice",
258
+ )
259
+ )
260
+ else:
261
+ trace = go.Volume if mode == "volume" else go.Isosurface
262
+
263
+ opacity_options = {"opacityscale": [[0, 0], [1, 1]]} if opacity_scale == "increasing" else {}
264
+
265
+ traces = [
266
+ trace(
267
+ x=positions[..., 0].ravel(),
268
+ y=positions[..., 1].ravel(),
269
+ z=positions[..., 2].ravel(),
270
+ value=values.ravel(),
271
+ isomin=low + (high - low) * 0.05,
272
+ isomax=high - (high - low) * 0.05,
273
+ cmin=color_low,
274
+ cmax=color_high,
275
+ surface_count=count,
276
+ opacity=alpha,
277
+ colorscale=colorscale,
278
+ colorbar=colorbar,
279
+ caps={"x_show": False, "y_show": False, "z_show": False},
280
+ hovertemplate=hover,
281
+ **opacity_options,
282
+ )
283
+ ]
284
+
285
+ figure = go.Figure(data=traces)
286
+
287
+ figure.update_layout(
288
+ title=f"{label}: {mode}" + (" (uniform field)" if uniform else ""),
289
+ scene={
290
+ "xaxis_title": f"x ({length_unit})",
291
+ "yaxis_title": f"y ({length_unit})",
292
+ "zaxis_title": f"z ({length_unit})",
293
+ "aspectmode": "data",
294
+ },
295
+ margin={"l": 0, "r": 0, "b": 0, "t": 45},
296
+ )
297
+
298
+ return figure
299
+
300
+ def _plot_3d_matplotlib(
301
+ self,
302
+ *,
303
+ mode,
304
+ values,
305
+ label,
306
+ positions,
307
+ length_unit,
308
+ indices,
309
+ color_low,
310
+ color_high,
311
+ colormap,
312
+ opacity,
313
+ uniform,
314
+ ):
315
+ """Render sampled cells with physical extents and a shared color scale."""
316
+
317
+ import matplotlib.pyplot as plt
318
+ from matplotlib.cm import ScalarMappable
319
+ from matplotlib.colors import Normalize
320
+
321
+ shape = self._volume.delta_refractive_index.shape
322
+
323
+ width = positions[1, 0, 0, 0] - positions[0, 0, 0, 0]
324
+
325
+ edges = [(np.arange(n + 1) - n / 2) * width for n in shape]
326
+
327
+ norm = Normalize(vmin=color_low, vmax=color_high)
328
+
329
+ cmap = plt.get_cmap(colormap)
330
+
331
+ figure = plt.figure(figsize=(8, 6), layout="constrained")
332
+
333
+ axis = figure.add_subplot(projection="3d")
334
+
335
+ if mode == "voxels":
336
+ filled = self._volume.delta_refractive_index != 0
337
+
338
+ if not np.any(filled):
339
+ filled = np.ones(shape, dtype=bool)
340
+
341
+ voxel_coordinates = np.meshgrid(*edges, indexing="ij")
342
+
343
+ axis.voxels(
344
+ *voxel_coordinates,
345
+ filled=filled,
346
+ facecolors=cmap(norm(values)),
347
+ edgecolors=(0.1, 0.1, 0.1, 0.25),
348
+ linewidth=0.3,
349
+ shade=False,
350
+ alpha=opacity,
351
+ )
352
+ else:
353
+ for normal, index in enumerate(indices):
354
+ horizontal, vertical = [i for i in range(3) if i != normal]
355
+
356
+ first, second = np.meshgrid(edges[horizontal], edges[vertical], indexing="ij")
357
+
358
+ position = (index - (shape[normal] - 1) / 2) * width
359
+
360
+ coordinates = [np.full_like(first, position) for _ in range(3)]
361
+
362
+ coordinates[horizontal], coordinates[vertical] = first, second
363
+
364
+ axis.plot_surface(
365
+ X=coordinates[0],
366
+ Y=coordinates[1],
367
+ Z=coordinates[2],
368
+ facecolors=cmap(norm(np.take(values, index, axis=normal))),
369
+ rstride=1,
370
+ cstride=1,
371
+ shade=False,
372
+ linewidth=0,
373
+ antialiased=False,
374
+ )
375
+
376
+ axis.set(
377
+ xlabel=f"x ({length_unit})",
378
+ ylabel=f"y ({length_unit})",
379
+ zlabel=f"z ({length_unit})",
380
+ xlim=(edges[0][0], edges[0][-1]),
381
+ ylim=(edges[1][0], edges[1][-1]),
382
+ zlim=(edges[2][0], edges[2][-1]),
383
+ title=f"{label}: {mode}" + (" (uniform field)" if uniform else ""),
384
+ )
385
+
386
+ axis.set_box_aspect(shape)
387
+
388
+ mappable = ScalarMappable(norm=norm, cmap=cmap)
389
+
390
+ colorbar = figure.colorbar(mappable, ax=axis, label=label, shrink=0.65, pad=0.1)
391
+
392
+ if uniform:
393
+ colorbar.set_ticks([float(values.flat[0])])
394
+
395
+ return figure