fluxfem 0.1.4__py3-none-any.whl → 0.2.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.
Files changed (45) hide show
  1. fluxfem/__init__.py +68 -0
  2. fluxfem/core/__init__.py +115 -10
  3. fluxfem/core/assembly.py +676 -91
  4. fluxfem/core/basis.py +73 -52
  5. fluxfem/core/dtypes.py +9 -1
  6. fluxfem/core/forms.py +10 -0
  7. fluxfem/core/mixed_assembly.py +263 -0
  8. fluxfem/core/mixed_space.py +348 -0
  9. fluxfem/core/mixed_weakform.py +97 -0
  10. fluxfem/core/solver.py +2 -0
  11. fluxfem/core/space.py +262 -17
  12. fluxfem/core/weakform.py +768 -7
  13. fluxfem/helpers_wf.py +49 -0
  14. fluxfem/mesh/__init__.py +54 -2
  15. fluxfem/mesh/base.py +316 -7
  16. fluxfem/mesh/contact.py +825 -0
  17. fluxfem/mesh/dtypes.py +12 -0
  18. fluxfem/mesh/hex.py +17 -16
  19. fluxfem/mesh/io.py +6 -4
  20. fluxfem/mesh/mortar.py +3907 -0
  21. fluxfem/mesh/supermesh.py +316 -0
  22. fluxfem/mesh/surface.py +22 -4
  23. fluxfem/mesh/tet.py +10 -4
  24. fluxfem/physics/diffusion.py +3 -0
  25. fluxfem/physics/elasticity/hyperelastic.py +3 -0
  26. fluxfem/physics/elasticity/linear.py +9 -2
  27. fluxfem/solver/__init__.py +42 -2
  28. fluxfem/solver/bc.py +38 -2
  29. fluxfem/solver/block_matrix.py +132 -0
  30. fluxfem/solver/block_system.py +454 -0
  31. fluxfem/solver/cg.py +115 -33
  32. fluxfem/solver/dirichlet.py +334 -4
  33. fluxfem/solver/newton.py +237 -60
  34. fluxfem/solver/petsc.py +439 -0
  35. fluxfem/solver/preconditioner.py +106 -0
  36. fluxfem/solver/result.py +18 -0
  37. fluxfem/solver/solve_runner.py +168 -1
  38. fluxfem/solver/solver.py +12 -1
  39. fluxfem/solver/sparse.py +124 -9
  40. fluxfem-0.2.0.dist-info/METADATA +303 -0
  41. fluxfem-0.2.0.dist-info/RECORD +59 -0
  42. fluxfem-0.1.4.dist-info/METADATA +0 -127
  43. fluxfem-0.1.4.dist-info/RECORD +0 -48
  44. {fluxfem-0.1.4.dist-info → fluxfem-0.2.0.dist-info}/LICENSE +0 -0
  45. {fluxfem-0.1.4.dist-info → fluxfem-0.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,316 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from typing import Iterable
6
+
7
+ import numpy as np
8
+
9
+ from .surface import SurfaceMesh
10
+
11
+
12
+ @dataclass(eq=False)
13
+ class SurfaceSupermesh:
14
+ """Intersection supermesh for two surface meshes."""
15
+ coords: np.ndarray
16
+ conn: np.ndarray
17
+ source_facets_a: np.ndarray
18
+ source_facets_b: np.ndarray
19
+
20
+
21
+ def _polygon_area_2d(pts: np.ndarray) -> float:
22
+ x = pts[:, 0]
23
+ y = pts[:, 1]
24
+ return 0.5 * float(np.sum(x * np.roll(y, -1) - y * np.roll(x, -1)))
25
+
26
+
27
+ def _cross2(a: np.ndarray, b: np.ndarray) -> float:
28
+ return float(a[0] * b[1] - a[1] * b[0])
29
+
30
+
31
+ def _line_intersection(p1, p2, p3, p4, *, tol: float):
32
+ d1 = p2 - p1
33
+ d2 = p4 - p3
34
+ denom = _cross2(d1, d2)
35
+ if abs(denom) < tol:
36
+ return p2
37
+ t = _cross2(p3 - p1, d2) / denom
38
+ return p1 + t * d1
39
+
40
+
41
+ def _sutherland_hodgman(subject: list[np.ndarray], clip: list[np.ndarray], *, tol: float):
42
+ if not subject:
43
+ return []
44
+ orient = np.sign(_polygon_area_2d(np.array(clip)))
45
+ if orient == 0:
46
+ return []
47
+
48
+ def inside(pt, a, b):
49
+ return orient * _cross2(b - a, pt - a) >= -tol
50
+
51
+ output = subject
52
+ for i in range(len(clip)):
53
+ input_list = output
54
+ if not input_list:
55
+ break
56
+ output = []
57
+ cp1 = clip[i]
58
+ cp2 = clip[(i + 1) % len(clip)]
59
+ s = input_list[-1]
60
+ for e in input_list:
61
+ if inside(e, cp1, cp2):
62
+ if not inside(s, cp1, cp2):
63
+ output.append(_line_intersection(s, e, cp1, cp2, tol=tol))
64
+ output.append(e)
65
+ elif inside(s, cp1, cp2):
66
+ output.append(_line_intersection(s, e, cp1, cp2, tol=tol))
67
+ s = e
68
+ return output
69
+
70
+
71
+ def _plane_basis(normal: np.ndarray):
72
+ n = normal / np.linalg.norm(normal)
73
+ ref = np.array([1.0, 0.0, 0.0], dtype=float)
74
+ if abs(np.dot(n, ref)) > 0.9:
75
+ ref = np.array([0.0, 1.0, 0.0], dtype=float)
76
+ t1 = np.cross(n, ref)
77
+ t1 = t1 / np.linalg.norm(t1)
78
+ t2 = np.cross(n, t1)
79
+ return t1, t2, n
80
+
81
+
82
+ def _facet_plane(pts: np.ndarray, *, tol: float):
83
+ n = None
84
+ for i in range(len(pts) - 2):
85
+ v1 = pts[i + 1] - pts[i]
86
+ v2 = pts[i + 2] - pts[i]
87
+ n_candidate = np.cross(v1, v2)
88
+ n_norm = np.linalg.norm(n_candidate)
89
+ if n_norm > tol:
90
+ n = n_candidate / n_norm
91
+ d = -float(np.dot(n, pts[i]))
92
+ return n, d
93
+ return None, None
94
+
95
+
96
+ def _coplanar(pts_a: np.ndarray, pts_b: np.ndarray, *, tol: float) -> bool:
97
+ n, d = _facet_plane(pts_a, tol=tol)
98
+ if n is None:
99
+ return False
100
+ n2, d2 = _facet_plane(pts_b, tol=tol)
101
+ if n2 is None:
102
+ return False
103
+ if abs(abs(np.dot(n, n2)) - 1.0) > 1e-4:
104
+ return False
105
+ dist_a = np.abs(pts_a @ n + d)
106
+ dist_b = np.abs(pts_b @ n + d)
107
+ return np.max(dist_a) <= tol and np.max(dist_b) <= tol
108
+
109
+
110
+ def _project(points: np.ndarray, origin: np.ndarray, t1: np.ndarray, t2: np.ndarray):
111
+ rel = points - origin[None, :]
112
+ x = rel @ t1
113
+ y = rel @ t2
114
+ return np.stack([x, y], axis=1)
115
+
116
+
117
+ def _unique_points(points: Iterable[np.ndarray], *, tol: float):
118
+ scale = 1.0 / tol
119
+ mapping: dict[tuple[int, int, int], int] = {}
120
+ coords: list[np.ndarray] = []
121
+ indices: list[int] = []
122
+ for p in points:
123
+ key = tuple(np.round(p * scale).astype(int))
124
+ idx = mapping.get(key)
125
+ if idx is None:
126
+ idx = len(coords)
127
+ mapping[key] = idx
128
+ coords.append(p)
129
+ indices.append(idx)
130
+ return np.asarray(coords, dtype=float), indices
131
+
132
+
133
+ def _facet_polygon_coords(coords: np.ndarray, facet: np.ndarray) -> np.ndarray:
134
+ n = int(len(facet))
135
+ if n == 9:
136
+ corner = [0, 2, 8, 6]
137
+ return coords[facet][corner]
138
+ return coords[facet]
139
+
140
+
141
+ def _triangle_min_angle(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray) -> float:
142
+ def angle(a, b, c):
143
+ v1 = a - b
144
+ v2 = c - b
145
+ n1 = np.linalg.norm(v1)
146
+ n2 = np.linalg.norm(v2)
147
+ if n1 == 0.0 or n2 == 0.0:
148
+ return 0.0
149
+ cosang = np.clip(np.dot(v1, v2) / (n1 * n2), -1.0, 1.0)
150
+ return float(np.arccos(cosang))
151
+
152
+ return min(angle(p1, p0, p2), angle(p0, p1, p2), angle(p0, p2, p1))
153
+
154
+
155
+ def _triangulate_polygon(indices: list[int], poly2d: np.ndarray) -> list[tuple[int, int, int]]:
156
+ n = len(indices)
157
+ if n < 3:
158
+ return []
159
+ if n == 3:
160
+ return [(indices[0], indices[1], indices[2])]
161
+ if n == 4:
162
+ p = poly2d
163
+ diag_pref = os.getenv("FLUXFEM_SUPERMESH_QUAD_DIAG", "alt").lower()
164
+ if diag_pref == "alt":
165
+ return [(indices[0], indices[1], indices[3]), (indices[1], indices[2], indices[3])]
166
+ if diag_pref == "fan":
167
+ return [(indices[0], indices[1], indices[2]), (indices[0], indices[2], indices[3])]
168
+ min_a = min(
169
+ _triangle_min_angle(p[0], p[1], p[2]),
170
+ _triangle_min_angle(p[0], p[2], p[3]),
171
+ )
172
+ min_b = min(
173
+ _triangle_min_angle(p[0], p[1], p[3]),
174
+ _triangle_min_angle(p[1], p[2], p[3]),
175
+ )
176
+ if min_b > min_a:
177
+ return [(indices[0], indices[1], indices[3]), (indices[1], indices[2], indices[3])]
178
+ return [(indices[0], indices[1], indices[2]), (indices[0], indices[2], indices[3])]
179
+ tris = []
180
+ for i in range(1, n - 1):
181
+ tris.append((indices[0], indices[i], indices[i + 1]))
182
+ return tris
183
+
184
+
185
+ def build_surface_supermesh(
186
+ surface_a: SurfaceMesh,
187
+ surface_b: SurfaceMesh,
188
+ *,
189
+ tol: float = 1e-8,
190
+ cache_enabled: bool | None = None,
191
+ cache_trace: bool | None = None,
192
+ ) -> SurfaceSupermesh:
193
+ from ..solver.bc import facet_normals
194
+ import hashlib
195
+
196
+ if cache_enabled is None:
197
+ cache_enabled = os.getenv("FLUXFEM_SUPERMESH_CACHE", "0") not in ("0", "", "false", "False")
198
+ if cache_trace is None:
199
+ cache_trace = os.getenv("FLUXFEM_SUPERMESH_CACHE_TRACE", "0") not in ("0", "", "false", "False")
200
+
201
+ def _array_sig(arr: np.ndarray) -> tuple:
202
+ arr_c = np.ascontiguousarray(arr)
203
+ h = hashlib.blake2b(arr_c.view(np.uint8), digest_size=8).hexdigest()
204
+ return (arr_c.shape, str(arr_c.dtype), h)
205
+ if cache_enabled:
206
+ global _SUPERMESH_CACHE
207
+ try:
208
+ _SUPERMESH_CACHE
209
+ except NameError:
210
+ _SUPERMESH_CACHE = {}
211
+ key = (
212
+ _array_sig(np.asarray(surface_a.coords)),
213
+ _array_sig(np.asarray(surface_a.conn)),
214
+ _array_sig(np.asarray(surface_b.coords)),
215
+ _array_sig(np.asarray(surface_b.conn)),
216
+ float(tol),
217
+ )
218
+ cached = _SUPERMESH_CACHE.get(key)
219
+ if cached is not None:
220
+ if cache_trace:
221
+ print(f"[supermesh] cache hit n_tris={int(cached.conn.shape[0])}", flush=True)
222
+ return cached
223
+
224
+ coords_a = np.asarray(surface_a.coords, dtype=float)
225
+ coords_b = np.asarray(surface_b.coords, dtype=float)
226
+ facets_a = np.asarray(surface_a.conn, dtype=int)
227
+ facets_b = np.asarray(surface_b.conn, dtype=int)
228
+ normals_a = facet_normals(surface_a, outward_from=np.mean(coords_a, axis=0), normalize=True)
229
+
230
+ all_coords: list[np.ndarray] = []
231
+ all_conn: list[tuple[int, int, int]] = []
232
+ src_a: list[int] = []
233
+ src_b: list[int] = []
234
+
235
+ for ia, fa in enumerate(facets_a):
236
+ pts_a = _facet_polygon_coords(coords_a, fa)
237
+ min_a = pts_a.min(axis=0)
238
+ max_a = pts_a.max(axis=0)
239
+ for ib, fb in enumerate(facets_b):
240
+ pts_b = _facet_polygon_coords(coords_b, fb)
241
+ if np.any(pts_b.max(axis=0) < min_a - tol) or np.any(pts_b.min(axis=0) > max_a + tol):
242
+ continue
243
+ if not _coplanar(pts_a, pts_b, tol=tol):
244
+ continue
245
+
246
+ n, _d = _facet_plane(pts_a, tol=tol)
247
+ if n is not None:
248
+ n_ref = normals_a[int(ia)]
249
+ if np.dot(n, n_ref) < 0.0:
250
+ n = -n
251
+ t1, t2, _ = _plane_basis(n)
252
+ origin = pts_a[0]
253
+
254
+ poly_a = _project(pts_a, origin, t1, t2)
255
+ poly_b = _project(pts_b, origin, t1, t2)
256
+
257
+ inter = _sutherland_hodgman(
258
+ [p.copy() for p in poly_a],
259
+ [p.copy() for p in poly_b],
260
+ tol=tol,
261
+ )
262
+ if len(inter) < 3:
263
+ continue
264
+ inter_np = np.asarray(inter)
265
+ if abs(_polygon_area_2d(inter_np)) <= tol:
266
+ continue
267
+ center = np.mean(inter_np, axis=0)
268
+ angles = np.arctan2(inter_np[:, 1] - center[1], inter_np[:, 0] - center[0])
269
+ order = np.argsort(angles)
270
+ inter_np = inter_np[order]
271
+
272
+ inter_3d = origin[None, :] + inter_np[:, 0:1] * t1 + inter_np[:, 1:2] * t2
273
+ coords_local, idx = _unique_points(inter_3d, tol=tol)
274
+ base = len(all_coords)
275
+ for p in coords_local:
276
+ all_coords.append(p)
277
+ tris = _triangulate_polygon(idx, inter_np)
278
+ for a_idx, b_idx, c_idx in tris:
279
+ a_id = base + a_idx
280
+ b_id = base + b_idx
281
+ c_id = base + c_idx
282
+ if n is not None:
283
+ pa = all_coords[a_id]
284
+ pb = all_coords[b_id]
285
+ pc = all_coords[c_id]
286
+ n_tri = np.cross(pb - pa, pc - pa)
287
+ if np.dot(n_tri, n) < 0.0:
288
+ b_id, c_id = c_id, b_id
289
+ all_conn.append((a_id, b_id, c_id))
290
+ src_a.append(ia)
291
+ src_b.append(ib)
292
+
293
+ if not all_conn:
294
+ sm = SurfaceSupermesh(
295
+ coords=np.zeros((0, 3), dtype=float),
296
+ conn=np.zeros((0, 3), dtype=int),
297
+ source_facets_a=np.zeros((0,), dtype=int),
298
+ source_facets_b=np.zeros((0,), dtype=int),
299
+ )
300
+ if cache_enabled:
301
+ _SUPERMESH_CACHE[key] = sm
302
+ return sm
303
+
304
+ coords = np.asarray(all_coords, dtype=float)
305
+ conn = np.asarray(all_conn, dtype=int)
306
+ sm = SurfaceSupermesh(
307
+ coords=coords,
308
+ conn=conn,
309
+ source_facets_a=np.asarray(src_a, dtype=int),
310
+ source_facets_b=np.asarray(src_b, dtype=int),
311
+ )
312
+ if cache_enabled:
313
+ _SUPERMESH_CACHE[key] = sm
314
+ if cache_trace:
315
+ print(f"[supermesh] cache store n_tris={int(sm.conn.shape[0])}", flush=True)
316
+ return sm
fluxfem/mesh/surface.py CHANGED
@@ -4,6 +4,8 @@ from dataclasses import dataclass
4
4
  from typing import Optional
5
5
  import jax
6
6
  import jax.numpy as jnp
7
+
8
+ from .dtypes import INDEX_DTYPE
7
9
  import numpy as np
8
10
 
9
11
  DTYPE = jnp.float64 if jax.config.read("jax_enable_x64") else jnp.float32
@@ -53,8 +55,8 @@ class SurfaceMesh(BaseMesh):
53
55
  node_tags: Optional[jnp.ndarray] = None,
54
56
  ) -> "SurfaceMesh":
