fluxfem 0.1.1a0__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.
- fluxfem/__init__.py +343 -0
- fluxfem/core/__init__.py +316 -0
- fluxfem/core/assembly.py +788 -0
- fluxfem/core/basis.py +996 -0
- fluxfem/core/data.py +64 -0
- fluxfem/core/dtypes.py +4 -0
- fluxfem/core/forms.py +234 -0
- fluxfem/core/interp.py +55 -0
- fluxfem/core/solver.py +113 -0
- fluxfem/core/space.py +419 -0
- fluxfem/core/weakform.py +818 -0
- fluxfem/helpers_num.py +11 -0
- fluxfem/helpers_wf.py +42 -0
- fluxfem/mesh/__init__.py +29 -0
- fluxfem/mesh/base.py +244 -0
- fluxfem/mesh/hex.py +327 -0
- fluxfem/mesh/io.py +87 -0
- fluxfem/mesh/predicate.py +45 -0
- fluxfem/mesh/surface.py +257 -0
- fluxfem/mesh/tet.py +246 -0
- fluxfem/physics/__init__.py +53 -0
- fluxfem/physics/diffusion.py +18 -0
- fluxfem/physics/elasticity/__init__.py +39 -0
- fluxfem/physics/elasticity/hyperelastic.py +99 -0
- fluxfem/physics/elasticity/linear.py +58 -0
- fluxfem/physics/elasticity/materials.py +32 -0
- fluxfem/physics/elasticity/stress.py +46 -0
- fluxfem/physics/operators.py +109 -0
- fluxfem/physics/postprocess.py +113 -0
- fluxfem/solver/__init__.py +47 -0
- fluxfem/solver/bc.py +439 -0
- fluxfem/solver/cg.py +326 -0
- fluxfem/solver/dirichlet.py +126 -0
- fluxfem/solver/history.py +31 -0
- fluxfem/solver/newton.py +400 -0
- fluxfem/solver/result.py +62 -0
- fluxfem/solver/solve_runner.py +534 -0
- fluxfem/solver/solver.py +148 -0
- fluxfem/solver/sparse.py +188 -0
- fluxfem/tools/__init__.py +7 -0
- fluxfem/tools/jit.py +51 -0
- fluxfem/tools/timer.py +659 -0
- fluxfem/tools/visualizer.py +101 -0
- fluxfem-0.1.1a0.dist-info/METADATA +111 -0
- fluxfem-0.1.1a0.dist-info/RECORD +47 -0
- fluxfem-0.1.1a0.dist-info/WHEEL +4 -0
- fluxfem-0.1.1a0.dist-info/licenses/LICENSE +201 -0
fluxfem/solver/bc.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Sequence
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
|
|
9
|
+
from ..mesh.surface import SurfaceMesh
|
|
10
|
+
from ..core.forms import vector_load_form
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _quad_area(p0, p1, p2, p3):
|
|
14
|
+
"""Compute quad area by splitting into two triangles."""
|
|
15
|
+
a1 = 0.5 * np.linalg.norm(np.cross(p1 - p0, p3 - p0))
|
|
16
|
+
a2 = 0.5 * np.linalg.norm(np.cross(p2 - p1, p3 - p1))
|
|
17
|
+
return a1 + a2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _polygon_area(pts: np.ndarray) -> float:
|
|
21
|
+
"""Polygon area in 3D by fan triangulation (works for tri/quad faces)."""
|
|
22
|
+
if pts.shape[0] < 3:
|
|
23
|
+
return 0.0
|
|
24
|
+
area = 0.0
|
|
25
|
+
p0 = pts[0]
|
|
26
|
+
for i in range(1, pts.shape[0] - 1):
|
|
27
|
+
v1 = pts[i] - p0
|
|
28
|
+
v2 = pts[i + 1] - p0
|
|
29
|
+
area += 0.5 * np.linalg.norm(np.cross(v1, v2))
|
|
30
|
+
return float(area)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(eq=False)
|
|
34
|
+
class SurfaceFormField:
|
|
35
|
+
N: np.ndarray
|
|
36
|
+
value_dim: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(eq=False)
|
|
40
|
+
class SurfaceFormContext:
|
|
41
|
+
v: SurfaceFormField
|
|
42
|
+
x_q: np.ndarray
|
|
43
|
+
w: np.ndarray
|
|
44
|
+
detJ: np.ndarray
|
|
45
|
+
facet_id: int
|
|
46
|
+
normal: np.ndarray | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def facet_area(coords: np.ndarray, nodes: np.ndarray) -> float:
|
|
50
|
+
"""Compute facet area for a 4-node quad (uses numpy)."""
|
|
51
|
+
pts = np.asarray(coords)[np.asarray(nodes, dtype=int)]
|
|
52
|
+
if pts.shape[0] != 4:
|
|
53
|
+
raise ValueError("facet_area expects 4-node quadrilateral facet")
|
|
54
|
+
return float(_quad_area(pts[0], pts[1], pts[2], pts[3]))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_neumann_load(
|
|
58
|
+
F: npt.ArrayLike,
|
|
59
|
+
facet_nodes: Sequence[int],
|
|
60
|
+
traction: npt.ArrayLike,
|
|
61
|
+
*,
|
|
62
|
+
dim: int = 1,
|
|
63
|
+
coords: Optional[npt.ArrayLike] = None,
|
|
64
|
+
area: float | None = None,
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
"""
|
|
67
|
+
Simple helper to add Neumann traction to the load vector.
|
|
68
|
+
- facet_nodes: face node IDs (length m)
|
|
69
|
+
- traction: scalar or length-dim array (outward traction)
|
|
70
|
+
- dim: dofs per node
|
|
71
|
+
- coords/area: provide area if known; if coords given (4-node quad) area is computed
|
|
72
|
+
"""
|
|
73
|
+
facet_nodes = np.asarray(facet_nodes, dtype=int)
|
|
74
|
+
m = len(facet_nodes)
|
|
75
|
+
if m == 0:
|
|
76
|
+
return np.asarray(F)
|
|
77
|
+
|
|
78
|
+
if area is None:
|
|
79
|
+
if coords is not None:
|
|
80
|
+
area = facet_area(coords, facet_nodes)
|
|
81
|
+
else:
|
|
82
|
+
area = 1.0 # fallback: treat as unit area
|
|
83
|
+
|
|
84
|
+
traction = np.asarray(traction, dtype=float)
|
|
85
|
+
if traction.ndim == 0 and dim > 1:
|
|
86
|
+
traction = np.full(dim, float(traction))
|
|
87
|
+
|
|
88
|
+
F_new = np.asarray(F, dtype=float).copy()
|
|
89
|
+
if dim == 1:
|
|
90
|
+
load = float(traction) * area / m
|
|
91
|
+
np.add.at(F_new, facet_nodes, load)
|
|
92
|
+
else:
|
|
93
|
+
for n in facet_nodes:
|
|
94
|
+
for d in range(dim):
|
|
95
|
+
dof = dim * n + d
|
|
96
|
+
F_new[dof] += traction[d] * area / m
|
|
97
|
+
return F_new
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def vector_surface_load_form(ctx: SurfaceFormContext, load: npt.ArrayLike) -> np.ndarray:
|
|
101
|
+
"""
|
|
102
|
+
Linear form for vector surface load: v · t on a facet.
|
|
103
|
+
load: (dim,) or (n_q, dim)
|
|
104
|
+
returns: (n_q, n_nodes*dim)
|
|
105
|
+
"""
|
|
106
|
+
load_arr = np.asarray(load, dtype=float)
|
|
107
|
+
return np.asarray(vector_load_form(ctx.v, load_arr))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def make_vector_surface_load_form(load_fn):
|
|
111
|
+
"""
|
|
112
|
+
Build a vector surface load form from a callable f(x_q) -> (n_q, dim).
|
|
113
|
+
"""
|
|
114
|
+
def _form(ctx: SurfaceFormContext, _params):
|
|
115
|
+
load_q = load_fn(ctx.x_q)
|
|
116
|
+
return vector_surface_load_form(ctx, load_q)
|
|
117
|
+
|
|
118
|
+
return _form
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _surface_quadrature(node_coords: np.ndarray):
|
|
122
|
+
m = node_coords.shape[0]
|
|
123
|
+
if m == 4:
|
|
124
|
+
gauss_pts = np.array([-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0)])
|
|
125
|
+
gauss_wts = np.array([1.0, 1.0])
|
|
126
|
+
N_list = []
|
|
127
|
+
x_list = []
|
|
128
|
+
w_list = []
|
|
129
|
+
detJ_list = []
|
|
130
|
+
for xi, wx in zip(gauss_pts, gauss_wts):
|
|
131
|
+
for eta, wy in zip(gauss_pts, gauss_wts):
|
|
132
|
+
N = 0.25 * np.array(
|
|
133
|
+
[
|
|
134
|
+
(1 - xi) * (1 - eta),
|
|
135
|
+
(1 + xi) * (1 - eta),
|
|
136
|
+
(1 + xi) * (1 + eta),
|
|
137
|
+
(1 - xi) * (1 + eta),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
dN_dxi = 0.25 * np.array(
|
|
141
|
+
[-(1 - eta), (1 - eta), (1 + eta), -(1 + eta)]
|
|
142
|
+
)
|
|
143
|
+
dN_deta = 0.25 * np.array(
|
|
144
|
+
[-(1 - xi), -(1 + xi), (1 + xi), (1 - xi)]
|
|
145
|
+
)
|
|
146
|
+
dx_dxi = np.sum(node_coords * dN_dxi[:, None], axis=0)
|
|
147
|
+
dx_deta = np.sum(node_coords * dN_deta[:, None], axis=0)
|
|
148
|
+
J_s = np.linalg.norm(np.cross(dx_dxi, dx_deta))
|
|
149
|
+
w = wx * wy
|
|
150
|
+
x_q = np.sum(node_coords * N[:, None], axis=0)
|
|
151
|
+
N_list.append(N)
|
|
152
|
+
x_list.append(x_q)
|
|
153
|
+
w_list.append(w)
|
|
154
|
+
detJ_list.append(J_s)
|
|
155
|
+
return (
|
|
156
|
+
np.asarray(N_list, dtype=float),
|
|
157
|
+
np.asarray(x_list, dtype=float),
|
|
158
|
+
np.asarray(w_list, dtype=float),
|
|
159
|
+
np.asarray(detJ_list, dtype=float),
|
|
160
|
+
)
|
|
161
|
+
if m == 3:
|
|
162
|
+
area = _polygon_area(node_coords)
|
|
163
|
+
N = np.array([[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]], dtype=float)
|
|
164
|
+
x_q = np.array([node_coords.mean(axis=0)], dtype=float)
|
|
165
|
+
w = np.array([1.0], dtype=float)
|
|
166
|
+
detJ = np.array([area], dtype=float)
|
|
167
|
+
return N, x_q, w, detJ
|
|
168
|
+
area = _polygon_area(node_coords)
|
|
169
|
+
N = np.full((1, m), 1.0 / float(m), dtype=float)
|
|
170
|
+
x_q = np.array([node_coords.mean(axis=0)], dtype=float)
|
|
171
|
+
w = np.array([1.0], dtype=float)
|
|
172
|
+
detJ = np.array([area], dtype=float)
|
|
173
|
+
return N, x_q, w, detJ
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def assemble_surface_linear_form(
|
|
177
|
+
surface: SurfaceMesh,
|
|
178
|
+
form,
|
|
179
|
+
params,
|
|
180
|
+
*,
|
|
181
|
+
dim: int,
|
|
182
|
+
n_total_nodes: int | None = None,
|
|
183
|
+
F0: npt.ArrayLike | None = None,
|
|
184
|
+
) -> np.ndarray:
|
|
185
|
+
"""
|
|
186
|
+
Assemble a linear form over surface facets using a weak-form callback.
|
|
187
|
+
"""
|
|
188
|
+
facets = np.asarray(surface.conn, dtype=int)
|
|
189
|
+
coords = np.asarray(surface.coords)
|
|
190
|
+
n_nodes = surface.n_nodes if n_total_nodes is None else int(n_total_nodes)
|
|
191
|
+
n_dofs = n_nodes * dim
|
|
192
|
+
F = np.zeros(n_dofs, dtype=float) if F0 is None else np.asarray(F0, dtype=float).copy()
|
|
193
|
+
if F.shape[0] != n_dofs:
|
|
194
|
+
raise ValueError(f"F length {F.shape[0]} does not match expected {n_dofs}")
|
|
195
|
+
|
|
196
|
+
normals = facet_normals(surface, outward_from=np.mean(coords, axis=0), normalize=True)
|
|
197
|
+
for facet_id, facet in enumerate(facets):
|
|
198
|
+
node_coords = coords[facet]
|
|
199
|
+
N, x_q, w, detJ = _surface_quadrature(node_coords)
|
|
200
|
+
ctx = SurfaceFormContext(
|
|
201
|
+
v=SurfaceFormField(N=N, value_dim=dim),
|
|
202
|
+
x_q=x_q,
|
|
203
|
+
w=w,
|
|
204
|
+
detJ=detJ,
|
|
205
|
+
facet_id=facet_id,
|
|
206
|
+
normal=normals[facet_id],
|
|
207
|
+
)
|
|
208
|
+
fe_q = form(ctx, params)
|
|
209
|
+
if fe_q.ndim != 2 or fe_q.shape[0] != N.shape[0]:
|
|
210
|
+
raise ValueError("surface form must return array shape (n_q, n_ldofs)")
|
|
211
|
+
if getattr(form, "_includes_measure", False):
|
|
212
|
+
fe = np.einsum("qi->i", fe_q)
|
|
213
|
+
else:
|
|
214
|
+
wJ = w * detJ
|
|
215
|
+
fe = np.einsum("qi,q->i", fe_q, wJ)
|
|
216
|
+
for a, node in enumerate(facet):
|
|
217
|
+
for d in range(dim):
|
|
218
|
+
local = dim * a + d
|
|
219
|
+
dof = dim * int(node) + d
|
|
220
|
+
F[dof] += fe[local]
|
|
221
|
+
return F
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def add_robin(
|
|
225
|
+
F: npt.ArrayLike,
|
|
226
|
+
K: npt.ArrayLike,
|
|
227
|
+
facet_nodes: Sequence[int],
|
|
228
|
+
alpha: float,
|
|
229
|
+
g: npt.ArrayLike,
|
|
230
|
+
*,
|
|
231
|
+
dim: int = 1,
|
|
232
|
+
coords: Optional[npt.ArrayLike] = None,
|
|
233
|
+
area: float | None = None,
|
|
234
|
+
):
|
|
235
|
+
"""
|
|
236
|
+
Add simple Robin term ∫ alpha (u - g) v dΓ with diagonal approximation.
|
|
237
|
+
- facet_nodes: face node IDs
|
|
238
|
+
- alpha: scalar coefficient
|
|
239
|
+
- g: target value (scalar or length dim)
|
|
240
|
+
- dim: dofs per node
|
|
241
|
+
- coords/area: provide area if known; if coords given (4-node quad) area is computed
|
|
242
|
+
Returns: (F_new, K_new)
|
|
243
|
+
"""
|
|
244
|
+
facet_nodes = np.asarray(facet_nodes, dtype=int)
|
|
245
|
+
m = len(facet_nodes)
|
|
246
|
+
if m == 0:
|
|
247
|
+
return np.asarray(F), np.asarray(K)
|
|
248
|
+
|
|
249
|
+
if area is None:
|
|
250
|
+
if coords is not None:
|
|
251
|
+
area = facet_area(coords, facet_nodes)
|
|
252
|
+
else:
|
|
253
|
+
area = 1.0
|
|
254
|
+
|
|
255
|
+
alpha = float(alpha)
|
|
256
|
+
g = np.asarray(g, dtype=float)
|
|
257
|
+
if g.ndim == 0 and dim > 1:
|
|
258
|
+
g = np.full(dim, float(g))
|
|
259
|
+
|
|
260
|
+
F_new = np.asarray(F, dtype=float).copy()
|
|
261
|
+
K_new = np.asarray(K, dtype=float).copy()
|
|
262
|
+
|
|
263
|
+
weight = alpha * area / m
|
|
264
|
+
|
|
265
|
+
if dim == 1:
|
|
266
|
+
for n in facet_nodes:
|
|
267
|
+
K_new[n, n] += weight
|
|
268
|
+
F_new[n] += weight * float(g)
|
|
269
|
+
else:
|
|
270
|
+
for n in facet_nodes:
|
|
271
|
+
for d in range(dim):
|
|
272
|
+
dof = dim * n + d
|
|
273
|
+
K_new[dof, dof] += weight
|
|
274
|
+
F_new[dof] += weight * g[d]
|
|
275
|
+
|
|
276
|
+
return F_new, K_new
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def assemble_surface_load(
|
|
280
|
+
surface: SurfaceMesh,
|
|
281
|
+
load: npt.ArrayLike,
|
|
282
|
+
*,
|
|
283
|
+
dim: int,
|
|
284
|
+
n_total_nodes: int | None = None,
|
|
285
|
+
F0: npt.ArrayLike | None = None,
|
|
286
|
+
) -> np.ndarray:
|
|
287
|
+
"""
|
|
288
|
+
Assemble a global RHS vector from constant surface load (vector per facet node).
|
|
289
|
+
|
|
290
|
+
- surface: SurfaceMesh whose node numbering matches the volume mesh
|
|
291
|
+
- load: array-like with shape (dim,) or (n_facets, dim)
|
|
292
|
+
- dim: dofs per node (e.g., 3 for displacement)
|
|
293
|
+
- n_total_nodes: optional total nodes for sizing; defaults to surface mesh node count
|
|
294
|
+
- F0: optional initial RHS to add into (copied)
|
|
295
|
+
"""
|
|
296
|
+
facets = np.asarray(surface.conn, dtype=int)
|
|
297
|
+
n_facets = facets.shape[0]
|
|
298
|
+
load_arr = np.asarray(load, dtype=float)
|
|
299
|
+
if load_arr.ndim == 1:
|
|
300
|
+
load_arr = np.tile(load_arr[None, :], (n_facets, 1))
|
|
301
|
+
elif load_arr.shape[0] != n_facets:
|
|
302
|
+
raise ValueError("load must be shape (dim,) or (n_facets, dim)")
|
|
303
|
+
|
|
304
|
+
n_nodes = surface.n_nodes if n_total_nodes is None else int(n_total_nodes)
|
|
305
|
+
n_dofs = n_nodes * dim
|
|
306
|
+
F = np.zeros(n_dofs, dtype=float) if F0 is None else np.asarray(F0, dtype=float).copy()
|
|
307
|
+
if F.shape[0] != n_dofs:
|
|
308
|
+
raise ValueError(f"F length {F.shape[0]} does not match expected {n_dofs}")
|
|
309
|
+
|
|
310
|
+
coords = np.asarray(surface.coords)
|
|
311
|
+
# Use a simple 2x2 Gauss integration for quad facets (planar assumption).
|
|
312
|
+
gauss_pts = np.array([-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0)])
|
|
313
|
+
gauss_wts = np.array([1.0, 1.0])
|
|
314
|
+
|
|
315
|
+
for facet, t in zip(facets, load_arr):
|
|
316
|
+
node_coords = coords[facet]
|
|
317
|
+
m = len(facet)
|
|
318
|
+
if m == 4:
|
|
319
|
+
for xi, wx in zip(gauss_pts, gauss_wts):
|
|
320
|
+
for eta, wy in zip(gauss_pts, gauss_wts):
|
|
321
|
+
# Bilinear shape functions on reference square [-1,1]^2
|
|
322
|
+
N = 0.25 * np.array(
|
|
323
|
+
[
|
|
324
|
+
(1 - xi) * (1 - eta),
|
|
325
|
+
(1 + xi) * (1 - eta),
|
|
326
|
+
(1 + xi) * (1 + eta),
|
|
327
|
+
(1 - xi) * (1 + eta),
|
|
328
|
+
]
|
|
329
|
+
)
|
|
330
|
+
dN_dxi = 0.25 * np.array(
|
|
331
|
+
[-(1 - eta), (1 - eta), (1 + eta), -(1 + eta)]
|
|
332
|
+
)
|
|
333
|
+
dN_deta = 0.25 * np.array(
|
|
334
|
+
[-(1 - xi), -(1 + xi), (1 + xi), (1 - xi)]
|
|
335
|
+
)
|
|
336
|
+
# Surface Jacobian |dX/dxi x dX/deta|
|
|
337
|
+
dx_dxi = np.sum(node_coords * dN_dxi[:, None], axis=0)
|
|
338
|
+
dx_deta = np.sum(node_coords * dN_deta[:, None], axis=0)
|
|
339
|
+
J_s = np.linalg.norm(np.cross(dx_dxi, dx_deta))
|
|
340
|
+
w = wx * wy
|
|
341
|
+
for a, Na in enumerate(N):
|
|
342
|
+
for d in range(dim):
|
|
343
|
+
dof = dim * facet[a] + d
|
|
344
|
+
F[dof] += Na * t[d] * J_s * w
|
|
345
|
+
elif m == 3:
|
|
346
|
+
# Linear triangle: one-point (centroid) quadrature is exact for constant traction.
|
|
347
|
+
area = _polygon_area(node_coords)
|
|
348
|
+
N = np.array([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], dtype=float)
|
|
349
|
+
for a, Na in enumerate(N):
|
|
350
|
+
for d in range(dim):
|
|
351
|
+
dof = dim * facet[a] + d
|
|
352
|
+
F[dof] += Na * t[d] * area
|
|
353
|
+
else:
|
|
354
|
+
# fallback: uniform distribution using facet area
|
|
355
|
+
area = _polygon_area(node_coords)
|
|
356
|
+
for n in facet:
|
|
357
|
+
for d in range(dim):
|
|
358
|
+
dof = dim * n + d
|
|
359
|
+
F[dof] += area * t[d] / float(m)
|
|
360
|
+
return F
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def assemble_surface_traction(
|
|
364
|
+
surface: SurfaceMesh,
|
|
365
|
+
traction: float | Sequence[float],
|
|
366
|
+
*,
|
|
367
|
+
dim: int = 3,
|
|
368
|
+
n_total_nodes: int | None = None,
|
|
369
|
+
F0: npt.ArrayLike | None = None,
|
|
370
|
+
outward_from: npt.ArrayLike | None = None,
|
|
371
|
+
) -> np.ndarray:
|
|
372
|
+
"""
|
|
373
|
+
Assemble surface load from scalar traction acting along facet normals (mechanics).
|
|
374
|
+
- traction: scalar or (n_facets,) array, positive = along oriented normal, negative = opposite.
|
|
375
|
+
- dim: must be 3 (vector mechanics).
|
|
376
|
+
"""
|
|
377
|
+
if dim != 3:
|
|
378
|
+
raise ValueError("assemble_surface_traction expects dim=3 (vector field). Use assemble_surface_load for other cases.")
|
|
379
|
+
normals = facet_normals(surface, outward_from=outward_from, normalize=True)
|
|
380
|
+
traction_arr = np.asarray(traction, dtype=float)
|
|
381
|
+
if traction_arr.ndim > 1:
|
|
382
|
+
raise ValueError("traction must be scalar or shape (n_facets,), not a vector per component")
|
|
383
|
+
if traction_arr.ndim == 0:
|
|
384
|
+
traction_arr = np.full((normals.shape[0],), float(traction_arr))
|
|
385
|
+
if traction_arr.shape[0] != normals.shape[0]:
|
|
386
|
+
raise ValueError("traction must be scalar or match number of facets")
|
|
387
|
+
load = traction_arr[:, None] * normals # along normal
|
|
388
|
+
return assemble_surface_load(surface, load, dim=dim, n_total_nodes=n_total_nodes, F0=F0)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def facet_normals(surface: SurfaceMesh, *, outward_from: npt.ArrayLike | None = None, normalize: bool = True) -> np.ndarray:
|
|
392
|
+
"""
|
|
393
|
+
Compute per-facet normals for a SurfaceMesh.
|
|
394
|
+
|
|
395
|
+
- outward_from: optional point (3,) assumed to lie inside the volume; normals are flipped to point away from this point.
|
|
396
|
+
- normalize: return unit normals if True.
|
|
397
|
+
|
|
398
|
+
Note: orientation of input facets may be inconsistent; outward_from can be used to enforce a consistent outward direction.
|
|
399
|
+
"""
|
|
400
|
+
coords = np.asarray(surface.coords)
|
|
401
|
+
facets = np.asarray(surface.conn, dtype=int)
|
|
402
|
+
normals = np.zeros((facets.shape[0], 3), dtype=float)
|
|
403
|
+
|
|
404
|
+
for i, facet in enumerate(facets):
|
|
405
|
+
if len(facet) < 3:
|
|
406
|
+
continue
|
|
407
|
+
p0, p1, p2 = coords[facet[0]], coords[facet[1]], coords[facet[2]]
|
|
408
|
+
n = np.cross(p1 - p0, p2 - p0)
|
|
409
|
+
if normalize:
|
|
410
|
+
norm = np.linalg.norm(n)
|
|
411
|
+
n = n / norm if norm != 0.0 else n
|
|
412
|
+
if outward_from is not None:
|
|
413
|
+
c = coords[facet].mean(axis=0)
|
|
414
|
+
v = c - np.asarray(outward_from, dtype=float)
|
|
415
|
+
if np.dot(n, v) < 0:
|
|
416
|
+
n = -n
|
|
417
|
+
normals[i] = n
|
|
418
|
+
return normals
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def assemble_surface_flux(
|
|
422
|
+
surface: SurfaceMesh,
|
|
423
|
+
flux: npt.ArrayLike,
|
|
424
|
+
*,
|
|
425
|
+
n_total_nodes: int | None = None,
|
|
426
|
+
F0: npt.ArrayLike | None = None,
|
|
427
|
+
outward_from: npt.ArrayLike | None = None,
|
|
428
|
+
) -> np.ndarray:
|
|
429
|
+
"""
|
|
430
|
+
Scalar flux along facet normal (dim=1). Positive flux acts along oriented normal.
|
|
431
|
+
"""
|
|
432
|
+
normals = facet_normals(surface, outward_from=outward_from, normalize=True)
|
|
433
|
+
flux_arr = np.asarray(flux, dtype=float)
|
|
434
|
+
if flux_arr.ndim == 0:
|
|
435
|
+
flux_arr = np.full((normals.shape[0],), float(flux_arr))
|
|
436
|
+
if flux_arr.shape[0] != normals.shape[0]:
|
|
437
|
+
raise ValueError("flux must be scalar or match number of facets")
|
|
438
|
+
load = flux_arr[:, None] * normals
|
|
439
|
+
return assemble_surface_load(surface, load, dim=1, n_total_nodes=n_total_nodes, F0=F0)
|