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.
@@ -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
+ )