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/property.py ADDED
@@ -0,0 +1,602 @@
1
+ """
2
+ Redbird Property Module - Optical property management for DOT/NIRS.
3
+
4
+ INDEX CONVENTION: All mesh indices (elem, face) stored in cfg are 1-based
5
+ to match MATLAB/iso2mesh. This module converts to 0-based only when
6
+ indexing numpy arrays.
7
+
8
+ Functions:
9
+ extinction: Get molar extinction coefficients for chromophores
10
+ updateprop: Update optical properties from physiological parameters
11
+ getbulk: Get bulk/background optical properties
12
+ musp2sasp: Convert mus' to scattering amplitude/power
13
+ setmesh: Associate new mesh with simulation structure
14
+ """
15
+
16
+ __all__ = [
17
+ "extinction",
18
+ "updateprop",
19
+ "getbulk",
20
+ "musp2sasp",
21
+ "setmesh",
22
+ "get_chromophore_table",
23
+ ]
24
+
25
+ import numpy as np
26
+ from scipy import interpolate
27
+ from typing import Dict, Tuple, Optional, Union, List, Any
28
+
29
+
30
+ def extinction(
31
+ wavelengths: Union[List[str], List[float], np.ndarray],
32
+ chromophores: Union[str, List[str]],
33
+ **interp_opts,
34
+ ) -> Tuple[np.ndarray, dict]:
35
+ """
36
+ Get molar extinction coefficients for chromophores.
37
+
38
+ Data compiled by Scott Prahl from https://omlc.org/spectra/hemoglobin/
39
+
40
+ Parameters
41
+ ----------
42
+ wavelengths : list or ndarray
43
+ Wavelengths in nm (as strings or numbers)
44
+ chromophores : str or list
45
+ Chromophore names: 'hbo', 'hbr', 'water', 'lipids', 'aa3'
46
+ **interp_opts : dict
47
+ Options passed to scipy.interpolate.interp1d
48
+
49
+ Returns
50
+ -------
51
+ extin : ndarray
52
+ Extinction coefficients (Nwv x Nchrome)
53
+ Units: 1/(mm*uM) for hemoglobin, 1/mm for water/lipids
54
+ chrome : dict
55
+ Full lookup tables for each chromophore
56
+ """
57
+ chrome = _get_chromophore_data()
58
+
59
+ # Convert wavelengths to float array
60
+ if isinstance(wavelengths, (list, tuple)):
61
+ wavelengths = [float(w) if isinstance(w, str) else w for w in wavelengths]
62
+ wavelengths = np.atleast_1d(wavelengths).astype(float)
63
+
64
+ # Handle single chromophore
65
+ if isinstance(chromophores, str):
66
+ chromophores = [chromophores]
67
+
68
+ extin = np.zeros((len(wavelengths), len(chromophores)))
69
+
70
+ for j, chrom in enumerate(chromophores):
71
+ chrom_lower = chrom.lower()
72
+ if chrom_lower not in chrome:
73
+ raise ValueError(
74
+ f"Unknown chromophore: {chrom}. " f"Available: {list(chrome.keys())}"
75
+ )
76
+
77
+ spectrum = chrome[chrom_lower]
78
+
79
+ # Interpolate to requested wavelengths
80
+ f = interpolate.interp1d(
81
+ spectrum[:, 0],
82
+ spectrum[:, 1],
83
+ kind="linear",
84
+ fill_value="extrapolate",
85
+ **interp_opts,
86
+ )
87
+ extin[:, j] = f(wavelengths)
88
+
89
+ return extin, chrome
90
+
91
+
92
+ def updateprop(cfg: dict, wv: str = None) -> Union[np.ndarray, dict]:
93
+ """
94
+ Update optical properties from physiological parameters.
95
+
96
+ Converts chromophore concentrations and scattering parameters to
97
+ wavelength-dependent mua and musp.
98
+
99
+ Parameters
100
+ ----------
101
+ cfg : dict
102
+ Configuration with:
103
+ - param: dict with 'hbo', 'hbr', 'water', 'lipids', 'scatamp', 'scatpow'
104
+ - prop: template for output format
105
+ - node, elem: mesh data (1-based elem)
106
+ wv : str, optional
107
+ Specific wavelength to update (if None, update all)
108
+
109
+ Returns
110
+ -------
111
+ prop : ndarray or dict
112
+ Updated optical properties [mua, musp, g, n]
113
+ Dict keyed by wavelength if multi-wavelength
114
+
115
+ Notes
116
+ -----
117
+ mua = sum_i(extin_i * C_i) where C_i is concentration
118
+ musp = scatamp * (lambda_nm)^(-scatpow)
119
+ """
120
+ if "param" not in cfg or not isinstance(cfg.get("prop"), dict):
121
+ return cfg.get("prop")
122
+
123
+ wavelengths = list(cfg["prop"].keys()) if wv is None else [wv]
124
+ params = cfg["param"]
125
+
126
+ prop_out = {}
127
+
128
+ for wavelen in wavelengths:
129
+ # Default tissue composition values
130
+ if "water" not in params:
131
+ params["water"] = 0.23
132
+ if "lipids" not in params:
133
+ params["lipids"] = 0.58
134
+
135
+ # Get chromophore types present in params
136
+ types = [t for t in ["hbo", "hbr", "water", "lipids", "aa3"] if t in params]
137
+
138
+ if not types:
139
+ raise ValueError(
140
+ "No recognized chromophores in cfg.param. "
141
+ "Expected one or more of: hbo, hbr, water, lipids, aa3"
142
+ )
143
+
144
+ # Get extinction coefficients at this wavelength
145
+ extin, _ = extinction(float(wavelen), types)
146
+
147
+ # Compute mua as sum of extinction * concentration
148
+ first_param = params[types[0]]
149
+ if np.isscalar(first_param):
150
+ mua = 0.0
151
+ else:
152
+ mua = np.zeros_like(first_param, dtype=float)
153
+
154
+ for j, t in enumerate(types):
155
+ mua = mua + extin[0, j] * params[t]
156
+
157
+ # Compute musp from scattering amplitude and power
158
+ # musp = scatamp * (wavelength_nm)^(-scatpow)
159
+ if "scatamp" in params and "scatpow" in params:
160
+ musp = params["scatamp"] * (float(wavelen)) ** (-params["scatpow"])
161
+ else:
162
+ musp = None
163
+
164
+ # Build property array based on mesh size
165
+ segprop = cfg["prop"][wavelen]
166
+ nn = (
167
+ cfg["node"].shape[0]
168
+ if "node" in cfg
169
+ else (len(mua) if hasattr(mua, "__len__") else 1)
170
+ )
171
+ ne = (
172
+ cfg["elem"].shape[0]
173
+ if "elem" in cfg
174
+ else (len(mua) if hasattr(mua, "__len__") else 1)
175
+ )
176
+
177
+ mua_len = len(mua) if hasattr(mua, "__len__") else 1
178
+
179
+ if mua_len < min(nn, ne):
180
+ # Label-based properties: mua/musp are per-label
181
+ # segprop[0] is background, segprop[1:] are tissue labels
182
+ new_prop = segprop.copy()
183
+ if hasattr(mua, "__len__"):
184
+ new_prop[1 : mua_len + 1, 0] = mua
185
+ if musp is not None:
186
+ new_prop[1 : mua_len + 1, 1] = musp
187
+ new_prop[1 : mua_len + 1, 2] = 0 # g=0 when using musp directly
188
+ else:
189
+ new_prop[1, 0] = mua
190
+ if musp is not None:
191
+ new_prop[1, 1] = musp
192
+ new_prop[1, 2] = 0
193
+ else:
194
+ # Node/element based properties
195
+ if musp is not None:
196
+ n_ref = segprop[1, 3] if segprop.shape[0] > 1 else 1.37
197
+ if hasattr(mua, "__len__"):
198
+ new_prop = np.column_stack(
199
+ [mua, musp, np.zeros_like(musp), np.full_like(musp, n_ref)]
200
+ )
201
+ else:
202
+ new_prop = np.array([[mua, musp, 0, n_ref]])
203
+ else:
204
+ # Keep existing musp, g, n from template
205
+ if hasattr(mua, "__len__"):
206
+ tile_prop = np.tile(segprop[1, 1:], (len(mua), 1))
207
+ new_prop = np.column_stack([mua, tile_prop])
208
+ else:
209
+ new_prop = np.array([[mua] + list(segprop[1, 1:])])
210
+
211
+ prop_out[wavelen] = new_prop
212
+
213
+ return prop_out if len(wavelengths) > 1 else prop_out[wavelengths[0]]
214
+
215
+
216
+ def getbulk(cfg: dict) -> Union[np.ndarray, dict]:
217
+ """
218
+ Get bulk/background optical properties.
219
+
220
+ Returns the optical properties of the outer-most layer that interfaces
221
+ with air (used for boundary condition calculation).
222
+
223
+ Parameters
224
+ ----------
225
+ cfg : dict
226
+ Configuration with 'prop', optionally 'bulk', 'seg', 'face'
227
+ elem and face are 1-based indices
228
+
229
+ Returns
230
+ -------
231
+ bkprop : ndarray or dict
232
+ [mua, mus, g, n] for the bulk medium
233
+ Dict keyed by wavelength if multi-wavelength
234
+
235
+ Notes
236
+ -----
237
+ Priority: cfg.bulk > cfg.prop at surface node > default [0, 0, 0, 1.37]
238
+ """
239
+ bkprop_default = np.array([0.0, 0.0, 0.0, 1.37])
240
+
241
+ # If explicit bulk properties provided, use those
242
+ if "bulk" in cfg:
243
+ bulk = cfg["bulk"]
244
+ bkprop = bkprop_default.copy()
245
+ if "mua" in bulk:
246
+ bkprop[0] = bulk["mua"]
247
+ if "dcoeff" in bulk:
248
+ # Convert diffusion coefficient to mus
249
+ bkprop[1] = 1.0 / (3 * bulk["dcoeff"])
250
+ bkprop[2] = 0
251
+ if "musp" in bulk:
252
+ bkprop[1] = bulk["musp"]
253
+ bkprop[2] = 0
254
+ if "g" in bulk:
255
+ bkprop[2] = bulk["g"]
256
+ if "n" in bulk:
257
+ bkprop[3] = bulk["n"]
258
+ return bkprop
259
+
260
+ if "prop" not in cfg or cfg["prop"] is None:
261
+ return bkprop_default
262
+
263
+ prop = cfg["prop"]
264
+
265
+ # Multi-wavelength handling
266
+ if isinstance(prop, dict):
267
+ bkprop = {}
268
+ for wv, p in prop.items():
269
+ bkprop[wv] = _extract_bulk_from_prop(p, cfg)
270
+ return bkprop
271
+
272
+ return _extract_bulk_from_prop(prop, cfg)
273
+
274
+
275
+ def _extract_bulk_from_prop(prop: np.ndarray, cfg: dict) -> np.ndarray:
276
+ """
277
+ Extract bulk property from property array.
278
+
279
+ Determines property format (label-based vs node/element-based) and
280
+ extracts the appropriate surface property.
281
+ """
282
+ bkprop_default = np.array([0.0, 0.0, 0.0, 1.37])
283
+
284
+ nn = cfg["node"].shape[0] if "node" in cfg else prop.shape[0]
285
+ ne = cfg["elem"].shape[0] if "elem" in cfg else prop.shape[0]
286
+
287
+ if prop.shape[0] < min(nn, ne):
288
+ # Label-based properties
289
+ if "seg" in cfg:
290
+ seg = cfg["seg"]
291
+ if "face" in cfg and len(seg) == nn:
292
+ # Node-based segmentation, get label at first surface node
293
+ # face is 1-based, convert for indexing
294
+ face_node_0 = cfg["face"][0, 0] - 1 # Convert to 0-based
295
+ label = seg[face_node_0]
296
+ elif "face" in cfg and len(seg) == ne:
297
+ # Element-based segmentation, find element containing face node
298
+ elem_0 = cfg["elem"][:, :4].astype(int) - 1 # Convert to 0-based
299
+ face_node_0 = cfg["face"][0, 0] - 1
300
+ eid = np.where(np.any(elem_0 == face_node_0, axis=1))[0]
301
+ label = seg[eid[0]] if len(eid) > 0 else 0
302
+ else:
303
+ label = seg[0]
304
+
305
+ # prop row 0 is background, row label+1 is the tissue
306
+ prop_idx = int(label)
307
+ if prop.shape[0] > prop_idx:
308
+ return prop[prop_idx, :]
309
+ else:
310
+ return prop[1, :] if prop.shape[0] > 1 else bkprop_default
311
+ else:
312
+ # No segmentation, use first tissue label
313
+ return prop[1, :] if prop.shape[0] > 1 else bkprop_default
314
+
315
+ elif prop.shape[0] == nn:
316
+ # Node-based properties
317
+ if "face" in cfg:
318
+ face_node_0 = cfg["face"][0, 0] - 1 # Convert to 0-based
319
+ return prop[face_node_0, :]
320
+ return prop[0, :]
321
+
322
+ elif prop.shape[0] == ne:
323
+ # Element-based properties
324
+ if "face" in cfg:
325
+ elem_0 = cfg["elem"][:, :4].astype(int) - 1
326
+ face_node_0 = cfg["face"][0, 0] - 1
327
+ eid = np.where(np.any(elem_0 == face_node_0, axis=1))[0]
328
+ if len(eid) > 0:
329
+ return prop[eid[0], :]
330
+ return prop[0, :]
331
+
332
+ return bkprop_default
333
+
334
+
335
+ def musp2sasp(musp: np.ndarray, wavelength: np.ndarray) -> Tuple[float, float]:
336
+ """
337
+ Convert mus' at two wavelengths to scattering amplitude and power.
338
+
339
+ Uses the relation: musp = sa * (lambda/500nm)^(-sp)
340
+
341
+ Parameters
342
+ ----------
343
+ musp : ndarray
344
+ Reduced scattering coefficients at two wavelengths (1/mm)
345
+ wavelength : ndarray
346
+ Wavelengths in nm (length 2)
347
+
348
+ Returns
349
+ -------
350
+ sa : float
351
+ Scattering amplitude (musp at 500nm)
352
+ sp : float
353
+ Scattering power (wavelength exponent)
354
+ """
355
+ if len(musp) < 2 or len(wavelength) < 2:
356
+ raise ValueError("Need at least 2 wavelengths to fit scattering parameters")
357
+
358
+ lam = wavelength[:2] / 500.0
359
+
360
+ # sp = log(musp1/musp2) / log(lam2/lam1)
361
+ sp = np.log(musp[0] / musp[1]) / np.log(lam[1] / lam[0])
362
+
363
+ # sa = average of musp / lam^(-sp) at both wavelengths
364
+ sa = 0.5 * (musp[0] / lam[0] ** (-sp) + musp[1] / lam[1] ** (-sp))
365
+
366
+ return sa, sp
367
+
368
+
369
+ def setmesh(
370
+ cfg0: dict,
371
+ node: np.ndarray,
372
+ elem: np.ndarray,
373
+ prop: np.ndarray = None,
374
+ propidx: np.ndarray = None,
375
+ ) -> dict:
376
+ """
377
+ Associate a new mesh with simulation structure.
378
+
379
+ Clears derived quantities that need recomputation with the new mesh.
380
+
381
+ Parameters
382
+ ----------
383
+ cfg0 : dict
384
+ Original configuration
385
+ node : ndarray
386
+ New node coordinates (Nn x 3)
387
+ elem : ndarray
388
+ New element connectivity (Ne x 4+), 1-based indices
389
+ prop : ndarray, optional
390
+ New optical properties
391
+ propidx : ndarray, optional
392
+ Segmentation labels
393
+
394
+ Returns
395
+ -------
396
+ cfg : dict
397
+ Updated configuration with new mesh
398
+ """
399
+ from .utility import meshprep
400
+
401
+ # Fields that depend on mesh geometry and need recomputation
402
+ clear_fields = [
403
+ "face",
404
+ "area",
405
+ "evol",
406
+ "deldotdel",
407
+ "isreoriented",
408
+ "nvol",
409
+ "cols",
410
+ "rows",
411
+ "idxsum",
412
+ "idxcount",
413
+ "musp0",
414
+ "reff",
415
+ ]
416
+
417
+ cfg = {k: v for k, v in cfg0.items() if k not in clear_fields}
418
+
419
+ cfg["node"] = node
420
+ cfg["elem"] = elem[:, :4] if elem.shape[1] > 4 else elem
421
+
422
+ if prop is not None:
423
+ cfg["prop"] = prop
424
+
425
+ if propidx is not None:
426
+ cfg["seg"] = propidx
427
+ elif elem.shape[1] > 4:
428
+ cfg["seg"] = elem[:, 4].astype(int)
429
+
430
+ # Prepare mesh (computes face, area, evol, deldotdel, etc.)
431
+ cfg, _ = meshprep(cfg)
432
+
433
+ return cfg
434
+
435
+
436
+ # ============== Chromophore Data ==============
437
+
438
+
439
+ def _get_chromophore_data() -> dict:
440
+ """
441
+ Get built-in chromophore extinction coefficient tables.
442
+
443
+ Returns dict with keys: 'hbo', 'hbr', 'water', 'lipids', 'aa3'
444
+ Each value is Nx2 array: [wavelength_nm, extinction_coeff]
445
+
446
+ Units:
447
+ - HbO2, Hb: 1/(mm*uM) - multiply by concentration in uM to get 1/mm
448
+ - Water, lipids: 1/mm - multiply by volume fraction
449
+ """
450
+ chrome = {}
451
+
452
+ # Hemoglobin data (HbO2 and Hb) from Scott Prahl / OMLC
453
+ # Original units: cm-1/M, converted to 1/(mm*uM) via 2.303e-7
454
+ # Wavelength (nm), HbO2 (cm-1/M), Hb (cm-1/M)
455
+ hb_raw = np.array(
456
+ [
457
+ [250, 106112, 112736],
458
+ [260, 116376, 116296],
459
+ [270, 136068, 122880],
460
+ [280, 131936, 118872],
461
+ [290, 104752, 98364],
462
+ [300, 65972, 64440],
463
+ [310, 63352, 59156],
464
+ [320, 78752, 74508],
465
+ [330, 97512, 90856],
466
+ [340, 107884, 108472],
467
+ [350, 106576, 122092],
468
+ [360, 94744, 134940],
469
+ [370, 88176, 139968],
470
+ [380, 109564, 145232],
471
+ [390, 167748, 167780],
472
+ [400, 266232, 223296],
473
+ [410, 466840, 303956],
474
+ [420, 480360, 407560],
475
+ [430, 246072, 528600],
476
+ [440, 102580, 413280],
477
+ [450, 62816, 103292],
478
+ [460, 44480, 23388.8],
479
+ [470, 33209.2, 16156.4],
480
+ [480, 26629.2, 14550],
481
+ [490, 23684.4, 16684],
482
+ [500, 20932.8, 20862],
483
+ [510, 20035.2, 25773.6],
484
+ [520, 24202.4, 31589.6],
485
+ [530, 39956.8, 39036.4],
486
+ [540, 53236, 46592],
487
+ [550, 43016, 53412],
488
+ [560, 32613.2, 53788],
489
+ [570, 44496, 45072],
490
+ [580, 50104, 37020],
491
+ [590, 14400.8, 28324.4],
492
+ [600, 3200, 14677.2],
493
+ [610, 1506, 9443.6],
494
+ [620, 942, 6509.6],
495
+ [630, 610, 5148.8],
496
+ [640, 442, 4345.2],
497
+ [650, 368, 3750.12],
498
+ [660, 319.6, 3226.56],
499
+ [670, 294, 2795.12],
500
+ [680, 277.6, 2407.92],
501
+ [690, 276, 2334.68],
502
+ [700, 290, 1794.28],
503
+ [710, 314, 1540.48],
504
+ [720, 348, 1325.88],
505
+ [730, 390, 1102.2],
506
+ [740, 446, 1115.88],
507
+ [750, 518, 1405.24],
508
+ [760, 586, 1548.52],
509
+ [770, 650, 1311.88],
510
+ [780, 710, 1075.44],
511
+ [790, 756, 890.8],
512
+ [800, 816, 761.72],
513
+ [810, 864, 717.08],
514
+ [820, 916, 693.76],
515
+ [830, 974, 693.04],
516
+ [840, 1022, 692.36],
517
+ [850, 1058, 691.32],
518
+ [860, 1092, 694.32],
519
+ [870, 1128, 705.84],
520
+ [880, 1154, 726.44],
521
+ [890, 1178, 743.6],
522
+ [900, 1198, 761.84],
523
+ [910, 1214, 774.56],
524
+ [920, 1224, 777.36],
525
+ [930, 1222, 763.84],
526
+ [940, 1214, 693.44],
527
+ [950, 1204, 602.24],
528
+ [960, 1186, 525.56],
529
+ [970, 1162, 429.32],
530
+ [980, 1128, 359.656],
531
+ [990, 1080, 283.22],
532
+ [1000, 1024, 206.784],
533
+ ]
534
+ )
535
+
536
+ # Convert units: 2.303 (ln to log10) * 1e-4 (cm to mm) * 1e-3 (M to mM) * 1e-3 (mM to uM)
537
+ # = 2.303e-7 converts from 1/(cm*M) to 1/(mm*uM)
538
+ conversion = 2.303e-7
539
+ chrome["hbo"] = np.column_stack([hb_raw[:, 0], hb_raw[:, 1] * conversion])
540
+ chrome["hbr"] = np.column_stack([hb_raw[:, 0], hb_raw[:, 2] * conversion])
541
+
542
+ # Water absorption coefficient (1/mm) - multiply by water fraction
543
+ # Data from Hale & Querry 1973, simplified for NIR range
544
+ water_wv = np.array([400, 500, 600, 650, 700, 750, 800, 850, 900, 950, 1000])
545
+ water_mua = (
546
+ np.array(
547
+ [
548
+ 0.00058,
549
+ 0.00025,
550
+ 0.0023,
551
+ 0.0032,
552
+ 0.006,
553
+ 0.026,
554
+ 0.02,
555
+ 0.043,
556
+ 0.068,
557
+ 0.39,
558
+ 0.36,
559
+ ]
560
+ )
561
+ * 0.1
562
+ ) # Convert cm-1 to mm-1
563
+ chrome["water"] = np.column_stack([water_wv, water_mua])
564
+
565
+ # Lipids absorption (1/mm) - multiply by lipid fraction
566
+ # Simplified approximation for NIR range
567
+ lipid_wv = np.arange(650, 1000, 10)
568
+ lipid_mua = 0.0005 * np.ones_like(lipid_wv, dtype=float)
569
+ chrome["lipids"] = np.column_stack([lipid_wv, lipid_mua])
570
+
571
+ # Cytochrome c oxidase (aa3) - difference spectrum
572
+ # Gaussian-like peak around 830nm (oxidized-reduced difference)
573
+ aa3_wv = np.arange(650, 950, 5)
574
+ aa3_mua = 0.5 * np.exp(-((aa3_wv - 830) ** 2) / (2 * 50**2)) + 0.4
575
+ chrome["aa3"] = np.column_stack([aa3_wv, aa3_mua])
576
+
577
+ return chrome
578
+
579
+
580
+ def get_chromophore_table(name: str) -> np.ndarray:
581
+ """
582
+ Get full chromophore lookup table.
583
+
584
+ Parameters
585
+ ----------
586
+ name : str
587
+ Chromophore name ('hbo', 'hbr', 'water', 'lipids', 'aa3')
588
+
589
+ Returns
590
+ -------
591
+ table : ndarray
592
+ Nx2 array of [wavelength_nm, extinction_coefficient]
593
+ """
594
+ chrome = _get_chromophore_data()
595
+
596
+ name = name.lower()
597
+ if name not in chrome:
598
+ raise ValueError(
599
+ f"Unknown chromophore: {name}. " f"Available: {list(chrome.keys())}"
600
+ )
601
+
602
+ return chrome[name]