MomentRT 0.1.0__tar.gz
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.
- momentrt-0.1.0/.zenodo.json +21 -0
- momentrt-0.1.0/CITATION.cff +19 -0
- momentrt-0.1.0/LICENSE +21 -0
- momentrt-0.1.0/MANIFEST.in +4 -0
- momentrt-0.1.0/MomentRT/__init__.py +39 -0
- momentrt-0.1.0/MomentRT/_version.py +3 -0
- momentrt-0.1.0/MomentRT/boundary.py +45 -0
- momentrt-0.1.0/MomentRT/diffusion.py +327 -0
- momentrt-0.1.0/MomentRT/fem.py +122 -0
- momentrt-0.1.0/MomentRT/fields.py +139 -0
- momentrt-0.1.0/MomentRT/geometry.py +100 -0
- momentrt-0.1.0/MomentRT/material.py +34 -0
- momentrt-0.1.0/MomentRT/medium.py +256 -0
- momentrt-0.1.0/MomentRT/mesh.py +59 -0
- momentrt-0.1.0/MomentRT/plotting.py +473 -0
- momentrt-0.1.0/MomentRT/results.py +22 -0
- momentrt-0.1.0/MomentRT/sections.py +118 -0
- momentrt-0.1.0/MomentRT/simulation.py +127 -0
- momentrt-0.1.0/MomentRT/source.py +72 -0
- momentrt-0.1.0/MomentRT/units.py +25 -0
- momentrt-0.1.0/MomentRT.egg-info/PKG-INFO +314 -0
- momentrt-0.1.0/MomentRT.egg-info/SOURCES.txt +30 -0
- momentrt-0.1.0/MomentRT.egg-info/dependency_links.txt +1 -0
- momentrt-0.1.0/MomentRT.egg-info/requires.txt +27 -0
- momentrt-0.1.0/MomentRT.egg-info/top_level.txt +1 -0
- momentrt-0.1.0/PKG-INFO +314 -0
- momentrt-0.1.0/README.rst +266 -0
- momentrt-0.1.0/conda.recipe/meta.yaml +55 -0
- momentrt-0.1.0/pyproject.toml +87 -0
- momentrt-0.1.0/setup.cfg +4 -0
- momentrt-0.1.0/tests/test_diffusion.py +191 -0
- momentrt-0.1.0/tests/test_sample.py +318 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "MomentRT: deterministic light transport using angular-moment methods",
|
|
3
|
+
"description": "MomentRT is a Python package for deterministic light transport using angular-moment methods. It includes finite-element diffusion through structured media and tools for visualizing optical fields.",
|
|
4
|
+
"creators": [
|
|
5
|
+
{
|
|
6
|
+
"name": "Poinsinet de Sivry-Houle, Martin"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"license": "mit",
|
|
10
|
+
"upload_type": "software",
|
|
11
|
+
"access_right": "open",
|
|
12
|
+
"version": "0.1.0",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"radiative transfer",
|
|
15
|
+
"light transport",
|
|
16
|
+
"diffusion",
|
|
17
|
+
"finite element method",
|
|
18
|
+
"tissue optics"
|
|
19
|
+
],
|
|
20
|
+
"publication_date": "2026-10-28"
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use MomentRT in research, please cite the software version used."
|
|
3
|
+
title: "MomentRT"
|
|
4
|
+
type: software
|
|
5
|
+
version: "0.1.0"
|
|
6
|
+
authors:
|
|
7
|
+
- family-names: "Poinsinet de Sivry-Houle"
|
|
8
|
+
given-names: "Martin"
|
|
9
|
+
abstract: >
|
|
10
|
+
MomentRT is a Python package for deterministic light transport using
|
|
11
|
+
angular-moment methods. It includes finite-element diffusion through
|
|
12
|
+
structured media and tools for visualizing optical fields.
|
|
13
|
+
license: "MIT"
|
|
14
|
+
keywords:
|
|
15
|
+
- radiative transfer
|
|
16
|
+
- light transport
|
|
17
|
+
- diffusion
|
|
18
|
+
- finite element method
|
|
19
|
+
- tissue optics
|
momentrt-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Poinsinet de Sivry-Houle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Deterministic light transport using angular-moment methods."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .boundary import ReflectingBoundary, RobinBoundary
|
|
5
|
+
from .diffusion import Diffusion, InfiniteDiffusion
|
|
6
|
+
from .fields import ScalarField, VectorField
|
|
7
|
+
from .geometry import Box, Layer, Sphere
|
|
8
|
+
from .material import Material
|
|
9
|
+
from .medium import HomogeneousMedium, StructuredMedium
|
|
10
|
+
from .mesh import CartesianMesh
|
|
11
|
+
from .results import SimulationResult
|
|
12
|
+
from .sections import SampleSection
|
|
13
|
+
from .simulation import Simulation
|
|
14
|
+
from .source import PointSource, SurfaceSource, UniformSurfaceSource
|
|
15
|
+
from .units import ureg
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Diffusion",
|
|
19
|
+
"RobinBoundary",
|
|
20
|
+
"ReflectingBoundary",
|
|
21
|
+
"SurfaceSource",
|
|
22
|
+
"UniformSurfaceSource",
|
|
23
|
+
"VectorField",
|
|
24
|
+
"Box",
|
|
25
|
+
"Layer",
|
|
26
|
+
"Sphere",
|
|
27
|
+
"StructuredMedium",
|
|
28
|
+
"CartesianMesh",
|
|
29
|
+
"HomogeneousMedium",
|
|
30
|
+
"InfiniteDiffusion",
|
|
31
|
+
"Material",
|
|
32
|
+
"PointSource",
|
|
33
|
+
"SampleSection",
|
|
34
|
+
"ScalarField",
|
|
35
|
+
"Simulation",
|
|
36
|
+
"SimulationResult",
|
|
37
|
+
"ureg",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Explicit partial-current boundary conditions for steady-state diffusion."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .units import ureg
|
|
6
|
+
|
|
7
|
+
FACES = ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RobinBoundary:
|
|
11
|
+
"""Diffuse internal reflection with an explicitly supplied effective reflectance.
|
|
12
|
+
|
|
13
|
+
This is a partial-current coefficient, not normal-incidence Fresnel R.
|
|
14
|
+
No refractive-index-to-reflectance approximation is selected implicitly.
|
|
15
|
+
With R=effective_reflectance, beta=(1-R)/(2(1+R)) and outward net
|
|
16
|
+
current J.n = beta Phi - 2 I_in/(1+R). I_in is incoming diffuse
|
|
17
|
+
partial current already coupled into the sample (W/m^2).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, effective_reflectance):
|
|
21
|
+
value = effective_reflectance
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
isinstance(value, (ureg.Quantity, bool))
|
|
25
|
+
or not np.isscalar(value)
|
|
26
|
+
or not np.isfinite(value)
|
|
27
|
+
or not 0 <= value < 1
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("effective_reflectance must be a plain number in [0, 1).")
|
|
30
|
+
|
|
31
|
+
self.effective_reflectance = value
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def leakage_coefficient(self):
|
|
35
|
+
return (1 - self.effective_reflectance) / (2 * (1 + self.effective_reflectance))
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def source_factor(self):
|
|
39
|
+
return 2 / (1 + self.effective_reflectance)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReflectingBoundary:
|
|
43
|
+
"""Zero normal current; illumination on this face is unsupported."""
|
|
44
|
+
|
|
45
|
+
leakage_coefficient = 0.0
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Analytical steady-state diffusion Green function in an infinite medium."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .fields import ScalarField
|
|
6
|
+
from .medium import HomogeneousMedium
|
|
7
|
+
from .source import PointSource
|
|
8
|
+
from .units import ureg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InfiniteDiffusion:
|
|
12
|
+
"""Evaluate Phi = P exp(-sqrt(mu_a/D) r) / (4 pi D r).
|
|
13
|
+
|
|
14
|
+
No boundary, Fresnel interface, or finite-source correction is applied.
|
|
15
|
+
The observation window does not truncate the physical medium.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def solve(self, *, medium, source, mesh):
|
|
19
|
+
if not isinstance(medium, HomogeneousMedium):
|
|
20
|
+
raise TypeError(
|
|
21
|
+
"InfiniteDiffusion supports only HomogeneousMedium; finite structured samples require a boundary-aware solver."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if not isinstance(source, PointSource):
|
|
25
|
+
raise TypeError(
|
|
26
|
+
"InfiniteDiffusion requires an embedded PointSource, not surface illumination."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
absorption = medium.material.absorption.to("1/m").magnitude
|
|
30
|
+
|
|
31
|
+
scattering = medium.material.reduced_scattering.to("1/m").magnitude
|
|
32
|
+
|
|
33
|
+
diffusion_length = 1 / (3 * (absorption + scattering))
|
|
34
|
+
|
|
35
|
+
displacement = mesh.points.to("m").magnitude - source.position.to("m").magnitude
|
|
36
|
+
|
|
37
|
+
distance = np.linalg.norm(displacement, axis=-1)
|
|
38
|
+
|
|
39
|
+
if np.any(distance == 0):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"The point source is singular; choose a grid that excludes its position."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
values = (
|
|
45
|
+
source.optical_power.to("W").magnitude
|
|
46
|
+
* np.exp(-np.sqrt(absorption / diffusion_length) * distance)
|
|
47
|
+
/ (4 * np.pi * diffusion_length * distance)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return ScalarField(
|
|
51
|
+
mesh=mesh,
|
|
52
|
+
values=values * ureg.watt / ureg.meter**2,
|
|
53
|
+
name="Fluence rate",
|
|
54
|
+
metadata={
|
|
55
|
+
"transport": "infinite-medium steady-state P1 diffusion",
|
|
56
|
+
"wavelength": source.wavelength,
|
|
57
|
+
"source_position": source.position,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Diffusion:
|
|
63
|
+
"""Steady-state P1 diffusion with linear tetrahedral finite elements.
|
|
64
|
+
|
|
65
|
+
All six boundary faces must be configured explicitly. Material properties
|
|
66
|
+
are constant per element, sampled at its centroid. Interfaces not aligned
|
|
67
|
+
to element faces are approximated and require mesh refinement. Continuous
|
|
68
|
+
fluence assumes equal refractive indices across all internal regions;
|
|
69
|
+
internal index jumps are rejected rather than silently ignored.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, boundaries):
|
|
73
|
+
from .boundary import FACES, ReflectingBoundary, RobinBoundary
|
|
74
|
+
|
|
75
|
+
if set(boundaries) != set(FACES):
|
|
76
|
+
raise ValueError(f"Configure exactly these boundary faces: {FACES}.")
|
|
77
|
+
|
|
78
|
+
if any(
|
|
79
|
+
not isinstance(condition, (RobinBoundary, ReflectingBoundary))
|
|
80
|
+
for condition in boundaries.values()
|
|
81
|
+
):
|
|
82
|
+
raise TypeError("Each face requires RobinBoundary or ReflectingBoundary.")
|
|
83
|
+
|
|
84
|
+
self.boundaries = dict(boundaries)
|
|
85
|
+
|
|
86
|
+
def solve(self, *, medium, source, mesh):
|
|
87
|
+
from scipy.sparse import coo_matrix
|
|
88
|
+
from scipy.sparse.linalg import spsolve
|
|
89
|
+
|
|
90
|
+
from .boundary import RobinBoundary
|
|
91
|
+
from .fem import (
|
|
92
|
+
boundary_triangles,
|
|
93
|
+
element_geometry,
|
|
94
|
+
source_load,
|
|
95
|
+
tetrahedralize,
|
|
96
|
+
triangle_areas,
|
|
97
|
+
)
|
|
98
|
+
from .fields import VectorField
|
|
99
|
+
from .medium import StructuredMedium
|
|
100
|
+
from .results import SimulationResult
|
|
101
|
+
from .source import SurfaceSource, UniformSurfaceSource
|
|
102
|
+
|
|
103
|
+
if not isinstance(medium, StructuredMedium):
|
|
104
|
+
raise TypeError("Diffusion requires a finite StructuredMedium.")
|
|
105
|
+
|
|
106
|
+
if not isinstance(source, UniformSurfaceSource):
|
|
107
|
+
raise TypeError("Diffusion requires SurfaceSource or UniformSurfaceSource.")
|
|
108
|
+
|
|
109
|
+
if medium.background is None:
|
|
110
|
+
raise ValueError("Configure the sample background before solving.")
|
|
111
|
+
|
|
112
|
+
materials = [
|
|
113
|
+
medium.background,
|
|
114
|
+
*(shape.material for shape in medium.structures),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if any(
|
|
118
|
+
material.refractive_index != medium.background.refractive_index
|
|
119
|
+
for material in materials
|
|
120
|
+
):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"Internal refractive-index jumps are not supported by this continuous-fluence FEM solver."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
lower = medium.origin.to("m").magnitude
|
|
126
|
+
|
|
127
|
+
upper = (medium.origin + medium.size).to("m").magnitude
|
|
128
|
+
|
|
129
|
+
mesh_lower = mesh.origin.to("m").magnitude
|
|
130
|
+
|
|
131
|
+
mesh_upper = (
|
|
132
|
+
mesh_lower + (np.asarray(mesh.shape) - 1) * mesh.spacing.to("m").magnitude
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not np.allclose(
|
|
136
|
+
mesh_lower, lower, rtol=1e-12, atol=1e-15
|
|
137
|
+
) or not np.allclose(mesh_upper, upper, rtol=1e-12, atol=1e-15):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"The mesh must span exactly the finite sample; use CartesianMesh.from_sample()."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
condition = self.boundaries[source.face]
|
|
143
|
+
|
|
144
|
+
if not isinstance(condition, RobinBoundary):
|
|
145
|
+
raise ValueError("The illuminated face requires a RobinBoundary.")
|
|
146
|
+
|
|
147
|
+
if isinstance(source, SurfaceSource):
|
|
148
|
+
axis = "xyz".index(source.face[0])
|
|
149
|
+
|
|
150
|
+
position = source.position.to("m").magnitude
|
|
151
|
+
|
|
152
|
+
face_coordinate = (
|
|
153
|
+
lower[axis] if source.face.endswith("min") else upper[axis]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not np.isclose(position[axis], face_coordinate, rtol=1e-12, atol=1e-15):
|
|
157
|
+
raise ValueError("Source position must lie on its configured face.")
|
|
158
|
+
|
|
159
|
+
in_plane = [i for i in range(3) if i != axis]
|
|
160
|
+
|
|
161
|
+
radius = source.radius.to("m").magnitude
|
|
162
|
+
|
|
163
|
+
if np.any(position[in_plane] - radius < lower[in_plane]) or np.any(
|
|
164
|
+
position[in_plane] + radius > upper[in_plane]
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("The entire illumination disk must fit on its face.")
|
|
167
|
+
|
|
168
|
+
points = mesh.points.to("m").magnitude.reshape(-1, 3)
|
|
169
|
+
|
|
170
|
+
tetrahedra = tetrahedralize(mesh=mesh)
|
|
171
|
+
|
|
172
|
+
volumes, gradients = element_geometry(points=points, tetrahedra=tetrahedra)
|
|
173
|
+
|
|
174
|
+
centers = points[tetrahedra].mean(axis=1)
|
|
175
|
+
|
|
176
|
+
labels = medium.material_labels(points=centers * ureg.meter)
|
|
177
|
+
|
|
178
|
+
absorption = np.array(
|
|
179
|
+
[material.absorption.to("1/m").magnitude for material in materials]
|
|
180
|
+
)[labels]
|
|
181
|
+
|
|
182
|
+
scattering = np.array(
|
|
183
|
+
[material.reduced_scattering.to("1/m").magnitude for material in materials]
|
|
184
|
+
)[labels]
|
|
185
|
+
|
|
186
|
+
coefficient = 1 / (3 * (absorption + scattering))
|
|
187
|
+
|
|
188
|
+
stiffness = (
|
|
189
|
+
volumes[:, None, None]
|
|
190
|
+
* coefficient[:, None, None]
|
|
191
|
+
* np.einsum("tik,tjk->tij", gradients, gradients)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
mass = (
|
|
195
|
+
volumes[:, None, None]
|
|
196
|
+
* absorption[:, None, None]
|
|
197
|
+
* (np.ones((4, 4)) + np.eye(4))
|
|
198
|
+
/ 20
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
rows = np.broadcast_to(tetrahedra[:, :, None], stiffness.shape).ravel()
|
|
202
|
+
|
|
203
|
+
columns = np.broadcast_to(tetrahedra[:, None, :], stiffness.shape).ravel()
|
|
204
|
+
|
|
205
|
+
row_blocks = [rows]
|
|
206
|
+
|
|
207
|
+
column_blocks = [columns]
|
|
208
|
+
|
|
209
|
+
data_blocks = [(stiffness + mass).ravel()]
|
|
210
|
+
|
|
211
|
+
faces = boundary_triangles(tetrahedra=tetrahedra, shape=mesh.shape)
|
|
212
|
+
|
|
213
|
+
nodal_leakage = {}
|
|
214
|
+
|
|
215
|
+
for face, triangles in faces.items():
|
|
216
|
+
areas = triangle_areas(points=points, triangles=triangles)
|
|
217
|
+
|
|
218
|
+
beta = self.boundaries[face].leakage_coefficient
|
|
219
|
+
|
|
220
|
+
boundary_mass = (
|
|
221
|
+
beta * areas[:, None, None] * (np.ones((3, 3)) + np.eye(3)) / 12
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
row_blocks.append(
|
|
225
|
+
np.broadcast_to(triangles[:, :, None], boundary_mass.shape).ravel()
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
column_blocks.append(
|
|
229
|
+
np.broadcast_to(triangles[:, None, :], boundary_mass.shape).ravel()
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
data_blocks.append(boundary_mass.ravel())
|
|
233
|
+
|
|
234
|
+
weights = np.zeros(len(points))
|
|
235
|
+
|
|
236
|
+
np.add.at(weights, triangles.ravel(), np.repeat(beta * areas / 3, 3))
|
|
237
|
+
|
|
238
|
+
nodal_leakage[face] = weights
|
|
239
|
+
|
|
240
|
+
matrix = coo_matrix(
|
|
241
|
+
(
|
|
242
|
+
np.concatenate(data_blocks),
|
|
243
|
+
(np.concatenate(row_blocks), np.concatenate(column_blocks)),
|
|
244
|
+
),
|
|
245
|
+
shape=(len(points), len(points)),
|
|
246
|
+
).tocsr()
|
|
247
|
+
|
|
248
|
+
local_load, sampled_area = source_load(
|
|
249
|
+
points=points, triangles=faces[source.face], source=source
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
load = np.zeros(len(points))
|
|
253
|
+
|
|
254
|
+
np.add.at(
|
|
255
|
+
load,
|
|
256
|
+
faces[source.face].ravel(),
|
|
257
|
+
local_load.ravel() * condition.source_factor,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
values = spsolve(matrix, load)
|
|
261
|
+
|
|
262
|
+
if not np.all(np.isfinite(values)):
|
|
263
|
+
raise RuntimeError("The diffusion solve returned nonfinite fluence.")
|
|
264
|
+
|
|
265
|
+
relative_residual = np.linalg.norm(matrix @ values - load) / np.linalg.norm(
|
|
266
|
+
load
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if relative_residual > 1e-8:
|
|
270
|
+
raise RuntimeError(
|
|
271
|
+
f"Diffusion linear residual is too large: {relative_residual:g}."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
absorbed = np.sum(absorption * volumes * values[tetrahedra].mean(axis=1))
|
|
275
|
+
|
|
276
|
+
power = source.optical_power.to("W").magnitude
|
|
277
|
+
|
|
278
|
+
escaping = {
|
|
279
|
+
face: float(weights @ values) for face, weights in nodal_leakage.items()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Remove the incident-field contribution to the gross Robin leakage.
|
|
283
|
+
# This distinguishes outgoing transmitted partial current from net current.
|
|
284
|
+
escaping[source.face] -= 2 * condition.leakage_coefficient * power
|
|
285
|
+
|
|
286
|
+
flux_values = -coefficient[:, None] * np.einsum(
|
|
287
|
+
"ti,tij->tj", values[tetrahedra], gradients
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
fluence = ScalarField(
|
|
291
|
+
mesh=mesh,
|
|
292
|
+
values=values.reshape(mesh.shape) * ureg.watt / ureg.meter**2,
|
|
293
|
+
name="Fluence rate",
|
|
294
|
+
metadata={
|
|
295
|
+
"transport": "steady-state tetrahedral P1 FEM diffusion",
|
|
296
|
+
"wavelength": source.wavelength,
|
|
297
|
+
"coefficient_sampling": "element centroid",
|
|
298
|
+
"source_face": source.face,
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
flux = VectorField(
|
|
303
|
+
positions=centers * ureg.meter,
|
|
304
|
+
values=flux_values * ureg.watt / ureg.meter**2,
|
|
305
|
+
name="Diffusive flux",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return SimulationResult(
|
|
309
|
+
fluence=fluence,
|
|
310
|
+
flux=flux,
|
|
311
|
+
energy_balance={
|
|
312
|
+
"incoming_power": source.optical_power,
|
|
313
|
+
"absorbed_power": absorbed * ureg.watt,
|
|
314
|
+
"escaping_power": sum(escaping.values()) * ureg.watt,
|
|
315
|
+
"residual_power": (power - absorbed - sum(escaping.values()))
|
|
316
|
+
* ureg.watt,
|
|
317
|
+
},
|
|
318
|
+
outgoing_power_by_face={
|
|
319
|
+
face: value * ureg.watt for face, value in escaping.items()
|
|
320
|
+
},
|
|
321
|
+
diagnostics={
|
|
322
|
+
"linear_relative_residual": relative_residual,
|
|
323
|
+
"source_quadrature_area": sampled_area * ureg.meter**2,
|
|
324
|
+
"tetrahedron_count": len(tetrahedra),
|
|
325
|
+
"minimum_fluence": values.min() * ureg.watt / ureg.meter**2,
|
|
326
|
+
},
|
|
327
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Linear tetrahedral FEM kernels on a conforming Cartesian subdivision."""
|
|
2
|
+
|
|
3
|
+
from itertools import permutations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tetrahedralize(*, mesh):
|
|
9
|
+
"""Split each voxel into six tetrahedra sharing its lower/upper diagonal."""
|
|
10
|
+
shape = np.asarray(mesh.shape)
|
|
11
|
+
|
|
12
|
+
cells = np.stack(
|
|
13
|
+
np.meshgrid(*(np.arange(n - 1) for n in shape), indexing="ij"), axis=-1
|
|
14
|
+
).reshape(-1, 3)
|
|
15
|
+
|
|
16
|
+
tetrahedra = []
|
|
17
|
+
|
|
18
|
+
for order in permutations(range(3)):
|
|
19
|
+
offsets = np.zeros((4, 3), dtype=int)
|
|
20
|
+
|
|
21
|
+
for step, axis in enumerate(order, start=1):
|
|
22
|
+
offsets[step] = offsets[step - 1]
|
|
23
|
+
|
|
24
|
+
offsets[step, axis] += 1
|
|
25
|
+
|
|
26
|
+
indices = cells[:, None, :] + offsets
|
|
27
|
+
|
|
28
|
+
tetrahedra.append(np.ravel_multi_index(indices.transpose(2, 0, 1), shape))
|
|
29
|
+
|
|
30
|
+
return np.concatenate(tetrahedra)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def element_geometry(*, points, tetrahedra):
|
|
34
|
+
vertices = points[tetrahedra]
|
|
35
|
+
|
|
36
|
+
edges = vertices[:, 1:] - vertices[:, :1]
|
|
37
|
+
|
|
38
|
+
volumes = np.abs(np.linalg.det(edges)) / 6
|
|
39
|
+
|
|
40
|
+
gradients = np.empty((len(tetrahedra), 4, 3))
|
|
41
|
+
|
|
42
|
+
gradients[:, 1:] = np.linalg.inv(edges).transpose(0, 2, 1)
|
|
43
|
+
|
|
44
|
+
gradients[:, 0] = -gradients[:, 1:].sum(axis=1)
|
|
45
|
+
|
|
46
|
+
return volumes, gradients
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def boundary_triangles(*, tetrahedra, shape):
|
|
50
|
+
candidates = np.concatenate(
|
|
51
|
+
[tetrahedra[:, face] for face in ([0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3])]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
unique, counts = np.unique(np.sort(candidates, axis=1), axis=0, return_counts=True)
|
|
55
|
+
|
|
56
|
+
triangles = unique[counts == 1]
|
|
57
|
+
|
|
58
|
+
indices = np.stack(np.unravel_index(triangles, shape), axis=-1)
|
|
59
|
+
|
|
60
|
+
faces = {}
|
|
61
|
+
|
|
62
|
+
for axis, name in enumerate("xyz"):
|
|
63
|
+
faces[f"{name}_min"] = triangles[np.all(indices[..., axis] == 0, axis=1)]
|
|
64
|
+
|
|
65
|
+
faces[f"{name}_max"] = triangles[
|
|
66
|
+
np.all(indices[..., axis] == shape[axis] - 1, axis=1)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
return faces
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def triangle_areas(*, points, triangles):
|
|
73
|
+
vertices = points[triangles]
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
np.linalg.norm(
|
|
77
|
+
np.cross(vertices[:, 1] - vertices[:, 0], vertices[:, 2] - vertices[:, 0]),
|
|
78
|
+
axis=1,
|
|
79
|
+
)
|
|
80
|
+
/ 2
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def source_load(*, points, triangles, source):
|
|
85
|
+
"""Three-point triangle quadrature for the disk footprint and nodal load."""
|
|
86
|
+
from .source import SurfaceSource
|
|
87
|
+
|
|
88
|
+
barycentric = np.array(
|
|
89
|
+
[[2 / 3, 1 / 6, 1 / 6], [1 / 6, 2 / 3, 1 / 6], [1 / 6, 1 / 6, 2 / 3]]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
quadrature = np.einsum("qa,tad->tqd", barycentric, points[triangles])
|
|
93
|
+
|
|
94
|
+
areas = triangle_areas(points=points, triangles=triangles)
|
|
95
|
+
|
|
96
|
+
selected = np.ones(quadrature.shape[:2], dtype=bool)
|
|
97
|
+
|
|
98
|
+
if isinstance(source, SurfaceSource):
|
|
99
|
+
axis = "xyz".index(source.face[0])
|
|
100
|
+
|
|
101
|
+
in_plane = [i for i in range(3) if i != axis]
|
|
102
|
+
|
|
103
|
+
displacement = (
|
|
104
|
+
quadrature[..., in_plane] - source.position.to("m").magnitude[in_plane]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
selected = (
|
|
108
|
+
np.linalg.norm(displacement, axis=-1) <= source.radius.to("m").magnitude
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
sampled_area = np.sum(areas[:, None] * selected / 3)
|
|
112
|
+
|
|
113
|
+
if sampled_area == 0:
|
|
114
|
+
raise ValueError("The mesh does not resolve the source footprint; refine it.")
|
|
115
|
+
|
|
116
|
+
irradiance = source.optical_power.to("W").magnitude / sampled_area
|
|
117
|
+
|
|
118
|
+
local_load = (
|
|
119
|
+
irradiance * areas[:, None] * np.einsum("tq,qa->ta", selected / 3, barycentric)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return local_load, sampled_area
|