MomentRT 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.
- MomentRT/__init__.py +39 -0
- MomentRT/_version.py +3 -0
- MomentRT/boundary.py +45 -0
- MomentRT/diffusion.py +327 -0
- MomentRT/fem.py +122 -0
- MomentRT/fields.py +139 -0
- MomentRT/geometry.py +100 -0
- MomentRT/material.py +34 -0
- MomentRT/medium.py +256 -0
- MomentRT/mesh.py +59 -0
- MomentRT/plotting.py +473 -0
- MomentRT/results.py +22 -0
- MomentRT/sections.py +118 -0
- MomentRT/simulation.py +127 -0
- MomentRT/source.py +72 -0
- MomentRT/units.py +25 -0
- momentrt-0.1.0.dist-info/METADATA +314 -0
- momentrt-0.1.0.dist-info/RECORD +21 -0
- momentrt-0.1.0.dist-info/WHEEL +5 -0
- momentrt-0.1.0.dist-info/licenses/LICENSE +21 -0
- momentrt-0.1.0.dist-info/top_level.txt +1 -0
MomentRT/__init__.py
ADDED
|
@@ -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
|
+
]
|
MomentRT/_version.py
ADDED
MomentRT/boundary.py
ADDED
|
@@ -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
|
MomentRT/diffusion.py
ADDED
|
@@ -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
|
+
)
|
MomentRT/fem.py
ADDED
|
@@ -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
|
MomentRT/fields.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Unit-bearing nodal scalar fields with mesh-aware visualization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .units import ureg
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScalarField:
|
|
9
|
+
def __init__(self, *, mesh, values, name, metadata=None):
|
|
10
|
+
if not isinstance(values, ureg.Quantity):
|
|
11
|
+
raise TypeError("values must carry units.")
|
|
12
|
+
|
|
13
|
+
if values.shape != mesh.shape or not np.all(np.isfinite(values.magnitude)):
|
|
14
|
+
raise ValueError("Field values must be finite and match the mesh shape.")
|
|
15
|
+
|
|
16
|
+
self.mesh = mesh
|
|
17
|
+
|
|
18
|
+
self.values = values
|
|
19
|
+
|
|
20
|
+
self.name = name
|
|
21
|
+
|
|
22
|
+
self.metadata = dict(metadata or {})
|
|
23
|
+
|
|
24
|
+
def to_pyvista(self, *, length_unit="mm"):
|
|
25
|
+
grid = self.mesh.to_pyvista(length_unit=length_unit)
|
|
26
|
+
|
|
27
|
+
grid.point_data[self.name] = self.values.magnitude.ravel(order="F")
|
|
28
|
+
|
|
29
|
+
grid.field_data["value_units"] = [str(self.values.units)]
|
|
30
|
+
|
|
31
|
+
grid.field_data["coordinate_units"] = [length_unit]
|
|
32
|
+
|
|
33
|
+
grid.field_data["association"] = ["point"]
|
|
34
|
+
|
|
35
|
+
for key, value in self.metadata.items():
|
|
36
|
+
grid.field_data[key] = [str(value)]
|
|
37
|
+
|
|
38
|
+
return grid
|
|
39
|
+
|
|
40
|
+
def export(self, *, path, length_unit="mm"):
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
if Path(path).suffix != ".vti":
|
|
44
|
+
raise ValueError("Cartesian fields must be exported with a .vti extension.")
|
|
45
|
+
|
|
46
|
+
grid = self.to_pyvista(length_unit=length_unit)
|
|
47
|
+
|
|
48
|
+
grid.save(path)
|
|
49
|
+
|
|
50
|
+
def plot(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
view="slices",
|
|
54
|
+
scale="linear",
|
|
55
|
+
length_unit="mm",
|
|
56
|
+
show=True,
|
|
57
|
+
):
|
|
58
|
+
from .plotting import plot_field
|
|
59
|
+
|
|
60
|
+
return plot_field(
|
|
61
|
+
field=self,
|
|
62
|
+
view=view,
|
|
63
|
+
scale=scale,
|
|
64
|
+
length_unit=length_unit,
|
|
65
|
+
show=show,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class VectorField:
|
|
70
|
+
"""Element-centered vectors, without smoothing across material interfaces."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, positions, values, name):
|
|
73
|
+
from .units import validate_units
|
|
74
|
+
|
|
75
|
+
validate_units(value=positions, unit="m", name="positions")
|
|
76
|
+
|
|
77
|
+
if positions.ndim != 2 or positions.shape[1] != 3 or len(positions) == 0:
|
|
78
|
+
raise ValueError("positions must have shape (N, 3).")
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(values, ureg.Quantity)
|
|
82
|
+
or values.shape != positions.shape
|
|
83
|
+
or not np.all(np.isfinite(values.magnitude))
|
|
84
|
+
):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"values must be finite unit-bearing vectors matching positions."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self.positions = positions
|
|
90
|
+
|
|
91
|
+
self.values = values
|
|
92
|
+
|
|
93
|
+
self.name = name
|
|
94
|
+
|
|
95
|
+
def plot(self, *, max_vectors=500, length_unit="mm", show=True):
|
|
96
|
+
from .plotting import make_figure, require_plotly
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
isinstance(max_vectors, bool)
|
|
100
|
+
or not isinstance(max_vectors, (int, np.integer))
|
|
101
|
+
or max_vectors < 1
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("max_vectors must be a positive integer.")
|
|
104
|
+
|
|
105
|
+
go = require_plotly()
|
|
106
|
+
|
|
107
|
+
selection = np.linspace(
|
|
108
|
+
0, len(self.values) - 1, min(max_vectors, len(self.values)), dtype=int
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
points = self.positions.to(length_unit).magnitude[selection]
|
|
112
|
+
|
|
113
|
+
vectors = self.values.magnitude[selection]
|
|
114
|
+
|
|
115
|
+
figure = make_figure(length_unit=length_unit)
|
|
116
|
+
|
|
117
|
+
figure.add_trace(
|
|
118
|
+
go.Cone(
|
|
119
|
+
x=points[:, 0],
|
|
120
|
+
y=points[:, 1],
|
|
121
|
+
z=points[:, 2],
|
|
122
|
+
u=vectors[:, 0],
|
|
123
|
+
v=vectors[:, 1],
|
|
124
|
+
w=vectors[:, 2],
|
|
125
|
+
colorscale="Viridis",
|
|
126
|
+
sizemode="scaled",
|
|
127
|
+
sizeref=0.25,
|
|
128
|
+
colorbar={"title": {"text": f"{self.name} [{self.values.units:~}]"}},
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
figure.update_layout(
|
|
133
|
+
title=f"{self.name} (element centers; relative arrow lengths)"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if show:
|
|
137
|
+
figure.show()
|
|
138
|
+
|
|
139
|
+
return figure
|