55
57
  coords_j = jnp.asarray(coords, dtype=DTYPE)
56
- facets_j = jnp.asarray(facets, dtype=jnp.int32)
57
- tags_j = None if facet_tags is None else jnp.asarray(facet_tags, dtype=jnp.int32)
58
+ facets_j = jnp.asarray(facets, dtype=INDEX_DTYPE)
59
+ tags_j = None if facet_tags is None else jnp.asarray(facet_tags, dtype=INDEX_DTYPE)
58
60
  node_tags_j = None if node_tags is None else jnp.asarray(node_tags)
59
61
  return cls(coords=coords_j, conn=facets_j, cell_tags=tags_j, node_tags=node_tags_j, facet_tags=tags_j)
60
62
 
@@ -117,6 +119,22 @@ class SurfaceMesh(BaseMesh):
117
119
  n_total_nodes = int(getattr(space, "mesh", self).n_nodes)
118
120
  return self.assemble_linear_form(form, params, dim=dim, n_total_nodes=n_total_nodes, F0=F0)
119
121
 
122
+
123
+ @dataclass(frozen=True)
124
+ class SurfaceWithElemConn:
125
+ surface: SurfaceMesh
126
+ elem_conn: np.ndarray
127
+
128
+
129
+ def surface_with_elem_conn(mesh: BaseMesh, facets, *, mode: str = "touching") -> SurfaceWithElemConn:
130
+ """
131
+ Build a SurfaceMesh from facets and return it with a matching elem_conn array.
132
+ """
133
+ surface = SurfaceMesh.from_facets(mesh.coords, facets, node_tags=mesh.node_tags)
134
+ elems = mesh.elements_from_facets(facets, mode=mode)
135
+ elem_conn = np.asarray(mesh.conn, dtype=int)[elems]
136
+ return SurfaceWithElemConn(surface=surface, elem_conn=elem_conn)
137
+
120
138
  def assemble_traction(
121
139
  self,
122
140
  traction,
@@ -162,8 +180,8 @@ class SurfaceMeshPytree(BaseMeshPytree):
162
180
  node_tags: Optional[jnp.ndarray] = None,
163
181
  ) -> "SurfaceMeshPytree":
