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/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Class-based API for vector Born scattering from finite dielectric samples."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .model import AnalyticalMedium
|
|
5
|
+
from .media import Medium, RandomMedium
|
|
6
|
+
from .volume import Volume
|
|
7
|
+
from .geometry import Layer, Sphere, Ellipsoid, Box, Cylinder, StructuredMedium
|
|
8
|
+
from .rotation import Rotation
|
|
9
|
+
from .source import Source
|
|
10
|
+
from .solver import Solver
|
|
11
|
+
from .grid import Grid
|
|
12
|
+
from .directions import Directions
|
|
13
|
+
from .sampling import AngularSampling
|
|
14
|
+
from .results import Result
|
|
15
|
+
from .angular_data import AngularData
|
|
16
|
+
from .material import Material
|
|
17
|
+
from .ensemble_sampling import EnsembleSampling
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Directions",
|
|
21
|
+
"Material",
|
|
22
|
+
"EnsembleSampling",
|
|
23
|
+
"__version__",
|
|
24
|
+
"Medium",
|
|
25
|
+
"RandomMedium",
|
|
26
|
+
"AnalyticalMedium",
|
|
27
|
+
"StructuredMedium",
|
|
28
|
+
"Volume",
|
|
29
|
+
"Source",
|
|
30
|
+
"Solver",
|
|
31
|
+
"Grid",
|
|
32
|
+
"AngularSampling",
|
|
33
|
+
"AngularData",
|
|
34
|
+
"Result",
|
|
35
|
+
"Layer",
|
|
36
|
+
"Sphere",
|
|
37
|
+
"Ellipsoid",
|
|
38
|
+
"Box",
|
|
39
|
+
"Cylinder",
|
|
40
|
+
"Rotation",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name):
|
|
45
|
+
"""Keep deprecated advanced imports available during API migration."""
|
|
46
|
+
|
|
47
|
+
from importlib import import_module
|
|
48
|
+
import warnings
|
|
49
|
+
|
|
50
|
+
locations = {
|
|
51
|
+
"BornSeries": "series",
|
|
52
|
+
"ensemble_scattering": "ensemble",
|
|
53
|
+
"random_volume": "media",
|
|
54
|
+
"angular_scattering": "model",
|
|
55
|
+
"optical_properties": "model",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if name not in locations:
|
|
59
|
+
raise AttributeError(f"module 'bornsim' has no attribute {name!r}")
|
|
60
|
+
|
|
61
|
+
module = locations[name]
|
|
62
|
+
|
|
63
|
+
warnings.warn(
|
|
64
|
+
f"Import {name} from bornsim.{module}; top-level convenience imports are deprecated.",
|
|
65
|
+
DeprecationWarning,
|
|
66
|
+
stacklevel=2,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return getattr(import_module(f".{module}", __name__), name)
|
bornsim/_archives.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Versioned numeric result archives with explicit SI schema and no pickle."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import numpy as np
|
|
6
|
+
from .source import Source
|
|
7
|
+
from .units import ureg
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_RESULT_UNITS = {
|
|
11
|
+
"differential": "1 / meter / steradian",
|
|
12
|
+
"term_differential": "1 / meter / steradian",
|
|
13
|
+
"stderr": "1 / meter / steradian",
|
|
14
|
+
"angles": "radian",
|
|
15
|
+
"azimuths": "radian",
|
|
16
|
+
"directional_differential": "1 / meter / steradian",
|
|
17
|
+
"amplitudes": "meter",
|
|
18
|
+
"directional_amplitudes": "meter",
|
|
19
|
+
"mu_s": "1 / meter",
|
|
20
|
+
"mu_s_prime": "1 / meter",
|
|
21
|
+
"g": "dimensionless",
|
|
22
|
+
"directions": "dimensionless",
|
|
23
|
+
"field_norms": "dimensionless",
|
|
24
|
+
"sample_volume": "meter**3",
|
|
25
|
+
"azimuth_stderr": "1 / meter / steradian",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _ResultArchive:
|
|
30
|
+
"""Encode and restore validated result data without storing voxel fields."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def save(*, result, path):
|
|
34
|
+
"""Revalidate and encode result arrays with explicit SI metadata."""
|
|
35
|
+
|
|
36
|
+
# Immutable results retain their construction-time validation.
|
|
37
|
+
validated = result
|
|
38
|
+
|
|
39
|
+
arrays = {}
|
|
40
|
+
|
|
41
|
+
units = {}
|
|
42
|
+
|
|
43
|
+
for name, unit in _RESULT_UNITS.items():
|
|
44
|
+
if name in ("directional_differential", "directional_amplitudes"):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
value = getattr(validated, name)
|
|
48
|
+
|
|
49
|
+
if value is not None:
|
|
50
|
+
arrays[name] = value.to(unit).magnitude
|
|
51
|
+
|
|
52
|
+
units[name] = unit
|
|
53
|
+
|
|
54
|
+
metadata = {
|
|
55
|
+
"format": "bornsim-result",
|
|
56
|
+
"schema_version": 2,
|
|
57
|
+
"azimuth_averaged": validated.azimuth_averaged,
|
|
58
|
+
"wavelength_m": float(validated.source.wavelength.to("meter").magnitude),
|
|
59
|
+
"kind": validated.kind,
|
|
60
|
+
"realizations": validated.realizations,
|
|
61
|
+
"warnings": validated.warnings,
|
|
62
|
+
"provenance": validated.provenance,
|
|
63
|
+
"units": units,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
encoded_metadata = np.array(json.dumps(metadata, allow_nan=False))
|
|
67
|
+
|
|
68
|
+
path = Path(path)
|
|
69
|
+
|
|
70
|
+
with path.open("wb") as stream:
|
|
71
|
+
np.savez_compressed(stream, metadata=encoded_metadata, **arrays)
|
|
72
|
+
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def load(*, result_type, path):
|
|
77
|
+
"""Check the archive schema and construct the requested result class."""
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with np.load(path, allow_pickle=False) as archive:
|
|
81
|
+
metadata = json.loads(str(archive["metadata"].item()))
|
|
82
|
+
|
|
83
|
+
unsupported_schema = (
|
|
84
|
+
metadata["format"] != "bornsim-result"
|
|
85
|
+
or type(metadata["schema_version"]) is not int
|
|
86
|
+
or metadata["schema_version"] not in (1, 2)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if unsupported_schema:
|
|
90
|
+
raise ValueError("Unsupported result archive schema.")
|
|
91
|
+
|
|
92
|
+
units = metadata["units"]
|
|
93
|
+
|
|
94
|
+
if not isinstance(units, dict) or not set(units) <= set(_RESULT_UNITS):
|
|
95
|
+
raise ValueError("Invalid result archive fields.")
|
|
96
|
+
|
|
97
|
+
if any(unit != _RESULT_UNITS[name] for name, unit in units.items()):
|
|
98
|
+
raise ValueError("Invalid result archive units; archives require SI units.")
|
|
99
|
+
|
|
100
|
+
if set(archive.files) != set(units) | {"metadata"} or "differential" not in units:
|
|
101
|
+
raise ValueError("Result archive arrays do not match metadata.")
|
|
102
|
+
|
|
103
|
+
arrays = {name: archive[name] * ureg.Unit(unit) for name, unit in units.items()}
|
|
104
|
+
|
|
105
|
+
return result_type(
|
|
106
|
+
source=Source(wavelength=metadata["wavelength_m"] * ureg.meter),
|
|
107
|
+
kind=metadata["kind"],
|
|
108
|
+
azimuth_averaged=metadata.get("azimuth_averaged", False),
|
|
109
|
+
realizations=metadata["realizations"],
|
|
110
|
+
warnings=metadata["warnings"],
|
|
111
|
+
provenance=metadata["provenance"],
|
|
112
|
+
**arrays,
|
|
113
|
+
)
|
|
114
|
+
except (KeyError, TypeError, AttributeError) as error:
|
|
115
|
+
raise ValueError("Invalid or incomplete result archive metadata.") from error
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _promote_legacy_directional_data(*, result):
|
|
119
|
+
"""Restore schema-1 averages without losing their directional arrays."""
|
|
120
|
+
|
|
121
|
+
if result.differential is None:
|
|
122
|
+
raise ValueError("differential is required.")
|
|
123
|
+
|
|
124
|
+
data = getattr(result, "differential").magnitude
|
|
125
|
+
|
|
126
|
+
legacy = getattr(result, "directional_differential")
|
|
127
|
+
|
|
128
|
+
if data.ndim == 2 and legacy is not None:
|
|
129
|
+
if result.azimuths is None:
|
|
130
|
+
raise ValueError("azimuths and directional_differential must be supplied together.")
|
|
131
|
+
|
|
132
|
+
expected = (*data.shape, len(getattr(result, "azimuths").magnitude))
|
|
133
|
+
|
|
134
|
+
if legacy.shape != expected:
|
|
135
|
+
raise ValueError(f"directional_differential must have shape {expected}.")
|
|
136
|
+
|
|
137
|
+
if not np.allclose(legacy.magnitude.mean(axis=-1), data, rtol=1e-10, atol=0):
|
|
138
|
+
raise ValueError("differential must equal the azimuth average of directional_differential.")
|
|
139
|
+
|
|
140
|
+
result.differential = legacy
|
|
141
|
+
|
|
142
|
+
if result.stderr is not None:
|
|
143
|
+
result.azimuth_stderr, result.stderr = result.stderr, None
|
|
144
|
+
|
|
145
|
+
# Legacy isolated terms were stored only as an average. Do not
|
|
146
|
+
# invent per-direction terms from that curve.
|
|
147
|
+
result.term_differential = None
|
|
148
|
+
|
|
149
|
+
data = legacy.magnitude
|
|
150
|
+
|
|
151
|
+
if result.directional_amplitudes is not None:
|
|
152
|
+
invalid_condition = result.amplitudes is not None and not np.allclose(
|
|
153
|
+
getattr(result, "amplitudes").magnitude,
|
|
154
|
+
getattr(result, "directional_amplitudes").magnitude,
|
|
155
|
+
rtol=1e-12,
|
|
156
|
+
atol=0,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if invalid_condition:
|
|
160
|
+
raise ValueError("amplitudes and directional_amplitudes must agree.")
|
|
161
|
+
|
|
162
|
+
result.amplitudes = result.directional_amplitudes
|
|
163
|
+
|
|
164
|
+
if data.ndim == 3:
|
|
165
|
+
if legacy is not None and not np.allclose(legacy.magnitude, data, rtol=1e-12, atol=0):
|
|
166
|
+
raise ValueError("directional_differential must agree with differential.")
|
|
167
|
+
|
|
168
|
+
result.directional_differential = result.differential
|
|
169
|
+
|
|
170
|
+
result.directional_amplitudes = result.amplitudes
|
|
171
|
+
elif result.directional_amplitudes is not None:
|
|
172
|
+
raise ValueError("directional_amplitudes require a full angular single-volume result.")
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Result plotting behind the public Result visualization methods."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ._validation import _integer
|
|
5
|
+
from .units import _dimensionless, ureg
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _ResultPlotter:
|
|
9
|
+
"""Display one result without changing its data, normalization or units."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *, result):
|
|
12
|
+
self._result = result
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def _pyplot():
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
|
|
18
|
+
return plt
|
|
19
|
+
|
|
20
|
+
def plot(self, *, terms=False, log_y=False, title=None, azimuth=0):
|
|
21
|
+
"""Render coherent or isolated differential scattering curves."""
|
|
22
|
+
|
|
23
|
+
return self._plot_differential(
|
|
24
|
+
terms=terms,
|
|
25
|
+
log_y=log_y,
|
|
26
|
+
title=title,
|
|
27
|
+
azimuth=azimuth,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def plot_cross_section(
|
|
31
|
+
self, *, volume=None, area_unit="nanometer**2", terms=False, log_y=False, title=None, azimuth=0
|
|
32
|
+
):
|
|
33
|
+
"""Scale finite-sample curves and uncertainties by their box volume."""
|
|
34
|
+
|
|
35
|
+
from .volume import Volume
|
|
36
|
+
from pint.errors import DimensionalityError, UndefinedUnitError
|
|
37
|
+
|
|
38
|
+
if self._result.kind == "analytical":
|
|
39
|
+
raise ValueError("Cross sections require a finite-sample result.")
|
|
40
|
+
|
|
41
|
+
if volume is not None:
|
|
42
|
+
if not isinstance(volume, Volume):
|
|
43
|
+
raise TypeError("volume must be a Volume.")
|
|
44
|
+
|
|
45
|
+
grid = self._result.provenance.get("grid")
|
|
46
|
+
|
|
47
|
+
if grid is not None:
|
|
48
|
+
# Saved results retain their original provenance keys.
|
|
49
|
+
recorded_background_refractive_index = grid.get(
|
|
50
|
+
"background_refractive_index",
|
|
51
|
+
grid.get("background_index", volume.background_refractive_index),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
incompatible_grid = (
|
|
55
|
+
tuple(grid["shape"]) != volume.delta_refractive_index.shape
|
|
56
|
+
or grid["spacing_m"] != float(volume.spacing.to("meter").magnitude)
|
|
57
|
+
or recorded_background_refractive_index != volume.background_refractive_index
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if incompatible_grid:
|
|
61
|
+
raise ValueError("volume must match the recorded sample grid and background_refractive_index.")
|
|
62
|
+
|
|
63
|
+
physical_volume = float(volume.volume.to("meter**3").magnitude)
|
|
64
|
+
|
|
65
|
+
recorded = self._result.sample_volume
|
|
66
|
+
|
|
67
|
+
invalid_condition = recorded is not None and not np.isclose(
|
|
68
|
+
recorded.to("meter**3").magnitude, physical_volume, rtol=1e-10, atol=0
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if invalid_condition:
|
|
72
|
+
raise ValueError("volume must match the recorded sample volume.")
|
|
73
|
+
else:
|
|
74
|
+
if self._result.sample_volume is None:
|
|
75
|
+
raise ValueError("sample_volume is unavailable; supply the sample Volume for a legacy result.")
|
|
76
|
+
|
|
77
|
+
physical_volume = float(self._result.sample_volume.to("meter**3").magnitude)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
area = (1.0 * ureg.meter**2).to(area_unit)
|
|
81
|
+
except (DimensionalityError, UndefinedUnitError, TypeError, ValueError) as error:
|
|
82
|
+
raise ValueError("area_unit must name an area unit.") from error
|
|
83
|
+
|
|
84
|
+
return self._plot_differential(
|
|
85
|
+
terms=terms,
|
|
86
|
+
log_y=log_y,
|
|
87
|
+
title="Finite-sample differential cross section" if title is None else title,
|
|
88
|
+
scale=physical_volume * float(area.magnitude),
|
|
89
|
+
azimuth=azimuth,
|
|
90
|
+
ylabel=f"Differential cross section ({area.units:~P} sr⁻¹)",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _plot_differential(
|
|
94
|
+
self, *, terms, log_y, title, azimuth=0, scale=1.0, ylabel="Differential scattering (m⁻¹ sr⁻¹)"
|
|
95
|
+
):
|
|
96
|
+
"""Render curves and uncertainty using one shared display scale."""
|
|
97
|
+
|
|
98
|
+
values = self._result.term_differential if terms else self._result.differential
|
|
99
|
+
|
|
100
|
+
if values is None:
|
|
101
|
+
raise ValueError("Isolated term curves are unavailable for this result.")
|
|
102
|
+
|
|
103
|
+
values = scale * values.to("1 / meter / steradian").magnitude
|
|
104
|
+
|
|
105
|
+
selected = self._select_azimuth(azimuth=azimuth)
|
|
106
|
+
|
|
107
|
+
if values.ndim == 3:
|
|
108
|
+
values = values[..., selected]
|
|
109
|
+
|
|
110
|
+
plt = self._pyplot()
|
|
111
|
+
|
|
112
|
+
x = (
|
|
113
|
+
np.arange(values.shape[1])
|
|
114
|
+
if self._result.angles is None
|
|
115
|
+
else np.rad2deg(self._result.angles.to("radian").magnitude)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
figure, axis = plt.subplots(figsize=(8, 5), layout="constrained")
|
|
119
|
+
|
|
120
|
+
for index, curve in enumerate(values):
|
|
121
|
+
errors = None
|
|
122
|
+
|
|
123
|
+
if not terms and self._result.stderr is not None:
|
|
124
|
+
candidate = self._result.stderr.to("1 / meter / steradian").magnitude[index]
|
|
125
|
+
|
|
126
|
+
if candidate.ndim == 2:
|
|
127
|
+
candidate = candidate[:, selected]
|
|
128
|
+
|
|
129
|
+
if np.any(np.isfinite(candidate)):
|
|
130
|
+
errors = scale * np.ma.masked_invalid(candidate)
|
|
131
|
+
|
|
132
|
+
label = f"{'Term' if terms else 'Through order'} {index + 1}"
|
|
133
|
+
|
|
134
|
+
style = {"marker": "o", "linestyle": "none"} if self._result.angles is None else {"linewidth": 2}
|
|
135
|
+
|
|
136
|
+
if errors is None:
|
|
137
|
+
axis.plot(
|
|
138
|
+
x,
|
|
139
|
+
curve,
|
|
140
|
+
label=label,
|
|
141
|
+
**style,
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
axis.errorbar(x, curve, yerr=errors, capsize=2, label=label, **style)
|
|
145
|
+
|
|
146
|
+
default_title = "Analytical scattering" if self._result.kind == "analytical" else "Finite-sample scattering"
|
|
147
|
+
|
|
148
|
+
default_title += self._angular_label(azimuth=selected)
|
|
149
|
+
|
|
150
|
+
axis.set(
|
|
151
|
+
title=default_title if title is None else title,
|
|
152
|
+
xlabel="Direction index" if self._result.angles is None else "Scattering angle (degrees)",
|
|
153
|
+
ylabel=ylabel,
|
|
154
|
+
yscale="log" if log_y else "linear",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
axis.grid(alpha=0.25)
|
|
158
|
+
|
|
159
|
+
axis.legend(frameon=False)
|
|
160
|
+
|
|
161
|
+
return figure
|
|
162
|
+
|
|
163
|
+
def _select_azimuth(self, *, azimuth):
|
|
164
|
+
count = 1 if self._result.azimuths is None else len(self._result.azimuths)
|
|
165
|
+
|
|
166
|
+
return _integer(value=azimuth, name="azimuth", low=0, high=count - 1)
|
|
167
|
+
|
|
168
|
+
def _angular_label(self, *, azimuth):
|
|
169
|
+
if getattr(self._result, "meridian_azimuth", None) is not None:
|
|
170
|
+
degrees = float(np.rad2deg(self._result.meridian_azimuth.magnitude))
|
|
171
|
+
|
|
172
|
+
return f" · meridian phi = {degrees:g} degrees"
|
|
173
|
+
|
|
174
|
+
if self._result.azimuth_averaged:
|
|
175
|
+
return " · azimuth average"
|
|
176
|
+
|
|
177
|
+
if self._result.azimuths is not None:
|
|
178
|
+
degrees = float(np.rad2deg(self._result.azimuths.magnitude[azimuth]))
|
|
179
|
+
|
|
180
|
+
return f" · meridian phi = {degrees:g} degrees"
|
|
181
|
+
|
|
182
|
+
return ""
|
|
183
|
+
|
|
184
|
+
def plot_phase_function(self, *, view="angular", order=None, log_y=False, azimuth=0, backend=None):
|
|
185
|
+
"""Render normalized densities while retaining directional 3D asymmetry."""
|
|
186
|
+
|
|
187
|
+
if view != "angular" and getattr(self._result, "meridian_azimuth", None) is not None:
|
|
188
|
+
raise ValueError("Use full angular data for polar or 3D plots; a selected meridian has only one side.")
|
|
189
|
+
|
|
190
|
+
if view not in ("angular", "polar", "3d"):
|
|
191
|
+
raise ValueError("view must be angular, polar, or 3d.")
|
|
192
|
+
|
|
193
|
+
if backend is None:
|
|
194
|
+
backend = "plotly" if view == "3d" else "matplotlib"
|
|
195
|
+
|
|
196
|
+
if backend not in ("matplotlib", "plotly"):
|
|
197
|
+
raise ValueError("backend must be matplotlib or plotly.")
|
|
198
|
+
|
|
199
|
+
if backend == "plotly" and view != "3d":
|
|
200
|
+
raise ValueError("The Plotly backend is only supported for the 3D phase view.")
|
|
201
|
+
|
|
202
|
+
if log_y and view != "angular":
|
|
203
|
+
raise ValueError("log_y is only supported for the angular view.")
|
|
204
|
+
|
|
205
|
+
phase = self._result.phase_function.magnitude
|
|
206
|
+
|
|
207
|
+
theta = self._result.angles.to("radian").magnitude
|
|
208
|
+
|
|
209
|
+
indices = np.argsort(theta)
|
|
210
|
+
|
|
211
|
+
theta, phase = theta[indices], phase[:, indices]
|
|
212
|
+
|
|
213
|
+
orders = (
|
|
214
|
+
range(len(phase))
|
|
215
|
+
if order is None
|
|
216
|
+
else [
|
|
217
|
+
_integer(
|
|
218
|
+
value=order,
|
|
219
|
+
name="order",
|
|
220
|
+
low=1,
|
|
221
|
+
high=len(phase),
|
|
222
|
+
)
|
|
223
|
+
- 1
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
insufficient_angular_coverage = view in ("polar", "3d") and (
|
|
228
|
+
len(np.unique(theta)) < 3
|
|
229
|
+
or not np.isclose(theta[0], 0, atol=1e-10, rtol=0)
|
|
230
|
+
or not np.isclose(theta[-1], np.pi, atol=1e-10, rtol=0)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if insufficient_angular_coverage:
|
|
234
|
+
raise ValueError("Polar and 3D views require at least three distinct angles spanning 0 to pi.")
|
|
235
|
+
|
|
236
|
+
title = (
|
|
237
|
+
"Analytical phase function"
|
|
238
|
+
if self._result.kind == "analytical"
|
|
239
|
+
else "Directional finite-sample phase function"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
meridian = self._select_azimuth(azimuth=azimuth)
|
|
243
|
+
|
|
244
|
+
degrees = np.rad2deg(theta)
|
|
245
|
+
|
|
246
|
+
if view == "3d":
|
|
247
|
+
selected = len(phase) - 1 if order is None else orders[0]
|
|
248
|
+
|
|
249
|
+
if phase.ndim == 3:
|
|
250
|
+
phi = np.concatenate([self._result.azimuths.magnitude, [2 * np.pi]])
|
|
251
|
+
|
|
252
|
+
directional = phase[selected]
|
|
253
|
+
|
|
254
|
+
radius = np.concatenate([directional, directional[:, :1]], axis=-1)
|
|
255
|
+
|
|
256
|
+
surface_label = "full azimuthal distribution"
|
|
257
|
+
elif self._result.kind == "analytical" or self._result.azimuth_averaged:
|
|
258
|
+
phi = np.linspace(0, 2 * np.pi, 97)
|
|
259
|
+
|
|
260
|
+
radius = np.broadcast_to(phase[selected, :, None], (len(theta), len(phi)))
|
|
261
|
+
|
|
262
|
+
surface_label = (
|
|
263
|
+
"axisymmetric analytical surface"
|
|
264
|
+
if self._result.kind == "analytical"
|
|
265
|
+
else "explicit azimuth-average surface"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if self._result.azimuth_averaged:
|
|
269
|
+
title = "Azimuth-averaged finite-sample phase function"
|
|
270
|
+
else:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
"Directional phase data are unavailable; recompute with Solver.solve using AngularSampling."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
sine = np.sin(theta)[:, None]
|
|
276
|
+
|
|
277
|
+
x = radius * sine * np.cos(phi)
|
|
278
|
+
|
|
279
|
+
y = radius * sine * np.sin(phi)
|
|
280
|
+
|
|
281
|
+
z = radius * np.cos(theta)[:, None]
|
|
282
|
+
|
|
283
|
+
if backend == "plotly":
|
|
284
|
+
import plotly.graph_objects as go
|
|
285
|
+
|
|
286
|
+
figure = go.Figure(
|
|
287
|
+
data=[
|
|
288
|
+
go.Surface(
|
|
289
|
+
x=x,
|
|
290
|
+
y=y,
|
|
291
|
+
z=z,
|
|
292
|
+
surfacecolor=radius,
|
|
293
|
+
customdata=radius,
|
|
294
|
+
colorscale="Viridis",
|
|
295
|
+
cmin=0,
|
|
296
|
+
cmax=float(radius.max()),
|
|
297
|
+
colorbar={"title": "p (sr⁻¹)"},
|
|
298
|
+
hovertemplate="Phase density p=%{customdata:.5g} sr⁻¹<extra></extra>",
|
|
299
|
+
)
|
|
300
|
+
],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
figure.update_layout(
|
|
304
|
+
title=f"{title}<br>Through order {selected + 1} · {surface_label}",
|
|
305
|
+
scene={
|
|
306
|
+
"xaxis_title": "p ŝx (sr⁻¹)",
|
|
307
|
+
"yaxis_title": "p ŝy (sr⁻¹)",
|
|
308
|
+
"zaxis_title": "p ŝz (sr⁻¹); incidence +z",
|
|
309
|
+
"aspectmode": "data",
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return figure
|
|
314
|
+
|
|
315
|
+
from matplotlib.cm import ScalarMappable
|
|
316
|
+
from matplotlib.colors import Normalize
|
|
317
|
+
|
|
318
|
+
plt = self._pyplot()
|
|
319
|
+
|
|
320
|
+
norm = Normalize(vmin=0, vmax=float(radius.max()))
|
|
321
|
+
|
|
322
|
+
cmap = plt.get_cmap("viridis")
|
|
323
|
+
|
|
324
|
+
figure = plt.figure(figsize=(9, 6), layout="constrained")
|
|
325
|
+
|
|
326
|
+
axis = figure.add_subplot(projection="3d")
|
|
327
|
+
|
|
328
|
+
axis.plot_surface(
|
|
329
|
+
x,
|
|
330
|
+
y,
|
|
331
|
+
z,
|
|
332
|
+
facecolors=cmap(norm(radius)),
|
|
333
|
+
rcount=len(theta),
|
|
334
|
+
ccount=len(phi),
|
|
335
|
+
linewidth=0,
|
|
336
|
+
shade=False,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
extent = np.array([np.ptp(x), np.ptp(y), np.ptp(z)])
|
|
340
|
+
|
|
341
|
+
axis.set_box_aspect(extent)
|
|
342
|
+
|
|
343
|
+
axis.set(
|
|
344
|
+
xlabel="p ŝx (sr⁻¹)",
|
|
345
|
+
ylabel="p ŝy (sr⁻¹)",
|
|
346
|
+
zlabel="p ŝz (sr⁻¹); incidence +z",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
axis.set_title(f"{title}\nThrough order {selected + 1} · {surface_label}")
|
|
350
|
+
|
|
351
|
+
axis.view_init(elev=20, azim=45)
|
|
352
|
+
|
|
353
|
+
figure.colorbar(ScalarMappable(norm=norm, cmap=cmap), ax=axis, label="p (sr⁻¹)", shrink=0.75, pad=0.12)
|
|
354
|
+
else:
|
|
355
|
+
plt = self._pyplot()
|
|
356
|
+
|
|
357
|
+
opposite = None
|
|
358
|
+
|
|
359
|
+
if phase.ndim == 3:
|
|
360
|
+
if view == "polar":
|
|
361
|
+
count = len(self._result.azimuths)
|
|
362
|
+
|
|
363
|
+
if count % 2:
|
|
364
|
+
raise ValueError("A full polar plane requires an even azimuth sample count.")
|
|
365
|
+
|
|
366
|
+
opposite = phase[..., (meridian + count // 2) % count]
|
|
367
|
+
|
|
368
|
+
phase = phase[..., meridian]
|
|
369
|
+
|
|
370
|
+
title += self._angular_label(azimuth=meridian)
|
|
371
|
+
|
|
372
|
+
projection = "polar" if view == "polar" else None
|
|
373
|
+
|
|
374
|
+
figure, axis = plt.subplots(figsize=(8, 5), subplot_kw={"projection": projection}, layout="constrained")
|
|
375
|
+
|
|
376
|
+
for index in orders:
|
|
377
|
+
if view == "polar":
|
|
378
|
+
axis.plot(
|
|
379
|
+
np.concatenate([theta, 2 * np.pi - theta[-2::-1]]),
|
|
380
|
+
np.concatenate([phase[index], (phase if opposite is None else opposite)[index, -2::-1]]),
|
|
381
|
+
label=f"Through order {index + 1}",
|
|
382
|
+
linewidth=2,
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
axis.plot(
|
|
386
|
+
degrees,
|
|
387
|
+
phase[index],
|
|
388
|
+
label=f"Through order {index + 1}",
|
|
389
|
+
linewidth=2,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if view == "polar":
|
|
393
|
+
axis.set_theta_zero_location("N")
|
|
394
|
+
|
|
395
|
+
axis.set_theta_direction(-1)
|
|
396
|
+
|
|
397
|
+
axis.set_ylim(bottom=0)
|
|
398
|
+
|
|
399
|
+
axis.set_title(f"{title}\nMeridian cut · incidence at 0° · p (sr⁻¹)", pad=20)
|
|
400
|
+
else:
|
|
401
|
+
axis.set(
|
|
402
|
+
title=title,
|
|
403
|
+
xlabel="Scattering angle θ (degrees)",
|
|
404
|
+
ylabel="Phase function p (sr⁻¹)",
|
|
405
|
+
yscale="log" if log_y else "linear",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
axis.grid(alpha=0.25)
|
|
409
|
+
|
|
410
|
+
axis.legend(frameon=False)
|
|
411
|
+
|
|
412
|
+
return figure
|
|
413
|
+
|
|
414
|
+
def plot_field_norms(self, *, log_y=True):
|
|
415
|
+
"""Draw relative Born field norms for each available realization."""
|
|
416
|
+
|
|
417
|
+
if self._result.field_norms is None:
|
|
418
|
+
raise ValueError("Field norms are only available for numerical results.")
|
|
419
|
+
|
|
420
|
+
norms = np.atleast_2d(
|
|
421
|
+
_dimensionless(
|
|
422
|
+
value=self._result.field_norms,
|
|
423
|
+
name="field_norms",
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
plt = self._pyplot()
|
|
428
|
+
|
|
429
|
+
from matplotlib.ticker import MaxNLocator
|
|
430
|
+
|
|
431
|
+
figure, axis = plt.subplots(figsize=(8, 5), layout="constrained")
|
|
432
|
+
|
|
433
|
+
for index, curve in enumerate(norms):
|
|
434
|
+
axis.plot(
|
|
435
|
+
np.arange(1, len(curve) + 1),
|
|
436
|
+
curve,
|
|
437
|
+
"o-",
|
|
438
|
+
label=f"Realization {index + 1}",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
axis.set(
|
|
442
|
+
title="Born field terms\nDecreasing terms do not certify convergence",
|
|
443
|
+
xlabel="Field-term order j",
|
|
444
|
+
ylabel="‖Ej‖ / ‖Einc‖ (dimensionless)",
|
|
445
|
+
yscale="log" if log_y else "linear",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
axis.xaxis.set_major_locator(MaxNLocator(integer=True))
|
|
449
|
+
|
|
450
|
+
axis.grid(alpha=0.25)
|
|
451
|
+
|
|
452
|
+
axis.legend(frameon=False)
|
|
453
|
+
|
|
454
|
+
return figure
|