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/ad.py
ADDED
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
"""Class for doing adding-doubling calculations for a sample.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
>>> import iadpython as iad
|
|
6
|
+
>>> n=4
|
|
7
|
+
>>> sample = iad.Sample(a=0.9, b=10, g=0.9, n=1.5, quad_pts=4)
|
|
8
|
+
>>> ur1, ut1, uru, utu = sample.rt()
|
|
9
|
+
>>> print(ur1)
|
|
10
|
+
>>> print(ut1)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import iadpython.fresnel
|
|
17
|
+
import iadpython.quadrature
|
|
18
|
+
import iadpython.start
|
|
19
|
+
import iadpython.combine
|
|
20
|
+
import iadpython.layer
|
|
21
|
+
import iadpython.redistribution
|
|
22
|
+
from scipy.interpolate import CubicSpline as _CubicSpline
|
|
23
|
+
from scipy.integrate import quad as _quad
|
|
24
|
+
|
|
25
|
+
G_MINUS_ONE_SUBSTITUTE = -0.9999
|
|
26
|
+
_OUTER_INTERFACE_GL_ORDER = 64
|
|
27
|
+
_OUTER_INTERFACE_GL_X, _OUTER_INTERFACE_GL_W = np.polynomial.legendre.leggauss(_OUTER_INTERFACE_GL_ORDER)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stringify(form, x):
|
|
31
|
+
"""
|
|
32
|
+
Create a string from x.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
form: format for conversion
|
|
36
|
+
x: scalar or array
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
reasonable string
|
|
40
|
+
"""
|
|
41
|
+
if x is None:
|
|
42
|
+
s = "None"
|
|
43
|
+
elif np.isscalar(x) or np.ndim(x) == 0:
|
|
44
|
+
s = form % x
|
|
45
|
+
else:
|
|
46
|
+
mn = min(x)
|
|
47
|
+
mx = max(x)
|
|
48
|
+
s = form % mn
|
|
49
|
+
s += " to "
|
|
50
|
+
s += form % mx
|
|
51
|
+
return s
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sanitize_anisotropy(g):
|
|
55
|
+
"""Map singular anisotropy endpoint g=-1 to a nearby finite value."""
|
|
56
|
+
if g is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
if np.isscalar(g):
|
|
60
|
+
if np.isclose(g, -1.0):
|
|
61
|
+
return G_MINUS_ONE_SUBSTITUTE
|
|
62
|
+
return g
|
|
63
|
+
|
|
64
|
+
arr = np.asarray(g)
|
|
65
|
+
return np.where(np.isclose(arr, -1.0), G_MINUS_ONE_SUBSTITUTE, arr)
|
|
66
|
+
|
|
67
|
+
def _indices_match(a, b, *, tol=1e-12):
|
|
68
|
+
"""Compare refractive indices with a small complex tolerance."""
|
|
69
|
+
return bool(np.isclose(np.real(a), np.real(b), atol=tol, rtol=0.0) and
|
|
70
|
+
np.isclose(np.imag(a), np.imag(b), atol=tol, rtol=0.0))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Sample:
|
|
74
|
+
"""
|
|
75
|
+
Most things can be changed after creation by assigning to an element.
|
|
76
|
+
|
|
77
|
+
The angle of incidence is assumed to be perpendicular to the
|
|
78
|
+
surface. This is stored as the cosine of the angle and therefore to
|
|
79
|
+
change it to 60° from the normal, one does
|
|
80
|
+
|
|
81
|
+
Example::
|
|
82
|
+
|
|
83
|
+
sample.nu_0 = 0.5
|
|
84
|
+
|
|
85
|
+
To avoid needing to calculate the quadrature angles each time a
|
|
86
|
+
calculation is done, the `Sample` object stores the quadrature angles as
|
|
87
|
+
well as the redistribution function. A bit of trouble is taken
|
|
88
|
+
to ensure that these values get updated when something changes
|
|
89
|
+
e.g., the anisotropy, the angle of incidence, or the number of
|
|
90
|
+
quadrature points.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
- a: albedo
|
|
94
|
+
- b: optical thickness
|
|
95
|
+
- d: physical thickness [mm]
|
|
96
|
+
- g: scattering anisotropy [-]
|
|
97
|
+
- n: index of refraction of sample
|
|
98
|
+
- n_above: index of refraction of slide above
|
|
99
|
+
- n_below: index of refraction of slide below
|
|
100
|
+
- quad_pts: number of quadrature points
|
|
101
|
+
|
|
102
|
+
Multilayer samples.
|
|
103
|
+
Build a stack from explicit layers: ``Sample(layers=[Layer(...),
|
|
104
|
+
Layer(...)])``. Each :class:`iadpython.Layer` carries its own ``a``,
|
|
105
|
+
``b``, ``g``, ``n``, ``d`` and phase function (Henyey-Greenstein or
|
|
106
|
+
TABULATED, freely mixed between layers). The layer axis is the
|
|
107
|
+
position in the list; the *wavelength* axis is arrays inside each
|
|
108
|
+
layer attribute (scalars broadcast across the sweep). Call ``rt()``
|
|
109
|
+
to drive the wavelength sweep, or ``rt_matrices`` + ``UX1_and_UXU``
|
|
110
|
+
for a single-wavelength stack.
|
|
111
|
+
|
|
112
|
+
Flat arrays assigned to ``a``/``b``/``g`` of a non-layered sample
|
|
113
|
+
always mean wavelengths (they are swept by ``rt``); the historical
|
|
114
|
+
form where such arrays meant layers was removed.
|
|
115
|
+
|
|
116
|
+
When the per-layer indices differ, the real Fresnel interfaces
|
|
117
|
+
between the layers are modelled on a shared Snell-invariant grid that
|
|
118
|
+
handles total internal reflection, so arbitrarily large index steps
|
|
119
|
+
are allowed. Equal indices run the original single-index code
|
|
120
|
+
unchanged. The mismatched solver splits the angular grid into one
|
|
121
|
+
panel per distinct index, each with ``quad_pts`` nodes, so the working
|
|
122
|
+
dimension is ``quad_pts`` times the number of distinct indices. Keep
|
|
123
|
+
``quad_pts`` moderate (roughly 8-16); as with the classic solver,
|
|
124
|
+
very large values eventually become ill-conditioned. Strongly
|
|
125
|
+
scattering, weakly absorbing stacks with large index steps converge
|
|
126
|
+
more slowly and benefit from a somewhat higher ``quad_pts``.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
a=0,
|
|
133
|
+
b=1,
|
|
134
|
+
g=0,
|
|
135
|
+
d=1,
|
|
136
|
+
n=1,
|
|
137
|
+
n_above=1,
|
|
138
|
+
n_below=1,
|
|
139
|
+
n_outer_above=1,
|
|
140
|
+
n_outer_below=1,
|
|
141
|
+
n_sample_boundary=None,
|
|
142
|
+
quad_pts=4,
|
|
143
|
+
pf_type="HG",
|
|
144
|
+
pf_data=None,
|
|
145
|
+
layers=None,
|
|
146
|
+
):
|
|
147
|
+
"""Object initialization.
|
|
148
|
+
|
|
149
|
+
``pf_data`` may be a pandas ``DataFrame`` with one column per
|
|
150
|
+
wavelength when ``pf_type`` is ``'TABULATED'``, or a plain ndarray of
|
|
151
|
+
Legendre moments (1-D, or 2-D shape ``(n_mom, n_wavelength)``) when
|
|
152
|
+
``pf_type`` is ``'MOMENTS'`` -- moments supplied directly bypass the
|
|
153
|
+
spline+quadrature ``'TABULATED'`` needs, for callers that can
|
|
154
|
+
evaluate and integrate their own phase function exactly. The number
|
|
155
|
+
of columns must match other wavelength-dependent parameters.
|
|
156
|
+
|
|
157
|
+
``layers`` may be a list of :class:`iadpython.Layer` objects to build
|
|
158
|
+
a multilayer sample. When given, the flat ``a``/``b``/``g``/``d``/
|
|
159
|
+
``n``/``pf_type``/``pf_data`` arguments are ignored -- each layer
|
|
160
|
+
carries its own values (scalar or wavelength-dependent). Boundary
|
|
161
|
+
properties (``n_above`` etc.) always live on the Sample.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
object with all details needed to do a radiative calculation
|
|
165
|
+
"""
|
|
166
|
+
self.a = a
|
|
167
|
+
self.b = b
|
|
168
|
+
self._g = sanitize_anisotropy(g)
|
|
169
|
+
self.d = d # thickness of sample in mm
|
|
170
|
+
self._n = n
|
|
171
|
+
self.n_above = n_above
|
|
172
|
+
self.n_below = n_below
|
|
173
|
+
self.n_outer_above = n_outer_above
|
|
174
|
+
self.n_outer_below = n_outer_below
|
|
175
|
+
self.n_sample_boundary = n if n_sample_boundary is None else n_sample_boundary
|
|
176
|
+
self.d_above = 1 # thickness of top slide in mm
|
|
177
|
+
self.d_below = 1 # thickness of bot slide in mm
|
|
178
|
+
self._nu_0 = 1.0
|
|
179
|
+
self.b_above = 0
|
|
180
|
+
self.b_below = 0
|
|
181
|
+
self._quad_pts = quad_pts
|
|
182
|
+
# ----- phase-function control ----------------------------------
|
|
183
|
+
self.pf_type = str(pf_type).upper() # 'HG' or 'TABULATED'
|
|
184
|
+
self._pf_data = None
|
|
185
|
+
self.pf_data = pf_data # None or pandas DataFrame
|
|
186
|
+
# ---------------------------------------------------------------
|
|
187
|
+
self.b_thinnest = None
|
|
188
|
+
self.nu = None
|
|
189
|
+
self.twonuw = None
|
|
190
|
+
self.hp = None
|
|
191
|
+
self.hm = None
|
|
192
|
+
# ----- multilayer structure (Layer API) ------------------------
|
|
193
|
+
# layers: public list of iadpython.Layer objects (or None for the
|
|
194
|
+
# classic flat single-layer form). _layer_pf: internal marker set by
|
|
195
|
+
# _materialize_layers -- a per-layer [(pf_type, one_column_df)] list
|
|
196
|
+
# signalling that a/b/g/n currently hold the arrays-over-layers
|
|
197
|
+
# convention for one wavelength of a layered solve.
|
|
198
|
+
self.layers = list(layers) if layers is not None else None
|
|
199
|
+
self._layer_pf = None
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def n(self):
|
|
203
|
+
"""Getter property for index of refraction."""
|
|
204
|
+
return self._n
|
|
205
|
+
|
|
206
|
+
@n.setter
|
|
207
|
+
def n(self, value):
|
|
208
|
+
"""When index is changed quadrature becomes invalid.
|
|
209
|
+
|
|
210
|
+
``value`` may be a scalar (single index shared by every layer) or an
|
|
211
|
+
array with one entry per layer. A per-layer array enables the
|
|
212
|
+
refractive-index-mismatched multilayer solver (see ``rt_matrices``).
|
|
213
|
+
"""
|
|
214
|
+
same = np.array_equal(np.atleast_1d(value), np.atleast_1d(self._n))
|
|
215
|
+
if not same:
|
|
216
|
+
self.nu = None
|
|
217
|
+
self.twonuw = None
|
|
218
|
+
self.hp = None
|
|
219
|
+
self.hm = None
|
|
220
|
+
self._n = value
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def nu_0(self):
|
|
224
|
+
"""Getter property for index of refraction."""
|
|
225
|
+
return self._nu_0
|
|
226
|
+
|
|
227
|
+
@nu_0.setter
|
|
228
|
+
def nu_0(self, value):
|
|
229
|
+
"""When index is changed quadrature becomes invalid."""
|
|
230
|
+
if value != self._nu_0:
|
|
231
|
+
self.nu = None
|
|
232
|
+
self.twonuw = None
|
|
233
|
+
self.hp = None
|
|
234
|
+
self.hm = None
|
|
235
|
+
self._nu_0 = value
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def g(self):
|
|
239
|
+
"""Getter property for anisotropy."""
|
|
240
|
+
return self._g
|
|
241
|
+
|
|
242
|
+
@g.setter
|
|
243
|
+
def g(self, value):
|
|
244
|
+
"""When anisotropy is changed phi is invalid."""
|
|
245
|
+
value = sanitize_anisotropy(value)
|
|
246
|
+
if np.isscalar(value) and np.isscalar(self._g) and value == self._g:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
self.hp = None
|
|
250
|
+
self.hm = None
|
|
251
|
+
self._g = value
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def quad_pts(self):
|
|
255
|
+
"""Getter property for number of quadrature points."""
|
|
256
|
+
return self._quad_pts
|
|
257
|
+
|
|
258
|
+
@quad_pts.setter
|
|
259
|
+
def quad_pts(self, value):
|
|
260
|
+
"""When quadrature points are changed everything is invalid."""
|
|
261
|
+
if value != self._quad_pts:
|
|
262
|
+
self.nu = None
|
|
263
|
+
self.twonuw = None
|
|
264
|
+
self.hp = None
|
|
265
|
+
self.hm = None
|
|
266
|
+
self._quad_pts = value
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def pf_data(self):
|
|
270
|
+
"""Phase function data as DataFrame."""
|
|
271
|
+
return self._pf_data
|
|
272
|
+
|
|
273
|
+
@pf_data.setter
|
|
274
|
+
def pf_data(self, value):
|
|
275
|
+
"""Set phase data and invalidate cached hp/hm."""
|
|
276
|
+
self._pf_data = value
|
|
277
|
+
self.hp = None
|
|
278
|
+
self.hm = None
|
|
279
|
+
|
|
280
|
+
def mu_a(self):
|
|
281
|
+
"""Absorption coefficient for the sample.
|
|
282
|
+
|
|
283
|
+
Works for scalar or per-layer array ``b`` (elementwise ``np.isinf``
|
|
284
|
+
check, so a mix of finite and semi-infinite layers is handled too).
|
|
285
|
+
"""
|
|
286
|
+
if self.a is None or self.b is None or self.d is None:
|
|
287
|
+
return None
|
|
288
|
+
a = np.asarray(self.a, dtype=float)
|
|
289
|
+
b = np.asarray(self.b, dtype=float)
|
|
290
|
+
d = np.asarray(self.d, dtype=float)
|
|
291
|
+
with np.errstate(invalid="ignore"):
|
|
292
|
+
finite = (1 - a) * b / d
|
|
293
|
+
out = np.where(np.isinf(b), 1 - a, finite)
|
|
294
|
+
return float(out) if out.ndim == 0 else out
|
|
295
|
+
|
|
296
|
+
def mu_s(self):
|
|
297
|
+
"""Scattering coefficient for the sample.
|
|
298
|
+
|
|
299
|
+
Works for scalar or per-layer array ``b``; see ``mu_a``.
|
|
300
|
+
"""
|
|
301
|
+
if self.a is None or self.b is None or self.d is None:
|
|
302
|
+
return None
|
|
303
|
+
a = np.asarray(self.a, dtype=float)
|
|
304
|
+
b = np.asarray(self.b, dtype=float)
|
|
305
|
+
d = np.asarray(self.d, dtype=float)
|
|
306
|
+
with np.errstate(invalid="ignore"):
|
|
307
|
+
finite = a * b / d
|
|
308
|
+
out = np.where(np.isinf(b), a, finite)
|
|
309
|
+
return float(out) if out.ndim == 0 else out
|
|
310
|
+
|
|
311
|
+
def mu_sp(self):
|
|
312
|
+
"""Reduced scattering coefficient for the sample.
|
|
313
|
+
|
|
314
|
+
Works for scalar or per-layer array ``b``; see ``mu_a``.
|
|
315
|
+
"""
|
|
316
|
+
if self.a is None or self.b is None or self.d is None or self.g is None:
|
|
317
|
+
return None
|
|
318
|
+
a = np.asarray(self.a, dtype=float)
|
|
319
|
+
b = np.asarray(self.b, dtype=float)
|
|
320
|
+
d = np.asarray(self.d, dtype=float)
|
|
321
|
+
g = np.asarray(self.g, dtype=float)
|
|
322
|
+
with np.errstate(invalid="ignore"):
|
|
323
|
+
finite = (1 - g) * a * b / d
|
|
324
|
+
out = np.where(np.isinf(b), a * (1 - g), finite)
|
|
325
|
+
return float(out) if out.ndim == 0 else out
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
# Per-layer refractive index support
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
#
|
|
331
|
+
# ``Sample.n`` may be a scalar (all layers share one index, the classic
|
|
332
|
+
# case) or a 1-D array with one entry per layer. When the per-layer
|
|
333
|
+
# indices differ, ``rt_matrices`` switches to a Snell-invariant "master
|
|
334
|
+
# grid" solver that handles the Fresnel interfaces *between* layers,
|
|
335
|
+
# including total internal reflection for large index steps. The helpers
|
|
336
|
+
# below are the gate (``is_index_matched``) that keeps the classic,
|
|
337
|
+
# single-index code path byte-for-byte unchanged, plus the grid
|
|
338
|
+
# construction used only by the mismatched solver.
|
|
339
|
+
|
|
340
|
+
def index_array(self):
|
|
341
|
+
"""Return the per-layer refractive indices as a 1-D array."""
|
|
342
|
+
return np.atleast_1d(self.n)
|
|
343
|
+
|
|
344
|
+
def is_index_matched(self, tol=1e-9):
|
|
345
|
+
"""True when every layer shares the same refractive index.
|
|
346
|
+
|
|
347
|
+
This is the performance gate: when it returns True the sample is a
|
|
348
|
+
single homogeneous index and all the original (fast) code paths run
|
|
349
|
+
untouched. Only a genuine per-layer index mismatch activates the
|
|
350
|
+
master-grid machinery.
|
|
351
|
+
"""
|
|
352
|
+
n = np.atleast_1d(self.n)
|
|
353
|
+
if n.size == 1:
|
|
354
|
+
return True
|
|
355
|
+
return bool(np.allclose(n, n.flat[0], atol=tol, rtol=0.0))
|
|
356
|
+
|
|
357
|
+
def n_max(self):
|
|
358
|
+
"""Largest real part among the per-layer indices (the densest layer)."""
|
|
359
|
+
return float(np.max(np.real(np.atleast_1d(self.n))))
|
|
360
|
+
|
|
361
|
+
def _master_breakpoints_u(self, n_hi):
|
|
362
|
+
r"""Invariant values :math:`u=\eta^2` where a medium's critical angle sits.
|
|
363
|
+
|
|
364
|
+
A ray with invariant :math:`\eta` is totally internally reflected when
|
|
365
|
+
it meets a medium of index :math:`n<\eta`, i.e. once :math:`u>n^2`.
|
|
366
|
+
Splitting the master grid at :math:`u=n_k^2` for every distinct medium
|
|
367
|
+
index (layers and escape media) makes each layer's propagating range a
|
|
368
|
+
union of whole panels, so its flux integral converges, and it puts the
|
|
369
|
+
physically measured air cone (``u`` up to the outer index squared) in
|
|
370
|
+
its own fully resolved panel.
|
|
371
|
+
"""
|
|
372
|
+
indices = list(np.real(np.atleast_1d(self.n)))
|
|
373
|
+
for exit_index in (self._exit_index(top=True), self._exit_index(top=False)):
|
|
374
|
+
if exit_index is not None:
|
|
375
|
+
indices.append(float(exit_index))
|
|
376
|
+
|
|
377
|
+
n_hi2 = n_hi**2
|
|
378
|
+
breaks = set()
|
|
379
|
+
for n_k in indices:
|
|
380
|
+
u_k = float(n_k) ** 2
|
|
381
|
+
if 1e-12 < u_k < n_hi2 - 1e-12:
|
|
382
|
+
breaks.add(u_k)
|
|
383
|
+
return sorted(breaks)
|
|
384
|
+
|
|
385
|
+
def _build_master_grid(self):
|
|
386
|
+
r"""Build the Snell-invariant master quadrature shared by all layers.
|
|
387
|
+
|
|
388
|
+
The invariant along any ray crossing planar interfaces is
|
|
389
|
+
:math:`\eta=n\sin\theta` (Snell's law). We discretise the squared
|
|
390
|
+
invariant :math:`u=\eta^2` on :math:`[0, n_{max}^2]`, split into panels
|
|
391
|
+
at every medium's :math:`u=n_k^2` (see ``_master_breakpoints_u``). Each
|
|
392
|
+
panel carries ``quad_pts`` nodes, so both the measured air cone and the
|
|
393
|
+
internally trapped range are resolved. Building directly in ``u`` keeps
|
|
394
|
+
the flux weights well conditioned (unlike a densest-layer cosine grid,
|
|
395
|
+
whose air cone is a narrow, weight-starved sliver).
|
|
396
|
+
|
|
397
|
+
Each layer's cosine grid then follows in closed form,
|
|
398
|
+
|
|
399
|
+
.. math:: \nu_{k,j}=\sqrt{1-u_j/n_k^2},
|
|
400
|
+
|
|
401
|
+
with flux weights :math:`\Omega_j/n_k^2` — the :math:`1/n_k^2` being
|
|
402
|
+
exactly the :math:`n^2`-law of radiance. A channel with
|
|
403
|
+
:math:`u_j>n_k^2` does not propagate in layer ``k`` (it is beyond the
|
|
404
|
+
critical angle) and is flagged inactive; at an interface it is totally
|
|
405
|
+
internally reflected, which is how arbitrarily large index steps stay
|
|
406
|
+
exact.
|
|
407
|
+
|
|
408
|
+
Stores ``_u_master`` (the :math:`u_j`), ``_omega_master`` (the weights
|
|
409
|
+
:math:`\Omega_j` integrating :math:`\int du`) and ``_n_hi``.
|
|
410
|
+
"""
|
|
411
|
+
n_hi = self.n_max()
|
|
412
|
+
edges = [0.0] + self._master_breakpoints_u(n_hi) + [n_hi**2]
|
|
413
|
+
n_panels = len(edges) - 1
|
|
414
|
+
m = self.quad_pts
|
|
415
|
+
|
|
416
|
+
u_parts = []
|
|
417
|
+
w_parts = []
|
|
418
|
+
for i in range(n_panels):
|
|
419
|
+
lo, hi = edges[i], edges[i + 1]
|
|
420
|
+
if i == 0:
|
|
421
|
+
# First panel: Radau reflected so that u = 0 (normal incidence,
|
|
422
|
+
# nu = 1 in every layer) is a node -- needed for the collimated
|
|
423
|
+
# beam. radau() includes its upper endpoint, so build on
|
|
424
|
+
# [0, hi] and reflect u -> hi - u to land the node on u = 0.
|
|
425
|
+
x, w = iadpython.quadrature.radau(m, a=0.0, b=hi)
|
|
426
|
+
x = hi - x
|
|
427
|
+
else:
|
|
428
|
+
x, w = iadpython.quadrature.gauss(m, a=lo, b=hi)
|
|
429
|
+
u_parts.append(x)
|
|
430
|
+
w_parts.append(w)
|
|
431
|
+
|
|
432
|
+
u = np.concatenate(u_parts)
|
|
433
|
+
omega = np.concatenate(w_parts)
|
|
434
|
+
# Sort by decreasing u so that the per-layer cosines nu = sqrt(1-u/n^2)
|
|
435
|
+
# come out increasing (grazing first, normal incidence u=0 last), the
|
|
436
|
+
# same channel ordering the classic single-index grid uses.
|
|
437
|
+
order = np.argsort(u)[::-1]
|
|
438
|
+
self._u_master = u[order]
|
|
439
|
+
self._omega_master = omega[order]
|
|
440
|
+
self._n_hi = n_hi
|
|
441
|
+
self._master_dim = len(u)
|
|
442
|
+
return self._u_master, self._omega_master
|
|
443
|
+
|
|
444
|
+
def layer_grid(self, n_k):
|
|
445
|
+
r"""Cosine grid and flux weights of one layer on the master grid.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
n_k: refractive index of the layer.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
``(nu_k, twonuw_k, active)`` each of length ``quad_pts``.
|
|
452
|
+
``nu_k`` are the direction cosines in the layer (0 on inactive
|
|
453
|
+
channels), ``twonuw_k`` the adding-doubling flux weights
|
|
454
|
+
:math:`\Omega_j/n_k^2` (a harmless placeholder of 1 on inactive
|
|
455
|
+
channels so that ``1/twonuw`` never divides by zero), and
|
|
456
|
+
``active`` a boolean mask of the channels that propagate in the
|
|
457
|
+
layer.
|
|
458
|
+
"""
|
|
459
|
+
if getattr(self, "_u_master", None) is None:
|
|
460
|
+
self._build_master_grid()
|
|
461
|
+
|
|
462
|
+
nk = float(np.real(n_k))
|
|
463
|
+
nk2 = nk * nk
|
|
464
|
+
u = self._u_master
|
|
465
|
+
active = u <= nk2 * (1.0 + 1e-9)
|
|
466
|
+
|
|
467
|
+
nu_k = np.zeros_like(u)
|
|
468
|
+
nu_k[active] = np.sqrt(np.clip(1.0 - u[active] / nk2, 0.0, 1.0))
|
|
469
|
+
twonuw_k = np.where(active, self._omega_master / nk2, 1.0)
|
|
470
|
+
return nu_k, twonuw_k, active
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _slice_start_grid(nu, nu_c):
|
|
474
|
+
"""First channel of ``nu`` whose cosine exceeds the escape cutoff ``nu_c``."""
|
|
475
|
+
idx = np.where(nu > nu_c)[0]
|
|
476
|
+
if idx.size == 0:
|
|
477
|
+
return len(nu) - 1
|
|
478
|
+
return int(idx[0])
|
|
479
|
+
|
|
480
|
+
def _exit_index(self, *, top):
|
|
481
|
+
"""Effective real index that limits escape on one side.
|
|
482
|
+
|
|
483
|
+
Only ``slide``/``outer`` need to be real here -- they are the two
|
|
484
|
+
values this function actually reduces to a critical-angle index.
|
|
485
|
+
``n_sample_boundary`` feeds the *Fresnel amplitude* calculation in
|
|
486
|
+
``rt_matrices`` and is unrelated to this geometric cutoff; gating on
|
|
487
|
+
it made every absorbing sample (any nonzero Im(n_sample_boundary),
|
|
488
|
+
however small) fall back to ``nu_c = 0`` -- i.e. "no critical angle,
|
|
489
|
+
full hemisphere escapes" -- which silently breaks the n**2 diffuse
|
|
490
|
+
(Lambertian) normalization in ``UX1_and_UXU`` for any index-mismatched
|
|
491
|
+
absorbing sample (URU could exceed 1).
|
|
492
|
+
"""
|
|
493
|
+
slide = self.n_above if top else self.n_below
|
|
494
|
+
outer = self.n_outer_above if top else self.n_outer_below
|
|
495
|
+
b_slide = self.b_above if top else self.b_below
|
|
496
|
+
|
|
497
|
+
if (iadpython.fresnel._is_complex_value(slide) or
|
|
498
|
+
iadpython.fresnel._is_complex_value(outer)):
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
slide_real = iadpython.fresnel._transport_index(slide)
|
|
502
|
+
outer_real = iadpython.fresnel._transport_index(outer)
|
|
503
|
+
|
|
504
|
+
# A slide index of 1.0 with zero optical thickness represents "no slide".
|
|
505
|
+
if np.isclose(b_slide, 0.0) and np.isclose(slide_real, 1.0):
|
|
506
|
+
return outer_real
|
|
507
|
+
|
|
508
|
+
return min(slide_real, outer_real)
|
|
509
|
+
|
|
510
|
+
def nu_c_above(self):
|
|
511
|
+
"""Cosine of the escape critical angle on the top side."""
|
|
512
|
+
n_exit = self._exit_index(top=True)
|
|
513
|
+
if n_exit is None:
|
|
514
|
+
return 0.0
|
|
515
|
+
return iadpython.fresnel.cos_critical(self.n, n_exit)
|
|
516
|
+
|
|
517
|
+
def nu_c_below(self):
|
|
518
|
+
"""Cosine of the escape critical angle on the bottom side."""
|
|
519
|
+
n_exit = self._exit_index(top=False)
|
|
520
|
+
if n_exit is None:
|
|
521
|
+
return 0.0
|
|
522
|
+
return iadpython.fresnel.cos_critical(self.n, n_exit)
|
|
523
|
+
|
|
524
|
+
def _slice_start(self, nu_c):
|
|
525
|
+
"""Index of the first quadrature angle that can escape."""
|
|
526
|
+
idx = np.where(self.nu > nu_c)[0]
|
|
527
|
+
if idx.size == 0:
|
|
528
|
+
return len(self.nu) - 1
|
|
529
|
+
return int(idx[0])
|
|
530
|
+
|
|
531
|
+
def _collapse_to_outer_interface(self):
|
|
532
|
+
"""True when a zero-thickness sample reduces to one direct interface.
|
|
533
|
+
|
|
534
|
+
Only a scalar ``d`` can legitimately trigger this (a single
|
|
535
|
+
zero-thickness layer). A per-layer ``d`` array -- used for the
|
|
536
|
+
``mu_a``/``mu_s``/``mu_sp`` display quantities in a multilayer stack,
|
|
537
|
+
not consumed by the RT solver itself -- never collapses the sample.
|
|
538
|
+
"""
|
|
539
|
+
if not np.isscalar(self.d) and np.ndim(self.d) > 0:
|
|
540
|
+
return False
|
|
541
|
+
return (
|
|
542
|
+
np.isclose(self.d, 0.0)
|
|
543
|
+
and np.isclose(self.b_above, 0.0)
|
|
544
|
+
and np.isclose(self.b_below, 0.0)
|
|
545
|
+
and _indices_match(self.n_above, 1.0)
|
|
546
|
+
and _indices_match(self.n_below, 1.0)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _outer_interface_rt(self, nu):
|
|
550
|
+
"""Direct outer-medium interface RT for the collapsed zero-thickness case."""
|
|
551
|
+
return iadpython.fresnel.interface_rt(self.n_outer_above, nu, self.n_outer_below)
|
|
552
|
+
|
|
553
|
+
def _outer_interface_average_rt(self, nu_min=0.0, nu_max=1.0, *, weighted):
|
|
554
|
+
"""Average direct-interface RT over an angular interval."""
|
|
555
|
+
nu_min = max(float(nu_min), 0.0)
|
|
556
|
+
nu_max = min(float(nu_max), 1.0)
|
|
557
|
+
if nu_max <= nu_min:
|
|
558
|
+
return 0.0, 0.0
|
|
559
|
+
|
|
560
|
+
jac = 0.5 * (nu_max - nu_min)
|
|
561
|
+
nu = jac * _OUTER_INTERFACE_GL_X + 0.5 * (nu_max + nu_min)
|
|
562
|
+
r, t = self._outer_interface_rt(nu)
|
|
563
|
+
if weighted:
|
|
564
|
+
denom = nu_max**2 - nu_min**2
|
|
565
|
+
r_avg = jac * np.dot(_OUTER_INTERFACE_GL_W, 2.0 * nu * r) / denom
|
|
566
|
+
t_avg = jac * np.dot(_OUTER_INTERFACE_GL_W, 2.0 * nu * t) / denom
|
|
567
|
+
else:
|
|
568
|
+
r_avg = 0.5 * np.dot(_OUTER_INTERFACE_GL_W, r)
|
|
569
|
+
t_avg = 0.5 * np.dot(_OUTER_INTERFACE_GL_W, t)
|
|
570
|
+
ur = float(np.real(r_avg))
|
|
571
|
+
ut = float(np.real(t_avg))
|
|
572
|
+
return float(ur), float(ut)
|
|
573
|
+
|
|
574
|
+
def nu_c(self):
|
|
575
|
+
"""Smallest positive escape critical-angle cosine across both boundaries."""
|
|
576
|
+
criticals = [nu_c for nu_c in (self.nu_c_above(), self.nu_c_below()) if nu_c > 0]
|
|
577
|
+
if not criticals:
|
|
578
|
+
return 0.0
|
|
579
|
+
return min(criticals)
|
|
580
|
+
|
|
581
|
+
def _deltaM_forward_fraction(self):
|
|
582
|
+
"""delta-M forward-scattering fraction f (order-quad_pts Legendre moment).
|
|
583
|
+
|
|
584
|
+
HG: g**quad_pts. TABULATED: the actual order-quad_pts moment, so the
|
|
585
|
+
albedo/optical-depth reduction below stays consistent with the
|
|
586
|
+
redistribution-matrix truncation in phase_legendre(). MOMENTS: read
|
|
587
|
+
directly off the supplied array via the same validation/normalization
|
|
588
|
+
helper phase_legendre() uses, instead of re-deriving anything.
|
|
589
|
+
"""
|
|
590
|
+
pf_type = str(getattr(self, "pf_type", "HG")).upper()
|
|
591
|
+
if pf_type == "TABULATED" and self.pf_data is not None:
|
|
592
|
+
a_raw = iadpython.legendre_coeffs_from_df(
|
|
593
|
+
self.pf_data, quad_pts=self.quad_pts, n_mom=2 * self.quad_pts + 1)
|
|
594
|
+
return float(np.clip(np.ravel(np.asarray(a_raw)[self.quad_pts])[0], 0.0, 1.0 - 1e-10))
|
|
595
|
+
if pf_type == "MOMENTS" and self.pf_data is not None:
|
|
596
|
+
a_raw = iadpython.redistribution._prepare_moments(self.pf_data, self.quad_pts + 1)
|
|
597
|
+
return float(np.clip(a_raw[self.quad_pts], 0.0, 1.0 - 1e-10))
|
|
598
|
+
return self.g**self.quad_pts
|
|
599
|
+
|
|
600
|
+
def a_delta_M(self):
|
|
601
|
+
"""Reduced albedo in delta-M approximation."""
|
|
602
|
+
af = self.a * self._deltaM_forward_fraction()
|
|
603
|
+
num = np.asarray(self.a - af, dtype=float)
|
|
604
|
+
den = np.asarray(1 - af, dtype=float)
|
|
605
|
+
out = np.zeros_like(num, dtype=float)
|
|
606
|
+
np.divide(num, den, out=out, where=~np.isclose(den, 0.0))
|
|
607
|
+
if out.ndim == 0:
|
|
608
|
+
return float(out)
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
def b_delta_M(self):
|
|
612
|
+
"""Reduced optical thickness in delta-M approximation."""
|
|
613
|
+
af = self.a * self._deltaM_forward_fraction()
|
|
614
|
+
return self.b * (1 - af)
|
|
615
|
+
|
|
616
|
+
def as_array(self):
|
|
617
|
+
"""Return details as an array."""
|
|
618
|
+
return [self.a, self.b, self.g, self.d, self.n]
|
|
619
|
+
|
|
620
|
+
def init_from_array(self, a):
|
|
621
|
+
"""Initialize basic details as an array."""
|
|
622
|
+
self.a = a[0]
|
|
623
|
+
self.b = a[1]
|
|
624
|
+
self.g = a[2]
|
|
625
|
+
self.d = a[3]
|
|
626
|
+
self.n = a[4]
|
|
627
|
+
|
|
628
|
+
def __str__(self):
|
|
629
|
+
"""Return basic details as a string for printing."""
|
|
630
|
+
s = "Intrinsic Properties\n"
|
|
631
|
+
s += " albedo = %s\n" % stringify("%.3f", self.a)
|
|
632
|
+
s += " optical thickness = %s\n" % stringify("%.3f", self.b)
|
|
633
|
+
s += " anisotropy = %s\n" % stringify("%.3f", self.g)
|
|
634
|
+
s += " thickness = %s mm\n" % stringify("%.3f", self.d)
|
|
635
|
+
s += " sample index = %s\n" % stringify("%.3f", self.n)
|
|
636
|
+
s += " top slide index = %s\n" % stringify("%.3f", self.n_above)
|
|
637
|
+
s += " top outer index = %s\n" % stringify("%.3f", self.n_outer_above)
|
|
638
|
+
if self.b_above != 0:
|
|
639
|
+
s += " top slide OD = %s\n" % stringify("%.3f", self.b_above)
|
|
640
|
+
s += " bottom slide index = %s\n" % stringify("%.3f", self.n_below)
|
|
641
|
+
s += " bottom outer index = %s\n" % stringify("%.3f", self.n_outer_below)
|
|
642
|
+
if self.b_below != 0:
|
|
643
|
+
s += " bottom slide OD = %s\n" % stringify("%.3f", self.b_below)
|
|
644
|
+
s += " cos(theta incident) = %s\n" % stringify("%.3f", self.nu_0)
|
|
645
|
+
s += " quadrature points = %d\n" % self.quad_pts
|
|
646
|
+
|
|
647
|
+
s += "\n"
|
|
648
|
+
s += "Derived quantities\n"
|
|
649
|
+
s += " mu_a = %s 1/mm\n" % stringify("%.3f", self.mu_a())
|
|
650
|
+
s += " mu_s = %s 1/mm\n" % stringify("%.3f", self.mu_s())
|
|
651
|
+
s += " mu_s*(1-g) = %s 1/mm\n" % stringify("%.3f", self.mu_sp())
|
|
652
|
+
s += " theta incident = %.1f°\n" % np.degrees(np.arccos(self.nu_0))
|
|
653
|
+
s += " cos(theta critical) = %.4f\n" % self.nu_c()
|
|
654
|
+
s += " theta critical = %.1f°\n" % np.degrees(np.arccos(self.nu_c()))
|
|
655
|
+
return s
|
|
656
|
+
|
|
657
|
+
def wrmatrix(self, a, title=None):
|
|
658
|
+
"""Print matrix and sums."""
|
|
659
|
+
n = self.quad_pts
|
|
660
|
+
|
|
661
|
+
# header line
|
|
662
|
+
if title is not None:
|
|
663
|
+
print(title)
|
|
664
|
+
print("cos_theta |", end="")
|
|
665
|
+
for i in range(n):
|
|
666
|
+
print("%9.5f" % self.nu[i], end="")
|
|
667
|
+
print(" | flux")
|
|
668
|
+
print("----------+", end="")
|
|
669
|
+
for i in range(n):
|
|
670
|
+
print("---------", end="")
|
|
671
|
+
print("-+---------")
|
|
672
|
+
|
|
673
|
+
# contents + row fluxes
|
|
674
|
+
for i in range(n):
|
|
675
|
+
print("%9.5f |" % self.nu[i], end="")
|
|
676
|
+
for j in range(n):
|
|
677
|
+
if a[i, j] < -100 or a[i, j] > 100:
|
|
678
|
+
print(" *****", end="")
|
|
679
|
+
else:
|
|
680
|
+
print("%9.5f" % a[i, j], end="")
|
|
681
|
+
flux = 0.0
|
|
682
|
+
for j in range(n):
|
|
683
|
+
flux += a[i, j] * self.twonuw[j]
|
|
684
|
+
print(" |%9.5f" % flux)
|
|
685
|
+
|
|
686
|
+
# identify index of first quadrature angle greater than the critical angle
|
|
687
|
+
nu_c = self.nu_c()
|
|
688
|
+
k = self._slice_start(nu_c)
|
|
689
|
+
UXx = np.dot(self.twonuw[k:], a[k:, k:])
|
|
690
|
+
tflux = np.dot(self.twonuw[k:], UXx) * self.n**2
|
|
691
|
+
|
|
692
|
+
# column fluxes
|
|
693
|
+
print("----------+", end="")
|
|
694
|
+
for i in range(n):
|
|
695
|
+
print("---------", end="")
|
|
696
|
+
print("-+---------")
|
|
697
|
+
print("%9s |" % "flux ", end="")
|
|
698
|
+
for i in range(n):
|
|
699
|
+
flux = 0.0
|
|
700
|
+
for j in range(n):
|
|
701
|
+
flux += a[j, i] * self.twonuw[j]
|
|
702
|
+
print("%9.5f" % flux, end="")
|
|
703
|
+
print(" |%9.5f\n" % tflux)
|
|
704
|
+
|
|
705
|
+
def wrarray(self, a, title=None):
|
|
706
|
+
"""Print diagonal array as matric with sums."""
|
|
707
|
+
b = np.diag(a)
|
|
708
|
+
self.wrmatrix(b, title)
|
|
709
|
+
|
|
710
|
+
def prmatrix(self, a, title=None):
|
|
711
|
+
"""Print matrix and sums."""
|
|
712
|
+
if title is not None:
|
|
713
|
+
print(title)
|
|
714
|
+
n = self.quad_pts
|
|
715
|
+
|
|
716
|
+
# first row
|
|
717
|
+
print("[[", end="")
|
|
718
|
+
for j in range(n - 1):
|
|
719
|
+
print("%9.5f," % a[0, j], end="")
|
|
720
|
+
print("%9.5f]," % a[0, -1])
|
|
721
|
+
|
|
722
|
+
for i in range(1, n - 1):
|
|
723
|
+
print(" [", end="")
|
|
724
|
+
for j in range(n - 1):
|
|
725
|
+
print("%9.5f," % a[i, j], end="")
|
|
726
|
+
print("%9.5f]," % a[i, -1])
|
|
727
|
+
|
|
728
|
+
# last row
|
|
729
|
+
print(" [", end="")
|
|
730
|
+
for j in range(n - 1):
|
|
731
|
+
print("%9.5f," % a[-1, j], end="")
|
|
732
|
+
print("%9.5f]]" % a[-1, -1])
|
|
733
|
+
|
|
734
|
+
def update_quadrature(self):
|
|
735
|
+
"""Calculate the correct set of quadrature points.
|
|
736
|
+
|
|
737
|
+
This returns the quadrature angles using Radau quadrature over the
|
|
738
|
+
interval 0 to 1 if there is no critical angle for total internal reflection
|
|
739
|
+
in the self. If there is a critical angle whose cosine is 'nu_c' then
|
|
740
|
+
Radau quadrature points are chosen from 0 to 'nu_c' and Radau
|
|
741
|
+
quadrature points over the interval 'nu_c' to 1.
|
|
742
|
+
|
|
743
|
+
Now we need to include three angles, the critical angle, the cone
|
|
744
|
+
angle, and perpendicular. Now the important angles are the ones in
|
|
745
|
+
the self. So we calculate the cosine of the critical angle in the
|
|
746
|
+
sample and cosine of the cone angle in the self.
|
|
747
|
+
|
|
748
|
+
The critical angle will always be greater than the cone angle in the
|
|
749
|
+
sample and therefore the cosine of the critical angle will always be
|
|
750
|
+
less than the cosine of the cone angle. Thus we will integrate from
|
|
751
|
+
zero to the cosine of the critical angle (using Gaussian quadrature
|
|
752
|
+
to avoid either endpoint) then from the critical angle to the cone
|
|
753
|
+
angle (using Radau quadrature so that the cosine angle will be
|
|
754
|
+
included) and finally from the cone angle to 1 (again using Radau
|
|
755
|
+
quadrature so that 1 will be included).
|
|
756
|
+
"""
|
|
757
|
+
nby2 = self.quad_pts // 2
|
|
758
|
+
|
|
759
|
+
if self.nu_0 == 1:
|
|
760
|
+
# case 1. Normal incidence, no critical angle
|
|
761
|
+
nu_c = self.nu_c()
|
|
762
|
+
if self._n == 1 or nu_c <= 0:
|
|
763
|
+
a1 = []
|
|
764
|
+
w1 = []
|
|
765
|
+
a2, w2 = iadpython.quadrature.radau(self.quad_pts, a=0, b=1)
|
|
766
|
+
|
|
767
|
+
# case 2. Normal incidence, with critical angle
|
|
768
|
+
else:
|
|
769
|
+
a1, w1 = iadpython.quadrature.gauss(nby2, a=0, b=nu_c)
|
|
770
|
+
a2, w2 = iadpython.quadrature.radau(nby2, a=nu_c, b=1)
|
|
771
|
+
else:
|
|
772
|
+
# case 3. Conical incidence. Include nu_0
|
|
773
|
+
nu_c = self.nu_c()
|
|
774
|
+
if self._n == 1.0 or nu_c <= 0:
|
|
775
|
+
a1, w1 = iadpython.quadrature.radau(nby2, a=0, b=self.nu_0)
|
|
776
|
+
a2, w2 = iadpython.quadrature.radau(nby2, a=self.nu_0, b=1)
|
|
777
|
+
|
|
778
|
+
# case 4. Conical incidence. Include nu_c, nu_00, and 1
|
|
779
|
+
else:
|
|
780
|
+
nby3 = int(self.quad_pts / 3)
|
|
781
|
+
|
|
782
|
+
# cosine of nu_0 in sample
|
|
783
|
+
nu_00 = iadpython.fresnel.cos_snell(
|
|
784
|
+
iadpython.fresnel._transport_index(self.n_outer_above),
|
|
785
|
+
self.nu_0,
|
|
786
|
+
self.n,
|
|
787
|
+
)
|
|
788
|
+
a00, w00 = iadpython.quadrature.gauss(nby3, a=0, b=nu_c)
|
|
789
|
+
a01, w01 = iadpython.quadrature.radau(nby3, a=nu_c, b=nu_00)
|
|
790
|
+
a1 = np.append(a00, a01)
|
|
791
|
+
w1 = np.append(w00, w01)
|
|
792
|
+
a2, w2 = iadpython.quadrature.radau(nby3, a=nu_00, b=1)
|
|
793
|
+
|
|
794
|
+
self.nu = np.append(a1, a2)
|
|
795
|
+
self.twonuw = 2 * self.nu * np.append(w1, w2)
|
|
796
|
+
|
|
797
|
+
def rt_matrices(self):
|
|
798
|
+
"""Total reflection and transmission.
|
|
799
|
+
|
|
800
|
+
This is the top level routine for accessing the adding-doubling
|
|
801
|
+
algorithm. By passing the optical paramters characteristic of the sample,
|
|
802
|
+
this routine will do what it must to return the total reflection and
|
|
803
|
+
transmission for collimated and diffuse irradiance.
|
|
804
|
+
|
|
805
|
+
This routine has three different components based on if zero, one, or
|
|
806
|
+
two boundary layers must be included. If the index of refraction of the
|
|
807
|
+
sample and the top and bottom slides are all one, then no boundaries need
|
|
808
|
+
to be included. If the top and bottom slides are identical, then some
|
|
809
|
+
simplifications can be made and some time saved as a consequence. If the
|
|
810
|
+
top and bottom slides are different, then the full red carpet treatment
|
|
811
|
+
is required.
|
|
812
|
+
|
|
813
|
+
Since the calculation time increases for each of these cases we test for
|
|
814
|
+
matched boundaries first. If the boundaries are matched then don't
|
|
815
|
+
bother with boundaries for the top and bottom. Just calculate the
|
|
816
|
+
integrated reflection and transmission. Similarly, if the top and
|
|
817
|
+
bottom slides are similar, then quickly calculate these.
|
|
818
|
+
"""
|
|
819
|
+
# Layered sample: convert to the internal flat convention in place
|
|
820
|
+
# (mirrors the pre-existing equal-index collapse, which also mutates
|
|
821
|
+
# self). Multi-wavelength stacks must go through rt().
|
|
822
|
+
if self.layers is not None:
|
|
823
|
+
n_wave = iadpython.layer.validate_layers(self.layers)
|
|
824
|
+
if n_wave > 1:
|
|
825
|
+
raise RuntimeError(
|
|
826
|
+
"rt_matrices: this layered sample spans %d wavelengths; "
|
|
827
|
+
"use rt() for the wavelength sweep" % n_wave
|
|
828
|
+
)
|
|
829
|
+
self._materialize_layers(0)
|
|
830
|
+
|
|
831
|
+
# Hard break: on a flat sample, arrays no longer mean layers.
|
|
832
|
+
self._guard_flat_arrays("rt_matrices")
|
|
833
|
+
if self._layer_pf is None:
|
|
834
|
+
for name in ("a", "b", "g"):
|
|
835
|
+
value = getattr(self, name)
|
|
836
|
+
if not np.isscalar(value) and np.ndim(value) > 0:
|
|
837
|
+
raise RuntimeError(
|
|
838
|
+
"rt_matrices: array-valued %s now means wavelengths "
|
|
839
|
+
"(use rt()); build a multilayer sample with "
|
|
840
|
+
"iadpython.Sample(layers=[iadpython.Layer(...), ...])"
|
|
841
|
+
% name
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
# cone not implemented yet
|
|
845
|
+
if self.nu_0 != 1.0:
|
|
846
|
+
# RT_Cone(n,sample,OBLIQUE,UR1,UT1,URU,UTU);
|
|
847
|
+
r, t = iadpython.start.zero_layer(self.quad_pts)
|
|
848
|
+
return r, r, t, t
|
|
849
|
+
|
|
850
|
+
# An equal-valued index *array* is physically single-index: collapse it
|
|
851
|
+
# to a scalar so the classic (scalar-n) path runs unchanged. Rebuild
|
|
852
|
+
# the quadrature so a subsequent UX1_and_UXU has a valid ``nu``.
|
|
853
|
+
if not np.isscalar(self.n) and np.ndim(self.n) > 0 and self.is_index_matched():
|
|
854
|
+
self.n = float(np.real(np.atleast_1d(self.n)[0]))
|
|
855
|
+
if not np.isscalar(self.n_sample_boundary) and np.ndim(self.n_sample_boundary) > 0:
|
|
856
|
+
self.n_sample_boundary = float(np.real(np.atleast_1d(self.n_sample_boundary)[0]))
|
|
857
|
+
self.update_quadrature()
|
|
858
|
+
|
|
859
|
+
# Per-layer refractive-index mismatch needs the master-grid solver,
|
|
860
|
+
# which builds the internal Fresnel interfaces explicitly. The classic
|
|
861
|
+
# single-index path below is left completely untouched.
|
|
862
|
+
if not self.is_index_matched():
|
|
863
|
+
return self._rt_matrices_mismatched()
|
|
864
|
+
|
|
865
|
+
R12, T12 = iadpython.simple_layer_matrices(self)
|
|
866
|
+
|
|
867
|
+
# all done if boundaries are not an issue
|
|
868
|
+
if (_indices_match(self.n_sample_boundary, self.n_outer_above) and
|
|
869
|
+
_indices_match(self.n_sample_boundary, self.n_outer_below) and
|
|
870
|
+
np.isclose(self.b_above, 0.0) and np.isclose(self.b_below, 0.0) and
|
|
871
|
+
np.isclose(self.n_above, 1.0) and np.isclose(self.n_below, 1.0)):
|
|
872
|
+
return R12, R12, T12, T12
|
|
873
|
+
|
|
874
|
+
# reflection/transmission arrays for top boundary
|
|
875
|
+
R01, R10, T01, T10 = iadpython.start.boundary_layer(self, top=True)
|
|
876
|
+
|
|
877
|
+
# same slide above and below.
|
|
878
|
+
if (self.n_above == self.n_below and
|
|
879
|
+
self.b_above == self.b_below and
|
|
880
|
+
self.n_outer_above == self.n_outer_below):
|
|
881
|
+
R03, T03 = iadpython.add_same_slides(self, R01, R10, T01, T10, R12, T12)
|
|
882
|
+
return R03, R03, T03, T03
|
|
883
|
+
|
|
884
|
+
# reflection/transmission arrays for bottom boundary
|
|
885
|
+
R23, R32, T23, T32 = iadpython.start.boundary_layer(self, top=False)
|
|
886
|
+
|
|
887
|
+
# different boundaries on top and bottom
|
|
888
|
+
R02, R20, T02, T20 = iadpython.add_slide_above(self, R01, R10, T01, T10, R12, R12, T12, T12)
|
|
889
|
+
R03, R30, T03, T30 = iadpython.add_slide_below(self, R02, R20, T02, T20, R23, R32, T23, T32)
|
|
890
|
+
|
|
891
|
+
return R03, R30, T03, T30
|
|
892
|
+
|
|
893
|
+
def _rt_matrices_mismatched(self):
|
|
894
|
+
"""Assemble R/T for an index-mismatched stack, adding air/slide boundaries.
|
|
895
|
+
|
|
896
|
+
The bare stack comes from ``mismatched_layer_matrices`` on the master
|
|
897
|
+
grid. The two outer boundaries are then added exactly as in the classic
|
|
898
|
+
path, but each is evaluated on the grid of the layer it touches: the top
|
|
899
|
+
boundary on the first layer's grid, the bottom boundary on the last
|
|
900
|
+
layer's grid.
|
|
901
|
+
"""
|
|
902
|
+
r_down, r_up, t_down, t_up = iadpython.combine.mismatched_layer_matrices(self)
|
|
903
|
+
|
|
904
|
+
ns = np.atleast_1d(self.n)
|
|
905
|
+
n_full = self._master_dim
|
|
906
|
+
|
|
907
|
+
# Top boundary: air/slide -> first layer.
|
|
908
|
+
s_top = iadpython.combine._grid_sample(
|
|
909
|
+
self, ns[0], self.layer_grid(ns[0]), n_full
|
|
910
|
+
)
|
|
911
|
+
R01, R10, T01, T10 = iadpython.start.boundary_layer(s_top, top=True)
|
|
912
|
+
R02, R20, T02, T20 = iadpython.add_slide_above(
|
|
913
|
+
s_top, R01, R10, T01, T10, r_down, r_up, t_down, t_up
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Bottom boundary: last layer -> slide/air.
|
|
917
|
+
s_bot = iadpython.combine._grid_sample(
|
|
918
|
+
self, ns[-1], self.layer_grid(ns[-1]), n_full
|
|
919
|
+
)
|
|
920
|
+
R23, R32, T23, T32 = iadpython.start.boundary_layer(s_bot, top=False)
|
|
921
|
+
R03, R30, T03, T30 = iadpython.add_slide_below(
|
|
922
|
+
s_bot, R02, R20, T02, T20, R23, R32, T23, T32
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
return R03, R30, T03, T30
|
|
926
|
+
|
|
927
|
+
def _UX1_and_UXU_mismatched(self, R, T):
|
|
928
|
+
"""Escape-flux integration for an index-mismatched stack.
|
|
929
|
+
|
|
930
|
+
Both reflectance and transmittance are referenced to the incident
|
|
931
|
+
(top) medium, so all sums use the first layer's grid and the top index
|
|
932
|
+
``n[0]``. This is what makes the collimated result satisfy
|
|
933
|
+
``ur1 + ut1 = 1`` for a lossless stack and the diffuse transmittance
|
|
934
|
+
obey reciprocity ``T(0->L) == T(L->0)`` exactly (both verified
|
|
935
|
+
numerically; the top-grid form is algebraically identical to summing
|
|
936
|
+
the exit rows on the bottom grid with an ``n[-1]**2`` factor).
|
|
937
|
+
|
|
938
|
+
The reflected and transmitted beams escape through different critical
|
|
939
|
+
angles, so the trapped-channel slice differs: reflection is cut at the
|
|
940
|
+
top escape angle (in ``n[0]``), transmission at the bottom escape angle
|
|
941
|
+
(in ``n[-1]``). Channel indices are shared across grids, so the cut is
|
|
942
|
+
applied as a shared channel offset.
|
|
943
|
+
"""
|
|
944
|
+
ns = np.atleast_1d(self.n)
|
|
945
|
+
n0 = float(np.real(ns[0]))
|
|
946
|
+
nL = float(np.real(ns[-1]))
|
|
947
|
+
|
|
948
|
+
nu0, tw0, _ = self.layer_grid(ns[0])
|
|
949
|
+
nuL, _twL, _ = self.layer_grid(ns[-1])
|
|
950
|
+
|
|
951
|
+
nu_c_r = iadpython.fresnel.cos_critical(n0, self._exit_index(top=True))
|
|
952
|
+
nu_c_t = iadpython.fresnel.cos_critical(nL, self._exit_index(top=False))
|
|
953
|
+
k_r = self._slice_start_grid(nu0, nu_c_r)
|
|
954
|
+
k_t = self._slice_start_grid(nuL, nu_c_t)
|
|
955
|
+
|
|
956
|
+
URx = np.dot(tw0[k_r:], R[k_r:, k_r:])
|
|
957
|
+
UTx = np.dot(tw0[k_t:], T[k_t:, k_t:])
|
|
958
|
+
URU = np.dot(tw0[k_r:], URx) * n0**2
|
|
959
|
+
UTU = np.dot(tw0[k_t:], UTx) * n0**2
|
|
960
|
+
|
|
961
|
+
return URx[-1], UTx[-1], URU, UTU
|
|
962
|
+
|
|
963
|
+
def UX1_and_UXU(self, R, T):
|
|
964
|
+
"""Calculate total reflected and transmitted fluxes from matrices.
|
|
965
|
+
|
|
966
|
+
The trick here is that the integration must be done over the fluxes
|
|
967
|
+
that leave the sample. This is not much of an issue for the transmitted
|
|
968
|
+
fluxes because they are zero. However, the internal reflected fluxes will
|
|
969
|
+
not be zero and should be excluded from the sums.
|
|
970
|
+
"""
|
|
971
|
+
if self._collapse_to_outer_interface():
|
|
972
|
+
ur1, ut1 = self._outer_interface_rt(self.nu_0)
|
|
973
|
+
uru, utu = self._outer_interface_average_rt(weighted=True)
|
|
974
|
+
return float(ur1), float(ut1), uru, utu
|
|
975
|
+
|
|
976
|
+
if not self.is_index_matched():
|
|
977
|
+
return self._UX1_and_UXU_mismatched(R, T)
|
|
978
|
+
|
|
979
|
+
k_r = self._slice_start(self.nu_c_above())
|
|
980
|
+
k_t = self._slice_start(self.nu_c_below())
|
|
981
|
+
URx = np.dot(self.twonuw[k_r:], R[k_r:, k_r:]) # Reflectance collimated incident flux
|
|
982
|
+
UTx = np.dot(self.twonuw[k_t:], T[k_t:, k_t:]) # Transmittance collimated incident flux
|
|
983
|
+
URU = np.dot(self.twonuw[k_r:], URx) * self.n**2 # Reflected diffuse (Lambertian) flux
|
|
984
|
+
UTU = np.dot(self.twonuw[k_t:], UTx) * self.n**2 # Transmitted diffuse (Lambertian) flux
|
|
985
|
+
|
|
986
|
+
return URx[-1], UTx[-1], URU, UTU
|
|
987
|
+
|
|
988
|
+
def rt_diffuse_cone(self, R, T, nu_min=0, nu_max=1):
|
|
989
|
+
"""Find average reflection and transmission for collimated incident flux over a cone.
|
|
990
|
+
The approximation is usefull to simulate coarse roughness (compared to the wavelength)
|
|
991
|
+
on the surface of the sample.
|
|
992
|
+
|
|
993
|
+
Parameters:
|
|
994
|
+
R: reflection matrix
|
|
995
|
+
T: transmission matrix
|
|
996
|
+
nu_min: cosine of minimum angle in cone
|
|
997
|
+
nu_max: cosine of maximum angle in cone
|
|
998
|
+
Returns:
|
|
999
|
+
reflected and transmitted fluxes over the cone
|
|
1000
|
+
"""
|
|
1001
|
+
if self._collapse_to_outer_interface():
|
|
1002
|
+
return self._outer_interface_average_rt(nu_min, nu_max, weighted=False)
|
|
1003
|
+
|
|
1004
|
+
nu_c_r = self.nu_c_above()
|
|
1005
|
+
nu_c_t = self.nu_c_below()
|
|
1006
|
+
nu_min = max(nu_min, nu_c_r)
|
|
1007
|
+
|
|
1008
|
+
k_r = self._slice_start(nu_c_r)
|
|
1009
|
+
k_t = self._slice_start(nu_c_t)
|
|
1010
|
+
URx = np.dot(self.twonuw[k_r:], R[k_r:, k_r:])
|
|
1011
|
+
UTx = np.dot(self.twonuw[k_t:], T[k_t:, k_t:])
|
|
1012
|
+
|
|
1013
|
+
# creaate interpolation functions for integration
|
|
1014
|
+
UR_nu = _CubicSpline(self.nu[k_r:], URx)
|
|
1015
|
+
UT_nu = _CubicSpline(self.nu[k_t:], UTx)
|
|
1016
|
+
|
|
1017
|
+
# Get average reflectance and transmittance over the cone
|
|
1018
|
+
if nu_max <= nu_min:
|
|
1019
|
+
return 0.0, 0.0
|
|
1020
|
+
UR_cone = _quad(UR_nu, nu_min, nu_max)[0] / (nu_max - nu_min)
|
|
1021
|
+
UT_cone = _quad(UT_nu, max(nu_min, nu_c_t), nu_max)[0] / (nu_max - nu_min)
|
|
1022
|
+
return UR_cone, UT_cone
|
|
1023
|
+
|
|
1024
|
+
@staticmethod
|
|
1025
|
+
def _wavelength_slice(value, i):
|
|
1026
|
+
"""Pick wavelength ``i`` from a scalar-or-array layer attribute.
|
|
1027
|
+
|
|
1028
|
+
Scalars are returned unchanged (held constant across the sweep);
|
|
1029
|
+
length-1 arrays broadcast; longer arrays are indexed.
|
|
1030
|
+
"""
|
|
1031
|
+
if value is None or np.isscalar(value) or np.ndim(value) == 0:
|
|
1032
|
+
return value
|
|
1033
|
+
if len(value) == 1:
|
|
1034
|
+
return value[0]
|
|
1035
|
+
return value[i]
|
|
1036
|
+
|
|
1037
|
+
def _materialize_layers(self, i):
|
|
1038
|
+
"""Convert this layered sample into the internal flat convention.
|
|
1039
|
+
|
|
1040
|
+
Writes wavelength ``i`` of every layer onto the flat attributes:
|
|
1041
|
+
``a``/``b``/``g``/``n``/``d`` become length-n_layers vectors (plain
|
|
1042
|
+
scalars for a single layer, so the classic code path runs unchanged)
|
|
1043
|
+
and ``_layer_pf`` becomes the per-layer ``(pf_type, one_column_df)``
|
|
1044
|
+
list that marks the arrays-over-layers convention as active for the
|
|
1045
|
+
solver internals. ``layers`` is cleared -- after materialization the
|
|
1046
|
+
sample is a single-wavelength snapshot of the stack.
|
|
1047
|
+
|
|
1048
|
+
Callers that need to preserve the layered sample work on a
|
|
1049
|
+
``copy.deepcopy`` (see ``rt``); ``rt_matrices`` materializes in place,
|
|
1050
|
+
mirroring the pre-existing equal-index collapse behavior.
|
|
1051
|
+
"""
|
|
1052
|
+
layers = self.layers
|
|
1053
|
+
|
|
1054
|
+
a_i = [self._wavelength_slice(layer.a, i) for layer in layers]
|
|
1055
|
+
b_i = [self._wavelength_slice(layer.b, i) for layer in layers]
|
|
1056
|
+
g_i = [self._wavelength_slice(layer.g, i) for layer in layers]
|
|
1057
|
+
n_i = [self._wavelength_slice(layer.n, i) for layer in layers]
|
|
1058
|
+
d_i = [layer.d for layer in layers]
|
|
1059
|
+
|
|
1060
|
+
pf_list = []
|
|
1061
|
+
for layer in layers:
|
|
1062
|
+
if layer.pf_type == "TABULATED":
|
|
1063
|
+
col = i if layer.pf_data.shape[1] > 1 else 0
|
|
1064
|
+
pf_list.append(("TABULATED", layer.pf_data.iloc[:, [col]]))
|
|
1065
|
+
elif layer.pf_type == "MOMENTS":
|
|
1066
|
+
if layer.pf_data.ndim == 2 and layer.pf_data.shape[1] > 1:
|
|
1067
|
+
# Defense in depth: validate_layers() should already have
|
|
1068
|
+
# checked shape[1] against the stack's wavelength count,
|
|
1069
|
+
# but a mismatched i here must raise clearly rather than
|
|
1070
|
+
# degrade into a bare IndexError deep in materialization.
|
|
1071
|
+
if i >= layer.pf_data.shape[1]:
|
|
1072
|
+
raise ValueError(
|
|
1073
|
+
"layer MOMENTS pf_data has %d columns but "
|
|
1074
|
+
"wavelength index %d was requested"
|
|
1075
|
+
% (layer.pf_data.shape[1], i)
|
|
1076
|
+
)
|
|
1077
|
+
col = i
|
|
1078
|
+
elif layer.pf_data.ndim == 2:
|
|
1079
|
+
col = 0
|
|
1080
|
+
else:
|
|
1081
|
+
col = None
|
|
1082
|
+
sliced = layer.pf_data[:, col] if col is not None else layer.pf_data
|
|
1083
|
+
pf_list.append(("MOMENTS", sliced))
|
|
1084
|
+
else:
|
|
1085
|
+
pf_list.append(("HG", None))
|
|
1086
|
+
|
|
1087
|
+
if len(layers) == 1:
|
|
1088
|
+
# single layer: plain flat sample, classic path byte-identical
|
|
1089
|
+
self.a = float(a_i[0])
|
|
1090
|
+
self.b = float(b_i[0])
|
|
1091
|
+
self.g = float(g_i[0])
|
|
1092
|
+
self.n = n_i[0]
|
|
1093
|
+
self.d = float(d_i[0])
|
|
1094
|
+
self.pf_type, self.pf_data = pf_list[0]
|
|
1095
|
+
self._layer_pf = None
|
|
1096
|
+
else:
|
|
1097
|
+
self.a = np.asarray(a_i, dtype=float)
|
|
1098
|
+
self.b = np.asarray(b_i, dtype=float)
|
|
1099
|
+
self.g = np.asarray(g_i, dtype=float)
|
|
1100
|
+
self.n = np.asarray(n_i)
|
|
1101
|
+
self.d = np.asarray(d_i, dtype=float)
|
|
1102
|
+
# flat pf attributes are unused on this path; per-layer pf rules
|
|
1103
|
+
self.pf_type = "HG"
|
|
1104
|
+
self.pf_data = None
|
|
1105
|
+
self._layer_pf = pf_list
|
|
1106
|
+
|
|
1107
|
+
self.n_sample_boundary = self.n
|
|
1108
|
+
self.layers = None
|
|
1109
|
+
|
|
1110
|
+
# setters invalidate most caches, but be explicit so a stale
|
|
1111
|
+
# quadrature or redistribution matrix can never leak across layers
|
|
1112
|
+
self.nu = None
|
|
1113
|
+
self.twonuw = None
|
|
1114
|
+
self.hp = None
|
|
1115
|
+
self.hm = None
|
|
1116
|
+
self.b_thinnest = None
|
|
1117
|
+
|
|
1118
|
+
def _guard_flat_arrays(self, caller):
|
|
1119
|
+
"""Hard break: flat samples may not carry array-valued n (or layers)."""
|
|
1120
|
+
if self._layer_pf is not None:
|
|
1121
|
+
return
|
|
1122
|
+
if not np.isscalar(self.n) and np.ndim(self.n) > 0:
|
|
1123
|
+
raise RuntimeError(
|
|
1124
|
+
"%s: array-valued n on a flat Sample is no longer supported; "
|
|
1125
|
+
"build a multilayer sample with iadpython.Sample(layers="
|
|
1126
|
+
"[iadpython.Layer(...), ...])" % caller
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
def rt(self):
|
|
1130
|
+
"""Find total reflection and transmission.
|
|
1131
|
+
|
|
1132
|
+
For a layered sample (``Sample(layers=[...])``) this sweeps the
|
|
1133
|
+
wavelength axis of the layers: each layer attribute may be a scalar
|
|
1134
|
+
(held constant) or an array over wavelengths, and tabulated phase
|
|
1135
|
+
functions contribute one ``pf_data`` column per wavelength. Scalars
|
|
1136
|
+
are returned for a single-wavelength stack, arrays otherwise.
|
|
1137
|
+
|
|
1138
|
+
For the classic flat sample, ``pf_data`` may contain one column per
|
|
1139
|
+
wavelength when ``pf_type`` is ``"TABULATED"``. Otherwise the ``g``
|
|
1140
|
+
array provides wavelength variation. Whenever any wavelength-dependent
|
|
1141
|
+
input is an array, the lengths of ``a``, ``b``, and the phase-function
|
|
1142
|
+
data must match.
|
|
1143
|
+
"""
|
|
1144
|
+
if self.layers is not None:
|
|
1145
|
+
n_wave = iadpython.layer.validate_layers(self.layers)
|
|
1146
|
+
if n_wave == 1:
|
|
1147
|
+
x = copy.deepcopy(self)
|
|
1148
|
+
x._materialize_layers(0)
|
|
1149
|
+
R, _, T, _ = x.rt_matrices()
|
|
1150
|
+
return x.UX1_and_UXU(R, T)
|
|
1151
|
+
|
|
1152
|
+
ur1 = np.empty(n_wave)
|
|
1153
|
+
ut1 = np.empty(n_wave)
|
|
1154
|
+
uru = np.empty(n_wave)
|
|
1155
|
+
utu = np.empty(n_wave)
|
|
1156
|
+
for i in range(n_wave):
|
|
1157
|
+
x = copy.deepcopy(self)
|
|
1158
|
+
x._materialize_layers(i)
|
|
1159
|
+
R, _, T, _ = x.rt_matrices()
|
|
1160
|
+
ur1[i], ut1[i], uru[i], utu[i] = x.UX1_and_UXU(R, T)
|
|
1161
|
+
return ur1, ut1, uru, utu
|
|
1162
|
+
|
|
1163
|
+
self._guard_flat_arrays("rt")
|
|
1164
|
+
|
|
1165
|
+
len_a = 0
|
|
1166
|
+
len_b = 0
|
|
1167
|
+
len_pf = 0
|
|
1168
|
+
|
|
1169
|
+
if not np.isscalar(self.a):
|
|
1170
|
+
len_a = len(self.a)
|
|
1171
|
+
|
|
1172
|
+
if not np.isscalar(self.b):
|
|
1173
|
+
len_b = len(self.b)
|
|
1174
|
+
|
|
1175
|
+
if self.pf_type == "TABULATED" and isinstance(self.pf_data, pd.DataFrame):
|
|
1176
|
+
len_pf = self.pf_data.shape[1]
|
|
1177
|
+
elif self.pf_type == "MOMENTS" and isinstance(self.pf_data, np.ndarray) and self.pf_data.ndim == 2:
|
|
1178
|
+
len_pf = self.pf_data.shape[1]
|
|
1179
|
+
elif not np.isscalar(self.g):
|
|
1180
|
+
len_pf = len(self.g)
|
|
1181
|
+
|
|
1182
|
+
thelen = max(len_a, len_b, len_pf)
|
|
1183
|
+
|
|
1184
|
+
if thelen == 0:
|
|
1185
|
+
R, _, T, _ = self.rt_matrices()
|
|
1186
|
+
return self.UX1_and_UXU(R, T)
|
|
1187
|
+
|
|
1188
|
+
if len_a and len_b and len_a != len_b:
|
|
1189
|
+
raise RuntimeError("rt: a and b arrays must be same length")
|
|
1190
|
+
|
|
1191
|
+
if len_pf and len_a and len_pf != len_a:
|
|
1192
|
+
raise RuntimeError("rt: pf_data and a arrays must be same length")
|
|
1193
|
+
|
|
1194
|
+
if len_pf and len_b and len_pf != len_b:
|
|
1195
|
+
raise RuntimeError("rt: pf_data and b arrays must be same length")
|
|
1196
|
+
|
|
1197
|
+
ur1 = np.empty(thelen)
|
|
1198
|
+
ut1 = np.empty(thelen)
|
|
1199
|
+
uru = np.empty(thelen)
|
|
1200
|
+
utu = np.empty(thelen)
|
|
1201
|
+
|
|
1202
|
+
if self.nu is None:
|
|
1203
|
+
self.update_quadrature()
|
|
1204
|
+
|
|
1205
|
+
sample = copy.deepcopy(self)
|
|
1206
|
+
for i in range(thelen):
|
|
1207
|
+
if len_a > 0:
|
|
1208
|
+
sample.a = self.a[i]
|
|
1209
|
+
|
|
1210
|
+
if len_b > 0:
|
|
1211
|
+
sample.b = self.b[i]
|
|
1212
|
+
|
|
1213
|
+
if self.pf_type == "TABULATED":
|
|
1214
|
+
if len_pf > 0:
|
|
1215
|
+
sample.pf_data = self.pf_data.iloc[:, [i]]
|
|
1216
|
+
elif self.pf_type == "MOMENTS":
|
|
1217
|
+
if len_pf > 0:
|
|
1218
|
+
sample.pf_data = self.pf_data[:, i]
|
|
1219
|
+
elif len_pf > 0:
|
|
1220
|
+
sample.g = self.g[i]
|
|
1221
|
+
|
|
1222
|
+
R, _, T, _ = sample.rt_matrices()
|
|
1223
|
+
ur1[i], ut1[i], uru[i], utu[i] = sample.UX1_and_UXU(R, T)
|
|
1224
|
+
|
|
1225
|
+
return ur1, ut1, uru, utu
|
|
1226
|
+
|
|
1227
|
+
def unscattered_scalar_rt(self):
|
|
1228
|
+
"""Find unscattered r and t for diagonal matrices (scalar of array version)."""
|
|
1229
|
+
if self._collapse_to_outer_interface():
|
|
1230
|
+
r, t = self._outer_interface_rt(self.nu_0)
|
|
1231
|
+
return float(r), float(t)
|
|
1232
|
+
|
|
1233
|
+
n_top = self.n_above
|
|
1234
|
+
n_slab = self.n_sample_boundary
|
|
1235
|
+
n_bot = self.n_below
|
|
1236
|
+
b_slab = self.b
|
|
1237
|
+
return iadpython.fresnel.specular_rt(
|
|
1238
|
+
n_top,
|
|
1239
|
+
n_slab,
|
|
1240
|
+
n_bot,
|
|
1241
|
+
b_slab,
|
|
1242
|
+
self.nu_0,
|
|
1243
|
+
n_outer_top=self.n_outer_above,
|
|
1244
|
+
n_outer_bot=self.n_outer_below,
|
|
1245
|
+
n_slab_transport=self.n,
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
def _unscattered_slabs(self):
|
|
1249
|
+
"""Ordered ``(index, optical thickness)`` slabs the ballistic beam crosses.
|
|
1250
|
+
|
|
1251
|
+
The sequence is: top slide (if any), every layer, bottom slide (if any).
|
|
1252
|
+
Following the same convention as ``_exit_index``, a slide of index 1.0
|
|
1253
|
+
with zero optical thickness means "no slide" and is omitted -- adding it
|
|
1254
|
+
would insert a spurious extra interface when the outer medium is not air.
|
|
1255
|
+
"""
|
|
1256
|
+
slabs = []
|
|
1257
|
+
|
|
1258
|
+
if not (np.isclose(self.b_above, 0.0) and _indices_match(self.n_above, 1.0)):
|
|
1259
|
+
slabs.append((self.n_above, self.b_above))
|
|
1260
|
+
|
|
1261
|
+
# Broadcast n and b to a common layer count: after the equal-index
|
|
1262
|
+
# collapse a materialized stack can carry a scalar n next to a
|
|
1263
|
+
# per-layer b (or vice versa) -- zip must never truncate layers.
|
|
1264
|
+
ns = np.atleast_1d(self.n)
|
|
1265
|
+
bs = np.atleast_1d(self.b)
|
|
1266
|
+
n_layers = max(ns.size, bs.size)
|
|
1267
|
+
if ns.size == 1:
|
|
1268
|
+
ns = np.full(n_layers, ns.flat[0])
|
|
1269
|
+
if bs.size == 1:
|
|
1270
|
+
bs = np.full(n_layers, bs.flat[0])
|
|
1271
|
+
for n_k, b_k in zip(ns, bs):
|
|
1272
|
+
slabs.append((n_k, b_k))
|
|
1273
|
+
|
|
1274
|
+
if not (np.isclose(self.b_below, 0.0) and _indices_match(self.n_below, 1.0)):
|
|
1275
|
+
slabs.append((self.n_below, self.b_below))
|
|
1276
|
+
|
|
1277
|
+
return slabs
|
|
1278
|
+
|
|
1279
|
+
def _unscattered_mismatched(self):
|
|
1280
|
+
r"""Ballistic (unscattered) R/T through a stack of mismatched layers.
|
|
1281
|
+
|
|
1282
|
+
This is the N-slab generalisation of ``fresnel.specular_rt``: an
|
|
1283
|
+
incoherent bottom-up recursion that accumulates Fresnel reflection at
|
|
1284
|
+
every interface and Beer-Lambert attenuation within every slab,
|
|
1285
|
+
including all multiple internal reflections. For slab ``k`` bounded
|
|
1286
|
+
above by medium ``a``, with ``(R, T)`` already known at its lower face,
|
|
1287
|
+
|
|
1288
|
+
.. math:: R' = r_1 + t_1 t_1' R e^{-2b_k/\nu_k} / D
|
|
1289
|
+
|
|
1290
|
+
.. math:: T' = t_1 T e^{-b_k/\nu_k} / D
|
|
1291
|
+
|
|
1292
|
+
where :math:`D = 1 - r_1' R e^{-2b_k/\nu_k}` sums the round trips.
|
|
1293
|
+
|
|
1294
|
+
The result is exact (no quadrature involved), so unlike the scattered
|
|
1295
|
+
solution it does not depend on ``quad_pts``. Angles follow the Snell
|
|
1296
|
+
invariant from the top outer medium; a totally internally reflected
|
|
1297
|
+
channel simply gets ``t_1 = 0``, which zeroes the transmission.
|
|
1298
|
+
|
|
1299
|
+
Returns:
|
|
1300
|
+
``(r, t)`` scalars for the whole stack.
|
|
1301
|
+
"""
|
|
1302
|
+
slabs = self._unscattered_slabs()
|
|
1303
|
+
n_out_top = iadpython.fresnel._transport_index(self.n_outer_above)
|
|
1304
|
+
n_out_bot = iadpython.fresnel._transport_index(self.n_outer_below)
|
|
1305
|
+
|
|
1306
|
+
def cos_in(n_medium):
|
|
1307
|
+
"""Cosine inside a medium for the beam entering at nu_0 from the top."""
|
|
1308
|
+
return iadpython.fresnel.cos_snell(
|
|
1309
|
+
n_out_top, self.nu_0, iadpython.fresnel._transport_index(n_medium)
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
# Start at the bottom face of the last slab, looking into the exit medium.
|
|
1313
|
+
n_last = slabs[-1][0]
|
|
1314
|
+
r_below, t_below = iadpython.fresnel.interface_rt(
|
|
1315
|
+
n_last, cos_in(n_last), n_out_bot
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
for k in range(len(slabs) - 1, -1, -1):
|
|
1319
|
+
n_k, b_k = slabs[k]
|
|
1320
|
+
nu_k = cos_in(n_k)
|
|
1321
|
+
n_a = n_out_top if k == 0 else slabs[k - 1][0]
|
|
1322
|
+
nu_a = cos_in(n_a)
|
|
1323
|
+
|
|
1324
|
+
r1, t1 = iadpython.fresnel.interface_rt(n_a, nu_a, n_k)
|
|
1325
|
+
r1p, t1p = iadpython.fresnel.interface_rt(n_k, nu_k, n_a)
|
|
1326
|
+
|
|
1327
|
+
expo = np.exp(-b_k / iadpython.fresnel._sanitize_path_cos(nu_k))
|
|
1328
|
+
denom = 1.0 - r1p * r_below * expo**2
|
|
1329
|
+
r_new = r1 + t1 * t1p * r_below * expo**2 / denom
|
|
1330
|
+
t_new = t1 * t_below * expo / denom
|
|
1331
|
+
r_below, t_below = r_new, t_new
|
|
1332
|
+
|
|
1333
|
+
return float(np.real(r_below)), float(np.real(t_below))
|
|
1334
|
+
|
|
1335
|
+
def unscattered_rt(self):
|
|
1336
|
+
"""Find unscattered r and t.
|
|
1337
|
+
|
|
1338
|
+
A layered sample (``Sample(layers=[...])``) sweeps the wavelength axis
|
|
1339
|
+
with the exact analytic N-slab recursion, returning scalars for a
|
|
1340
|
+
single wavelength and arrays otherwise. For the classic flat sample
|
|
1341
|
+
the historical behaviour is kept: a scalar ``b`` gives one pair, and
|
|
1342
|
+
an array ``b`` is treated as a *wavelength* sweep (matching ``rt``).
|
|
1343
|
+
"""
|
|
1344
|
+
if self.layers is not None:
|
|
1345
|
+
n_wave = iadpython.layer.validate_layers(self.layers)
|
|
1346
|
+
if n_wave == 1:
|
|
1347
|
+
x = copy.deepcopy(self)
|
|
1348
|
+
x._materialize_layers(0)
|
|
1349
|
+
return x._unscattered_mismatched()
|
|
1350
|
+
|
|
1351
|
+
r = np.empty(n_wave)
|
|
1352
|
+
t = np.empty(n_wave)
|
|
1353
|
+
for i in range(n_wave):
|
|
1354
|
+
x = copy.deepcopy(self)
|
|
1355
|
+
x._materialize_layers(i)
|
|
1356
|
+
r[i], t[i] = x._unscattered_mismatched()
|
|
1357
|
+
return r, t
|
|
1358
|
+
|
|
1359
|
+
self._guard_flat_arrays("unscattered_rt")
|
|
1360
|
+
|
|
1361
|
+
# A materialized layered sample (arrays = layers, _layer_pf set) and
|
|
1362
|
+
# any index-mismatched stack both use the exact N-slab recursion --
|
|
1363
|
+
# its per-layer b must not be mistaken for a wavelength sweep.
|
|
1364
|
+
if self._layer_pf is not None or not self.is_index_matched():
|
|
1365
|
+
return self._unscattered_mismatched()
|
|
1366
|
+
|
|
1367
|
+
if np.isscalar(self.b):
|
|
1368
|
+
return self.unscattered_scalar_rt()
|
|
1369
|
+
|
|
1370
|
+
r = np.empty_like(self.b, dtype=type(self.nu_0))
|
|
1371
|
+
t = np.empty_like(self.b, dtype=type(self.nu_0))
|
|
1372
|
+
x = copy.deepcopy(self)
|
|
1373
|
+
for i, b in enumerate(self.b):
|
|
1374
|
+
x.b = b
|
|
1375
|
+
r[i], t[i] = x.unscattered_scalar_rt()
|
|
1376
|
+
|
|
1377
|
+
return r, t
|