radphen-iadpython 0.7.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.
- iadpython/__init__.py +59 -0
- iadpython/ad.py +1377 -0
- iadpython/agrid.py +306 -0
- iadpython/cache.py +133 -0
- iadpython/combine.py +620 -0
- iadpython/constants.py +3 -0
- iadpython/data/M38597.csv +759 -0
- iadpython/double.py +376 -0
- iadpython/fresnel.py +642 -0
- iadpython/grid.py +125 -0
- iadpython/iad.py +615 -0
- iadpython/iadc.py +1003 -0
- iadpython/iadcommand.py +712 -0
- iadpython/layer.py +241 -0
- iadpython/nist.py +114 -0
- iadpython/port.py +242 -0
- iadpython/quadrature.py +172 -0
- iadpython/redistribution.py +318 -0
- iadpython/rxt.py +201 -0
- iadpython/sphere.py +582 -0
- iadpython/start.py +377 -0
- iadpython/txt.py +147 -0
- radphen_iadpython-0.7.0.dist-info/METADATA +264 -0
- radphen_iadpython-0.7.0.dist-info/RECORD +28 -0
- radphen_iadpython-0.7.0.dist-info/WHEEL +5 -0
- radphen_iadpython-0.7.0.dist-info/entry_points.txt +2 -0
- radphen_iadpython-0.7.0.dist-info/licenses/LICENSE.txt +18 -0
- radphen_iadpython-0.7.0.dist-info/top_level.txt +1 -0
iadpython/layer.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""One layer of a multilayer sample.
|
|
2
|
+
|
|
3
|
+
A :class:`Layer` bundles the optical properties of one slab in a stack. The
|
|
4
|
+
two independent axes of a simulation are kept structurally distinct:
|
|
5
|
+
|
|
6
|
+
* **layer axis** -- the position of the ``Layer`` in the ``layers`` list passed
|
|
7
|
+
to :class:`iadpython.Sample`;
|
|
8
|
+
* **wavelength axis** -- arrays *inside* each ``Layer`` attribute (``a``,
|
|
9
|
+
``b``, ``g``, ``n`` may each be a scalar or a 1-D array over wavelengths;
|
|
10
|
+
a ``TABULATED`` phase function carries one ``pf_data`` column per
|
|
11
|
+
wavelength).
|
|
12
|
+
|
|
13
|
+
A scalar attribute is automatically held constant (broadcast) across all
|
|
14
|
+
wavelengths of the sweep, so layers with constant properties can be freely
|
|
15
|
+
mixed with wavelength-dependent ones.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
>>> import iadpython as iad
|
|
20
|
+
>>> l0 = iad.Layer(a=0.95, b=0.84, g=0.20, n=1.35, d=0.10)
|
|
21
|
+
>>> l1 = iad.Layer(a=0.81, b=0.31, g=0.85, n=2.00, d=0.05)
|
|
22
|
+
>>> s = iad.Sample(layers=[l0, l1], quad_pts=16)
|
|
23
|
+
>>> ur1, ut1, uru, utu = s.rt()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
__all__ = ("Layer",)
|
|
29
|
+
|
|
30
|
+
_PF_TYPES = ("HG", "TABULATED", "MOMENTS")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _as_scalar_or_1d(value, name):
|
|
34
|
+
"""Return ``value`` unchanged if scalar, else as a 1-D float array.
|
|
35
|
+
|
|
36
|
+
Lists/tuples are converted to numpy arrays so downstream code can rely on
|
|
37
|
+
numpy semantics. Anything with more than one dimension is rejected --
|
|
38
|
+
the wavelength axis is the only in-attribute axis a ``Layer`` carries.
|
|
39
|
+
"""
|
|
40
|
+
if value is None or np.isscalar(value):
|
|
41
|
+
return value
|
|
42
|
+
arr = np.asarray(value)
|
|
43
|
+
if arr.ndim == 0:
|
|
44
|
+
return arr.item()
|
|
45
|
+
if arr.ndim > 1:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Layer.%s must be a scalar or 1-D wavelength array, got shape %s"
|
|
48
|
+
% (name, arr.shape)
|
|
49
|
+
)
|
|
50
|
+
return arr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt(value):
|
|
54
|
+
"""Compact scalar-or-range formatting for repr (no iadpython imports)."""
|
|
55
|
+
if value is None:
|
|
56
|
+
return "None"
|
|
57
|
+
if np.isscalar(value) or np.ndim(value) == 0:
|
|
58
|
+
return "%.3f" % np.real(value)
|
|
59
|
+
arr = np.asarray(value)
|
|
60
|
+
return "%.3f..%.3f[%d]" % (np.real(arr.min()), np.real(arr.max()), arr.size)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Layer:
|
|
64
|
+
"""Optical properties of one slab in a multilayer sample.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
a: single-scattering albedo (scalar or wavelength array)
|
|
68
|
+
b: optical thickness (scalar or wavelength array)
|
|
69
|
+
g: scattering anisotropy, used when ``pf_type == 'HG'``
|
|
70
|
+
(scalar or wavelength array)
|
|
71
|
+
d: physical thickness [mm] (scalar; informational -- the radiative
|
|
72
|
+
transfer solve consumes ``b``, not ``d``)
|
|
73
|
+
n: refractive index (scalar or wavelength array)
|
|
74
|
+
pf_type: ``'HG'`` (Henyey-Greenstein, from ``g``), ``'TABULATED'``
|
|
75
|
+
(from ``pf_data``), or ``'MOMENTS'`` (from ``pf_data``)
|
|
76
|
+
pf_data: for ``'TABULATED'``, a pandas ``DataFrame`` indexed by
|
|
77
|
+
mu = cos(theta) with one column per wavelength (a single column
|
|
78
|
+
is broadcast across the sweep). For ``'MOMENTS'``, a plain
|
|
79
|
+
ndarray of Legendre moments a_l -- 1-D ``(n_mom,)`` (broadcast
|
|
80
|
+
across the sweep) or 2-D ``(n_mom, n_wavelength)`` -- supplied
|
|
81
|
+
directly instead of a tabulated phase function, bypassing the
|
|
82
|
+
spline+quadrature step ``'TABULATED'`` requires. Required when
|
|
83
|
+
``pf_type`` is ``'TABULATED'`` or ``'MOMENTS'``.
|
|
84
|
+
|
|
85
|
+
Cross-layer consistency (matching wavelength counts, physical ranges) is
|
|
86
|
+
checked at solve time by ``validate_layers`` so partially built layers can
|
|
87
|
+
be edited freely before use.
|
|
88
|
+
|
|
89
|
+
Note on ``pf_type='MOMENTS'`` in a mismatched-refractive-index stack:
|
|
90
|
+
unlike ``'TABULATED'`` (a continuous curve that can be re-integrated to
|
|
91
|
+
any order on demand), a MOMENTS array is a fixed, finite set of
|
|
92
|
+
precomputed numbers. The mismatched-index solver's internal master grid
|
|
93
|
+
may need a *larger* effective quadrature order for some sub-layers than
|
|
94
|
+
the Sample's own ``quad_pts`` -- so a MOMENTS array sized only for the
|
|
95
|
+
nominal ``quad_pts`` can raise "needs at least N moments" partway
|
|
96
|
+
through a solve. Supply generously more moments than ``2*quad_pts+1``
|
|
97
|
+
when index-mismatched layers are involved.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, a=0, b=1, g=0, d=1, n=1, pf_type="HG", pf_data=None):
|
|
101
|
+
"""Create one layer; see class docstring for attribute meanings."""
|
|
102
|
+
self.a = _as_scalar_or_1d(a, "a")
|
|
103
|
+
self.b = _as_scalar_or_1d(b, "b")
|
|
104
|
+
self.g = _as_scalar_or_1d(g, "g")
|
|
105
|
+
self.n = _as_scalar_or_1d(n, "n")
|
|
106
|
+
self.d = d
|
|
107
|
+
|
|
108
|
+
self.pf_type = str(pf_type).upper()
|
|
109
|
+
if self.pf_type not in _PF_TYPES:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Layer pf_type must be one of %s, got %r" % (_PF_TYPES, pf_type)
|
|
112
|
+
)
|
|
113
|
+
if self.pf_type == "TABULATED":
|
|
114
|
+
# duck-typed DataFrame check to keep this module pandas-free
|
|
115
|
+
if pf_data is None or not hasattr(pf_data, "iloc"):
|
|
116
|
+
raise TypeError(
|
|
117
|
+
"pf_type='TABULATED' requires pf_data to be a pandas "
|
|
118
|
+
"DataFrame (index = cos(theta), one column per wavelength)"
|
|
119
|
+
)
|
|
120
|
+
elif self.pf_type == "MOMENTS":
|
|
121
|
+
# Legendre moments a_l, bypassing TABULATED's spline+quadrature.
|
|
122
|
+
# Explicitly reject DataFrame-like input -- np.asarray(df) would
|
|
123
|
+
# otherwise silently coerce it instead of failing loudly.
|
|
124
|
+
if pf_data is None or hasattr(pf_data, "iloc"):
|
|
125
|
+
raise TypeError(
|
|
126
|
+
"pf_type='MOMENTS' requires pf_data to be a plain array "
|
|
127
|
+
"of Legendre moments (1-D), or a 2-D array (n_mom, "
|
|
128
|
+
"n_wavelength) for a wavelength sweep -- not a DataFrame"
|
|
129
|
+
)
|
|
130
|
+
pf_data = np.asarray(pf_data, dtype=float)
|
|
131
|
+
if pf_data.ndim not in (1, 2):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"MOMENTS pf_data must be 1-D (n_mom,) or 2-D (n_mom, "
|
|
134
|
+
"n_wavelength), got shape %s" % (pf_data.shape,)
|
|
135
|
+
)
|
|
136
|
+
self.pf_data = pf_data
|
|
137
|
+
|
|
138
|
+
def n_wavelengths(self):
|
|
139
|
+
"""Number of wavelengths this layer's own data spans (1 if scalar)."""
|
|
140
|
+
counts = [1]
|
|
141
|
+
for value in (self.a, self.b, self.g, self.n):
|
|
142
|
+
if not (value is None or np.isscalar(value) or np.ndim(value) == 0):
|
|
143
|
+
counts.append(len(value))
|
|
144
|
+
if self.pf_type == "TABULATED" and self.pf_data is not None:
|
|
145
|
+
counts.append(self.pf_data.shape[1])
|
|
146
|
+
if self.pf_type == "MOMENTS" and self.pf_data is not None and self.pf_data.ndim == 2:
|
|
147
|
+
counts.append(self.pf_data.shape[1])
|
|
148
|
+
return max(counts)
|
|
149
|
+
|
|
150
|
+
def __repr__(self):
|
|
151
|
+
"""Compact one-line description."""
|
|
152
|
+
s = "Layer(a=%s, b=%s, g=%s, n=%s, d=%s, pf=%s" % (
|
|
153
|
+
_fmt(self.a),
|
|
154
|
+
_fmt(self.b),
|
|
155
|
+
_fmt(self.g),
|
|
156
|
+
_fmt(self.n),
|
|
157
|
+
_fmt(self.d),
|
|
158
|
+
self.pf_type,
|
|
159
|
+
)
|
|
160
|
+
if self.pf_type == "TABULATED" and self.pf_data is not None:
|
|
161
|
+
s += "[%d col]" % self.pf_data.shape[1]
|
|
162
|
+
if self.pf_type == "MOMENTS" and self.pf_data is not None:
|
|
163
|
+
if self.pf_data.ndim == 2:
|
|
164
|
+
s += "[%d mom x %d col]" % self.pf_data.shape
|
|
165
|
+
else:
|
|
166
|
+
s += "[%d mom]" % self.pf_data.shape[0]
|
|
167
|
+
return s + ")"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_layers(layers):
|
|
171
|
+
"""Check a list of layers for cross-layer consistency.
|
|
172
|
+
|
|
173
|
+
Determines the wavelength count of the sweep as the maximum over every
|
|
174
|
+
layer's array lengths and ``pf_data`` column counts, then verifies that
|
|
175
|
+
each per-layer axis is compatible with it (length 1 broadcasts).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
layers: sequence of :class:`Layer` objects (at least one)
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
n_wavelengths: the number of wavelengths of the sweep (>= 1)
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
RuntimeError: on empty input, non-Layer entries, conflicting
|
|
185
|
+
wavelength counts, or out-of-range ``a``/``b`` values.
|
|
186
|
+
"""
|
|
187
|
+
if layers is None or len(layers) == 0:
|
|
188
|
+
raise RuntimeError("Sample(layers=...) needs at least one Layer")
|
|
189
|
+
|
|
190
|
+
for k, layer in enumerate(layers):
|
|
191
|
+
if not isinstance(layer, Layer):
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
"layers[%d] is %s, expected iadpython.Layer" % (k, type(layer).__name__)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
n_wave = max(layer.n_wavelengths() for layer in layers)
|
|
197
|
+
|
|
198
|
+
for k, layer in enumerate(layers):
|
|
199
|
+
for name in ("a", "b", "g", "n"):
|
|
200
|
+
value = getattr(layer, name)
|
|
201
|
+
if value is None or np.isscalar(value) or np.ndim(value) == 0:
|
|
202
|
+
continue
|
|
203
|
+
if len(value) not in (1, n_wave):
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
"layers[%d].%s has %d wavelengths but the stack sweep "
|
|
206
|
+
"has %d (lengths must be 1 or match)"
|
|
207
|
+
% (k, name, len(value), n_wave)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if not (np.isscalar(layer.d) or np.ndim(layer.d) == 0):
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
"layers[%d].d must be a scalar physical thickness "
|
|
213
|
+
"(wavelength-dependent d is not meaningful)" % k
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if layer.pf_type == "TABULATED":
|
|
217
|
+
ncols = layer.pf_data.shape[1]
|
|
218
|
+
if ncols not in (1, n_wave):
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
"layers[%d].pf_data has %d columns but the stack sweep "
|
|
221
|
+
"has %d wavelengths (columns must be 1 or match)"
|
|
222
|
+
% (k, ncols, n_wave)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if layer.pf_type == "MOMENTS" and layer.pf_data.ndim == 2:
|
|
226
|
+
ncols = layer.pf_data.shape[1]
|
|
227
|
+
if ncols not in (1, n_wave):
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
"layers[%d].pf_data has %d columns but the stack sweep "
|
|
230
|
+
"has %d wavelengths (columns must be 1 or match)"
|
|
231
|
+
% (k, ncols, n_wave)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
a_arr = np.atleast_1d(layer.a).astype(float)
|
|
235
|
+
b_arr = np.atleast_1d(layer.b).astype(float)
|
|
236
|
+
if np.any(a_arr < 0) or np.any(a_arr > 1):
|
|
237
|
+
raise RuntimeError("layers[%d].a must lie in [0, 1]" % k)
|
|
238
|
+
if np.any(b_arr < 0):
|
|
239
|
+
raise RuntimeError("layers[%d].b must be non-negative" % k)
|
|
240
|
+
|
|
241
|
+
return n_wave
|
iadpython/nist.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Module for importing reflection spectra from NIST database.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
>>> import numpy as np
|
|
6
|
+
>>> import matplotlib.pyplot as plt
|
|
7
|
+
>>> import iadpython as iad
|
|
8
|
+
|
|
9
|
+
>>> # Retrieve and plot subject 5
|
|
10
|
+
|
|
11
|
+
>>> subject_number = 5
|
|
12
|
+
>>> lambda0, R = iad.subject_average_reflectance(subject_number)
|
|
13
|
+
>>> plt.plot(lambda0, R)
|
|
14
|
+
>>> plt.xlabel("Wavelength (nm)")
|
|
15
|
+
>>> plt.ylabel("Total Reflectance")
|
|
16
|
+
>>> plt.title("Subject #%d" % subject_number)
|
|
17
|
+
>>> plt.show()
|
|
18
|
+
|
|
19
|
+
>>> # Retrieve and plot all subjects
|
|
20
|
+
|
|
21
|
+
>>> lambda0, R = iad.all_average_reflectances()
|
|
22
|
+
>>> for i in range(100):
|
|
23
|
+
>>> plt.plot(lambda0, R[:, i])
|
|
24
|
+
>>> plt.xlabel("Wavelength (nm)")
|
|
25
|
+
>>> plt.ylabel("Total Reflectance")
|
|
26
|
+
>>> plt.title("All Subjects")
|
|
27
|
+
>>> plt.show()
|
|
28
|
+
|
|
29
|
+
Reference:
|
|
30
|
+
<https://nvlpubs.nist.gov/nistpubs/jres/122/jres.122.026.pdf>
|
|
31
|
+
nist_db = 'https://doi.org/10.18434/M38597'
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
"subject_reflectances",
|
|
39
|
+
"subject_average_reflectance",
|
|
40
|
+
"all_average_reflectances",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_subject_data(cols):
|
|
45
|
+
"""
|
|
46
|
+
Load and return data from a CSV file based on specified columns.
|
|
47
|
+
|
|
48
|
+
This function reads data from a CSV file located in the 'data' directory relative
|
|
49
|
+
to the script's location and allows you to specify the columns to extract.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
cols: A tuple of column indices to extract from the CSV file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An array containing the requested data.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
To extract columns 1 and 2 from the CSV file, you can call the function like this:
|
|
59
|
+
>>> cols_to_extract = (1, 2)
|
|
60
|
+
>>> data = get_subject_data(cols_to_extract)
|
|
61
|
+
"""
|
|
62
|
+
script_dir = os.path.dirname(__file__) # Path to directory of this file
|
|
63
|
+
data_file_path = os.path.join(script_dir, "data", "M38597.csv")
|
|
64
|
+
data = np.loadtxt(data_file_path, skiprows=8, usecols=cols, delimiter=",", encoding="latin1")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def subject_reflectances(subject_number):
|
|
69
|
+
"""Extract all reflection data for one subject."""
|
|
70
|
+
if subject_number <= 0 or subject_number > 100:
|
|
71
|
+
raise ValueError("subject_number must be 1 to 100")
|
|
72
|
+
|
|
73
|
+
col = (subject_number - 1) * 4 + 1
|
|
74
|
+
|
|
75
|
+
cols = (0, col, col + 1, col + 2, col + 3)
|
|
76
|
+
|
|
77
|
+
data = get_subject_data(cols)
|
|
78
|
+
|
|
79
|
+
lambda0 = data[:, 0]
|
|
80
|
+
r_1 = data[:, 1]
|
|
81
|
+
r_2 = data[:, 2]
|
|
82
|
+
r_3 = data[:, 3]
|
|
83
|
+
r_ave = data[:, 4]
|
|
84
|
+
|
|
85
|
+
return lambda0, r_1, r_2, r_3, r_ave
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def subject_average_reflectance(subject_number):
|
|
89
|
+
"""Extract average reflection for one subject."""
|
|
90
|
+
if subject_number <= 0 or subject_number > 100:
|
|
91
|
+
raise ValueError("subject_number must be 1 to 100")
|
|
92
|
+
|
|
93
|
+
col = (subject_number - 1) * 4 + 4
|
|
94
|
+
|
|
95
|
+
cols = (0, col)
|
|
96
|
+
|
|
97
|
+
data = get_subject_data(cols)
|
|
98
|
+
|
|
99
|
+
lambda0 = data[:, 0]
|
|
100
|
+
r_ave = data[:, 1]
|
|
101
|
+
|
|
102
|
+
return lambda0, r_ave
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def all_average_reflectances():
|
|
106
|
+
"""Extract average reflectance for all subjects."""
|
|
107
|
+
cols = [4 * i for i in range(101)]
|
|
108
|
+
|
|
109
|
+
data = get_subject_data(cols)
|
|
110
|
+
|
|
111
|
+
lambda0 = data[:, 0]
|
|
112
|
+
r_ave = data[:, 1:]
|
|
113
|
+
|
|
114
|
+
return lambda0, r_ave
|
iadpython/port.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# pylint: disable=protected-access)
|
|
2
|
+
"""
|
|
3
|
+
Implementation of container class for an integrating sphere port.
|
|
4
|
+
|
|
5
|
+
This module defines the Port class, which is integral to the analysis of light properties
|
|
6
|
+
within an integrating sphere. The Port class specifically models a port on the sphere's
|
|
7
|
+
surface, calculating and storing its geometrical properties and its interaction with light.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
Port: Represents a port in an integrating sphere. It holds information about
|
|
11
|
+
the port's dimensions, position, and optical properties. The class provides
|
|
12
|
+
methods to calculate the port's geometrical characteristics, such as the
|
|
13
|
+
area of the spherical cap it forms, its sagitta, and the chord distance from
|
|
14
|
+
the port's center to its edge. Additionally, it includes functionality to
|
|
15
|
+
assess whether a given point on the sphere's surface falls within the port's
|
|
16
|
+
boundaries.
|
|
17
|
+
|
|
18
|
+
Functions:
|
|
19
|
+
- cap_area_exact: Calculates the area of the spherical cap formed by the port.
|
|
20
|
+
- relative_cap_area: Calculates the approximate relative area of the spherical cap.
|
|
21
|
+
- calculate_sagitta: Determines the sagitta (or height) of the spherical cap.
|
|
22
|
+
- max_center_chord: Computes the maximum distance from the port's center to its edge.
|
|
23
|
+
- cap_area_exact: Calculates the exact relative area of the spherical cap.
|
|
24
|
+
- hit: Checks if a point on the sphere falls within the port's boundaries.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> import iadpython as iad
|
|
28
|
+
>>> sphere = iad.Sphere(200, 20) # Example of creating a sphere object
|
|
29
|
+
>>> port = Port(sphere, 20, 0.5, 0, 0, 0)
|
|
30
|
+
>>> print(port)
|
|
31
|
+
|
|
32
|
+
See Also:
|
|
33
|
+
Sphere: Another class/module within the iadpython package that models the entire integrating sphere.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import random
|
|
37
|
+
import numpy as np
|
|
38
|
+
import iadpython as iad
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def uniform_disk():
|
|
42
|
+
"""
|
|
43
|
+
Generate a point uniformly distributed on a unit disk.
|
|
44
|
+
|
|
45
|
+
This function generates and returns a point (x, y) that is uniformly distributed
|
|
46
|
+
over the area of a unit disk centered at the origin (0, 0). The unit disk is
|
|
47
|
+
defined as the set of all points (x, y) such that x^2 + y^2 <= 1. The method
|
|
48
|
+
uses rejection sampling, where random points are generated within the square
|
|
49
|
+
that bounds the unit disk, and only points that fall within the disk are accepted.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
tuple of (float, float, float): A point (x, y) where x and y are the
|
|
53
|
+
coordinates of the point uniformly distributed within the unit disk,
|
|
54
|
+
and s is the square of the distance from the origin to this point.
|
|
55
|
+
"""
|
|
56
|
+
s = 2
|
|
57
|
+
while s > 1:
|
|
58
|
+
x = 2 * random.random() - 1
|
|
59
|
+
y = 2 * random.random() - 1
|
|
60
|
+
s = x * x + y * y
|
|
61
|
+
return x, y, s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Port:
|
|
65
|
+
"""
|
|
66
|
+
Container class for a port on an integrating sphere.
|
|
67
|
+
|
|
68
|
+
A container class for a port in an integrating sphere, which is a structure
|
|
69
|
+
used to analyze light properties. The class calculates and stores various
|
|
70
|
+
geometrical properties of the port, such as its diameter, position, and the
|
|
71
|
+
relative area of the spherical cap it forms on the sphere's surface.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
sphere (object): Reference to the sphere the port belongs to.
|
|
75
|
+
d (float): Diameter of the port in millimeters (mm).
|
|
76
|
+
uru (float): Diffuse reflectance of diffuse light on the port.
|
|
77
|
+
x (float): X-coordinate of the port's center on the sphere's surface.
|
|
78
|
+
y (float): Y-coordinate of the port's center on the sphere's surface.
|
|
79
|
+
z (float): Z-coordinate of the port's center on the sphere's surface.
|
|
80
|
+
a (float): Relative area of the port's spherical cap to the sphere's surface.
|
|
81
|
+
chord2 (float): Square of the distance from the port center to the port edge.
|
|
82
|
+
sagitta (float): The sagitta (height) of the spherical cap formed by the port.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
Importing the module and creating a sphere with a port:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import iadpython as iad
|
|
89
|
+
s = iad.Sphere(200, 20)
|
|
90
|
+
print(s.sample)
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, sphere, d, uru=0, x=0, y=0, z=0):
|
|
94
|
+
"""
|
|
95
|
+
Initializes a Port object with specified dimensions and position within a sphere.
|
|
96
|
+
|
|
97
|
+
The coordinates (x,y,z) indicate the center of the port and must be located
|
|
98
|
+
on the sphere surface. They default to the center of the sphere (0,0,0). The
|
|
99
|
+
cap location is only used by the `MCSphere` class which uses the `hit()`
|
|
100
|
+
method.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
sphere: The sphere object to which the port belongs.
|
|
104
|
+
d: Diameter of the port in millimeters.
|
|
105
|
+
uru: Diffuse reflectance of diffuse light on the port.
|
|
106
|
+
x: X-coordinate of the port's center on the sphere's surface.
|
|
107
|
+
y: Y-coordinate of the port's center on the sphere's surface.
|
|
108
|
+
z: Z-coordinate of the port's center on the sphere's surface.
|
|
109
|
+
"""
|
|
110
|
+
self.sphere = sphere
|
|
111
|
+
self._d = d
|
|
112
|
+
self.uru = uru
|
|
113
|
+
|
|
114
|
+
self.x = x
|
|
115
|
+
self.y = y
|
|
116
|
+
self.z = z
|
|
117
|
+
|
|
118
|
+
self.a = self.relative_cap_area()
|
|
119
|
+
self.sagitta = self.calculate_sagitta()
|
|
120
|
+
self.chord2 = self.max_center_chord() ** 2
|
|
121
|
+
|
|
122
|
+
def __repr__(self):
|
|
123
|
+
"""Short string for port."""
|
|
124
|
+
return "d=%5.1fmm, uru=%s\n" % (
|
|
125
|
+
self.d,
|
|
126
|
+
iad.stringify("%5.1f%%", self.uru * 100),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def __str__(self):
|
|
130
|
+
"""Return basic details as a string for printing."""
|
|
131
|
+
s = ""
|
|
132
|
+
s += " diameter = %7.2f mm\n" % self.d
|
|
133
|
+
s += " radius = %7.2f mm\n" % (self.d / 2)
|
|
134
|
+
s += " chord = %7.2f mm\n" % np.sqrt(self.chord2)
|
|
135
|
+
s += " sagitta = %7.2f mm\n" % self.sagitta
|
|
136
|
+
s += " center = (%6.1f, %6.1f, %6.1f) mm\n" % (self.x, self.y, self.z)
|
|
137
|
+
s += " relative area = %7.2f%%\n" % (self.a * 100)
|
|
138
|
+
s += " uru = %s\n" % iad.stringify("%5.1f%%", self.uru * 100)
|
|
139
|
+
return s
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def d(self):
|
|
143
|
+
"""float: Gets the diameter of the port."""
|
|
144
|
+
return self._d
|
|
145
|
+
|
|
146
|
+
@d.setter
|
|
147
|
+
def d(self, value):
|
|
148
|
+
"""
|
|
149
|
+
Sets the diameter of the port and recalculates geometrical properties.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
value (float): The new diameter of the port.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
AssertionError: If the new diameter exceeds the diameter of the sphere.
|
|
156
|
+
"""
|
|
157
|
+
assert self.sphere.d >= value, "Sphere must be bigger than the port."
|
|
158
|
+
self._d = value
|
|
159
|
+
self.a = self.relative_cap_area()
|
|
160
|
+
self.sagitta = self.calculate_sagitta()
|
|
161
|
+
self.chord2 = self.max_center_chord()
|
|
162
|
+
self.sphere._a_wall = 1 - self.sphere.sample.a - self.sphere.third.a - self.sphere.detector.a
|
|
163
|
+
|
|
164
|
+
def cap_area(self):
|
|
165
|
+
"""Approximate area of spherical cap."""
|
|
166
|
+
return np.pi * self.d**2 / 4
|
|
167
|
+
|
|
168
|
+
def cap_area_exact(self):
|
|
169
|
+
"""Exact area of spherical cap."""
|
|
170
|
+
R = self.sphere.d / 2
|
|
171
|
+
r = self.d / 2
|
|
172
|
+
h = R - np.sqrt(R**2 - r**2)
|
|
173
|
+
return 2 * np.pi * R * h
|
|
174
|
+
|
|
175
|
+
def relative_cap_area(self):
|
|
176
|
+
"""Calculate approx relative area of spherical cap."""
|
|
177
|
+
R = self.sphere.d / 2
|
|
178
|
+
r = self.d / 2
|
|
179
|
+
return r**2 / (4 * R**2)
|
|
180
|
+
|
|
181
|
+
def calculate_sagitta(self):
|
|
182
|
+
"""Calculate sagitta of spherical cap."""
|
|
183
|
+
R = self.sphere.d / 2
|
|
184
|
+
r = self.d / 2
|
|
185
|
+
return R - np.sqrt(R**2 - r**2)
|
|
186
|
+
|
|
187
|
+
def max_center_chord(self):
|
|
188
|
+
"""Distance of chord from port center (on sphere) to port edge."""
|
|
189
|
+
r = self.d / 2
|
|
190
|
+
s = self.sagitta
|
|
191
|
+
return np.sqrt(r**2 + s**2)
|
|
192
|
+
|
|
193
|
+
def relative_cap_area_exact(self):
|
|
194
|
+
"""Calculate relative area of spherical cap."""
|
|
195
|
+
h = (self.sphere.d - np.sqrt(self.sphere.d**2 - self.d**2)) / 2
|
|
196
|
+
return h / self.sphere.d
|
|
197
|
+
|
|
198
|
+
def hit(self):
|
|
199
|
+
"""Determine if point on the sphere within the port."""
|
|
200
|
+
r2 = (self.x - self.sphere.x) ** 2
|
|
201
|
+
r2 += (self.y - self.sphere.y) ** 2
|
|
202
|
+
r2 += (self.z - self.sphere.z) ** 2
|
|
203
|
+
# print('cap center (%7.2f, %7.2f, %7.2f)' % (self.x,self.y,self.z))
|
|
204
|
+
# print('pt on sph (%7.2f, %7.2f, %7.2f)' % (self.sphere.x,self.sphere.y,self.sphere.z))
|
|
205
|
+
# print("cap distance %7.2f %7.2f"% (r2, self.chord2))
|
|
206
|
+
return r2 < self.chord2
|
|
207
|
+
|
|
208
|
+
def set_center(self, x, y, z):
|
|
209
|
+
"""Centers the cap at x,y,z."""
|
|
210
|
+
assert x**2 + y**2 + z**2 - (self.sphere.d / 2) ** 2 < 1e-6, "center not on sphere."
|
|
211
|
+
self.x = x
|
|
212
|
+
self.y = y
|
|
213
|
+
self.z = z
|
|
214
|
+
|
|
215
|
+
def uniform(self):
|
|
216
|
+
"""
|
|
217
|
+
Generate a point uniformly distributed on a spherical cap.
|
|
218
|
+
|
|
219
|
+
This function generates points uniformly distributed over a spherical cap,
|
|
220
|
+
defined by a specified sagitta (height of the cap from its base to the top)
|
|
221
|
+
and sphere radius. The spherical cap can be positioned either at the top or
|
|
222
|
+
bottom of the sphere. The method utilizes principles from uniform distribution
|
|
223
|
+
on a disk and transforms these points to the spherical cap geometry.
|
|
224
|
+
|
|
225
|
+
WARNING: The cap assumed to be at the top of a sphere. Nothing is done to rotate
|
|
226
|
+
the points so they align with the center of the cap!
|
|
227
|
+
|
|
228
|
+
The algorithm to generate a random point on a spherical cap is adapted from
|
|
229
|
+
http://marc-b-reynolds.github.io/distribution/2016/11/28/Uniform.html
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
sagitta: The height of the spherical cap from its base to the top.
|
|
233
|
+
sphere_radius: The radius of the sphere with the cap.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
A numpy array of a random point on the spherical cap surface.
|
|
237
|
+
"""
|
|
238
|
+
h = self.sagitta / (self.sphere.d / 2)
|
|
239
|
+
ux, uy, s = uniform_disk()
|
|
240
|
+
root = np.sqrt(h * (2 - h * s))
|
|
241
|
+
point = np.array([ux * root, uy * root, 1 - h * s]) * (self.sphere.d / 2)
|
|
242
|
+
return point
|