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/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