164
182
  coords_j = jnp.asarray(coords, dtype=DTYPE)
165
- facets_j = jnp.asarray(facets, dtype=jnp.int32)
166
- tags_j = None if facet_tags is None else jnp.asarray(facet_tags, dtype=jnp.int32)
183
+ facets_j = jnp.asarray(facets, dtype=INDEX_DTYPE)
184
+ tags_j = None if facet_tags is None else jnp.asarray(facet_tags, dtype=INDEX_DTYPE)
167
185
  node_tags_j = None if node_tags is None else jnp.asarray(node_tags)
168
186
  return cls(coords=coords_j, conn=facets_j, cell_tags=tags_j, node_tags=node_tags_j, facet_tags=tags_j)
169
187
 
fluxfem/mesh/tet.py CHANGED
@@ -5,6 +5,8 @@ import jax
5
5
  import jax.numpy as jnp
6
6
  import numpy as np
7
7
 
8
+ from .dtypes import NP_INDEX_DTYPE
9
+
8
10
  DTYPE = jnp.float64 if jax.config.read("jax_enable_x64") else jnp.float32
9
11
 
10
12
 
@@ -58,6 +60,10 @@ class StructuredTetBox:
58
60
  J = np.stack([p1 - p0, p2 - p0, p3 - p0], axis=1)
