adtlert 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.
adtlert/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Top-level package for differentiable time-lapse ERT on Torch."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from adtlert.fem import P1ElementData, build_p1_element_data, p1_shape_functions
6
+ from adtlert.forward import (
7
+ ERT2p5DApparentResistivityFunction,
8
+ ERTForward2p5D,
9
+ ERTForwardModeling,
10
+ ForwardResponse,
11
+ apparent_resistivity_autograd,
12
+ )
13
+ from adtlert.inversion import (
14
+ ERTInversion,
15
+ InversionConfig,
16
+ TimeLapseERTInversion,
17
+ WindowedTimeLapseERTInversion,
18
+ )
19
+ from adtlert.mesh import Mesh
20
+ from adtlert.survey import Survey
21
+
22
+ try:
23
+ __version__ = version("adtlert")
24
+ except PackageNotFoundError: # pragma: no cover - source tree without installation
25
+ __version__ = "0+unknown"
26
+
27
+ __all__ = [
28
+ "ERTForward2p5D",
29
+ "ERT2p5DApparentResistivityFunction",
30
+ "ERTForwardModeling",
31
+ "ERTInversion",
32
+ "ForwardResponse",
33
+ "InversionConfig",
34
+ "Mesh",
35
+ "P1ElementData",
36
+ "Survey",
37
+ "TimeLapseERTInversion",
38
+ "WindowedTimeLapseERTInversion",
39
+ "apparent_resistivity_autograd",
40
+ "build_p1_element_data",
41
+ "p1_shape_functions",
42
+ "__version__",
43
+ ]
@@ -0,0 +1,72 @@
1
+ """Finite-element basis and quadrature utilities."""
2
+
3
+ from adtlert.fem.assembly import (
4
+ COORouting,
5
+ assemble_global_bcoo,
6
+ assemble_helmholtz_operator,
7
+ assemble_local_mass,
8
+ assemble_local_mass_p2,
9
+ assemble_local_stiffness,
10
+ assemble_local_stiffness_p2,
11
+ build_coo_routing,
12
+ build_coo_routing_from_connectivity,
13
+ )
14
+ from adtlert.fem.boundary import (
15
+ BoundaryRouting,
16
+ assemble_boundary_bcoo,
17
+ assemble_local_boundary_mass,
18
+ assemble_local_boundary_mass_p2,
19
+ build_boundary_routing,
20
+ build_boundary_routing_from_connectivity,
21
+ robin_boundary_coefficients,
22
+ )
23
+ from adtlert.fem.p1 import (
24
+ P1ElementData,
25
+ build_p1_element_data,
26
+ p1_shape_functions,
27
+ reference_shape_gradients,
28
+ triangle_quadrature,
29
+ )
30
+ from adtlert.fem.p2 import P2ElementData, build_p2_element_data, p2_shape_functions, reference_shape_gradients_p2
31
+ from adtlert.fem.tet_p1 import (
32
+ TetrahedronP1Data,
33
+ TetrahedronP2Data,
34
+ build_tetrahedron_p1_data,
35
+ build_tetrahedron_p2_data,
36
+ p2_tetrahedron_shape_values,
37
+ p2_triangle_mass_template,
38
+ )
39
+
40
+ __all__ = [
41
+ "BoundaryRouting",
42
+ "COORouting",
43
+ "assemble_boundary_bcoo",
44
+ "P1ElementData",
45
+ "assemble_global_bcoo",
46
+ "assemble_helmholtz_operator",
47
+ "assemble_local_boundary_mass",
48
+ "assemble_local_boundary_mass_p2",
49
+ "assemble_local_mass",
50
+ "assemble_local_mass_p2",
51
+ "assemble_local_stiffness",
52
+ "assemble_local_stiffness_p2",
53
+ "build_boundary_routing",
54
+ "build_boundary_routing_from_connectivity",
55
+ "build_p1_element_data",
56
+ "build_p2_element_data",
57
+ "build_coo_routing",
58
+ "build_coo_routing_from_connectivity",
59
+ "p1_shape_functions",
60
+ "p2_shape_functions",
61
+ "reference_shape_gradients",
62
+ "reference_shape_gradients_p2",
63
+ "robin_boundary_coefficients",
64
+ "triangle_quadrature",
65
+ "P2ElementData",
66
+ "TetrahedronP1Data",
67
+ "TetrahedronP2Data",
68
+ "build_tetrahedron_p1_data",
69
+ "build_tetrahedron_p2_data",
70
+ "p2_tetrahedron_shape_values",
71
+ "p2_triangle_mass_template",
72
+ ]
@@ -0,0 +1,220 @@
1
+ """Tensorized assembly utilities for finite-element sparse operators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from adtlert.utils.torch_runtime import Array
8
+ from adtlert.utils.torch_runtime import torch_np
9
+ from adtlert.utils.torch_runtime import BCOO
10
+ import numpy as np
11
+
12
+ from adtlert.fem.p1 import P1ElementData
13
+ from adtlert.fem.p2 import P2ElementData
14
+ from adtlert.mesh import Mesh
15
+ from adtlert.utils.dtypes import FLOAT_DTYPE, INT_DTYPE, NP_FLOAT_DTYPE
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class COORouting:
20
+ """Reusable COO routing indices for sparse element assembly."""
21
+
22
+ indices: Array
23
+ shape: tuple[int, int]
24
+
25
+
26
+ def _expand_coefficient(coefficients: Array | float, element_data: P1ElementData) -> Array:
27
+ coefficient_array = torch_np.asarray(coefficients, dtype=FLOAT_DTYPE)
28
+ cell_count = element_data.cell_areas.shape[0]
29
+ quadrature_count = element_data.quadrature_weights.shape[0]
30
+
31
+ if coefficient_array.ndim == 0:
32
+ return torch_np.broadcast_to(coefficient_array, (cell_count, quadrature_count))
33
+ if coefficient_array.shape == (cell_count,):
34
+ return torch_np.broadcast_to(coefficient_array[:, None], (cell_count, quadrature_count))
35
+ if coefficient_array.shape == (cell_count, quadrature_count):
36
+ return coefficient_array
37
+
38
+ raise ValueError(
39
+ "coefficients must be scalar, shape (num_cells,), or shape (num_cells, num_quadrature)"
40
+ )
41
+
42
+
43
+ def _static_scalar_value(value: Array | float) -> float | None:
44
+ try:
45
+ value_array = np.asarray(value)
46
+ except Exception:
47
+ return None
48
+ if value_array.ndim != 0:
49
+ return None
50
+ return float(value_array)
51
+
52
+
53
+ def _expand_coefficient_generic(
54
+ coefficients: Array | float,
55
+ *,
56
+ cell_count: int,
57
+ quadrature_count: int,
58
+ ) -> Array:
59
+ coefficient_array = torch_np.asarray(coefficients, dtype=FLOAT_DTYPE)
60
+
61
+ if coefficient_array.ndim == 0:
62
+ return torch_np.broadcast_to(coefficient_array, (cell_count, quadrature_count))
63
+ if coefficient_array.shape == (cell_count,):
64
+ return torch_np.broadcast_to(coefficient_array[:, None], (cell_count, quadrature_count))
65
+ if coefficient_array.shape == (cell_count, quadrature_count):
66
+ return coefficient_array
67
+
68
+ raise ValueError(
69
+ "coefficients must be scalar, shape (num_cells,), or shape (num_cells, num_quadrature)"
70
+ )
71
+
72
+
73
+ def build_coo_routing(mesh: Mesh) -> COORouting:
74
+ """Precompute the COO routing pattern for cellwise assembly."""
75
+
76
+ cells = np.asarray(mesh.cells, dtype=np.int32)
77
+ local_dof = int(cells.shape[1])
78
+ row_indices = np.broadcast_to(cells[:, :, None], (mesh.cell_count, local_dof, local_dof)).reshape(-1)
79
+ col_indices = np.broadcast_to(cells[:, None, :], (mesh.cell_count, local_dof, local_dof)).reshape(-1)
80
+ indices = np.stack((row_indices, col_indices), axis=1).astype(np.int32)
81
+ return COORouting(indices=torch_np.asarray(indices, dtype=INT_DTYPE), shape=(mesh.node_count, mesh.node_count))
82
+
83
+
84
+ def build_coo_routing_from_connectivity(connectivity: Array, node_count: int) -> COORouting:
85
+ """Precompute COO routing for arbitrary local connectivity."""
86
+
87
+ connectivity_np = np.asarray(connectivity, dtype=np.int32)
88
+ local_dof = int(connectivity_np.shape[1])
89
+ row_indices = np.broadcast_to(
90
+ connectivity_np[:, :, None],
91
+ (connectivity_np.shape[0], local_dof, local_dof),
92
+ ).reshape(-1)
93
+ col_indices = np.broadcast_to(
94
+ connectivity_np[:, None, :],
95
+ (connectivity_np.shape[0], local_dof, local_dof),
96
+ ).reshape(-1)
97
+ indices = np.stack((row_indices, col_indices), axis=1).astype(np.int32)
98
+ return COORouting(indices=torch_np.asarray(indices, dtype=INT_DTYPE), shape=(node_count, node_count))
99
+
100
+
101
+ def assemble_local_stiffness(element_data: P1ElementData, conductivity: Array | float) -> Array:
102
+ """Assemble elementwise diffusion matrices with batched einsums."""
103
+
104
+ static_conductivity = _static_scalar_value(conductivity)
105
+ if static_conductivity is not None:
106
+ dtype = NP_FLOAT_DTYPE
107
+ gradients = np.asarray(element_data.gradients, dtype=dtype)
108
+ weights = (
109
+ 2.0
110
+ * np.asarray(element_data.cell_areas, dtype=dtype)[:, None]
111
+ * np.asarray(element_data.quadrature_weights, dtype=dtype)[None, :]
112
+ )
113
+ local = np.einsum("eqid,eqjd,eq->eij", gradients, gradients, static_conductivity * weights)
114
+ return torch_np.asarray(local, dtype=FLOAT_DTYPE)
115
+
116
+ conductivity_values = _expand_coefficient(conductivity, element_data)
117
+ weights = 2.0 * element_data.cell_areas[:, None] * element_data.quadrature_weights[None, :]
118
+ return torch_np.einsum(
119
+ "eqid,eqjd,eq->eij",
120
+ element_data.gradients,
121
+ element_data.gradients,
122
+ conductivity_values * weights,
123
+ )
124
+
125
+
126
+ def assemble_local_mass(
127
+ element_data: P1ElementData, coefficients: Array | float = 1.0
128
+ ) -> Array:
129
+ """Assemble elementwise mass matrices with optional scalar coefficients."""
130
+
131
+ static_coefficient = _static_scalar_value(coefficients)
132
+ if static_coefficient is not None:
133
+ dtype = NP_FLOAT_DTYPE
134
+ shape_values = np.asarray(element_data.shape_values, dtype=dtype)
135
+ weights = (
136
+ 2.0
137
+ * np.asarray(element_data.cell_areas, dtype=dtype)[:, None]
138
+ * np.asarray(element_data.quadrature_weights, dtype=dtype)[None, :]
139
+ )
140
+ local = np.einsum("qi,qj,eq->eij", shape_values, shape_values, static_coefficient * weights)
141
+ return torch_np.asarray(local, dtype=FLOAT_DTYPE)
142
+
143
+ coefficient_values = _expand_coefficient(coefficients, element_data)
144
+ weights = 2.0 * element_data.cell_areas[:, None] * element_data.quadrature_weights[None, :]
145
+ return torch_np.einsum(
146
+ "qi,qj,eq->eij",
147
+ element_data.shape_values,
148
+ element_data.shape_values,
149
+ coefficient_values * weights,
150
+ )
151
+
152
+
153
+ def assemble_local_stiffness_p2(
154
+ element_data: P2ElementData,
155
+ conductivity: Array | float,
156
+ ) -> Array:
157
+ """Assemble quadratic elementwise diffusion matrices."""
158
+
159
+ conductivity_values = _expand_coefficient_generic(
160
+ conductivity,
161
+ cell_count=element_data.cell_areas.shape[0],
162
+ quadrature_count=element_data.quadrature_weights.shape[0],
163
+ )
164
+ weights = 2.0 * element_data.cell_areas[:, None] * element_data.quadrature_weights[None, :]
165
+ return torch_np.einsum(
166
+ "eqid,eqjd,eq->eij",
167
+ element_data.gradients,
168
+ element_data.gradients,
169
+ conductivity_values * weights,
170
+ )
171
+
172
+
173
+ def assemble_local_mass_p2(
174
+ element_data: P2ElementData,
175
+ coefficients: Array | float = 1.0,
176
+ ) -> Array:
177
+ """Assemble quadratic elementwise mass matrices."""
178
+
179
+ coefficient_values = _expand_coefficient_generic(
180
+ coefficients,
181
+ cell_count=element_data.cell_areas.shape[0],
182
+ quadrature_count=element_data.quadrature_weights.shape[0],
183
+ )
184
+ weights = 2.0 * element_data.cell_areas[:, None] * element_data.quadrature_weights[None, :]
185
+ return torch_np.einsum(
186
+ "qi,qj,eq->eij",
187
+ element_data.shape_values,
188
+ element_data.shape_values,
189
+ coefficient_values * weights,
190
+ )
191
+
192
+
193
+ def assemble_global_bcoo(local_matrices: Array, routing: COORouting) -> BCOO:
194
+ """Reduce local element matrices into a global sparse operator."""
195
+
196
+ sparse_matrix = BCOO(
197
+ (local_matrices.reshape(-1), routing.indices),
198
+ shape=routing.shape,
199
+ unique_indices=False,
200
+ )
201
+ return sparse_matrix.sum_duplicates()
202
+
203
+
204
+ def assemble_helmholtz_operator(
205
+ element_data: P1ElementData,
206
+ routing: COORouting,
207
+ conductivity: Array | float,
208
+ wavenumber_sq: Array | float,
209
+ ) -> BCOO:
210
+ """Assemble a sparse Helmholtz-type operator K + k^2 M."""
211
+
212
+ conductivity_values = torch_np.asarray(conductivity, dtype=FLOAT_DTYPE)
213
+ local_operator = assemble_local_stiffness(
214
+ element_data=element_data,
215
+ conductivity=conductivity,
216
+ ) + assemble_local_mass(
217
+ element_data=element_data,
218
+ coefficients=conductivity_values * wavenumber_sq,
219
+ )
220
+ return assemble_global_bcoo(local_operator, routing)
@@ -0,0 +1,176 @@
1
+ """Boundary assembly utilities for 2.5D ERT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import numpy as np
7
+ from scipy.special import k0 as besselk0
8
+ from scipy.special import k1 as besselk1
9
+
10
+ from adtlert.utils.torch_runtime import Array
11
+ from adtlert.utils.torch_runtime import torch_np
12
+ from adtlert.utils.torch_runtime import BCOO
13
+
14
+ from adtlert.mesh import Mesh
15
+ from adtlert.utils.dtypes import FLOAT_DTYPE, INT_DTYPE, NP_FLOAT_DTYPE
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BoundaryRouting:
20
+ """Reusable COO routing indices for boundary-edge assembly."""
21
+
22
+ indices: Array
23
+ shape: tuple[int, int]
24
+
25
+
26
+ def build_boundary_routing(mesh: Mesh) -> BoundaryRouting:
27
+ """Precompute the COO routing pattern for boundary edge assembly."""
28
+
29
+ return build_boundary_routing_from_connectivity(mesh.boundary_edges, mesh.node_count)
30
+
31
+
32
+ def build_boundary_routing_from_connectivity(boundary_connectivity: Array, node_count: int) -> BoundaryRouting:
33
+ """Precompute COO routing for arbitrary boundary connectivity."""
34
+
35
+ connectivity_np = np.asarray(boundary_connectivity, dtype=np.int32)
36
+ edge_count = connectivity_np.shape[0]
37
+ local_dof = int(connectivity_np.shape[1])
38
+ row_indices = np.broadcast_to(connectivity_np[:, :, None], (edge_count, local_dof, local_dof)).reshape(-1)
39
+ col_indices = np.broadcast_to(connectivity_np[:, None, :], (edge_count, local_dof, local_dof)).reshape(-1)
40
+ indices = np.stack((row_indices, col_indices), axis=1).astype(np.int32)
41
+ return BoundaryRouting(indices=torch_np.asarray(indices, dtype=INT_DTYPE), shape=(node_count, node_count))
42
+
43
+
44
+ def _static_scalar_value(value: Array | float) -> float | None:
45
+ try:
46
+ value_array = np.asarray(value)
47
+ except Exception:
48
+ return None
49
+ if value_array.ndim != 0:
50
+ return None
51
+ return float(value_array)
52
+
53
+
54
+ def assemble_local_boundary_mass(mesh: Mesh, coefficients: Array | float) -> Array:
55
+ """Assemble midpoint-based Robin edge matrices."""
56
+
57
+ static_coefficient = _static_scalar_value(coefficients)
58
+ if static_coefficient is not None:
59
+ dtype = NP_FLOAT_DTYPE
60
+ reference = np.asarray([[2.0, 1.0], [1.0, 2.0]], dtype=dtype) / 6.0
61
+ lengths = np.asarray(mesh.boundary_edge_lengths, dtype=dtype)
62
+ local = static_coefficient * lengths[:, None, None] * reference[None, :, :]
63
+ return torch_np.asarray(local, dtype=FLOAT_DTYPE)
64
+
65
+ coefficient_array = torch_np.asarray(coefficients, dtype=FLOAT_DTYPE)
66
+ if coefficient_array.ndim == 0:
67
+ coefficient_array = torch_np.broadcast_to(coefficient_array, (mesh.boundary_edges.shape[0],))
68
+ elif coefficient_array.shape != (mesh.boundary_edges.shape[0],):
69
+ raise ValueError("boundary coefficients must be scalar or shape (num_boundary_edges,)")
70
+
71
+ reference = torch_np.asarray([[2.0, 1.0], [1.0, 2.0]], dtype=FLOAT_DTYPE) / 6.0
72
+ return coefficient_array[:, None, None] * mesh.boundary_edge_lengths[:, None, None] * reference
73
+
74
+
75
+ def assemble_local_boundary_mass_p2(lengths: Array, coefficients: Array | float) -> Array:
76
+ """Assemble quadratic Robin edge matrices."""
77
+
78
+ static_coefficient = _static_scalar_value(coefficients)
79
+ if static_coefficient is not None:
80
+ dtype = NP_FLOAT_DTYPE
81
+ length_array_np = np.asarray(lengths, dtype=dtype)
82
+ reference = np.asarray(
83
+ [
84
+ [4.0, 2.0, -1.0],
85
+ [2.0, 16.0, 2.0],
86
+ [-1.0, 2.0, 4.0],
87
+ ],
88
+ dtype=dtype,
89
+ ) / 30.0
90
+ local = static_coefficient * length_array_np[:, None, None] * reference[None, :, :]
91
+ return torch_np.asarray(local, dtype=FLOAT_DTYPE)
92
+
93
+ length_array = torch_np.asarray(lengths, dtype=FLOAT_DTYPE)
94
+ coefficient_array = torch_np.asarray(coefficients, dtype=FLOAT_DTYPE)
95
+ if coefficient_array.ndim == 0:
96
+ coefficient_array = torch_np.broadcast_to(coefficient_array, (length_array.shape[0],))
97
+ elif coefficient_array.shape != (length_array.shape[0],):
98
+ raise ValueError("boundary coefficients must be scalar or shape (num_boundary_edges,)")
99
+
100
+ reference = torch_np.asarray(
101
+ [
102
+ [4.0, 2.0, -1.0],
103
+ [2.0, 16.0, 2.0],
104
+ [-1.0, 2.0, 4.0],
105
+ ],
106
+ dtype=FLOAT_DTYPE,
107
+ ) / 30.0
108
+ return coefficient_array[:, None, None] * length_array[:, None, None] * reference
109
+
110
+
111
+ def assemble_boundary_bcoo(local_matrices: Array, routing: BoundaryRouting) -> BCOO:
112
+ """Reduce local boundary edge matrices into a global sparse operator."""
113
+
114
+ sparse_matrix = BCOO(
115
+ (local_matrices.reshape(-1), routing.indices),
116
+ shape=routing.shape,
117
+ unique_indices=False,
118
+ )
119
+ return sparse_matrix.sum_duplicates()
120
+
121
+
122
+ def _expand_cell_coefficient(coefficients: Array | float, mesh: Mesh) -> Array:
123
+ coefficient_array = torch_np.asarray(coefficients, dtype=FLOAT_DTYPE)
124
+ if coefficient_array.ndim == 0:
125
+ return torch_np.broadcast_to(coefficient_array, (mesh.cell_count,))
126
+ if coefficient_array.shape == (mesh.cell_count,):
127
+ return coefficient_array
128
+ raise ValueError("cell coefficients must be scalar or shape (num_cells,)")
129
+
130
+
131
+ def robin_boundary_coefficients(
132
+ mesh: Mesh,
133
+ conductivity: Array | float,
134
+ source_center: Array,
135
+ wavenumber: float,
136
+ ) -> Array:
137
+ """Compute 2.5D Robin coefficients for the mixed boundary condition."""
138
+
139
+ static_conductivity = _static_scalar_value(conductivity)
140
+ if static_conductivity is None:
141
+ conductivity_values = _expand_cell_coefficient(conductivity, mesh)
142
+ boundary_sigma = conductivity_values[mesh.boundary_edge_cells]
143
+ else:
144
+ boundary_sigma = np.full(mesh.boundary_edges.shape[0], static_conductivity, dtype=NP_FLOAT_DTYPE)
145
+
146
+ centers = np.asarray(mesh.boundary_edge_centers, dtype=float)
147
+ normals = np.asarray(mesh.boundary_edge_normals, dtype=float)
148
+ source = np.asarray(source_center, dtype=float)
149
+ mirrored_source = np.asarray([source[0], -source[1]], dtype=float)
150
+ r1 = centers - source
151
+ r2 = centers - mirrored_source
152
+ r1_abs = np.linalg.norm(r1, axis=1)
153
+ r2_abs = np.linalg.norm(r2, axis=1)
154
+ top_boundary = np.asarray(mesh.surface_edge_mask, dtype=bool)
155
+
156
+ geometry = np.zeros(centers.shape[0], dtype=float)
157
+ valid = (r1_abs > 1e-12) & (r2_abs > 1e-12) & (~top_boundary)
158
+ if np.any(valid):
159
+ denominator = besselk0(r1_abs[valid] * wavenumber) + besselk0(r2_abs[valid] * wavenumber)
160
+ stable = denominator > 1e-12
161
+ valid_indices = np.flatnonzero(valid)
162
+ if np.any(stable):
163
+ stable_indices = valid_indices[stable]
164
+ numerator = (
165
+ np.sum(r1[stable_indices] * normals[stable_indices], axis=1)
166
+ / r1_abs[stable_indices]
167
+ * besselk1(r1_abs[stable_indices] * wavenumber)
168
+ + np.sum(r2[stable_indices] * normals[stable_indices], axis=1)
169
+ / r2_abs[stable_indices]
170
+ * besselk1(r2_abs[stable_indices] * wavenumber)
171
+ )
172
+ geometry[stable_indices] = wavenumber * numerator / denominator[stable]
173
+
174
+ if static_conductivity is not None:
175
+ return torch_np.asarray(boundary_sigma * geometry, dtype=FLOAT_DTYPE)
176
+ return boundary_sigma * torch_np.asarray(geometry, dtype=FLOAT_DTYPE)
adtlert/fem/p1.py ADDED
@@ -0,0 +1,134 @@
1
+ """P1 triangle basis functions and batched quadrature data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from adtlert.utils.torch_runtime import Array
8
+ from adtlert.utils.torch_runtime import torch_np
9
+ import numpy as np
10
+
11
+ from adtlert.mesh import Mesh
12
+ from adtlert.utils.dtypes import FLOAT_DTYPE, NP_FLOAT_DTYPE
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TriangleQuadrature:
17
+ """Reference triangle quadrature points and weights."""
18
+
19
+ points: Array
20
+ weights: Array
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class P1ElementData:
25
+ """Batched P1 element tensors for tensorized assembly."""
26
+
27
+ quadrature_points: Array
28
+ quadrature_weights: Array
29
+ shape_values: Array
30
+ reference_gradients: Array
31
+ gradients: Array
32
+ cell_quadrature_points: Array
33
+ cell_areas: Array
34
+
35
+
36
+ def triangle_quadrature(order: int = 2) -> TriangleQuadrature:
37
+ """Return a low-order Gauss rule on the reference triangle."""
38
+
39
+ if order == 1:
40
+ points = torch_np.asarray([[1.0 / 3.0, 1.0 / 3.0]], dtype=FLOAT_DTYPE)
41
+ weights = torch_np.asarray([0.5], dtype=FLOAT_DTYPE)
42
+ return TriangleQuadrature(points=points, weights=weights)
43
+
44
+ if order == 2:
45
+ points = torch_np.asarray(
46
+ [
47
+ [1.0 / 6.0, 1.0 / 6.0],
48
+ [2.0 / 3.0, 1.0 / 6.0],
49
+ [1.0 / 6.0, 2.0 / 3.0],
50
+ ],
51
+ dtype=FLOAT_DTYPE,
52
+ )
53
+ weights = torch_np.asarray([1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0], dtype=FLOAT_DTYPE)
54
+ return TriangleQuadrature(points=points, weights=weights)
55
+
56
+ raise ValueError(f"unsupported triangle quadrature order: {order}")
57
+
58
+
59
+ def p1_shape_functions(points: Array) -> Array:
60
+ """Evaluate P1 shape functions at reference coordinates."""
61
+
62
+ point_array = torch_np.asarray(points, dtype=FLOAT_DTYPE)
63
+ xi = point_array[..., 0]
64
+ eta = point_array[..., 1]
65
+ return torch_np.stack((1.0 - xi - eta, xi, eta), axis=-1)
66
+
67
+
68
+ def reference_shape_gradients() -> Array:
69
+ """Return the constant reference gradients of the P1 basis."""
70
+
71
+ return torch_np.asarray(
72
+ [
73
+ [-1.0, -1.0],
74
+ [1.0, 0.0],
75
+ [0.0, 1.0],
76
+ ],
77
+ dtype=FLOAT_DTYPE,
78
+ )
79
+
80
+
81
+ def build_p1_element_data(mesh: Mesh, quadrature_order: int = 2) -> P1ElementData:
82
+ """Prepare batched element tensors for later FEM assembly."""
83
+
84
+ dtype = NP_FLOAT_DTYPE
85
+ if quadrature_order == 1:
86
+ quadrature_points = np.asarray([[1.0 / 3.0, 1.0 / 3.0]], dtype=dtype)
87
+ quadrature_weights = np.asarray([0.5], dtype=dtype)
88
+ elif quadrature_order == 2:
89
+ quadrature_points = np.asarray(
90
+ [
91
+ [1.0 / 6.0, 1.0 / 6.0],
92
+ [2.0 / 3.0, 1.0 / 6.0],
93
+ [1.0 / 6.0, 2.0 / 3.0],
94
+ ],
95
+ dtype=dtype,
96
+ )
97
+ quadrature_weights = np.asarray([1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0], dtype=dtype)
98
+ else:
99
+ raise ValueError(f"unsupported triangle quadrature order: {quadrature_order}")
100
+
101
+ xi = quadrature_points[..., 0]
102
+ eta = quadrature_points[..., 1]
103
+ shape_values = np.stack((1.0 - xi - eta, xi, eta), axis=-1).astype(dtype)
104
+ ref_gradients = np.asarray(
105
+ [
106
+ [-1.0, -1.0],
107
+ [1.0, 0.0],
108
+ [0.0, 1.0],
109
+ ],
110
+ dtype=dtype,
111
+ )
112
+
113
+ cell_nodes = np.asarray(mesh.nodes, dtype=dtype)[np.asarray(mesh.cells, dtype=np.int32)]
114
+ edge_1 = cell_nodes[:, 1] - cell_nodes[:, 0]
115
+ edge_2 = cell_nodes[:, 2] - cell_nodes[:, 0]
116
+ jacobians = np.stack((edge_1, edge_2), axis=-1)
117
+ inverse_jacobian_t = np.linalg.inv(jacobians).transpose((0, 2, 1))
118
+ cell_gradients = np.einsum("eij,nj->eni", inverse_jacobian_t, ref_gradients)
119
+ gradients = np.broadcast_to(
120
+ cell_gradients[:, None, :, :],
121
+ (mesh.cell_count, quadrature_points.shape[0], 3, 2),
122
+ )
123
+ cell_quadrature_points = np.einsum("qi,eid->eqd", shape_values, cell_nodes)
124
+ cell_areas = 0.5 * np.abs(np.linalg.det(jacobians))
125
+
126
+ return P1ElementData(
127
+ quadrature_points=torch_np.asarray(quadrature_points, dtype=FLOAT_DTYPE),
128
+ quadrature_weights=torch_np.asarray(quadrature_weights, dtype=FLOAT_DTYPE),
129
+ shape_values=torch_np.asarray(shape_values, dtype=FLOAT_DTYPE),
130
+ reference_gradients=torch_np.asarray(ref_gradients, dtype=FLOAT_DTYPE),
131
+ gradients=torch_np.asarray(gradients, dtype=FLOAT_DTYPE),
132
+ cell_quadrature_points=torch_np.asarray(cell_quadrature_points, dtype=FLOAT_DTYPE),
133
+ cell_areas=torch_np.asarray(cell_areas, dtype=FLOAT_DTYPE),
134
+ )