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