59
61
  if np.linalg.det(J) < 0:
60
62
  tet[[1, 2]] = tet[[2, 1]] # swap corner1/corner2
63
+ if tet.shape[0] == 10:
64
+ # keep edge-node ordering consistent with corner swap
65
+ tet[[4, 6]] = tet[[6, 4]] # edges (0-1) <-> (0-2)
66
+ tet[[8, 9]] = tet[[9, 8]] # edges (1-3) <-> (2-3)
61
67
  conn[idx] = tet
62
68
  return conn
63
69
 
@@ -115,10 +121,10 @@ class StructuredTetBox:
115
121
  n12 = add_node(mid(p1, p2))
116
122
  n13 = add_node(mid(p1, p3))
117
123
  n23 = add_node(mid(p2, p3))
118
- conn_list.append([n0, n1, n2, n3, n01, n02, n03, n12, n13, n23])
124
+ conn_list.append([n0, n1, n2, n3, n01, n12, n02, n03, n13, n23])
119
125
 
120
126
  coords = np.asarray(coords_list, dtype=DTYPE)
121
- conn = np.asarray(conn_list, dtype=np.int32)
127
+ conn = np.asarray(conn_list, dtype=NP_INDEX_DTYPE)
122
128
  conn = self._fix_orientation(coords, conn)
