fluxfem 0.1.4__py3-none-any.whl → 0.2.1__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 (53) hide show
  1. fluxfem/__init__.py +69 -13
  2. fluxfem/core/__init__.py +140 -53
  3. fluxfem/core/assembly.py +691 -97
  4. fluxfem/core/basis.py +75 -54
  5. fluxfem/core/context_types.py +36 -12
  6. fluxfem/core/dtypes.py +9 -1
  7. fluxfem/core/forms.py +10 -0
  8. fluxfem/core/mixed_assembly.py +263 -0
  9. fluxfem/core/mixed_space.py +382 -0
  10. fluxfem/core/mixed_weakform.py +97 -0
  11. fluxfem/core/solver.py +2 -0
  12. fluxfem/core/space.py +315 -30
  13. fluxfem/core/weakform.py +821 -42
  14. fluxfem/helpers_wf.py +49 -0
  15. fluxfem/mesh/__init__.py +54 -2
  16. fluxfem/mesh/base.py +318 -9
  17. fluxfem/mesh/contact.py +841 -0
  18. fluxfem/mesh/dtypes.py +12 -0
  19. fluxfem/mesh/hex.py +17 -16
  20. fluxfem/mesh/io.py +9 -6
  21. fluxfem/mesh/mortar.py +3970 -0
  22. fluxfem/mesh/supermesh.py +318 -0
  23. fluxfem/mesh/surface.py +104 -26
  24. fluxfem/mesh/tet.py +16 -7
  25. fluxfem/physics/diffusion.py +3 -0
  26. fluxfem/physics/elasticity/hyperelastic.py +35 -3
  27. fluxfem/physics/elasticity/linear.py +22 -4
  28. fluxfem/physics/elasticity/stress.py +9 -5
  29. fluxfem/physics/operators.py +12 -5
  30. fluxfem/physics/postprocess.py +29 -3
  31. fluxfem/solver/__init__.py +47 -2
  32. fluxfem/solver/bc.py +38 -2
  33. fluxfem/solver/block_matrix.py +284 -0
  34. fluxfem/solver/block_system.py +477 -0
  35. fluxfem/solver/cg.py +150 -55
  36. fluxfem/solver/dirichlet.py +358 -5
  37. fluxfem/solver/history.py +15 -3
  38. fluxfem/solver/newton.py +260 -70
  39. fluxfem/solver/petsc.py +445 -0
  40. fluxfem/solver/preconditioner.py +109 -0
  41. fluxfem/solver/result.py +18 -0
  42. fluxfem/solver/solve_runner.py +208 -23
  43. fluxfem/solver/solver.py +35 -12
  44. fluxfem/solver/sparse.py +149 -15
  45. fluxfem/tools/jit.py +19 -7
  46. fluxfem/tools/timer.py +14 -12
  47. fluxfem/tools/visualizer.py +16 -4
  48. fluxfem-0.2.1.dist-info/METADATA +314 -0
  49. fluxfem-0.2.1.dist-info/RECORD +59 -0
  50. fluxfem-0.1.4.dist-info/METADATA +0 -127
  51. fluxfem-0.1.4.dist-info/RECORD +0 -48
  52. {fluxfem-0.1.4.dist-info → fluxfem-0.2.1.dist-info}/LICENSE +0 -0
  53. {fluxfem-0.1.4.dist-info → fluxfem-0.2.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,841 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Mapping, Sequence, TYPE_CHECKING, TypeAlias
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ from .mortar import (
10
+ assemble_mixed_surface_jacobian,
11
+ assemble_mixed_surface_residual,
12
+ assemble_onesided_bilinear,
13
+ assemble_contact_onesided_floor,
14
+ assemble_mortar_matrices,
15
+ map_surface_facets_to_tet_elements,
16
+ map_surface_facets_to_hex_elements,
17
+ )
18
+ from .supermesh import build_surface_supermesh
19
+ from .surface import SurfaceMesh
20
+ from .base import BaseMesh
21
+
22
+ if TYPE_CHECKING:
23
+ from .mortar import MortarMatrix
24
+ from ..core.weakform import Params as WeakParams
25
+ from .mortar import SurfaceMixedFormContext
26
+
27
+ ContactJacobianReturn: TypeAlias = np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray, int]
28
+ MixedSurfaceResidualForm: TypeAlias = Callable[
29
+ ["SurfaceMixedFormContext", Mapping[str, npt.ArrayLike], Any],
30
+ Mapping[str, npt.ArrayLike],
31
+ ]
32
+ SurfaceHatFn: TypeAlias = Callable[[np.ndarray], npt.ArrayLike]
33
+
34
+ _CONTACT_SETUP_CACHE: dict[tuple, "ContactSurfaceSpace"] = {}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ContactSide:
39
+ surface: SurfaceMesh
40
+ elem_conn: np.ndarray | None
41
+ value_dim: int
42
+ space: object | None = None
43
+
44
+ @classmethod
45
+ def from_facets(
46
+ cls,
47
+ mesh: BaseMesh,
48
+ facets: np.ndarray,
49
+ space=None,
50
+ *,
51
+ value_dim: int | None = None,
52
+ mode: str = "touching",
53
+ ):
54
+ side = mesh.surface_with_elem_conn_from_facets(facets, mode=mode)
55
+ if value_dim is None:
56
+ if space is None:
57
+ raise ValueError("space or value_dim is required for ContactSide.from_facets")
58
+ value_dim = int(getattr(space, "value_dim", 1))
59
+ return cls(surface=side.surface, elem_conn=side.elem_conn, value_dim=int(value_dim), space=space)
60
+
61
+ @classmethod
62
+ def from_surfaces(
63
+ cls,
64
+ surface: SurfaceMesh,
65
+ *,
66
+ elem_conn: np.ndarray | None = None,
67
+ value_dim: int = 1,
68
+ space: object | None = None,
69
+ ):
70
+ return cls(surface=surface, elem_conn=elem_conn, value_dim=int(value_dim), space=space)
71
+
72
+
73
+ def _facet_map_for_elem_conn(surface: SurfaceMesh, elem_conn: np.ndarray | None) -> np.ndarray:
74
+ if elem_conn is None:
75
+ raise ValueError("elem_conn is required to build facet_to_elem mapping.")
76
+ if elem_conn.shape[1] in {4, 10}:
77
+ return map_surface_facets_to_tet_elements(surface, elem_conn)
78
+ if elem_conn.shape[1] in {8, 20, 27}:
79
+ return map_surface_facets_to_hex_elements(surface, elem_conn)
80
+ raise NotImplementedError("elem_conn must be tet4/tet10/hex8/hex20/hex27")
81
+
82
+
83
+ def facet_gap_values(
84
+ coords: np.ndarray,
85
+ facets: np.ndarray,
86
+ u: np.ndarray,
87
+ n: np.ndarray,
88
+ c: float,
89
+ *,
90
+ value_dim: int | None = None,
91
+ reduce: str = "min",
92
+ ) -> tuple[np.ndarray, float]:
93
+ """
94
+ Compute per-facet gap values for a one-sided contact plane.
95
+
96
+ Returns (g_f, min_g_all) where g_f is reduced per facet and min_g_all is
97
+ the global minimum node gap.
98
+ """
99
+ coords_np = np.asarray(coords, dtype=float)
100
+ if value_dim is None:
101
+ value_dim = int(coords_np.shape[1])
102
+ u_nodes = np.asarray(u, dtype=float).reshape(-1, value_dim)
103
+ x_cur = coords_np + u_nodes
104
+ g_all = np.dot(x_cur, np.asarray(n, dtype=float)) - float(c)
105
+ min_g_all = float(np.min(g_all)) if g_all.size else 0.0
106
+ if facets is None or len(facets) == 0:
107
+ return np.zeros((0,), dtype=float), min_g_all
108
+ if reduce == "min":
109
+ g_f = np.array([np.min(g_all[np.asarray(facet, dtype=int)]) for facet in facets], dtype=float)
110
+ elif reduce == "mean":
111
+ g_f = np.array([np.mean(g_all[np.asarray(facet, dtype=int)]) for facet in facets], dtype=float)
112
+ else:
113
+ raise ValueError("reduce must be 'min' or 'mean'")
114
+ return g_f, min_g_all
115
+
116
+
117
+ def active_contact_facets(
118
+ coords: np.ndarray,
119
+ facets: np.ndarray,
120
+ u: np.ndarray,
121
+ n: np.ndarray,
122
+ c: float,
123
+ *,
124
+ value_dim: int | None = None,
125
+ reduce: str = "min",
126
+ threshold: float = 0.0,
127
+ ) -> tuple[np.ndarray, float]:
128
+ """Return active facet indices and global minimum gap for one-sided contact."""
129
+ g_f, min_g_all = facet_gap_values(
130
+ coords,
131
+ facets,
132
+ u,
133
+ n,
134
+ c,
135
+ value_dim=value_dim,
136
+ reduce=reduce,
137
+ )
138
+ active_ids = np.nonzero(g_f < threshold)[0]
139
+ return active_ids, min_g_all
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class OneSidedContact:
144
+ side: ContactSide
145
+ n: np.ndarray | None
146
+ c: float
147
+ k: float
148
+ beta: float
149
+ quad_order: int = 2
150
+ normal_sign: float = 1.0
151
+ tol: float = 1e-8
152
+ facet_map: np.ndarray | None = None
153
+
154
+ @classmethod
155
+ def from_side(
156
+ cls,
157
+ side: ContactSide,
158
+ *,
159
+ n: np.ndarray | None,
160
+ c: float,
161
+ k: float,
162
+ beta: float,
163
+ quad_order: int = 2,
164
+ normal_sign: float = 1.0,
165
+ tol: float = 1e-8,
166
+ facet_map: np.ndarray | None = None,
167
+ ) -> "OneSidedContact":
168
+ if facet_map is None:
169
+ facet_map = _facet_map_for_elem_conn(side.surface, side.elem_conn)
170
+ return cls(
171
+ side=side,
172
+ n=n,
173
+ c=float(c),
174
+ k=float(k),
175
+ beta=float(beta),
176
+ quad_order=int(quad_order),
177
+ normal_sign=float(normal_sign),
178
+ tol=float(tol),
179
+ facet_map=facet_map,
180
+ )
181
+
182
+ def assemble(self, u, *, return_metrics: bool = False):
183
+ return assemble_contact_onesided_floor(
184
+ self.side.surface,
185
+ np.asarray(u, dtype=float),
186
+ n=None if self.n is None else np.asarray(self.n, dtype=float),
187
+ c=self.c,
188
+ k=self.k,
189
+ beta=self.beta,
190
+ value_dim=self.side.value_dim,
191
+ elem_conn=np.asarray(self.side.elem_conn) if self.side.elem_conn is not None else None,
192
+ facet_to_elem=self.facet_map,
193
+ quad_order=self.quad_order,
194
+ normal_sign=self.normal_sign,
195
+ tol=self.tol,
196
+ return_metrics=return_metrics,
197
+ )
198
+
199
+
200
+ @dataclass(eq=False)
201
+ class OneSidedContactSurfaceSpace:
202
+ """Surface wrapper for one-sided (Dirichlet) contact assembly."""
203
+
204
+ surface_slave: SurfaceMesh
205
+ elem_conn_slave: np.ndarray
206
+ facet_to_elem_slave: np.ndarray
207
+ value_dim: int = 1
208
+ quad_order: int = 2
209
+ normal_sign: float = 1.0
210
+ tol: float = 1e-8
211
+ surface_master: SurfaceMesh | None = None
212
+ elem_conn_master: np.ndarray | None = None
213
+ facet_to_elem_master: np.ndarray | None = None
214
+
215
+ @classmethod
216
+ def from_side(
217
+ cls,
218
+ side: ContactSide,
219
+ *,
220
+ surface_master: SurfaceMesh | None = None,
221
+ elem_conn_master: np.ndarray | None = None,
222
+ facet_to_elem_master: np.ndarray | None = None,
223
+ quad_order: int = 2,
224
+ normal_sign: float = 1.0,
225
+ tol: float = 1e-8,
226
+ ) -> "OneSidedContactSurfaceSpace":
227
+ if side.elem_conn is None:
228
+ raise ValueError("side.elem_conn is required for one-sided assembly")
229
+ facet_map_slave = _facet_map_for_elem_conn(side.surface, side.elem_conn)
230
+ facet_map_master = facet_to_elem_master
231
+ if surface_master is not None and elem_conn_master is not None and facet_map_master is None:
232
+ facet_map_master = _facet_map_for_elem_conn(surface_master, elem_conn_master)
233
+ return cls(
234
+ surface_slave=side.surface,
235
+ elem_conn_slave=np.asarray(side.elem_conn, dtype=int),
236
+ facet_to_elem_slave=np.asarray(facet_map_slave, dtype=int),
237
+ value_dim=int(side.value_dim),
238
+ quad_order=int(quad_order),
239
+ normal_sign=float(normal_sign),
240
+ tol=float(tol),
241
+ surface_master=surface_master,
242
+ elem_conn_master=None if elem_conn_master is None else np.asarray(elem_conn_master, dtype=int),
243
+ facet_to_elem_master=None if facet_map_master is None else np.asarray(facet_map_master, dtype=int),
244
+ )
245
+
246
+ @classmethod
247
+ def from_facets(
248
+ cls,
249
+ mesh: BaseMesh,
250
+ facets: np.ndarray,
251
+ space=None,
252
+ *,
253
+ surface_master: SurfaceMesh | None = None,
254
+ elem_conn_master: np.ndarray | None = None,
255
+ facet_to_elem_master: np.ndarray | None = None,
256
+ value_dim: int | None = None,
257
+ quad_order: int = 2,
258
+ normal_sign: float = 1.0,
259
+ tol: float = 1e-8,
260
+ mode: str = "touching",
261
+ ) -> "OneSidedContactSurfaceSpace":
262
+ side = ContactSide.from_facets(mesh, facets, space, value_dim=value_dim, mode=mode)
263
+ return cls.from_side(
264
+ side,
265
+ surface_master=surface_master,
266
+ elem_conn_master=elem_conn_master,
267
+ facet_to_elem_master=facet_to_elem_master,
268
+ quad_order=quad_order,
269
+ normal_sign=normal_sign,
270
+ tol=tol,
271
+ )
272
+
273
+ def assemble_bilinear(
274
+ self,
275
+ u_hat_fn: SurfaceHatFn | None,
276
+ params: "WeakParams",
277
+ *,
278
+ u_master: np.ndarray | None = None,
279
+ grad_source: str = "volume",
280
+ dof_source: str = "volume",
281
+ quad_order: int | None = None,
282
+ normal_sign: float | None = None,
283
+ tol: float | None = None,
284
+ ) -> tuple[np.ndarray, np.ndarray]:
285
+ return assemble_onesided_bilinear(
286
+ self.surface_slave,
287
+ u_hat_fn,
288
+ params,
289
+ surface_master=self.surface_master,
290
+ u_master=u_master,
291
+ value_dim=self.value_dim,
292
+ elem_conn=self.elem_conn_slave,
293
+ facet_to_elem=self.facet_to_elem_slave,
294
+ elem_conn_master=self.elem_conn_master,
295
+ facet_to_elem_master=self.facet_to_elem_master,
296
+ grad_source=grad_source,
297
+ dof_source=dof_source,
298
+ quad_order=self.quad_order if quad_order is None else int(quad_order),
299
+ normal_sign=self.normal_sign if normal_sign is None else float(normal_sign),
300
+ tol=self.tol if tol is None else float(tol),
301
+ )
302
+
303
+
304
+ @dataclass(eq=False)
305
+ class ContactSurfaceSpace:
306
+ """Surface interface wrapper for contact assembly on a supermesh."""
307
+
308
+ surface_master: SurfaceMesh
309
+ surface_slave: SurfaceMesh
310
+ supermesh_coords: np.ndarray
311
+ supermesh_conn: np.ndarray
312
+ source_facets_master: np.ndarray
313
+ source_facets_slave: np.ndarray
314
+ elem_conn_master: np.ndarray | None
315
+ elem_conn_slave: np.ndarray | None
316
+ facet_to_elem_master: np.ndarray | None
317
+ facet_to_elem_slave: np.ndarray | None
318
+ field_master: str = "a"
319
+ field_slave: str = "b"
320
+ value_dim_master: int = 1
321
+ value_dim_slave: int = 1
322
+ quad_order: int = 1
323
+ normal_sign: float | None = None
324
+ tol: float = 1e-8
325
+ backend: str = "jax"
326
+ fd_eps: float = 1e-6
327
+ fd_mode: str = "central"
328
+ fd_block_size: int = 1
329
+ batch_jac: bool | None = None
330
+
331
+ @classmethod
332
+ def from_surfaces(
333
+ cls,
334
+ surface_master: SurfaceMesh,
335
+ surface_slave: SurfaceMesh,
336
+ *,
337
+ elem_conn_master: np.ndarray | None = None,
338
+ elem_conn_slave: np.ndarray | None = None,
339
+ field_master: str = "a",
340
+ field_slave: str = "b",
341
+ value_dim_master: int = 1,
342
+ value_dim_slave: int = 1,
343
+ quad_order: int = 1,
344
+ normal_sign: float | None = None,
345
+ tol: float = 1e-8,
346
+ backend: str = "jax",
347
+ fd_eps: float = 1e-6,
348
+ fd_mode: str = "central",
349
+ fd_block_size: int = 1,
350
+ batch_jac: bool | None = None,
351
+ setup_cache_enabled: bool | None = None,
352
+ setup_cache_trace: bool | None = None,
353
+ ) -> "ContactSurfaceSpace":
354
+ import hashlib
355
+ import os
356
+
357
+ if setup_cache_enabled is None:
358
+ setup_cache_enabled = os.getenv("FLUXFEM_CONTACT_SETUP_CACHE", "0") not in ("0", "", "false", "False")
359
+ if setup_cache_trace is None:
360
+ setup_cache_trace = os.getenv("FLUXFEM_CONTACT_SETUP_CACHE_TRACE", "0") not in ("0", "", "false", "False")
361
+
362
+ def _array_sig(arr: np.ndarray) -> tuple:
363
+ arr_c = np.ascontiguousarray(arr)
364
+ h = hashlib.blake2b(arr_c.view(np.uint8), digest_size=8).hexdigest()
365
+ return (arr_c.shape, str(arr_c.dtype), h)
366
+
367
+ if setup_cache_enabled:
368
+ global _CONTACT_SETUP_CACHE
369
+ try:
370
+ _CONTACT_SETUP_CACHE
371
+ except NameError:
372
+ _CONTACT_SETUP_CACHE = {}
373
+ key = (
374
+ _array_sig(np.asarray(surface_master.coords)),
375
+ _array_sig(np.asarray(surface_master.conn)),
376
+ _array_sig(np.asarray(surface_slave.coords)),
377
+ _array_sig(np.asarray(surface_slave.conn)),
378
+ None if elem_conn_master is None else _array_sig(np.asarray(elem_conn_master)),
379
+ None if elem_conn_slave is None else _array_sig(np.asarray(elem_conn_slave)),
380
+ field_master,
381
+ field_slave,
382
+ int(value_dim_master),
383
+ int(value_dim_slave),
384
+ int(quad_order),
385
+ float(normal_sign) if normal_sign is not None else None,
386
+ float(tol),
387
+ backend,
388
+ float(fd_eps),
389
+ fd_mode,
390
+ int(fd_block_size),
391
+ bool(batch_jac) if batch_jac is not None else None,
392
+ )
393
+ cached = _CONTACT_SETUP_CACHE.get(key)
394
+ if cached is not None:
395
+ if setup_cache_trace:
396
+ print(
397
+ f"[contact] setup cache hit n_tris={int(cached.supermesh_conn.shape[0])}",
398
+ flush=True,
399
+ )
400
+ return cached
401
+
402
+ sm = build_surface_supermesh(surface_master, surface_slave, tol=tol)
403
+ facet_map_master = None
404
+ facet_map_slave = None
405
+ if elem_conn_master is not None:
406
+ if elem_conn_master.shape[1] in {4, 10}:
407
+ facet_map_master = map_surface_facets_to_tet_elements(surface_master, elem_conn_master)
408
+ elif elem_conn_master.shape[1] in {8, 20, 27}:
409
+ facet_map_master = map_surface_facets_to_hex_elements(surface_master, elem_conn_master)
410
+ else:
411
+ raise NotImplementedError("elem_conn_master must be tet4/tet10/hex8/hex20/hex27")
412
+ if elem_conn_slave is not None:
413
+ if elem_conn_slave.shape[1] in {4, 10}:
414
+ facet_map_slave = map_surface_facets_to_tet_elements(surface_slave, elem_conn_slave)
415
+ elif elem_conn_slave.shape[1] in {8, 20, 27}:
416
+ facet_map_slave = map_surface_facets_to_hex_elements(surface_slave, elem_conn_slave)
417
+ else:
418
+ raise NotImplementedError("elem_conn_slave must be tet4/tet10/hex8/hex20/hex27")
419
+ obj = cls(
420
+ surface_master=surface_master,
421
+ surface_slave=surface_slave,
422
+ supermesh_coords=sm.coords,
423
+ supermesh_conn=sm.conn,
424
+ source_facets_master=sm.source_facets_a,
425
+ source_facets_slave=sm.source_facets_b,
426
+ elem_conn_master=elem_conn_master,
427
+ elem_conn_slave=elem_conn_slave,
428
+ facet_to_elem_master=facet_map_master,
429
+ facet_to_elem_slave=facet_map_slave,
430
+ field_master=field_master,
431
+ field_slave=field_slave,
432
+ value_dim_master=value_dim_master,
433
+ value_dim_slave=value_dim_slave,
434
+ quad_order=quad_order,
435
+ normal_sign=normal_sign,
436
+ tol=tol,
437
+ backend=backend,
438
+ fd_eps=fd_eps,
439
+ fd_mode=fd_mode,
440
+ fd_block_size=fd_block_size,
441
+ batch_jac=batch_jac,
442
+ )
443
+ if setup_cache_enabled:
444
+ _CONTACT_SETUP_CACHE[key] = obj
445
+ if setup_cache_trace:
446
+ print(
447
+ f"[contact] setup cache store n_tris={int(obj.supermesh_conn.shape[0])}",
448
+ flush=True,
449
+ )
450
+ return obj
451
+
452
+ @classmethod
453
+ def from_facets(
454
+ cls,
455
+ coords: np.ndarray,
456
+ facets: np.ndarray,
457
+ *,
458
+ elem_conn: np.ndarray | None = None,
459
+ value_dim: int = 1,
460
+ quad_order: int = 1,
461
+ normal_sign: float | None = None,
462
+ tol: float = 1e-8,
463
+ backend: str = "jax",
464
+ fd_eps: float = 1e-6,
465
+ fd_mode: str = "central",
466
+ fd_block_size: int = 1,
467
+ batch_jac: bool | None = None,
468
+ setup_cache_enabled: bool | None = None,
469
+ setup_cache_trace: bool | None = None,
470
+ ) -> "ContactSurfaceSpace":
471
+ surface = SurfaceMesh.from_facets(coords, facets)
472
+ return cls.from_surfaces(
473
+ surface,
474
+ surface,
475
+ elem_conn_master=elem_conn,
476
+ elem_conn_slave=elem_conn,
477
+ value_dim_master=value_dim,
478
+ value_dim_slave=value_dim,
479
+ quad_order=quad_order,
480
+ normal_sign=normal_sign,
481
+ tol=tol,
482
+ backend=backend,
483
+ fd_eps=fd_eps,
484
+ fd_mode=fd_mode,
485
+ fd_block_size=fd_block_size,
486
+ batch_jac=batch_jac,
487
+ setup_cache_enabled=setup_cache_enabled,
488
+ setup_cache_trace=setup_cache_trace,
489
+ )
490
+
491
+ @classmethod
492
+ def from_surfaces_and_spaces(
493
+ cls,
494
+ surface_master: SurfaceMesh,
495
+ surface_slave: SurfaceMesh,
496
+ space_master,
497
+ space_slave,
498
+ *,
499
+ elem_conn_master: np.ndarray | None = None,
500
+ elem_conn_slave: np.ndarray | None = None,
501
+ field_master: str = "a",
502
+ field_slave: str = "b",
503
+ value_dim_master: int | None = None,
504
+ value_dim_slave: int | None = None,
505
+ quad_order: int = 1,
506
+ normal_sign: float | None = None,
507
+ tol: float = 1e-8,
508
+ backend: str = "jax",
509
+ fd_eps: float = 1e-6,
510
+ fd_mode: str = "central",
511
+ fd_block_size: int = 1,
512
+ batch_jac: bool | None = None,
513
+ ) -> "ContactSurfaceSpace":
514
+ if value_dim_master is None:
515
+ value_dim_master = int(getattr(space_master, "value_dim", 1))
516
+ if value_dim_slave is None:
517
+ value_dim_slave = int(getattr(space_slave, "value_dim", 1))
518
+ return cls.from_surfaces(
519
+ surface_master,
520
+ surface_slave,
521
+ elem_conn_master=elem_conn_master,
522
+ elem_conn_slave=elem_conn_slave,
523
+ field_master=field_master,
524
+ field_slave=field_slave,
525
+ value_dim_master=value_dim_master,
526
+ value_dim_slave=value_dim_slave,
527
+ quad_order=quad_order,
528
+ normal_sign=normal_sign,
529
+ tol=tol,
530
+ backend=backend,
531
+ fd_eps=fd_eps,
532
+ fd_mode=fd_mode,
533
+ fd_block_size=fd_block_size,
534
+ batch_jac=batch_jac,
535
+ )
536
+
537
+ @classmethod
538
+ def from_sides(
539
+ cls,
540
+ master: ContactSide,
541
+ slave: ContactSide,
542
+ *,
543
+ field_master: str = "a",
544
+ field_slave: str = "b",
545
+ quad_order: int = 1,
546
+ normal_sign: float | None = None,
547
+ tol: float = 1e-8,
548
+ backend: str = "jax",
549
+ fd_eps: float = 1e-6,
550
+ fd_mode: str = "central",
551
+ fd_block_size: int = 1,
552
+ batch_jac: bool | None = None,
553
+ setup_cache_enabled: bool | None = None,
554
+ setup_cache_trace: bool | None = None,
555
+ ) -> "ContactSurfaceSpace":
556
+ return cls.from_surfaces(
557
+ master.surface,
558
+ slave.surface,
559
+ elem_conn_master=master.elem_conn,
560
+ elem_conn_slave=slave.elem_conn,
561
+ field_master=field_master,
562
+ field_slave=field_slave,
563
+ value_dim_master=master.value_dim,
564
+ value_dim_slave=slave.value_dim,
565
+ quad_order=quad_order,
566
+ normal_sign=normal_sign,
567
+ tol=tol,
568
+ backend=backend,
569
+ fd_eps=fd_eps,
570
+ fd_mode=fd_mode,
571
+ fd_block_size=fd_block_size,
572
+ batch_jac=batch_jac,
573
+ setup_cache_enabled=setup_cache_enabled,
574
+ setup_cache_trace=setup_cache_trace,
575
+ )
576
+
577
+ @classmethod # type: ignore[no-redef]
578
+ def from_facets(
579
+ cls,
580
+ coords_master: np.ndarray,
581
+ facets_master: np.ndarray,
582
+ coords_slave: np.ndarray,
583
+ facets_slave: np.ndarray,
584
+ *,
585
+ elem_conn_master: np.ndarray | None = None,
586
+ elem_conn_slave: np.ndarray | None = None,
587
+ field_master: str = "a",
588
+ field_slave: str = "b",
589
+ value_dim_master: int = 1,
590
+ value_dim_slave: int = 1,
591
+ quad_order: int = 1,
592
+ normal_sign: float | None = None,
593
+ tol: float = 1e-8,
594
+ backend: str = "jax",
595
+ fd_eps: float = 1e-6,
596
+ fd_mode: str = "central",
597
+ batch_jac: bool | None = None,
598
+ setup_cache_enabled: bool | None = None,
599
+ setup_cache_trace: bool | None = None,
600
+ ) -> "ContactSurfaceSpace":
601
+ surface_master = SurfaceMesh.from_facets(coords_master, facets_master)
602
+ surface_slave = SurfaceMesh.from_facets(coords_slave, facets_slave)
603
+ return cls.from_surfaces(
604
+ surface_master,
605
+ surface_slave,
606
+ elem_conn_master=elem_conn_master,
607
+ elem_conn_slave=elem_conn_slave,
608
+ field_master=field_master,
609
+ field_slave=field_slave,
610
+ value_dim_master=value_dim_master,
611
+ value_dim_slave=value_dim_slave,
612
+ quad_order=quad_order,
613
+ normal_sign=normal_sign,
614
+ tol=tol,
615
+ backend=backend,
616
+ fd_eps=fd_eps,
617
+ fd_mode=fd_mode,
618
+ batch_jac=batch_jac,
619
+ setup_cache_enabled=setup_cache_enabled,
620
+ setup_cache_trace=setup_cache_trace,
621
+ )
622
+
623
+ def _split_fields(self, u: Mapping[str, np.ndarray] | Sequence[np.ndarray]):
624
+ if isinstance(u, Mapping):
625
+ return u[self.field_master], u[self.field_slave]
626
+ if len(u) != 2:
627
+ raise ValueError("u must be a mapping or a length-2 sequence")
628
+ return u[0], u[1]
629
+
630
+ def _auto_normal_sign(self) -> float:
631
+ if not hasattr(self.surface_master, "facet_normals"):
632
+ return 1.0
633
+ normals = self.surface_master.facet_normals()
634
+ coords = np.asarray(self.surface_master.coords)
635
+ coords_slave = np.asarray(self.surface_slave.coords)
636
+ facets_m = np.asarray(self.surface_master.conn, dtype=int)
637
+ facets_s = np.asarray(self.surface_slave.conn, dtype=int)
638
+ dots = []
639
+ for fa, fb in zip(self.source_facets_master, self.source_facets_slave):
640
+ n = normals[int(fa)]
641
+ cm = np.mean(coords[facets_m[int(fa)]], axis=0)
642
+ cs = np.mean(coords_slave[facets_s[int(fb)]], axis=0)
643
+ dots.append(float(np.dot(n, cs - cm)))
644
+ if not dots:
645
+ return 1.0
646
+ return 1.0 if np.sum(dots) >= 0.0 else -1.0
647
+
648
+ def _resolve_backend(self, backend: str | None) -> str:
649
+ use_backend = self.backend if backend is None else backend
650
+ if use_backend not in {"jax", "numpy"}:
651
+ raise ValueError("backend must be 'jax' or 'numpy'")
652
+ return use_backend
653
+
654
+ def assemble_mortar_matrices(self) -> tuple["MortarMatrix", "MortarMatrix"]:
655
+ """Return (M_aa, M_ab) mortar coupling matrices."""
656
+ return assemble_mortar_matrices(
657
+ self.supermesh_coords,
658
+ self.supermesh_conn,
659
+ self.source_facets_master,
660
+ self.source_facets_slave,
661
+ self.surface_master,
662
+ self.surface_slave,
663
+ )
664
+
665
+ def assemble_residual(
666
+ self,
667
+ res_form: MixedSurfaceResidualForm,
668
+ u: Mapping[str, npt.ArrayLike] | Sequence[npt.ArrayLike],
669
+ params: "WeakParams",
670
+ *,
671
+ normal_sign: float | None = None,
672
+ normal_source: str = "master",
673
+ ) -> np.ndarray:
674
+ u_master, u_slave = self._split_fields(u)
675
+ if normal_sign is None:
676
+ normal_sign = self.normal_sign
677
+ if normal_sign is None:
678
+ normal_sign = self._auto_normal_sign()
679
+ return assemble_mixed_surface_residual(
680
+ self.supermesh_coords,
681
+ self.supermesh_conn,
682
+ self.source_facets_master,
683
+ self.source_facets_slave,
684
+ self.surface_master,
685
+ self.surface_slave,
686
+ res_form,
687
+ u_master,
688
+ u_slave,
689
+ params,
690
+ value_dim_a=self.value_dim_master,
691
+ value_dim_b=self.value_dim_slave,
692
+ field_a=self.field_master,
693
+ field_b=self.field_slave,
694
+ elem_conn_a=self.elem_conn_master,
695
+ elem_conn_b=self.elem_conn_slave,
696
+ facet_to_elem_a=self.facet_to_elem_master,
697
+ facet_to_elem_b=self.facet_to_elem_slave,
698
+ normal_source=normal_source,
699
+ normal_from="master",
700
+ master_field=self.field_master,
701
+ normal_sign=normal_sign,
702
+ grad_source="volume",
703
+ dof_source="volume",
704
+ quad_order=self.quad_order,
705
+ tol=self.tol,
706
+ )
707
+
708
+ def assemble_jacobian(
709
+ self,
710
+ res_form: MixedSurfaceResidualForm,
711
+ u: Mapping[str, npt.ArrayLike] | Sequence[npt.ArrayLike],
712
+ params: "WeakParams",
713
+ *,
714
+ normal_sign: float | None = None,
715
+ normal_source: str = "master",
716
+ sparse: bool = False,
717
+ backend: str | None = None,
718
+ batch_jac: bool | None = None,
719
+ ) -> ContactJacobianReturn:
720
+ u_master, u_slave = self._split_fields(u)
721
+ if normal_sign is None:
722
+ normal_sign = self.normal_sign
723
+ if normal_sign is None:
724
+ normal_sign = self._auto_normal_sign()
725
+ use_backend = self._resolve_backend(backend)
726
+ use_batch_jac = self.batch_jac if batch_jac is None else batch_jac
727
+ return assemble_mixed_surface_jacobian(
728
+ self.supermesh_coords,
729
+ self.supermesh_conn,
730
+ self.source_facets_master,
731
+ self.source_facets_slave,
732
+ self.surface_master,
733
+ self.surface_slave,
734
+ res_form,
735
+ u_master,
736
+ u_slave,
737
+ params,
738
+ value_dim_a=self.value_dim_master,
739
+ value_dim_b=self.value_dim_slave,
740
+ field_a=self.field_master,
741
+ field_b=self.field_slave,
742
+ elem_conn_a=self.elem_conn_master,
743
+ elem_conn_b=self.elem_conn_slave,
744
+ facet_to_elem_a=self.facet_to_elem_master,
745
+ facet_to_elem_b=self.facet_to_elem_slave,
746
+ normal_source=normal_source,
747
+ normal_from="master",
748
+ master_field=self.field_master,
749
+ normal_sign=normal_sign,
750
+ grad_source="volume",
751
+ dof_source="volume",
752
+ quad_order=self.quad_order,
753
+ tol=self.tol,
754
+ sparse=sparse,
755
+ backend=use_backend,
756
+ batch_jac=use_batch_jac,
757
+ fd_eps=self.fd_eps,
758
+ fd_mode=self.fd_mode,
759
+ fd_block_size=self.fd_block_size,
760
+ )
761
+
762
+ def assemble_bilinear(
763
+ self,
764
+ bilin: Callable[..., Any],
765
+ u_master: Mapping[str, npt.ArrayLike] | Sequence[npt.ArrayLike] | npt.ArrayLike,
766
+ u_slave: npt.ArrayLike | None = None,
767
+ params: "WeakParams" | None = None,
768
+ *,
769
+ sparse: bool = False,
770
+ normal_source: str = "master",
771
+ ) -> ContactJacobianReturn:
772
+ """
773
+ Assemble a mixed surface bilinear form with signature (v1, v2, u1, u2, params).
774
+
775
+ Notes:
776
+ - v1/v2/u1/u2 are symbolic field refs; use .val/.grad/.sym_grad in the expression.
777
+ - The bilinear must be linear in v1 and v2 and include ds() in its expression.
778
+ - When building dot products, prefer dot(v1, ...) and dot(v2, ...) to keep shapes consistent.
779
+ - Normal orientation, grad_source, and dof_source are fixed internally for simplicity.
780
+ - u_master/u_slave can be passed as a single mapping/length-2 sequence; in that case,
781
+ pass params as the next positional arg or a keyword.
782
+ """
783
+ from ..core.weakform import (
784
+ compile_mixed_surface_residual,
785
+ compile_mixed_surface_residual_numpy,
786
+ unknown_ref,
787
+ test_ref,
788
+ param_ref,
789
+ zero_ref,
790
+ )
791
+
792
+ def _is_field_pair(obj) -> bool:
793
+ if isinstance(obj, Mapping):
794
+ return True
795
+ return isinstance(obj, Sequence) and not hasattr(obj, "shape")
796
+
797
+ if params is None:
798
+ if u_slave is None:
799
+ raise TypeError("params is required")
800
+ if _is_field_pair(u_master):
801
+ params = u_slave
802
+ u_master, u_slave = self._split_fields(u_master)
803
+ else:
804
+ raise TypeError("params is required")
805
+ elif u_slave is None:
806
+ u_master, u_slave = self._split_fields(u_master)
807
+
808
+ v1 = test_ref(self.field_master)
809
+ v2 = test_ref(self.field_slave)
810
+ u1 = unknown_ref(self.field_master)
811
+ u2 = unknown_ref(self.field_slave)
812
+ z1 = zero_ref(self.field_master)
813
+ z2 = zero_ref(self.field_slave)
814
+ p = param_ref()
815
+
816
+ expr_a = bilin(v1, z2, u1, u2, p)
817
+ expr_b = bilin(z1, v2, u1, u2, p)
818
+ use_backend = self._resolve_backend(None)
819
+ if use_backend == "numpy":
820
+ res_form = compile_mixed_surface_residual_numpy({self.field_master: expr_a, self.field_slave: expr_b})
821
+ else:
822
+ res_form = compile_mixed_surface_residual({self.field_master: expr_a, self.field_slave: expr_b})
823
+ return self.assemble_jacobian(
824
+ res_form,
825
+ {self.field_master: u_master, self.field_slave: u_slave},
826
+ params,
827
+ normal_sign=None,
828
+ normal_source=normal_source,
829
+ sparse=sparse,
830
+ backend=use_backend,
831
+ )
832
+
833
+
834
+ __all__ = [
835
+ "ContactSide",
836
+ "OneSidedContact",
837
+ "OneSidedContactSurfaceSpace",
838
+ "ContactSurfaceSpace",
839
+ "facet_gap_values",
840
+ "active_contact_facets",
841
+ ]