fluxfem 0.1.3a0__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.

Potentially problematic release.


This version of fluxfem might be problematic. Click here for more details.

Files changed (47) hide show
  1. fluxfem/__init__.py +343 -0
  2. fluxfem/core/__init__.py +318 -0
  3. fluxfem/core/assembly.py +788 -0
  4. fluxfem/core/basis.py +996 -0
  5. fluxfem/core/data.py +64 -0
  6. fluxfem/core/dtypes.py +4 -0
  7. fluxfem/core/forms.py +234 -0
  8. fluxfem/core/interp.py +55 -0
  9. fluxfem/core/solver.py +113 -0
  10. fluxfem/core/space.py +419 -0
  11. fluxfem/core/weakform.py +828 -0
  12. fluxfem/helpers_ts.py +11 -0
  13. fluxfem/helpers_wf.py +44 -0
  14. fluxfem/mesh/__init__.py +29 -0
  15. fluxfem/mesh/base.py +244 -0
  16. fluxfem/mesh/hex.py +327 -0
  17. fluxfem/mesh/io.py +87 -0
  18. fluxfem/mesh/predicate.py +45 -0
  19. fluxfem/mesh/surface.py +257 -0
  20. fluxfem/mesh/tet.py +246 -0
  21. fluxfem/physics/__init__.py +53 -0
  22. fluxfem/physics/diffusion.py +18 -0
  23. fluxfem/physics/elasticity/__init__.py +39 -0
  24. fluxfem/physics/elasticity/hyperelastic.py +99 -0
  25. fluxfem/physics/elasticity/linear.py +58 -0
  26. fluxfem/physics/elasticity/materials.py +32 -0
  27. fluxfem/physics/elasticity/stress.py +46 -0
  28. fluxfem/physics/operators.py +109 -0
  29. fluxfem/physics/postprocess.py +113 -0
  30. fluxfem/solver/__init__.py +47 -0
  31. fluxfem/solver/bc.py +439 -0
  32. fluxfem/solver/cg.py +326 -0
  33. fluxfem/solver/dirichlet.py +126 -0
  34. fluxfem/solver/history.py +31 -0
  35. fluxfem/solver/newton.py +400 -0
  36. fluxfem/solver/result.py +62 -0
  37. fluxfem/solver/solve_runner.py +534 -0
  38. fluxfem/solver/solver.py +148 -0
  39. fluxfem/solver/sparse.py +188 -0
  40. fluxfem/tools/__init__.py +7 -0
  41. fluxfem/tools/jit.py +51 -0
  42. fluxfem/tools/timer.py +659 -0
  43. fluxfem/tools/visualizer.py +101 -0
  44. fluxfem-0.1.3a0.dist-info/LICENSE +201 -0
  45. fluxfem-0.1.3a0.dist-info/METADATA +125 -0
  46. fluxfem-0.1.3a0.dist-info/RECORD +47 -0
  47. fluxfem-0.1.3a0.dist-info/WHEEL +4 -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)