123
129
  return TetMesh(coords=jnp.array(coords), conn=jnp.array(conn))
124
130
 
@@ -169,7 +175,7 @@ class StructuredTetBox:
169
175
  [v010, v001, v011, v111],
170
176
  ]
171
177
  )
172
- conn = np.asarray(conn_list, dtype=np.int32)
178
+ conn = np.asarray(conn_list, dtype=NP_INDEX_DTYPE)
173
179
  conn = self._fix_orientation(coords, conn)
174
180
  return TetMesh(coords=jnp.array(coords), conn=jnp.array(conn))
175
181
 
@@ -241,6 +247,6 @@ class StructuredTetTensorBox:
241
247
  T[:, (5 * ne):] = t[[0, 3, 6, 7]]
242
248
 
243
249
  coords = p.T.astype(DTYPE, copy=False)
244
- conn = T.T.astype(np.int32, copy=False)
250
+ conn = T.T.astype(NP_INDEX_DTYPE, copy=False)
245
251
  conn = self._fix_orientation(coords, conn)
246
252
  return TetMesh(coords=jnp.array(coords), conn=jnp.array(conn))
@@ -15,4 +15,7 @@ def diffusion_form(ctx: FormContext, kappa: float) -> jnp.ndarray:
15
15
  return kappa * G
