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.
@@ -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,4 @@
1
+ include LICENSE
2
+ include CITATION.cff
3
+ include .zenodo.json
4
+ include conda.recipe/meta.yaml
@@ -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,3 @@
1
+ """Package version shared with release metadata."""
2
+
3
+ __version__ = version = "0.1.0"
@@ -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