redbirdpy 0.1.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.
- redbirdpy/__init__.py +112 -0
- redbirdpy/analytical.py +927 -0
- redbirdpy/forward.py +589 -0
- redbirdpy/property.py +602 -0
- redbirdpy/recon.py +893 -0
- redbirdpy/solver.py +814 -0
- redbirdpy/utility.py +1117 -0
- redbirdpy-0.1.0.dist-info/METADATA +596 -0
- redbirdpy-0.1.0.dist-info/RECORD +13 -0
- redbirdpy-0.1.0.dist-info/WHEEL +5 -0
- redbirdpy-0.1.0.dist-info/licenses/LICENSE.txt +674 -0
- redbirdpy-0.1.0.dist-info/top_level.txt +1 -0
- redbirdpy-0.1.0.dist-info/zip-safe +1 -0
redbirdpy/analytical.py
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redbird Analytical Diffusion Models
|
|
3
|
+
|
|
4
|
+
INDEX CONVENTION: All mesh indices (elem, face) stored in cfg/recon are 1-based
|
|
5
|
+
to match MATLAB/iso2mesh. Conversion to 0-based occurs only when indexing numpy
|
|
6
|
+
arrays, using local variables named with '_0' suffix.
|
|
7
|
+
|
|
8
|
+
Functions:
|
|
9
|
+
infinite_cw: CW fluence for infinite homogeneous medium
|
|
10
|
+
semi_infinite_cw: CW fluence for semi-infinite medium
|
|
11
|
+
semi_infinite_cw_flux: CW surface flux (diffuse reflectance) for semi-infinite medium
|
|
12
|
+
infinite_td: Time-domain fluence for infinite medium
|
|
13
|
+
semi_infinite_td: Time-domain fluence for semi-infinite medium
|
|
14
|
+
sphere_infinite: CW/FD (cfg['omega']) fluence for sphere in infinite medium
|
|
15
|
+
sphere_semi_infinite: CW/FD fluence for sphere in semi-infinite medium
|
|
16
|
+
sphere_slab: CW/FD fluence for sphere in slab medium
|
|
17
|
+
|
|
18
|
+
Special Functions:
|
|
19
|
+
spbesselj: Spherical Bessel function of the first kind
|
|
20
|
+
spbessely: Spherical Bessel function of the second kind (Neumann)
|
|
21
|
+
spbesselh: Spherical Hankel function
|
|
22
|
+
spbesseljprime: Derivative of spherical Bessel function (first kind)
|
|
23
|
+
spbesselyprime: Derivative of spherical Bessel function (second kind)
|
|
24
|
+
spbesselhprime: Derivative of spherical Hankel function
|
|
25
|
+
spharmonic: Spherical harmonic function
|
|
26
|
+
|
|
27
|
+
References:
|
|
28
|
+
[Fang2010] Fang, "Mesh-based Monte Carlo method using fast ray-tracing"
|
|
29
|
+
[Boas2002] Boas et al., "Scattering of diffuse photon density waves"
|
|
30
|
+
[Haskell1994] Haskell et al., "Boundary conditions for diffusion equation"
|
|
31
|
+
[Kienle1997] Kienle & Patterson, "Improved solutions of diffusion equation"
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# CW solutions
|
|
36
|
+
"infinite_cw",
|
|
37
|
+
"semi_infinite_cw",
|
|
38
|
+
"semi_infinite_cw_flux",
|
|
39
|
+
# Time-domain solutions
|
|
40
|
+
"infinite_td",
|
|
41
|
+
"semi_infinite_td",
|
|
42
|
+
# Sphere solutions
|
|
43
|
+
"sphere_infinite",
|
|
44
|
+
"sphere_semi_infinite",
|
|
45
|
+
"sphere_slab",
|
|
46
|
+
# Special functions
|
|
47
|
+
"spbesselj",
|
|
48
|
+
"spbessely",
|
|
49
|
+
"spbesselh",
|
|
50
|
+
"spbesseljprime",
|
|
51
|
+
"spbesselyprime",
|
|
52
|
+
"spbesselhprime",
|
|
53
|
+
"spharmonic",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
import numpy as np
|
|
57
|
+
from math import factorial
|
|
58
|
+
from .utility import getdistance, getreff
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Lazy scipy import
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
_scipy_special = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_scipy_special():
|
|
69
|
+
"""Lazy import of scipy.special."""
|
|
70
|
+
global _scipy_special
|
|
71
|
+
if _scipy_special is None:
|
|
72
|
+
from scipy import special
|
|
73
|
+
|
|
74
|
+
_scipy_special = special
|
|
75
|
+
return _scipy_special
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# =============================================================================
|
|
79
|
+
# Spherical Bessel/Hankel Functions
|
|
80
|
+
# =============================================================================
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def spbesselj(n, z):
|
|
84
|
+
"""
|
|
85
|
+
Spherical Bessel function of the first kind.
|
|
86
|
+
|
|
87
|
+
Wrapper around scipy.special.spherical_jn.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
n : int
|
|
92
|
+
Order of the function
|
|
93
|
+
z : float or ndarray
|
|
94
|
+
Argument
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
jn : float or ndarray
|
|
99
|
+
Spherical Bessel function value(s)
|
|
100
|
+
|
|
101
|
+
Example
|
|
102
|
+
-------
|
|
103
|
+
>>> spbesselj(0, 1.0)
|
|
104
|
+
0.8414709848078965
|
|
105
|
+
"""
|
|
106
|
+
return _get_scipy_special().spherical_jn(n, z)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def spbessely(n, z):
|
|
110
|
+
"""
|
|
111
|
+
Spherical Bessel function of the second kind (Neumann function).
|
|
112
|
+
|
|
113
|
+
Wrapper around scipy.special.spherical_yn.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
n : int
|
|
118
|
+
Order of the function
|
|
119
|
+
z : float or ndarray
|
|
120
|
+
Argument
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
yn : float or ndarray
|
|
125
|
+
Spherical Neumann function value(s)
|
|
126
|
+
|
|
127
|
+
Example
|
|
128
|
+
-------
|
|
129
|
+
>>> spbessely(0, 1.0)
|
|
130
|
+
-0.5403023058681398
|
|
131
|
+
"""
|
|
132
|
+
return _get_scipy_special().spherical_yn(n, z)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def spbesselh(n, k, z):
|
|
136
|
+
"""
|
|
137
|
+
Spherical Hankel function.
|
|
138
|
+
|
|
139
|
+
h_n^(1)(z) = j_n(z) + i*y_n(z) (first kind, k=1)
|
|
140
|
+
h_n^(2)(z) = j_n(z) - i*y_n(z) (second kind, k=2)
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
n : int
|
|
145
|
+
Order of the function
|
|
146
|
+
k : int
|
|
147
|
+
Kind of Hankel function (1 or 2)
|
|
148
|
+
z : float or ndarray
|
|
149
|
+
Argument
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
hn : complex or ndarray
|
|
154
|
+
Spherical Hankel function value(s)
|
|
155
|
+
|
|
156
|
+
Example
|
|
157
|
+
-------
|
|
158
|
+
>>> spbesselh(0, 1, 1.0)
|
|
159
|
+
(0.8414709848078965+0.5403023058681398j)
|
|
160
|
+
"""
|
|
161
|
+
sp = _get_scipy_special()
|
|
162
|
+
jn = sp.spherical_jn(n, z)
|
|
163
|
+
yn = sp.spherical_yn(n, z)
|
|
164
|
+
if k == 1:
|
|
165
|
+
return jn + 1j * yn
|
|
166
|
+
elif k == 2:
|
|
167
|
+
return jn - 1j * yn
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError("k must be 1 or 2")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def spbesseljprime(n, z):
|
|
173
|
+
"""
|
|
174
|
+
Derivative of spherical Bessel function of the first kind.
|
|
175
|
+
|
|
176
|
+
Wrapper around scipy.special.spherical_jn with derivative=True.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
n : int
|
|
181
|
+
Order of the function
|
|
182
|
+
z : float or ndarray
|
|
183
|
+
Argument
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
jp : float or ndarray
|
|
188
|
+
Derivative value(s)
|
|
189
|
+
|
|
190
|
+
Example
|
|
191
|
+
-------
|
|
192
|
+
>>> spbesseljprime(0, 1.0)
|
|
193
|
+
-0.30116867893975674
|
|
194
|
+
"""
|
|
195
|
+
return _get_scipy_special().spherical_jn(n, z, derivative=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def spbesselyprime(n, z):
|
|
199
|
+
"""
|
|
200
|
+
Derivative of spherical Bessel function of the second kind (Neumann).
|
|
201
|
+
|
|
202
|
+
Wrapper around scipy.special.spherical_yn with derivative=True.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
n : int
|
|
207
|
+
Order of the function
|
|
208
|
+
z : float or ndarray
|
|
209
|
+
Argument
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
yp : float or ndarray
|
|
214
|
+
Derivative value(s)
|
|
215
|
+
|
|
216
|
+
Example
|
|
217
|
+
-------
|
|
218
|
+
>>> spbesselyprime(0, 1.0)
|
|
219
|
+
0.8414709848078965
|
|
220
|
+
"""
|
|
221
|
+
return _get_scipy_special().spherical_yn(n, z, derivative=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def spbesselhprime(n, k, z):
|
|
225
|
+
"""
|
|
226
|
+
Derivative of spherical Hankel function.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
n : int
|
|
231
|
+
Order of the function
|
|
232
|
+
k : int
|
|
233
|
+
Kind of Hankel function (1 or 2)
|
|
234
|
+
z : float or ndarray
|
|
235
|
+
Argument
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
hp : complex or ndarray
|
|
240
|
+
Derivative value(s)
|
|
241
|
+
|
|
242
|
+
Example
|
|
243
|
+
-------
|
|
244
|
+
>>> spbesselhprime(0, 1, 1.0)
|
|
245
|
+
(-0.30116867893975674-0.8414709848078965j)
|
|
246
|
+
"""
|
|
247
|
+
sp = _get_scipy_special()
|
|
248
|
+
jp = sp.spherical_jn(n, z, derivative=True)
|
|
249
|
+
yp = sp.spherical_yn(n, z, derivative=True)
|
|
250
|
+
if k == 1:
|
|
251
|
+
return jp + 1j * yp
|
|
252
|
+
elif k == 2:
|
|
253
|
+
return jp - 1j * yp
|
|
254
|
+
else:
|
|
255
|
+
raise ValueError("k must be 1 or 2")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# =============================================================================
|
|
259
|
+
# Spherical Harmonics
|
|
260
|
+
# =============================================================================
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def spharmonic(l, m, theta, phi):
|
|
264
|
+
"""
|
|
265
|
+
Spherical harmonic function Y_l^m(theta, phi).
|
|
266
|
+
|
|
267
|
+
Uses the convention where theta is the polar angle (0 to pi) and
|
|
268
|
+
phi is the azimuthal angle (0 to 2*pi). This matches the MATLAB
|
|
269
|
+
convention used in MMC/MCX.
|
|
270
|
+
|
|
271
|
+
Note: scipy.special.sph_harm uses opposite convention (phi, theta),
|
|
272
|
+
so we provide our own implementation for consistency.
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
l : int
|
|
277
|
+
Degree (order), l >= 0
|
|
278
|
+
m : int
|
|
279
|
+
Angular index, -l <= m <= l
|
|
280
|
+
theta : float or ndarray
|
|
281
|
+
Polar angle (0 to pi)
|
|
282
|
+
phi : float or ndarray
|
|
283
|
+
Azimuthal angle (0 to 2*pi)
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
Y : complex or ndarray
|
|
288
|
+
Spherical harmonic values
|
|
289
|
+
|
|
290
|
+
Example
|
|
291
|
+
-------
|
|
292
|
+
>>> spharmonic(1, 0, np.pi/4, 0)
|
|
293
|
+
(0.3454941494713355+0j)
|
|
294
|
+
"""
|
|
295
|
+
theta = np.atleast_1d(np.asarray(theta, dtype=float))
|
|
296
|
+
phi = np.atleast_1d(np.asarray(phi, dtype=float))
|
|
297
|
+
|
|
298
|
+
# Handle negative m using symmetry relation
|
|
299
|
+
coeff = 1.0
|
|
300
|
+
absm = abs(m)
|
|
301
|
+
if m < 0:
|
|
302
|
+
coeff = ((-1.0) ** m) * factorial(l + m) / factorial(l - m)
|
|
303
|
+
|
|
304
|
+
# Associated Legendre polynomial P_l^|m|(cos(theta))
|
|
305
|
+
Plm = _get_scipy_special().lpmv(absm, l, np.cos(theta))
|
|
306
|
+
|
|
307
|
+
# Normalization factor
|
|
308
|
+
norm = np.sqrt((2 * l + 1) * factorial(l - m) / (4 * np.pi * factorial(l + m)))
|
|
309
|
+
|
|
310
|
+
result = coeff * norm * Plm * np.exp(1j * m * phi)
|
|
311
|
+
|
|
312
|
+
# Return scalar if inputs were scalar
|
|
313
|
+
return result.item() if result.size == 1 else result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# =============================================================================
|
|
317
|
+
# CW Solutions for Homogeneous Media
|
|
318
|
+
# =============================================================================
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def infinite_cw(mua, musp, srcpos, detpos):
|
|
322
|
+
"""
|
|
323
|
+
Analytical CW diffusion solution for infinite homogeneous medium.
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
mua : float
|
|
328
|
+
Absorption coefficient (1/mm)
|
|
329
|
+
musp : float
|
|
330
|
+
Reduced scattering coefficient (1/mm)
|
|
331
|
+
srcpos : ndarray
|
|
332
|
+
Source position (1x3)
|
|
333
|
+
detpos : ndarray
|
|
334
|
+
Detector positions (Nx3)
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
phi : ndarray
|
|
339
|
+
Fluence at detector positions
|
|
340
|
+
"""
|
|
341
|
+
D = 1.0 / (3.0 * (mua + musp))
|
|
342
|
+
mu_eff = np.sqrt(mua / D)
|
|
343
|
+
srcpos, detpos = np.atleast_2d(srcpos), np.atleast_2d(detpos)
|
|
344
|
+
r = getdistance(srcpos, detpos)
|
|
345
|
+
return (1.0 / (4 * np.pi * D)) * np.exp(-mu_eff * r) / r
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def semi_infinite_cw(mua, musp, n_in, n_out, srcpos, detpos):
|
|
349
|
+
"""
|
|
350
|
+
Analytical CW diffusion solution for semi-infinite medium.
|
|
351
|
+
|
|
352
|
+
Uses extrapolated boundary condition with image source method.
|
|
353
|
+
See [Haskell1994], [Boas2002].
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
mua : float
|
|
358
|
+
Absorption coefficient (1/mm)
|
|
359
|
+
musp : float
|
|
360
|
+
Reduced scattering coefficient (1/mm)
|
|
361
|
+
n_in, n_out : float
|
|
362
|
+
Refractive indices (inside medium, outside)
|
|
363
|
+
srcpos : ndarray
|
|
364
|
+
Source position (Mx3), z=0 is the boundary
|
|
365
|
+
detpos : ndarray
|
|
366
|
+
Detector positions (Nx3)
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
phi : ndarray
|
|
371
|
+
Fluence at detector positions (MxN if M sources, else N)
|
|
372
|
+
"""
|
|
373
|
+
D = 1.0 / (3.0 * (mua + musp))
|
|
374
|
+
Reff = getreff(n_in, n_out)
|
|
375
|
+
mu_eff = np.sqrt(mua / D)
|
|
376
|
+
zb = 2 * D * (1 + Reff) / (1 - Reff)
|
|
377
|
+
z0 = 1.0 / (mua + musp)
|
|
378
|
+
|
|
379
|
+
srcpos, detpos = np.atleast_2d(srcpos), np.atleast_2d(detpos)
|
|
380
|
+
|
|
381
|
+
# Real source at z0 below surface, image source at -(z0 + 2*zb)
|
|
382
|
+
src_real = srcpos.copy()
|
|
383
|
+
src_real[:, 2] = srcpos[:, 2] + z0
|
|
384
|
+
src_image = srcpos.copy()
|
|
385
|
+
src_image[:, 2] = srcpos[:, 2] - z0 - 2 * zb
|
|
386
|
+
|
|
387
|
+
r1 = getdistance(src_real, detpos)
|
|
388
|
+
r2 = getdistance(src_image, detpos)
|
|
389
|
+
|
|
390
|
+
phi = (1.0 / (4 * np.pi * D)) * (
|
|
391
|
+
np.exp(-mu_eff * r1) / r1 - np.exp(-mu_eff * r2) / r2
|
|
392
|
+
)
|
|
393
|
+
return phi.squeeze()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def semi_infinite_cw_flux(mua, musp, n_in, n_out, srcpos, detpos):
|
|
397
|
+
"""
|
|
398
|
+
Compute surface flux (diffuse reflectance) for semi-infinite medium.
|
|
399
|
+
|
|
400
|
+
Implements Eq. 6 of [Kienle1997].
|
|
401
|
+
|
|
402
|
+
Parameters
|
|
403
|
+
----------
|
|
404
|
+
mua : float
|
|
405
|
+
Absorption coefficient (1/mm)
|
|
406
|
+
musp : float
|
|
407
|
+
Reduced scattering coefficient (1/mm)
|
|
408
|
+
n_in, n_out : float
|
|
409
|
+
Refractive indices (inside medium, outside)
|
|
410
|
+
srcpos : ndarray
|
|
411
|
+
Source positions (Mx3)
|
|
412
|
+
detpos : ndarray
|
|
413
|
+
Detector positions (Nx3)
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
flux : ndarray
|
|
418
|
+
Diffuse reflectance at detector positions (1/(mm^2))
|
|
419
|
+
"""
|
|
420
|
+
D = 1.0 / (3.0 * (mua + musp))
|
|
421
|
+
Reff = getreff(n_in, n_out)
|
|
422
|
+
z0 = 1.0 / (mua + musp)
|
|
423
|
+
zb = 2 * D * (1 + Reff) / (1 - Reff)
|
|
424
|
+
mu_eff = np.sqrt(3 * mua * (mua + musp))
|
|
425
|
+
|
|
426
|
+
srcpos, detpos = np.atleast_2d(srcpos), np.atleast_2d(detpos)
|
|
427
|
+
|
|
428
|
+
src_real = srcpos.copy()
|
|
429
|
+
src_real[:, 2] = srcpos[:, 2] + z0
|
|
430
|
+
src_image = srcpos.copy()
|
|
431
|
+
src_image[:, 2] = srcpos[:, 2] + z0 + 2 * zb
|
|
432
|
+
|
|
433
|
+
r1 = getdistance(src_real, detpos)
|
|
434
|
+
r2 = getdistance(src_image, detpos)
|
|
435
|
+
|
|
436
|
+
# Eq. 6 of Kienle1997
|
|
437
|
+
flux = (1.0 / (4 * np.pi)) * (
|
|
438
|
+
z0 * (mu_eff + 1.0 / r1) * np.exp(-mu_eff * r1) / r1**2
|
|
439
|
+
+ (z0 + 2 * zb) * (mu_eff + 1.0 / r2) * np.exp(-mu_eff * r2) / r2**2
|
|
440
|
+
)
|
|
441
|
+
return flux.squeeze()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# =============================================================================
|
|
445
|
+
# Time-Domain Solutions
|
|
446
|
+
# =============================================================================
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def infinite_td(mua, musp, n, srcpos, detpos, t):
|
|
450
|
+
"""
|
|
451
|
+
Time-domain diffusion solution for semi-infinite medium.
|
|
452
|
+
|
|
453
|
+
See [Boas2002].
|
|
454
|
+
|
|
455
|
+
Parameters
|
|
456
|
+
----------
|
|
457
|
+
mua : float
|
|
458
|
+
Absorption coefficient (1/mm)
|
|
459
|
+
musp : float
|
|
460
|
+
Reduced scattering coefficient (1/mm)
|
|
461
|
+
: float
|
|
462
|
+
|
|
463
|
+
n : float
|
|
464
|
+
Refractive indices (inside medium)
|
|
465
|
+
srcpos : ndarray
|
|
466
|
+
Source positions (Mx3)
|
|
467
|
+
detpos : ndarray
|
|
468
|
+
Detector positions (Nx3)
|
|
469
|
+
t : ndarray
|
|
470
|
+
Time points (s)
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
phi : ndarray
|
|
475
|
+
Fluence at detector positions for each time point (shape: len(t) x N)
|
|
476
|
+
Units: 1/(mm^2*s)
|
|
477
|
+
"""
|
|
478
|
+
D = 1.0 / (3.0 * (mua + musp))
|
|
479
|
+
|
|
480
|
+
C0 = 299792458000.0 # Speed of light in vacuum (mm/s)
|
|
481
|
+
v = C0 / n # Speed of light in medium (mm/s)
|
|
482
|
+
|
|
483
|
+
srcpos, detpos = np.atleast_2d(srcpos), np.atleast_2d(detpos)
|
|
484
|
+
t = np.atleast_1d(t)
|
|
485
|
+
|
|
486
|
+
r1 = getdistance(srcpos, detpos) # Shape: (Ndet, Nsrc)
|
|
487
|
+
r1 = r1.T # Transpose to (Nsrc, Ndet) - for single src, becomes (1, Ndet)
|
|
488
|
+
|
|
489
|
+
# Broadcast for time: result shape (len(t), n_det)
|
|
490
|
+
t = t[:, np.newaxis] # Shape: (Ntime, 1)
|
|
491
|
+
s = 4 * D * v * t # Shape: (Ntime, 1)
|
|
492
|
+
|
|
493
|
+
# r1 has shape (Nsrc, Ndet), for single source (1, Ndet)
|
|
494
|
+
# After squeeze, r1 becomes (Ndet,) which broadcasts with (Ntime, 1) -> (Ntime, Ndet)
|
|
495
|
+
r1 = r1.squeeze() # Remove single-source dimension
|
|
496
|
+
|
|
497
|
+
# Unit of phi: 1/(mm^2*s)
|
|
498
|
+
phi = (v / (s * np.pi) ** 1.5) * np.exp(-mua * v * t) * np.exp(-(r1**2) / s)
|
|
499
|
+
return phi.squeeze()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def semi_infinite_td(mua, musp, n_in, n_out, srcpos, detpos, t):
|
|
503
|
+
"""
|
|
504
|
+
Time-domain diffusion solution for semi-infinite medium.
|
|
505
|
+
|
|
506
|
+
See [Boas2002].
|
|
507
|
+
|
|
508
|
+
Parameters
|
|
509
|
+
----------
|
|
510
|
+
mua : float
|
|
511
|
+
Absorption coefficient (1/mm)
|
|
512
|
+
musp : float
|
|
513
|
+
Reduced scattering coefficient (1/mm)
|
|
514
|
+
n_in, n_out : float
|
|
515
|
+
Refractive indices (inside medium, outside)
|
|
516
|
+
srcpos : ndarray
|
|
517
|
+
Source positions (Mx3)
|
|
518
|
+
detpos : ndarray
|
|
519
|
+
Detector positions (Nx3)
|
|
520
|
+
t : ndarray
|
|
521
|
+
Time points (s)
|
|
522
|
+
|
|
523
|
+
Returns
|
|
524
|
+
-------
|
|
525
|
+
phi : ndarray
|
|
526
|
+
Fluence at detector positions for each time point (shape: len(t) x N)
|
|
527
|
+
Units: 1/(mm^2*s)
|
|
528
|
+
"""
|
|
529
|
+
D = 1.0 / (3.0 * (mua + musp))
|
|
530
|
+
|
|
531
|
+
C0 = 299792458000.0 # Speed of light in vacuum (mm/s)
|
|
532
|
+
v = C0 / n_in # Speed of light in medium (mm/s)
|
|
533
|
+
|
|
534
|
+
Reff = getreff(n_in, n_out)
|
|
535
|
+
zb = 2 * D * (1 + Reff) / (1 - Reff)
|
|
536
|
+
z0 = 1.0 / (mua + musp)
|
|
537
|
+
|
|
538
|
+
srcpos, detpos = np.atleast_2d(srcpos), np.atleast_2d(detpos)
|
|
539
|
+
t = np.atleast_1d(t)
|
|
540
|
+
|
|
541
|
+
src_real = srcpos.copy()
|
|
542
|
+
src_real[:, 2] = srcpos[:, 2] + z0
|
|
543
|
+
src_image = srcpos.copy()
|
|
544
|
+
src_image[:, 2] = srcpos[:, 2] - z0 - 2 * zb
|
|
545
|
+
|
|
546
|
+
r1 = getdistance(src_real, detpos).T.squeeze() # Transpose and squeeze
|
|
547
|
+
r2 = getdistance(src_image, detpos).T.squeeze() # Transpose and squeeze
|
|
548
|
+
|
|
549
|
+
# Broadcast for time: result shape (len(t), n_det)
|
|
550
|
+
t = t[:, np.newaxis]
|
|
551
|
+
s = 4 * D * v * t # (len(t), 1)
|
|
552
|
+
|
|
553
|
+
# Unit of phi: 1/(mm^2*s)
|
|
554
|
+
phi = (
|
|
555
|
+
(v / (s * np.pi) ** 1.5)
|
|
556
|
+
* np.exp(-mua * v * t)
|
|
557
|
+
* (np.exp(-(r1**2) / s) - np.exp(-(r2**2) / s))
|
|
558
|
+
)
|
|
559
|
+
return phi.squeeze()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# =============================================================================
|
|
563
|
+
# Sphere Diffusion Coefficients (Internal)
|
|
564
|
+
# =============================================================================
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _sphere_coeff_A(m, l, cfg):
|
|
568
|
+
"""Sphere exterior solution A coefficient."""
|
|
569
|
+
if (cfg["src"][1] in (0, np.pi)) and m != 0:
|
|
570
|
+
return 0.0
|
|
571
|
+
|
|
572
|
+
x, y = cfg["kout"] * cfg["a"], cfg["kin"] * cfg["a"]
|
|
573
|
+
Dout, Din = cfg["Dout"], cfg["Din"]
|
|
574
|
+
|
|
575
|
+
hl_src = spbesselh(l, 1, cfg["kout"] * cfg["src"][0])
|
|
576
|
+
Ylm_src = np.conj(spharmonic(l, m, cfg["src"][1], cfg["src"][2]))
|
|
577
|
+
|
|
578
|
+
jl_x, jl_y = spbesselj(l, x), spbesselj(l, y)
|
|
579
|
+
jlp_x, jlp_y = spbesseljprime(l, x), spbesseljprime(l, y)
|
|
580
|
+
hlp_x, hl_x = spbesselhprime(l, 1, x), spbesselh(l, 1, x)
|
|
581
|
+
|
|
582
|
+
numer = Dout * x * jlp_x * jl_y - Din * y * jl_x * jlp_y
|
|
583
|
+
denom = Dout * x * hlp_x * jl_y - Din * y * hl_x * jlp_y
|
|
584
|
+
|
|
585
|
+
return -1j * cfg["v"] * cfg["kout"] / Dout * hl_src * Ylm_src * numer / denom
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _sphere_coeff_C(m, l, cfg):
|
|
589
|
+
"""Sphere interior solution C coefficient."""
|
|
590
|
+
if (cfg["src"][1] in (0, np.pi)) and m != 0:
|
|
591
|
+
return 0.0
|
|
592
|
+
|
|
593
|
+
x, y = cfg["kout"] * cfg["a"], cfg["kin"] * cfg["a"]
|
|
594
|
+
Dout, Din = cfg["Dout"], cfg["Din"]
|
|
595
|
+
|
|
596
|
+
hl_src = spbesselh(l, 1, cfg["kout"] * cfg["src"][0])
|
|
597
|
+
Ylm_src = np.conj(spharmonic(l, m, cfg["src"][1], cfg["src"][2]))
|
|
598
|
+
|
|
599
|
+
jl_x, jlp_x = spbesselj(l, x), spbesseljprime(l, x)
|
|
600
|
+
jl_y, jlp_y = spbesselj(l, y), spbesseljprime(l, y)
|
|
601
|
+
hl_x, hlp_x = spbesselh(l, 1, x), spbesselhprime(l, 1, x)
|
|
602
|
+
|
|
603
|
+
# Wronskian-like numerator
|
|
604
|
+
numer = Dout * x * (hl_x * jlp_x - hlp_x * jl_x)
|
|
605
|
+
denom = Dout * x * hlp_x * jl_y - Din * y * hl_x * jlp_y
|
|
606
|
+
|
|
607
|
+
return -1j * cfg["v"] * cfg["kout"] / Dout * hl_src * Ylm_src * numer / denom
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# =============================================================================
|
|
611
|
+
# Sphere Field Components (Internal)
|
|
612
|
+
# =============================================================================
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _sphere_incident(r, theta, phi, cfg):
|
|
616
|
+
"""Incident field from point source in infinite medium."""
|
|
617
|
+
# Convert spherical to Cartesian for source
|
|
618
|
+
st, ct = np.sin(cfg["src"][1]), np.cos(cfg["src"][1])
|
|
619
|
+
xs = cfg["src"][0] * st * np.cos(cfg["src"][2])
|
|
620
|
+
ys = cfg["src"][0] * st * np.sin(cfg["src"][2])
|
|
621
|
+
zs = cfg["src"][0] * ct
|
|
622
|
+
|
|
623
|
+
# Field points
|
|
624
|
+
x = r * np.sin(theta) * np.cos(phi)
|
|
625
|
+
y = r * np.sin(theta) * np.sin(phi)
|
|
626
|
+
z = r * np.cos(theta)
|
|
627
|
+
|
|
628
|
+
dist = np.sqrt((x - xs) ** 2 + (y - ys) ** 2 + (z - zs) ** 2)
|
|
629
|
+
return cfg["v"] / (4 * np.pi * cfg["Dout"] * dist) * np.exp(1j * cfg["kout"] * dist)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _sphere_scatter(r, theta, phi, cfg):
|
|
633
|
+
"""Scattered field outside sphere (series expansion)."""
|
|
634
|
+
res = np.zeros_like(r, dtype=complex)
|
|
635
|
+
kout_r = cfg["kout"] * r
|
|
636
|
+
for l in range(cfg["maxl"] + 1):
|
|
637
|
+
jl, yl = spbesselj(l, kout_r), spbessely(l, kout_r)
|
|
638
|
+
for m in range(-l, l + 1):
|
|
639
|
+
A = _sphere_coeff_A(m, l, cfg)
|
|
640
|
+
if A == 0:
|
|
641
|
+
continue
|
|
642
|
+
B = 1j * A # B = i*A
|
|
643
|
+
Ylm = spharmonic(l, m, theta, phi)
|
|
644
|
+
res += (A * jl + B * yl) * Ylm
|
|
645
|
+
return res
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _sphere_interior(r, theta, phi, cfg):
|
|
649
|
+
"""Field inside sphere (series expansion)."""
|
|
650
|
+
res = np.zeros_like(r, dtype=complex)
|
|
651
|
+
kin_r = cfg["kin"] * r
|
|
652
|
+
for l in range(cfg["maxl"] + 1):
|
|
653
|
+
jl = spbesselj(l, kin_r)
|
|
654
|
+
for m in range(-l, l + 1):
|
|
655
|
+
C = _sphere_coeff_C(m, l, cfg)
|
|
656
|
+
if C == 0:
|
|
657
|
+
continue
|
|
658
|
+
Ylm = spharmonic(l, m, theta, phi)
|
|
659
|
+
res += C * jl * Ylm
|
|
660
|
+
return res
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _sphere_exterior(r, theta, phi, cfg):
|
|
664
|
+
"""Total exterior field = incident + scattered."""
|
|
665
|
+
return _sphere_incident(r, theta, phi, cfg) + _sphere_scatter(r, theta, phi, cfg)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# =============================================================================
|
|
669
|
+
# Sphere Configuration Helpers (Internal)
|
|
670
|
+
# =============================================================================
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _init_sphere_cfg(cfg):
|
|
674
|
+
"""Initialize derived parameters for sphere diffusion."""
|
|
675
|
+
cfg = cfg.copy()
|
|
676
|
+
cfg["Din"] = cfg["v"] / (3 * cfg["imusp"])
|
|
677
|
+
cfg["Dout"] = cfg["v"] / (3 * cfg["omusp"])
|
|
678
|
+
omega = cfg.get("omega", 0)
|
|
679
|
+
cfg["kin"] = np.sqrt((-cfg["v"] * cfg["imua"] + 1j * omega) / cfg["Din"])
|
|
680
|
+
cfg["kout"] = np.sqrt((-cfg["v"] * cfg["omua"] + 1j * omega) / cfg["Dout"])
|
|
681
|
+
return cfg
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _cart2sph_grid(xi, yi, zi):
|
|
685
|
+
"""Convert Cartesian meshgrid to spherical coordinates (R, theta, phi)."""
|
|
686
|
+
R = np.sqrt(xi**2 + yi**2 + zi**2).ravel()
|
|
687
|
+
T = np.arccos(np.clip(zi.ravel() / (R + 1e-30), -1, 1)) # theta (polar)
|
|
688
|
+
P = np.arctan2(yi.ravel(), xi.ravel()) # phi (azimuthal)
|
|
689
|
+
return R, T, P
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _compute_field(R, T, P, cfg):
|
|
693
|
+
"""Compute field for interior and exterior regions."""
|
|
694
|
+
res = np.zeros(len(R), dtype=complex)
|
|
695
|
+
idx_ext, idx_int = R > cfg["a"], R <= cfg["a"]
|
|
696
|
+
if np.any(idx_ext):
|
|
697
|
+
res[idx_ext] = _sphere_exterior(R[idx_ext], T[idx_ext], P[idx_ext], cfg)
|
|
698
|
+
if np.any(idx_int):
|
|
699
|
+
res[idx_int] = _sphere_interior(R[idx_int], T[idx_int], P[idx_int], cfg)
|
|
700
|
+
return res
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# =============================================================================
|
|
704
|
+
# Main Sphere Diffusion Functions
|
|
705
|
+
# =============================================================================
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def sphere_infinite(xrange, yrange, zrange, cfg):
|
|
709
|
+
"""
|
|
710
|
+
CW diffusion solution for a sphere in infinite homogeneous medium.
|
|
711
|
+
|
|
712
|
+
See [Fang2010].
|
|
713
|
+
|
|
714
|
+
Parameters
|
|
715
|
+
----------
|
|
716
|
+
xrange, yrange, zrange : ndarray
|
|
717
|
+
1D arrays defining the evaluation grid
|
|
718
|
+
cfg : dict
|
|
719
|
+
Problem configuration:
|
|
720
|
+
- v: speed of light (mm/s)
|
|
721
|
+
- a: sphere radius (mm)
|
|
722
|
+
- omua, omusp: outside (background) mua, mus' (1/mm)
|
|
723
|
+
- imua, imusp: inside (sphere) mua, mus' (1/mm)
|
|
724
|
+
- src: source position in spherical coords (R, theta, phi)
|
|
725
|
+
- maxl: maximum order for series expansion (default 20)
|
|
726
|
+
- omega: modulation frequency (default 0 for CW)
|
|
727
|
+
|
|
728
|
+
Returns
|
|
729
|
+
-------
|
|
730
|
+
phi : ndarray
|
|
731
|
+
Fluence on the grid (squeezed to remove singleton dims)
|
|
732
|
+
xi, yi, zi : ndarray
|
|
733
|
+
Meshgrid coordinates
|
|
734
|
+
"""
|
|
735
|
+
cfg.setdefault("maxl", 20)
|
|
736
|
+
cfg.setdefault("omega", 0)
|
|
737
|
+
cfg = _init_sphere_cfg(cfg)
|
|
738
|
+
|
|
739
|
+
xi, yi, zi = np.meshgrid(xrange, yrange, zrange, indexing="ij")
|
|
740
|
+
shape = xi.shape
|
|
741
|
+
R, T, P = _cart2sph_grid(xi, yi, zi)
|
|
742
|
+
|
|
743
|
+
res = _compute_field(R, T, P, cfg)
|
|
744
|
+
|
|
745
|
+
return (
|
|
746
|
+
np.squeeze(res.reshape(shape)),
|
|
747
|
+
np.squeeze(xi),
|
|
748
|
+
np.squeeze(yi),
|
|
749
|
+
np.squeeze(zi),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def sphere_semi_infinite(xrange, yrange, zrange, cfg, n0=1.0, n1=None):
|
|
754
|
+
"""
|
|
755
|
+
CW diffusion solution for a sphere in semi-infinite medium.
|
|
756
|
+
|
|
757
|
+
Uses image source method. First-order approximation; accurate when
|
|
758
|
+
sphere is far from boundary. See [Fang2010].
|
|
759
|
+
|
|
760
|
+
Parameters
|
|
761
|
+
----------
|
|
762
|
+
xrange, yrange, zrange : ndarray
|
|
763
|
+
1D arrays defining the evaluation grid
|
|
764
|
+
cfg : dict
|
|
765
|
+
Problem configuration (see sphere_infinite)
|
|
766
|
+
n0 : float
|
|
767
|
+
Refractive index of upper space (above boundary, default 1.0)
|
|
768
|
+
n1 : float
|
|
769
|
+
Refractive index of lower space/medium (default 1.37)
|
|
770
|
+
|
|
771
|
+
Returns
|
|
772
|
+
-------
|
|
773
|
+
phi : ndarray
|
|
774
|
+
Fluence on the grid
|
|
775
|
+
xi, yi, zi : ndarray
|
|
776
|
+
Meshgrid coordinates
|
|
777
|
+
"""
|
|
778
|
+
cfg.setdefault("maxl", 20)
|
|
779
|
+
cfg.setdefault("omega", 0)
|
|
780
|
+
cfg = _init_sphere_cfg(cfg)
|
|
781
|
+
|
|
782
|
+
if n1 is None:
|
|
783
|
+
n1 = 1.37 # typical tissue
|
|
784
|
+
|
|
785
|
+
Reff = getreff(n1, n0)
|
|
786
|
+
D = 1.0 / (3.0 * (cfg["omua"] + cfg["omusp"]))
|
|
787
|
+
zb = 2 * D * (1 + Reff) / (1 - Reff)
|
|
788
|
+
z0 = 1.0 / (cfg["omusp"] + cfg["omua"])
|
|
789
|
+
|
|
790
|
+
xi, yi, zi = np.meshgrid(xrange, yrange, zrange, indexing="ij")
|
|
791
|
+
shape = xi.shape
|
|
792
|
+
R, T, P = _cart2sph_grid(xi, yi, zi)
|
|
793
|
+
|
|
794
|
+
src0 = list(cfg["src"])
|
|
795
|
+
|
|
796
|
+
# Real source field for real sphere
|
|
797
|
+
cfg_real = cfg.copy()
|
|
798
|
+
cfg_real["src"] = [src0[0] - z0, src0[1], src0[2]]
|
|
799
|
+
res = _compute_field(R, T, P, cfg_real)
|
|
800
|
+
|
|
801
|
+
# Image source field for real sphere (subtract)
|
|
802
|
+
cfg_img = cfg.copy()
|
|
803
|
+
cfg_img["src"] = [src0[0] + z0 + 2 * zb, np.pi, src0[2]]
|
|
804
|
+
res -= _compute_field(R, T, P, cfg_img)
|
|
805
|
+
|
|
806
|
+
# Scattered field contributions from mirrored sphere
|
|
807
|
+
idx_ext = R > cfg["a"]
|
|
808
|
+
if np.any(idx_ext):
|
|
809
|
+
zi_m = zi.ravel() + 2 * (src0[0] + zb)
|
|
810
|
+
R_m, T_m, P_m = _cart2sph_grid(xi, yi, zi_m.reshape(shape))
|
|
811
|
+
|
|
812
|
+
# Real source scattered by mirrored sphere
|
|
813
|
+
cfg_s1 = cfg.copy()
|
|
814
|
+
cfg_s1["src"] = [src0[0] + z0 + 2 * zb, src0[1], src0[2]]
|
|
815
|
+
res[idx_ext] += _sphere_scatter(
|
|
816
|
+
R_m[idx_ext], T_m[idx_ext], P_m[idx_ext], cfg_s1
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Image source scattered by mirrored sphere
|
|
820
|
+
cfg_s2 = cfg.copy()
|
|
821
|
+
cfg_s2["src"] = [src0[0] - z0, src0[1], src0[2]]
|
|
822
|
+
res[idx_ext] -= _sphere_scatter(
|
|
823
|
+
R_m[idx_ext], T_m[idx_ext], P_m[idx_ext], cfg_s2
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
return (
|
|
827
|
+
np.squeeze(res.reshape(shape)),
|
|
828
|
+
np.squeeze(xi),
|
|
829
|
+
np.squeeze(yi),
|
|
830
|
+
np.squeeze(zi),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def sphere_slab(xrange, yrange, zrange, cfg, h, n0=1.0, n1=None):
|
|
835
|
+
"""
|
|
836
|
+
CW diffusion solution for a sphere in infinite slab.
|
|
837
|
+
|
|
838
|
+
Uses image source method for both boundaries. First-order approximation.
|
|
839
|
+
See [Fang2010].
|
|
840
|
+
|
|
841
|
+
Parameters
|
|
842
|
+
----------
|
|
843
|
+
xrange, yrange, zrange : ndarray
|
|
844
|
+
1D arrays defining the evaluation grid
|
|
845
|
+
cfg : dict
|
|
846
|
+
Problem configuration (see sphere_infinite)
|
|
847
|
+
h : float
|
|
848
|
+
Slab thickness (mm)
|
|
849
|
+
n0 : float
|
|
850
|
+
Refractive index of upper space (above slab, default 1.0)
|
|
851
|
+
n1 : float
|
|
852
|
+
Refractive index of slab medium (default 1.37)
|
|
853
|
+
|
|
854
|
+
Returns
|
|
855
|
+
-------
|
|
856
|
+
phi : ndarray
|
|
857
|
+
Fluence on the grid
|
|
858
|
+
xi, yi, zi : ndarray
|
|
859
|
+
Meshgrid coordinates
|
|
860
|
+
"""
|
|
861
|
+
cfg.setdefault("maxl", 20)
|
|
862
|
+
cfg.setdefault("omega", 0)
|
|
863
|
+
cfg = _init_sphere_cfg(cfg)
|
|
864
|
+
|
|
865
|
+
if n1 is None:
|
|
866
|
+
n1 = 1.37
|
|
867
|
+
|
|
868
|
+
# Reff for both boundaries (medium to air)
|
|
869
|
+
Reff1 = getreff(n1, n0) # lower boundary
|
|
870
|
+
Reff2 = getreff(n1, n0) # upper boundary
|
|
871
|
+
|
|
872
|
+
D = 1.0 / (3.0 * (cfg["omua"] + cfg["omusp"]))
|
|
873
|
+
zb1 = 2 * D * (1 + Reff1) / (1 - Reff1)
|
|
874
|
+
zb2 = 2 * D * (1 + Reff2) / (1 - Reff2)
|
|
875
|
+
z0 = 1.0 / (cfg["omusp"] + cfg["omua"])
|
|
876
|
+
|
|
877
|
+
xi, yi, zi = np.meshgrid(xrange, yrange, zrange, indexing="ij")
|
|
878
|
+
shape = xi.shape
|
|
879
|
+
|
|
880
|
+
# Start with semi-infinite solution (lower boundary)
|
|
881
|
+
res, _, _, _ = sphere_semi_infinite(xrange, yrange, zrange, cfg, n0, n1)
|
|
882
|
+
res = res.ravel()
|
|
883
|
+
|
|
884
|
+
R, T, P = _cart2sph_grid(xi, yi, zi)
|
|
885
|
+
idx_ext = R > cfg["a"]
|
|
886
|
+
|
|
887
|
+
src0 = list(cfg["src"])
|
|
888
|
+
|
|
889
|
+
# Image source at upper boundary (subtract)
|
|
890
|
+
cfg_upper = cfg.copy()
|
|
891
|
+
cfg_upper["src"] = [2 * h - src0[0] + 2 * zb2 - z0, np.pi - src0[1], src0[2]]
|
|
892
|
+
res -= _compute_field(R, T, P, cfg_upper)
|
|
893
|
+
|
|
894
|
+
# Second image source (add back)
|
|
895
|
+
cfg_upper2 = cfg.copy()
|
|
896
|
+
cfg_upper2["src"] = [
|
|
897
|
+
2 * h - src0[0] + 2 * zb2 + z0 + 2 * zb1,
|
|
898
|
+
np.pi - src0[1],
|
|
899
|
+
src0[2],
|
|
900
|
+
]
|
|
901
|
+
res += _compute_field(R, T, P, cfg_upper2)
|
|
902
|
+
|
|
903
|
+
# Scattered field from mirrored sphere at upper boundary
|
|
904
|
+
if np.any(idx_ext):
|
|
905
|
+
zi_m = zi.ravel() - 2 * (h - src0[0] + zb2)
|
|
906
|
+
R_m, T_m, P_m = _cart2sph_grid(xi, yi, zi_m.reshape(shape))
|
|
907
|
+
|
|
908
|
+
scatter_configs = [
|
|
909
|
+
([2 * h - src0[0] - z0, src0[1], src0[2]], 1),
|
|
910
|
+
([2 * h - src0[0] + 2 * zb2 + z0, src0[1], src0[2]], -1),
|
|
911
|
+
([src0[0] - z0, np.pi - src0[1], src0[2]], -1),
|
|
912
|
+
([src0[0] + 2 * zb1 + z0, np.pi - src0[1], src0[2]], 1),
|
|
913
|
+
]
|
|
914
|
+
|
|
915
|
+
for src_pos, sign in scatter_configs:
|
|
916
|
+
cfg_s = cfg.copy()
|
|
917
|
+
cfg_s["src"] = src_pos
|
|
918
|
+
res[idx_ext] += sign * _sphere_scatter(
|
|
919
|
+
R_m[idx_ext], T_m[idx_ext], P_m[idx_ext], cfg_s
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
return (
|
|
923
|
+
np.squeeze(res.reshape(shape)),
|
|
924
|
+
np.squeeze(xi),
|
|
925
|
+
np.squeeze(yi),
|
|
926
|
+
np.squeeze(zi),
|
|
927
|
+
)
|