16
16
 
17
17
 
18
+ diffusion_form._ff_kind = "bilinear"
19
+ diffusion_form._ff_domain = "volume"
20
+
18
21
  __all__ = ["diffusion_form"]
@@ -78,6 +78,9 @@ def neo_hookean_residual_form(ctx: FormContext, u_elem: jnp.ndarray, params) ->
78
78
  return jnp.einsum("qik,qk->qi", BT, S_voigt) # (n_q, n_ldofs)
79
79
 
80
80
 
81
+ neo_hookean_residual_form._ff_kind = "residual"
82
+ neo_hookean_residual_form._ff_domain = "volume"
83
+
81
84
  __all__ = [
82
85
  "right_cauchy_green",
83
86
  "green_lagrange_strain",
@@ -1,6 +1,5 @@
1
1
  import jax.numpy as jnp
2
2
 
3
- from ...core.assembly import assemble_linear_form
4
3
  from ...core.forms import FormContext, vector_load_form
5
4
  from ...core.basis import build_B_matrices
6
5
  from ...physics.operators import sym_grad
@@ -26,21 +25,29 @@ def linear_elasticity_form(ctx: FormContext, D: jnp.ndarray) -> jnp.ndarray:
26
25
  symmetric-gradient operator for the test/trial fields.
27
26
  """
28
27
  Bu = sym_grad(ctx.trial) # (n_q, 6, ndofs_e)
29
- Bv = sym_grad(ctx.test) # (n_q, 6, ndofs_e)
28
+ Bv = Bu if ctx.test is ctx.trial else sym_grad(ctx.test)
30
29
  return jnp.einsum("qik,kl,qlm->qim", jnp.swapaxes(Bv, 1, 2), D, Bu)
31
30
 
32
31
 
32
+ linear_elasticity_form._ff_kind = "bilinear"
33
+ linear_elasticity_form._ff_domain = "volume"
34
+
35
+
33
36
  def vector_body_force_form(ctx: FormContext, load_vec: jnp.ndarray) -> jnp.ndarray:
34
37
  """Linear form for 3D vector body force f (constant in space)."""
35
38
  return vector_load_form(ctx.test, load_vec)
36
39
 
37
40
 
41
+ vector_body_force_form._ff_kind = "linear"
42
+ vector_body_force_form._ff_domain = "volume"
43
+
38
44
  def assemble_constant_body_force(space, gravity_vec, density: float, *, sparse: bool = False):
39
45
  """
40
46
  Convenience: assemble body force from density * gravity vector.
41
47
  gravity_vec: length-3 array-like (direction and magnitude of g)
42
48
  density: scalar density (consistent with unit system)
43
49
  """
50
+ from ...core.assembly import assemble_linear_form
44
51
  g = jnp.asarray(gravity_vec)
45
52
  f_vec = density * g
46
53
  return assemble_linear_form(space, vector_body_force_form, params=f_vec, sparse=sparse)
@@ -1,13 +1,30 @@
1
- from .sparse import SparsityPattern, FluxSparseMatrix
1
+ from .sparse import (
2
+ SparsityPattern,
3
+ FluxSparseMatrix,
4
+ coalesce_coo,
5
+ concat_flux,
6
+ block_diag_flux,
7
+ )
2
8
  from .dirichlet import (
9
+ DirichletBC,
3
10
  enforce_dirichlet_dense,
11
+ enforce_dirichlet_dense_jax,
12
+ enforce_dirichlet_fluxsparse,
4
13
  enforce_dirichlet_sparse,
5
14
  free_dofs,
15
+ split_dirichlet_matrix,
16
+ restrict_flux_to_free,
17
+ condense_dirichlet_system,
18
+ enforce_dirichlet_system,
6
19
  condense_dirichlet_fluxsparse,
20
+ condense_dirichlet_fluxsparse_coo,
7
21
  condense_dirichlet_dense,
8
22
  expand_dirichlet_solution,
9
23
  )
10
- from .cg import cg_solve, cg_solve_jax
24
+ from .cg import cg_solve, cg_solve_jax, build_cg_operator, CGOperator
25
+ from .preconditioner import make_block_jacobi_preconditioner
26
+ from .block_system import build_block_system, split_block_matrix, BlockSystem
27
+ from .block_matrix import diag as block_diag, make as make_block_matrix
11
28
  from .newton import newton_solve
12
29
  from .solve_runner import (
13
30
  NonlinearAnalysis,
@@ -21,18 +38,38 @@ from .solve_runner import (
21
38
  LinearSolveRunner,
22
39
  )
23
40
  from .solver import LinearSolver, NonlinearSolver
41
+ from .petsc import petsc_solve, petsc_shell_solve, petsc_is_available
24
42
 
25
43
  __all__ = [
26
44
  "SparsityPattern",
27
45
  "FluxSparseMatrix",
46
+ "coalesce_coo",
47
+ "concat_flux",
48
+ "block_diag_flux",
49
+ "DirichletBC",
28
50
  "enforce_dirichlet_dense",
51
+ "enforce_dirichlet_dense_jax",
52
+ "enforce_dirichlet_fluxsparse",
29
53
  "enforce_dirichlet_sparse",
54
+ "split_dirichlet_matrix",
55
+ "enforce_dirichlet_system",
30
56
  "free_dofs",
57
+ "restrict_flux_to_free",
58
+ "condense_dirichlet_system",
31
59
  "condense_dirichlet_fluxsparse",
60
+ "condense_dirichlet_fluxsparse_coo",
32
61
  "condense_dirichlet_dense",
33
62
  "expand_dirichlet_solution",
34
63
  "cg_solve",
35
64
  "cg_solve_jax",
65
+ "build_cg_operator",
66
+ "CGOperator",
67
+ "make_block_jacobi_preconditioner",
68
+ "build_block_system",
69
+ "split_block_matrix",
70
+ "BlockSystem",
71
+ "block_diag",
72
+ "make_block_matrix",
36
73
  "newton_solve",
37
74
  "LinearAnalysis",
38
75
  "LinearSolveConfig",
@@ -44,4 +81,7 @@ __all__ = [
44
81
  "solve_nonlinear",
45
82
  "LinearSolver",
46
83
  "NonlinearSolver",
84
+ "petsc_solve",
85
+ "petsc_shell_solve",
86
+ "petsc_is_available",
47
87
  ]
fluxfem/solver/bc.py CHANGED
@@ -107,6 +107,10 @@ def vector_surface_load_form(ctx: SurfaceFormContext, load: npt.ArrayLike) -> np
107
107
  return np.asarray(vector_load_form(ctx.v, load_arr))
108
108
 
109
109
 
110
+ vector_surface_load_form._ff_kind = "linear"
111
+ vector_surface_load_form._ff_domain = "surface"
112
+
113
+
110
114
  def make_vector_surface_load_form(load_fn):
111
115
  """
112
116
  Build a vector surface load form from a callable f(x_q) -> (n_q, dim).
@@ -115,9 +119,32 @@ def make_vector_surface_load_form(load_fn):
115
119
  load_q = load_fn(ctx.x_q)
116
120
  return vector_surface_load_form(ctx, load_q)
117
121
 
122
+ _form._ff_kind = "linear"
123
+ _form._ff_domain = "surface"
118
124
  return _form
119
125
 
120
126
 
127
+ def traction_vector(traction, traction_dir: str) -> np.ndarray:
128
+ """
129
+ Resolve traction magnitude and direction string into a vector.
130
+ """
131
+ dir_map = {
132
+ "x": (1.0, 0.0, 0.0),
133
+ "xpos": (1.0, 0.0, 0.0),
134
+ "xneg": (-1.0, 0.0, 0.0),
135
+ "y": (0.0, 1.0, 0.0),
136
+ "ypos": (0.0, 1.0, 0.0),
137
+ "yneg": (0.0, -1.0, 0.0),
138
+ "z": (0.0, 0.0, 1.0),
139
+ "zpos": (0.0, 0.0, 1.0),
140
+ "zneg": (0.0, 0.0, -1.0),
141
+ }
142
+ key = traction_dir.strip().lower()
143
+ if key not in dir_map:
144
+ raise ValueError("TRACTION_DIR must be one of x/xpos/xneg/y/ypos/yneg/z/zpos/zneg")
145
+ return float(traction) * np.asarray(dir_map[key], dtype=float)
146
+
147
+
121
148
  def _surface_quadrature(node_coords: np.ndarray):
122
149
  m = node_coords.shape[0]
123
150
  if m == 4:
@@ -404,8 +431,17 @@ def facet_normals(surface: SurfaceMesh, *, outward_from: npt.ArrayLike | None =
404
431
  for i, facet in enumerate(facets):
405
432
  if len(facet) < 3:
406
433
  continue
407
- p0, p1, p2 = coords[facet[0]], coords[facet[1]], coords[facet[2]]
408
- n = np.cross(p1 - p0, p2 - p0)
434
+ n = None
435
+ p0 = coords[facet[0]]
436
+ for j in range(1, len(facet) - 1):
437
+ p1 = coords[facet[j]]
438
+ p2 = coords[facet[j + 1]]
439
+ n_candidate = np.cross(p1 - p0, p2 - p0)
440
+ if np.linalg.norm(n_candidate) > 0.0:
441
+ n = n_candidate
442
+ break
443
+ if n is None:
444
+ continue
409
445
  if normalize:
410
446
  norm = np.linalg.norm(n)
411
447
  n = n / norm if norm != 0.0 else n