EasyFEA 3.0.0__tar.gz → 3.2.0__tar.gz
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.
- easyfea-3.2.0/EasyFEA/FEM/Operators/NonLinear.py +907 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_group_elem.py +96 -12
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_linalg.py +9 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_mesher.py +32 -26
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/_laws.py +40 -58
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_hyperelastic.py +186 -15
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_simu.py +41 -124
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_weakforms.py +1 -1
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Matplotlib.py +1 -1
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/PyVista.py +13 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_tic.py +11 -5
- {easyfea-3.0.0/easyfea.egg-info → easyfea-3.2.0}/PKG-INFO +10 -9
- {easyfea-3.0.0 → easyfea-3.2.0}/README.md +9 -8
- {easyfea-3.0.0 → easyfea-3.2.0/easyfea.egg-info}/PKG-INFO +10 -9
- {easyfea-3.0.0 → easyfea-3.2.0}/pyproject.toml +1 -1
- easyfea-3.0.0/EasyFEA/FEM/Operators/NonLinear.py +0 -350
- {easyfea-3.0.0 → easyfea-3.2.0}/AUTHORS.md +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_beam.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_hexa.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_point.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_prism.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_quad.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_seg.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_tetra.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_tri.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/Bilinear.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/Linear.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_boundary_conditions.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_field.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_forms.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_gauss.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_mesh.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_utils.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_circle.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_contour.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_domain.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_geom.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_line.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_points.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_utils.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Beam/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Beam/_beam.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Elastic/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Elastic/_laws.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/_state.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_phasefield.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_thermal.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_utils.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_weakforms.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/Solvers.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_beam.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_dic.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_elastic.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_phasefield.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_thermal.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_utils.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Folder.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/GLTF.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/MeshIO.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Paraview.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Terminal.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/USD.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Vizir.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_cache.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_mpi.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_observers.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_params.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_requires.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_types.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/__about__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/__init__.py +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/LICENSE.txt +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/SOURCES.txt +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/dependency_links.txt +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/requires.txt +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/top_level.txt +0 -0
- {easyfea-3.0.0 → easyfea-3.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
# Copyright (C) 2021-2024 Université Gustave Eiffel.
|
|
2
|
+
# Copyright (C) 2025-2026 Université Gustave Eiffel, INRIA.
|
|
3
|
+
# This file is part of the EasyFEA project.
|
|
4
|
+
# EasyFEA is distributed under the terms of the GNU General Public License v3, see LICENSE.txt and CREDITS.md for more information.
|
|
5
|
+
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import TYPE_CHECKING, Optional, Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .._linalg import FeArray
|
|
12
|
+
from .._utils import MatrixType
|
|
13
|
+
from ...Models._utils import Project_matrix_to_vector, Project_vector_to_matrix
|
|
14
|
+
from ...Models.HyperElastic._state import HyperElasticState
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .._group_elem import _GroupElem
|
|
18
|
+
from ...Models.HyperElastic._laws import _HyperElastic
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ----------------------------------------------------------------------------
|
|
22
|
+
# Shared block-assembly kinematics
|
|
23
|
+
# ----------------------------------------------------------------------------
|
|
24
|
+
# These helpers back both the hyperelastic and Kelvin–Voigt operators: the
|
|
25
|
+
# block gradient / strain-displacement operator, the geometric-tangent stress
|
|
26
|
+
# block, and the (xi,...,xn,yi,...) → (xi,yi,zi,...) dof permutation are the
|
|
27
|
+
# same machinery regardless of which stress drives them.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def einsum(*args):
|
|
31
|
+
return np.asarray(np.einsum(*args, optimize=True))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_BLOCK_GRAD_B_ATTR = "_block_grad_B_cache"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __block_grad_B(
|
|
38
|
+
state: "HyperElasticState",
|
|
39
|
+
) -> tuple["FeArray", "FeArray"]:
|
|
40
|
+
"""Block gradient operator ``grad`` and ``B = De(u)·grad``.
|
|
41
|
+
|
|
42
|
+
``grad`` maps nodal dofs (laid out ``xi,...,xn,yi,...,yn,...``) to the flat displacement gradient; ``B`` is the nonlinear (Green-Lagrange) strain- displacement operator. Shared by :func:`SecondPiolaKirchhoffStressTensor` and :func:`KelvinVoigtDamping`.
|
|
43
|
+
|
|
44
|
+
Within one assembly the same ``state`` is handed to both operators, so the
|
|
45
|
+
result is memoized **on the state object** (not in any module-level
|
|
46
|
+
container): the cache lives and dies with that transient state, which both
|
|
47
|
+
avoids rebuilding the identical ``grad``/``B`` twice and cannot accumulate.
|
|
48
|
+
"""
|
|
49
|
+
cached = getattr(state, _BLOCK_GRAD_B_ATTR, None)
|
|
50
|
+
if cached is not None:
|
|
51
|
+
return cached
|
|
52
|
+
|
|
53
|
+
groupElem = state.groupElem
|
|
54
|
+
matrixType = state.matrixType
|
|
55
|
+
dN_e_pg = groupElem.Get_dN_e_pg(matrixType)
|
|
56
|
+
De_e_pg = state.Compute_De()
|
|
57
|
+
|
|
58
|
+
Ne, nPg = dN_e_pg.shape[:2]
|
|
59
|
+
nPe = groupElem.nPe
|
|
60
|
+
dim = groupElem.dim
|
|
61
|
+
nCols = De_e_pg.shape[-1] # = dim², the flat-grad dimension
|
|
62
|
+
|
|
63
|
+
grad_e_pg = FeArray.zeros(Ne, nPg, nCols, dim * nPe)
|
|
64
|
+
rows = np.arange(nCols).reshape((dim, dim))
|
|
65
|
+
cols = np.arange(dim * nPe).reshape(dN_e_pg._shape)
|
|
66
|
+
for i in range(dim):
|
|
67
|
+
grad_e_pg._assemble(rows[i], cols[i], value=dN_e_pg)
|
|
68
|
+
|
|
69
|
+
B_e_pg = De_e_pg @ grad_e_pg
|
|
70
|
+
result = (grad_e_pg, B_e_pg)
|
|
71
|
+
setattr(state, _BLOCK_GRAD_B_ATTR, result)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def __geometric_tangent(
|
|
76
|
+
wJ_e_pg: "FeArray",
|
|
77
|
+
state: "HyperElasticState",
|
|
78
|
+
dWde_e_pg: "FeArray",
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
r"""Geometric (initial-stress) tangent ``∫ gradᵀ · Sig · grad dΩ``.
|
|
81
|
+
|
|
82
|
+
Returned in component-major (``xi,...,xn,yi,...,yn,...``) layout to match the material tangent before the shared reorder.
|
|
83
|
+
|
|
84
|
+
Exploits the block structure ``Sig = I_dim ⊗ dWde``: with ``dWde`` the ``dim×dim`` PK2 (from the Kelvin-Mandel vector) and ``dN`` the cartesian shape-function gradients, the dense ``gradᵀ·Sig·grad`` collapses to a block-diagonal Kronecker product::
|
|
85
|
+
|
|
86
|
+
g = ∫ dNᵀ · dWde · dN dΩ (Ne, nPe, nPe)
|
|
87
|
+
Kgeo = g ⊗ I_dim (Ne, dim·nPe, dim·nPe)
|
|
88
|
+
|
|
89
|
+
i.e. ``Kgeo[j·nPe+a, k·nPe+b] = δ_{jk} · g[a,b]``. This avoids building the dense ``(Ne, nPg, dim², dim²)`` ``Sig`` and the ``dim²``-wide contraction.
|
|
90
|
+
"""
|
|
91
|
+
groupElem = state.groupElem
|
|
92
|
+
Ne, dim, nPe = groupElem.Ne, groupElem.dim, groupElem.nPe
|
|
93
|
+
sig_e_pg = Project_vector_to_matrix(dWde_e_pg) # (Ne, nPg, dim, dim)
|
|
94
|
+
dN_e_pg = groupElem.Get_dN_e_pg(state.matrixType) # (Ne, nPg, dim, nPe)
|
|
95
|
+
g_e = einsum("ep,epab,epac,epcd->ebd", wJ_e_pg, dN_e_pg, sig_e_pg, dN_e_pg)
|
|
96
|
+
return einsum("eab,jk->ejakb", g_e, np.eye(dim)).reshape(Ne, dim * nPe, dim * nPe)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def __reorder(dim: int, nPe: int) -> np.ndarray:
|
|
100
|
+
"""Permutation from ``(xi,...,xn,yi,...,yn,...)`` to ``(xi,yi,zi,...,xn,yn,zn)``."""
|
|
101
|
+
return np.arange(0, nPe * dim).reshape(-1, nPe).T.ravel()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def __reorder_dofs(dim: int, nPe: int, *arrays: np.ndarray) -> tuple[np.ndarray, ...]:
|
|
105
|
+
"""Reorder local arrays from component-major to interleaved dof layout.
|
|
106
|
+
|
|
107
|
+
Applies the :func:`__reorder` permutation — ``(xi,...,xn,yi,...,yn,...)`` → ``(xi,yi,zi,...,xn,yn,zn)`` — to each local array by its rank: a vector ``(Ne, nPe·dim)`` (residual / force) or a matrix ``(Ne, nPe·dim, nPe·dim)`` (tangent / mass / damping). Returns the reordered arrays in the given order, so every nonlinear operator shares this one permutation site instead of open-coding the fancy indexing.
|
|
108
|
+
"""
|
|
109
|
+
perm = __reorder(dim, nPe)
|
|
110
|
+
ri, rj = perm[:, None], perm[None, :]
|
|
111
|
+
reordered = [None] * len(arrays)
|
|
112
|
+
for i, array in enumerate(arrays):
|
|
113
|
+
if array.ndim == 2: # (Ne, ndof) vector
|
|
114
|
+
reordered[i] = array[:, perm]
|
|
115
|
+
elif array.ndim == 3: # (Ne, ndof, ndof) matrix
|
|
116
|
+
reordered[i] = array[:, ri, rj]
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"each array must be (Ne, ndof) or (Ne, ndof, ndof); got ndim {array.ndim}."
|
|
120
|
+
)
|
|
121
|
+
return tuple(reordered)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def __second_piola_block(
|
|
125
|
+
wJ_e_pg: "FeArray",
|
|
126
|
+
state: "HyperElasticState",
|
|
127
|
+
dWde_e_pg: "FeArray",
|
|
128
|
+
d2Wde_e_pg: "FeArray",
|
|
129
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
130
|
+
r"""Residual and material+geometric d2Wde for a Kelvin-Mandel dWde / d2Wde sampled at ``state`` — the shared core of the hyperelastic dWde operators::
|
|
131
|
+
|
|
132
|
+
R_e = ∫ Bᵀ · dWde dΩ
|
|
133
|
+
K_block = ∫ Bᵀ · d2Wde · B dΩ + ∫ gradᵀ (I ⊗ dWde) grad dΩ
|
|
134
|
+
|
|
135
|
+
Both component-major (``xi,...,xn,yi,...``), before the shared reorder. The fused einsum contracts the strain and Gauss-point axes in one pass, avoiding the per-Gauss ``(Ne, nPg, ndof, ndof)`` intermediate a chained ``Bᵀ @ d2Wde @ B`` would build (summation order differs from the matmul chain, so results match only to ~1e-14 relative). :func:`SecondPiolaKirchhoffStressTensor` feeds the constitutive ``(dWde, d2Wde)``; :func:`GonzalezStressTensor` feeds the discrete-gradient ``(Ŝ, ℂ̄)`` at the midpoint state.
|
|
136
|
+
"""
|
|
137
|
+
_, B_e_pg = __block_grad_B(state)
|
|
138
|
+
A_lin = einsum("ep,epji,epjk,epkl->eil", wJ_e_pg, B_e_pg, d2Wde_e_pg, B_e_pg)
|
|
139
|
+
A_geo = __geometric_tangent(wJ_e_pg, state, dWde_e_pg)
|
|
140
|
+
residual_e = einsum("ep,epi,epij->ej", wJ_e_pg, dWde_e_pg, B_e_pg)
|
|
141
|
+
return A_lin + A_geo, residual_e
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def SecondPiolaKirchhoffStressTensor(
|
|
145
|
+
material: "_HyperElastic",
|
|
146
|
+
state: "HyperElasticState",
|
|
147
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
148
|
+
"""Tangent and residual for a hyperelastic constitutive law.
|
|
149
|
+
|
|
150
|
+
Returns ``(K_e, R_e)`` in ``(xi,yi,zi,...,xn,yn,zn)``.
|
|
151
|
+
|
|
152
|
+
The operator pulls
|
|
153
|
+
|
|
154
|
+
- ``De_e_pg`` from ``state.Compute_De()`` — kinematic operator,
|
|
155
|
+
- ``dWde_e_pg`` from ``material.Compute_dWde(state)`` — PK2 in Kelvin-Mandel vector form (strictly ``∂W/∂e``; the non-conservative stresses have their own operators),
|
|
156
|
+
- ``d2Wde_e_pg`` from ``material.Compute_d2Wde(state)`` — consistent tangent in Kelvin-Mandel matrix form,
|
|
157
|
+
|
|
158
|
+
and assembles::
|
|
159
|
+
|
|
160
|
+
B_e_pg = De · grad (strain-displacement)
|
|
161
|
+
Sig_e_pg = block(P(dWde_e_pg)) (geometric tangent kernel)
|
|
162
|
+
|
|
163
|
+
K_e = ∫ Bᵀ · d2Wde · B dΩ + ∫ gradᵀ · Sig · grad dΩ
|
|
164
|
+
R_e = ∫ Bᵀ · dWde dΩ
|
|
165
|
+
|
|
166
|
+
where ``P(·)`` is the Kelvin-Mandel vector → symmetric matrix projection.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
material
|
|
171
|
+
Hyperelastic constitutive law — supplies ``Compute_dWde(state)`` and ``Compute_d2Wde(state)``.
|
|
172
|
+
state
|
|
173
|
+
Hyperelastic state — owns the mesh and the current displacement.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
A_e : ndarray of shape ``(Ne, nPe·dim, nPe·dim)``
|
|
178
|
+
Consistent tangent — sum of the linear (material) and nonlinear (geometric) pieces.
|
|
179
|
+
r_e : ndarray of shape ``(Ne, nPe·dim)``
|
|
180
|
+
Internal residual force.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
groupElem = state.groupElem
|
|
184
|
+
matrixType = state.matrixType
|
|
185
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(matrixType)
|
|
186
|
+
nPe = groupElem.nPe
|
|
187
|
+
dim = groupElem.dim
|
|
188
|
+
|
|
189
|
+
tangent_e, residual_e = __second_piola_block(
|
|
190
|
+
wJ_e_pg,
|
|
191
|
+
state,
|
|
192
|
+
material.Compute_dWde(state),
|
|
193
|
+
material.Compute_d2Wde(state),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if dim == 2:
|
|
197
|
+
thickness = material.thickness
|
|
198
|
+
tangent_e *= thickness
|
|
199
|
+
residual_e *= thickness
|
|
200
|
+
|
|
201
|
+
return __reorder_dofs(dim, nPe, tangent_e, residual_e)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def GonzalezStressTensor(
|
|
205
|
+
material: "_HyperElastic",
|
|
206
|
+
state_n: "HyperElasticState",
|
|
207
|
+
state_mid: "HyperElasticState",
|
|
208
|
+
state_np1: "HyperElasticState",
|
|
209
|
+
useConsistentTangent: bool = True,
|
|
210
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
211
|
+
r"""Energy-conserving (Gonzalez / Simo-Tarnow) tangent and residual.
|
|
212
|
+
|
|
213
|
+
Returns ``(K_e, R_e)`` in ``(xi,yi,zi,...,xn,yn,zn)`` for the **discrete-gradient** stress::
|
|
214
|
+
|
|
215
|
+
Ŝ = s̄ + α Δe , α = (ΔW − s̄·Δe) / (Δe·Δe) (α = 0 when Δe·Δe ≤ ε₀)
|
|
216
|
+
|
|
217
|
+
R_e = ∫ B_midᵀ · Ŝ dΩ
|
|
218
|
+
|
|
219
|
+
with ``s̄ = S(E(ū))`` the midpoint PK2, ``Δe = E(u_{n+1}) − E(u_n)`` and ``ΔW = W(u_{n+1}) − W(u_n)``. By construction ``Ŝ:Δe = ΔW``, and since ``Δe = B(ū)·Δu`` exactly, ``F_int·Δu = ΔW``: total energy is conserved for any stored energy. Kelvin-Mandel form makes the double contractions plain dot products, so no ``halfCrossed``-type correction is needed.
|
|
220
|
+
|
|
221
|
+
Tangent — the **consistent** Jacobian ``∂R_e/∂u_{n+1}``, built for :attr:`~EasyFEA.AlgoType.midpoint`'s ``coefK = 0.5``::
|
|
222
|
+
|
|
223
|
+
coefK · K_e = ½[ ∫ B_midᵀ ℂ̄ B_mid dΩ + A_geo(state_mid, Ŝ) ] # midpoint block, raw
|
|
224
|
+
+ α ∫ B_midᵀ B_{n+1} dΩ + ∫ (B_midᵀ Δe) ⊗ g dΩ # corrections, pre-doubled
|
|
225
|
+
|
|
226
|
+
The midpoint block is returned raw so ``coefK`` supplies its ``∂ū/∂u_{n+1} = ½`` chain factor, while the discrete-gradient corrections are genuine ``∂/∂u_{n+1}`` terms and are pre-doubled to survive it; ``g = ∂α/∂u_{n+1}`` and both corrections vanish where ``Δe·Δe ≤ ε₀``. The rank-1 term makes ``K_e`` non-symmetric.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
material
|
|
231
|
+
Hyperelastic constitutive law — supplies ``Compute_W`` / ``Compute_dWde`` / ``Compute_d2Wde``.
|
|
232
|
+
state_n, state_mid, state_np1
|
|
233
|
+
Hyperelastic states at ``u_n``, ``ū`` and ``u_{n+1}`` (same group / matrix type).
|
|
234
|
+
useConsistentTangent
|
|
235
|
+
If False, keep only the midpoint block: same residual, linear Newton convergence.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
K_e : ndarray of shape ``(Ne, nPe·dim, nPe·dim)``
|
|
240
|
+
Consistent tangent, built for ``coefK = 0.5``.
|
|
241
|
+
R_e : ndarray of shape ``(Ne, nPe·dim)``
|
|
242
|
+
Discrete-gradient internal residual force.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
eps0 = 1e-10
|
|
246
|
+
|
|
247
|
+
groupElem = state_mid.groupElem
|
|
248
|
+
matrixType = state_mid.matrixType
|
|
249
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(matrixType)
|
|
250
|
+
nPe = groupElem.nPe
|
|
251
|
+
dim = groupElem.dim
|
|
252
|
+
|
|
253
|
+
# --- 1. discrete-gradient stress Ŝ = s̄ + α Δe ---
|
|
254
|
+
s_mid = material.Compute_dWde(state_mid) # s̄ (Ne, nPg, d)
|
|
255
|
+
C_mid = material.Compute_d2Wde(state_mid) # ℂ̄ (Ne, nPg, d, d)
|
|
256
|
+
# Kelvin-Mandel strain increment Δe = E(u_{n+1}) − E(u_n), sliced to `d` so it
|
|
257
|
+
# shares s̄'s basis; the √2 shear factor makes s̄:ΔE = s̄·Δe a plain FeArray dot.
|
|
258
|
+
E_n = Project_matrix_to_vector(state_n.Compute_GreenLagrange())
|
|
259
|
+
E_np1 = Project_matrix_to_vector(state_np1.Compute_GreenLagrange())
|
|
260
|
+
dE = state_mid._Slice_Vector(E_np1 - E_n)
|
|
261
|
+
|
|
262
|
+
# numerator N = ΔW − s̄·Δe
|
|
263
|
+
N = (material.Compute_W(state_np1) - material.Compute_W(state_n)) - s_mid.dot(dE)
|
|
264
|
+
dEdE = dE.dot(dE) # Δe·Δe
|
|
265
|
+
# guard the vanishing denominator: α = 0 where Δe·Δe ≤ ε₀ (invD = 0 there)
|
|
266
|
+
inv_dEdE = np.divide(1.0, dEdE, out=np.zeros_like(dEdE), where=dEdE > eps0)
|
|
267
|
+
alpha = N * inv_dEdE # (Ne, nPg), N / Δe·Δe
|
|
268
|
+
S_hat = s_mid + alpha * dE # scalar-field α broadcasts over Δe
|
|
269
|
+
|
|
270
|
+
# --- 2. residual + midpoint material/geometric block (shared with SPK) ---
|
|
271
|
+
# Built RAW so it rides coefK = 0.5's ∂ū/∂u_{n+1} = ½ chain factor exactly like
|
|
272
|
+
# SecondPiolaKirchhoffStressTensor; the geometric block carries the full Ŝ.
|
|
273
|
+
tangent_e, residual_e = __second_piola_block(wJ_e_pg, state_mid, S_hat, C_mid)
|
|
274
|
+
|
|
275
|
+
if useConsistentTangent:
|
|
276
|
+
# --- 3. discrete-gradient tangent corrections (∂α/∂u_{n+1}) ---
|
|
277
|
+
# Genuine ∂/∂u_{n+1} terms (no ½ chain factor) → pre-doubled to survive coefK = 0.5;
|
|
278
|
+
# they vanish where α = 0 (invD = 0). The rank-1 term makes K non-symmetric.
|
|
279
|
+
_, B_mid = __block_grad_B(state_mid) # cache hit (built in phase 2)
|
|
280
|
+
_, B_np1 = __block_grad_B(state_np1)
|
|
281
|
+
s_np1 = material.Compute_dWde(state_np1) # S(E(u_{n+1}))
|
|
282
|
+
v = C_mid @ dE # ℂ̄ : Δe
|
|
283
|
+
# dof-covector g = ∂α/∂u_{n+1}
|
|
284
|
+
g = inv_dEdE * (B_np1.T @ s_np1 - 0.5 * (B_mid.T @ v) - B_np1.T @ s_mid) - (
|
|
285
|
+
N * inv_dEdE * inv_dEdE * 2.0
|
|
286
|
+
) * (B_np1.T @ dE)
|
|
287
|
+
tangent_e += 2.0 * (
|
|
288
|
+
# α ∫ B_midᵀ B_{n+1}
|
|
289
|
+
einsum("ep,ep,epji,epjk->eik", wJ_e_pg, alpha, B_mid, B_np1)
|
|
290
|
+
# rank-1 ∫ (B_midᵀΔe)⊗g
|
|
291
|
+
+ einsum("ep,epi,epj->eij", wJ_e_pg, B_mid.T @ dE, g)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if dim == 2:
|
|
295
|
+
thickness = material.thickness
|
|
296
|
+
tangent_e *= thickness
|
|
297
|
+
residual_e *= thickness
|
|
298
|
+
|
|
299
|
+
return __reorder_dofs(dim, nPe, tangent_e, residual_e)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@lru_cache(maxsize=None)
|
|
303
|
+
def __clenshaw_curtis(nPoints: int) -> tuple[tuple[float, ...], tuple[float, ...]]:
|
|
304
|
+
r"""Clenshaw-Curtis nodes and weights on ``[0, 1]``, using ``nPoints`` points.
|
|
305
|
+
|
|
306
|
+
Nodes are the Chebyshev extrema ``cos(kπ/n)`` (``n = nPoints - 1``) rescaled to the unit interval and returned in **increasing** order; weights come from the direct closed form (Trefethen, *Spectral Methods in MATLAB*, ``clencurt``) and sum to 1. ``nPoints = 1`` is the midpoint convention (single node at ``½``).
|
|
307
|
+
|
|
308
|
+
The classical rules fall out of the same formula — ``nPoints = 1, 2, 3`` are the midpoint, trapezoid and Simpson rules — so nothing downstream special-cases them, and more points converge spectrally for a smooth integrand. The endpoints are *exactly* ``0`` and ``1``, which :func:`TimeQuadratureStressTensor` relies on to reuse the two genuine end states rather than interpolate a strain there.
|
|
309
|
+
|
|
310
|
+
Returns tuples rather than arrays on purpose: the result is cached, and a mutable array handed out repeatedly could be modified in place by a caller and silently corrupt every later call. Cached because the same handful of rules is requested at every Newton iteration.
|
|
311
|
+
"""
|
|
312
|
+
assert nPoints >= 1, f"nPoints must be >= 1 (got {nPoints})."
|
|
313
|
+
if nPoints == 1:
|
|
314
|
+
return (0.5,), (1.0,)
|
|
315
|
+
|
|
316
|
+
n = nPoints - 1
|
|
317
|
+
theta = np.pi * np.arange(n + 1) / n
|
|
318
|
+
x = np.cos(theta)
|
|
319
|
+
w = np.zeros(n + 1)
|
|
320
|
+
ii = np.arange(1, n)
|
|
321
|
+
v = np.ones(n - 1)
|
|
322
|
+
if n % 2 == 0:
|
|
323
|
+
w[0] = w[n] = 1.0 / (n**2 - 1)
|
|
324
|
+
for k in range(1, n // 2):
|
|
325
|
+
v -= 2 * np.cos(2 * k * theta[ii]) / (4 * k**2 - 1)
|
|
326
|
+
v -= np.cos(n * theta[ii]) / (n**2 - 1)
|
|
327
|
+
else:
|
|
328
|
+
w[0] = w[n] = 1.0 / n**2
|
|
329
|
+
for k in range(1, (n - 1) // 2 + 1):
|
|
330
|
+
v -= 2 * np.cos(2 * k * theta[ii]) / (4 * k**2 - 1)
|
|
331
|
+
w[ii] = 2 * v / n
|
|
332
|
+
|
|
333
|
+
# [-1, 1] -> [0, 1], ascending. `cos(π/2)` is 6e-17 rather than 0, so snap the centre
|
|
334
|
+
# node to an exact ½ (it is a meaningful special value on the path).
|
|
335
|
+
nodes = 0.5 * x[::-1] + 0.5
|
|
336
|
+
nodes = np.where(np.abs(nodes - 0.5) < 1e-12, 0.5, nodes)
|
|
337
|
+
|
|
338
|
+
return tuple(nodes), tuple(0.5 * w[::-1])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _StrainPathState(HyperElasticState):
|
|
342
|
+
"""A point of the segment ``C(s) = C_n + s (C_{n+1} - C_n)`` — the strain path integrated by :func:`TimeQuadratureStressTensor`.
|
|
343
|
+
|
|
344
|
+
No displacement field produces such a strain, so only the constitutive response is defined: a law reads a state through the invariants ``I1…I8``, which all descend from :meth:`Compute_C`. The kinematic quantities raise.
|
|
345
|
+
|
|
346
|
+
:meth:`_sliced` builds a holder over an explicit ``C`` — used by the per-element adaptive path to evaluate the response on the still-refining subset of elements. ``Ne``/``nPg`` therefore come from the stored ``C`` (:meth:`_GetDims`), not from the group, so the two constructors stay consistent whether ``C`` spans the whole block or a subset.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
state_n: HyperElasticState,
|
|
352
|
+
state_np1: HyperElasticState,
|
|
353
|
+
s: float,
|
|
354
|
+
):
|
|
355
|
+
"""``s = 0`` sits at ``state_n``, ``s = 1`` at ``state_np1``."""
|
|
356
|
+
assert state_n.groupElem is state_np1.groupElem, "states must share their group"
|
|
357
|
+
assert (
|
|
358
|
+
state_n.matrixType == state_np1.matrixType
|
|
359
|
+
), "states must share matrixType"
|
|
360
|
+
|
|
361
|
+
# the displacement only fixes the solution dimension, it does not generate the strain
|
|
362
|
+
super().__init__(state_n.groupElem, state_n.displacement, state_n.matrixType)
|
|
363
|
+
|
|
364
|
+
C_n = state_n.Compute_C()
|
|
365
|
+
self.__C_e_pg = C_n + s * (state_np1.Compute_C() - C_n)
|
|
366
|
+
|
|
367
|
+
@classmethod
|
|
368
|
+
def _sliced(cls, template: HyperElasticState, C_e_pg) -> "_StrainPathState":
|
|
369
|
+
"""Holder over an explicit ``C_e_pg`` (typically an element subset of a path state). ``template`` lends its group / displacement / matrixType — used only for the scalar ``dim`` — while ``Ne``/``nPg`` come from ``C_e_pg``."""
|
|
370
|
+
obj = cls.__new__(cls)
|
|
371
|
+
HyperElasticState.__init__(
|
|
372
|
+
obj, template.groupElem, template.displacement, template.matrixType
|
|
373
|
+
)
|
|
374
|
+
obj.__C_e_pg = C_e_pg
|
|
375
|
+
return obj
|
|
376
|
+
|
|
377
|
+
def _GetDims(self) -> tuple[int, int, int]:
|
|
378
|
+
# Ne, nPg from the stored C (so a sliced holder sizes its arrays to the subset); dim is
|
|
379
|
+
# a scalar property of the discretisation, taken from the borrowed displacement/group.
|
|
380
|
+
Ne, nPg = self.__C_e_pg.shape[:2]
|
|
381
|
+
dim = self.displacement.size // self.groupElem.Ncoords
|
|
382
|
+
return Ne, nPg, dim
|
|
383
|
+
|
|
384
|
+
def Compute_C(self):
|
|
385
|
+
return self.__C_e_pg
|
|
386
|
+
|
|
387
|
+
def Compute_F(self):
|
|
388
|
+
raise NotImplementedError("an interpolated strain has no deformation gradient")
|
|
389
|
+
|
|
390
|
+
def Compute_De(self):
|
|
391
|
+
raise NotImplementedError(
|
|
392
|
+
"an interpolated strain has no strain-displacement operator"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def __AdaptiveTimeQuadratureStressTensor(
|
|
397
|
+
material: "_HyperElastic",
|
|
398
|
+
state_n: "HyperElasticState",
|
|
399
|
+
state_np1: "HyperElasticState",
|
|
400
|
+
tol: float,
|
|
401
|
+
maxPoints: int,
|
|
402
|
+
) -> tuple["FeArray", "FeArray", int]:
|
|
403
|
+
r"""Per-element adaptive strain-path quadrature — the ``tol``-driven path of :func:`TimeQuadratureStressTensor`.
|
|
404
|
+
|
|
405
|
+
Each element refines along the nested chain ``1, 3, 5, 9, …`` until *its own* energy defect is within ``tol``, then freezes — so a low-strain element stops at one point while a high-strain one keeps refining. The test is the **integrated** relative error over the element, ``∫_Ωe (S:Δe − ΔW)² dΩ ≤ tol² ∫_Ωe ΔW² dΩ``. It is evaluated as the Gauss-point sums ``Σ_p V_(ep)(S:Δe − ΔW)² ≤ tol² Σ_p V_(ep) ΔW²`` with ``V_(ep)`` the Gauss point's volume (weight × Jacobian) — so the ``V_(ep)`` factor is what makes each side a genuine *integral over the element*, **not** a pointwise energy-density comparison. Energy-safe because ``S:Δe = ΔW`` holds per Gauss point; the squared, volume-weighted form is safe against sign cancellation between Gauss points, and per element the well-resolved regions cannot mask a coarse one.
|
|
406
|
+
|
|
407
|
+
Only the still-active elements are evaluated at each level — the rule is applied to that subset via :meth:`_StrainPathState._sliced` — so the constitutive cost tracks the hard elements, not the mesh. (Nodes shared with a coarser level are re-evaluated rather than cached; the active set shrinks fast, so that stays cheap and keeps the loop plain.) Returns ``(dWde_quad, d2Wde_quad)`` — each row carrying its element's accepted rule — and the largest point count any element reached.
|
|
408
|
+
"""
|
|
409
|
+
groupElem = state_n.groupElem # state_n and state_np1 share the group
|
|
410
|
+
dim = groupElem.dim
|
|
411
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(
|
|
412
|
+
state_n.matrixType
|
|
413
|
+
) # V_(ep), ref config
|
|
414
|
+
|
|
415
|
+
C_n = state_n.Compute_C()
|
|
416
|
+
dC = state_np1.Compute_C() - C_n
|
|
417
|
+
dW = material.Compute_W(state_np1) - material.Compute_W(state_n) # ΔW per point
|
|
418
|
+
dE = state_np1.Compute_GreenLagrange() - state_n.Compute_GreenLagrange()
|
|
419
|
+
dE_vec = Project_matrix_to_vector(dE[..., :dim, :dim]) # Δe (Kelvin-Mandel)
|
|
420
|
+
refW = einsum("ep,ep,ep->e", wJ_e_pg, dW, dW) # Σ_p V ΔW² (Ne,)
|
|
421
|
+
Ne, nPg, ncomp = *wJ_e_pg.shape[:2], dE_vec.shape[-1]
|
|
422
|
+
|
|
423
|
+
def at(
|
|
424
|
+
s, e
|
|
425
|
+
): # constitutive-state holder at strain-path point s, restricted to elements e
|
|
426
|
+
return _StrainPathState._sliced(state_n, (C_n + s * dC)[e])
|
|
427
|
+
|
|
428
|
+
dWde_quad = FeArray.zeros(Ne, nPg, ncomp)
|
|
429
|
+
d2Wde_quad = FeArray.zeros(Ne, nPg, ncomp, ncomp)
|
|
430
|
+
activeElements = np.arange(Ne) # elements still refining
|
|
431
|
+
nPts, nPts_used = 1, 1
|
|
432
|
+
while activeElements.size:
|
|
433
|
+
nodes, weights = __clenshaw_curtis(nPts)
|
|
434
|
+
S = sum(
|
|
435
|
+
(
|
|
436
|
+
w * material.Compute_dWde(at(s, activeElements))
|
|
437
|
+
for s, w in zip(nodes, weights)
|
|
438
|
+
),
|
|
439
|
+
0.0,
|
|
440
|
+
)
|
|
441
|
+
defect = einsum("epi,epi->ep", S, dE_vec[activeElements]) - dW[activeElements]
|
|
442
|
+
next_nPts = 3 if nPts == 1 else 2 * nPts - 1 # next level in the chain
|
|
443
|
+
# accept an element once its own energy defect is within tol (all of them at the last level)
|
|
444
|
+
done = (next_nPts > max(maxPoints, 1)) | (
|
|
445
|
+
einsum("ep,ep,ep->e", wJ_e_pg[activeElements], defect, defect)
|
|
446
|
+
<= tol * tol * (refW[activeElements] + 1e-300)
|
|
447
|
+
)
|
|
448
|
+
if done.any():
|
|
449
|
+
e = activeElements[done] # elements accepting this rule
|
|
450
|
+
dWde_quad[e] = S[done]
|
|
451
|
+
# their tangent only: Σ_k 2 w_k s_k d2Wde, s=0 drops out (∂e/∂u = s B)
|
|
452
|
+
d2Wde_quad[e] = sum(
|
|
453
|
+
(
|
|
454
|
+
(2.0 * w * s) * material.Compute_d2Wde(at(s, e))
|
|
455
|
+
for s, w in zip(nodes, weights)
|
|
456
|
+
if s
|
|
457
|
+
),
|
|
458
|
+
0.0,
|
|
459
|
+
)
|
|
460
|
+
nPts_used = max(nPts_used, nPts)
|
|
461
|
+
activeElements = activeElements[~done]
|
|
462
|
+
nPts = next_nPts
|
|
463
|
+
|
|
464
|
+
return dWde_quad, d2Wde_quad, nPts_used
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def TimeQuadratureStressTensor(
|
|
468
|
+
material: "_HyperElastic",
|
|
469
|
+
state_n: "HyperElasticState",
|
|
470
|
+
state_mid: "HyperElasticState",
|
|
471
|
+
state_np1: "HyperElasticState",
|
|
472
|
+
nPoints: int,
|
|
473
|
+
tol: Optional[float] = None,
|
|
474
|
+
maxPoints: int = 33,
|
|
475
|
+
) -> tuple[np.ndarray, np.ndarray, int]:
|
|
476
|
+
r"""Tangent and residual for the PK2 stress **averaged along the strain path** of the step.
|
|
477
|
+
|
|
478
|
+
Returns ``(K_e, R_e)`` in ``(xi,yi,zi,...,xn,yn,zn)``. Where :func:`SecondPiolaKirchhoffStressTensor` samples the stress at one configuration, this integrates it along the straight segment joining the two end strains and contracts the result with the **midpoint** operator::
|
|
479
|
+
|
|
480
|
+
e(s) = e_n + s Δe , Δe = e_{n+1} - e_n , s ∈ [0, 1]
|
|
481
|
+
|
|
482
|
+
S_quad = ∫₀¹ ∂W/∂e(e(s)) ds ≈ Σ_k w_k ∂W/∂e(e(s_k))
|
|
483
|
+
R_e = ∫ B(ū)ᵀ · S_quad dΩ
|
|
484
|
+
|
|
485
|
+
``(s_k, w_k)`` is the Clenshaw-Curtis rule on ``nPoints`` points (:func:`__clenshaw_curtis`); ``1, 2, 3`` are the midpoint, trapezoid and Simpson rules. Intermediate nodes are :class:`_StrainPathState`; ``s = 0, 1`` reuse the end states.
|
|
486
|
+
|
|
487
|
+
**Adaptive (per-element) mode.** With ``tol`` set, the rule is chosen *element by element* by :func:`__AdaptiveTimeQuadratureStressTensor`: each element walks the nested chain ``1, 3, 5, 9, 17, 33`` (up to ``maxPoints``) and freezes once *its own* energy defect is within ``tol``, so points are spent only where the step is nonlinear — a low-strain element may stop at a single midpoint while a stiff one keeps refining. The defect ``S_quad:Δe − ΔW`` is the quadrature error of the discrete-gradient identity ``S_quad:Δe = ΔW`` (with ``ΔW`` *known* from the endpoints), so the test is absolute — no consecutive-difference guess. It is the **integrated** relative error over the element, ``∫_Ωe (S_quad:Δe − ΔW)² dΩ ≤ tol² ∫_Ωe ΔW² dΩ`` — the integrals are the volume-weighted Gauss-point sums ``Σ_p V_(ep)···``, *not* a pointwise energy-density comparison — so ``tol`` reads as "conserve energy to this relative tolerance". Squaring guards against sign cancellation between Gauss points, and being absolute the test accepts the coarsest rule directly: the ``1``-point midpoint is exact for a linear energy integrand, so a quadratic ``W`` converges at a single point. The tangent uses each element's accepted rule, so residual and tangent stay consistent.
|
|
488
|
+
|
|
489
|
+
Since ``Δe = B(ū)·Δu`` exactly and ``de/ds = Δe`` is constant along the segment, the fundamental theorem of calculus gives ``S_quad:Δe = ΔW`` once the ``s``-integral is exact — a **discrete gradient**. The energy defect is therefore just the quadrature error, which Clenshaw-Curtis drives down spectrally; a quadratic ``W`` is exact at every rule. Note ``nPoints = 1`` is the average-strain stress ``S(½(e_n + e_{n+1}))``, *not* the midpoint-displacement stress of :func:`SecondPiolaKirchhoffStressTensor`.
|
|
490
|
+
|
|
491
|
+
Tangent — only ``e_{n+1}`` depends on ``u_{n+1}``, and ``∂e(s_k)/∂u_{n+1} = s_k B_{n+1}``, so every node contracts with the same ``B_{n+1}`` and the constitutive tensors collapse into one weighted sum::
|
|
492
|
+
|
|
493
|
+
coefK · K_e = ½ A_geo(state_mid, S_quad) # raw
|
|
494
|
+
+ ∫ B(ū)ᵀ [ Σ_k w_k s_k ℂ(e(s_k)) ] B_{n+1} dΩ # pre-doubled
|
|
495
|
+
|
|
496
|
+
Built for :attr:`~EasyFEA.AlgoType.midpoint`'s ``coefK = 0.5`` as in :func:`GonzalezStressTensor`; the doubled weights sum to 1 whatever ``nPoints``. Pairing ``B(ū)`` with ``B_{n+1}`` makes ``K_e`` non-symmetric.
|
|
497
|
+
|
|
498
|
+
Parameters
|
|
499
|
+
----------
|
|
500
|
+
material
|
|
501
|
+
Hyperelastic constitutive law — supplies ``Compute_dWde(state)`` and ``Compute_d2Wde(state)``.
|
|
502
|
+
state_n, state_mid, state_np1
|
|
503
|
+
Hyperelastic states at ``u_n``, ``ū`` and ``u_{n+1}`` (same group / matrix type).
|
|
504
|
+
nPoints
|
|
505
|
+
Number of Clenshaw-Curtis points for the fixed rule (``tol is None``). Ignored when
|
|
506
|
+
adaptive, which always starts from 1 (the midpoint).
|
|
507
|
+
tol
|
|
508
|
+
If set, refine adaptively (see above) until the relative energy defect
|
|
509
|
+
``‖S_quad:Δe − ΔW‖ / ‖ΔW‖`` falls below ``tol``. ``None`` (default) keeps the fixed
|
|
510
|
+
``nPoints`` rule.
|
|
511
|
+
maxPoints
|
|
512
|
+
Adaptive only: cap on the number of points, by default 33. Refinement stops here even
|
|
513
|
+
if ``tol`` is not met.
|
|
514
|
+
|
|
515
|
+
Returns
|
|
516
|
+
-------
|
|
517
|
+
K_e : ndarray of shape ``(Ne, nPe·dim, nPe·dim)``
|
|
518
|
+
Consistent tangent, built for ``coefK = 0.5``.
|
|
519
|
+
R_e : ndarray of shape ``(Ne, nPe·dim)``
|
|
520
|
+
Internal residual force.
|
|
521
|
+
nPts : int
|
|
522
|
+
Clenshaw-Curtis points used — ``nPoints`` when fixed; when adaptive, the *largest* level
|
|
523
|
+
any element reached (individual elements may use fewer). Lets the caller log the cost.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
groupElem = state_mid.groupElem
|
|
527
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(state_mid.matrixType)
|
|
528
|
+
nPe = groupElem.nPe
|
|
529
|
+
dim = groupElem.dim
|
|
530
|
+
|
|
531
|
+
_, B_mid = __block_grad_B(state_mid)
|
|
532
|
+
_, B_np1 = __block_grad_B(state_np1)
|
|
533
|
+
|
|
534
|
+
if tol is None:
|
|
535
|
+
# Fixed rule: one Clenshaw-Curtis rule for the whole block.
|
|
536
|
+
dWde_quad = 0.0 # Σ_k w_k dWde(e(s_k))
|
|
537
|
+
d2Wde_quad = (
|
|
538
|
+
0.0 # Σ_k 2 w_k s_k d2Wde(e(s_k)); s=0 drops out (∂e/∂u_{n+1}=s B=0)
|
|
539
|
+
)
|
|
540
|
+
for s, w in zip(*__clenshaw_curtis(int(nPoints))):
|
|
541
|
+
if s == 0.0:
|
|
542
|
+
state = state_n
|
|
543
|
+
elif s == 1.0:
|
|
544
|
+
state = state_np1
|
|
545
|
+
else:
|
|
546
|
+
state = _StrainPathState(state_n, state_np1, s)
|
|
547
|
+
dWde_quad += w * material.Compute_dWde(state)
|
|
548
|
+
if s != 0.0:
|
|
549
|
+
d2Wde_quad += (2.0 * w * s) * material.Compute_d2Wde(state)
|
|
550
|
+
nPts_used = int(nPoints)
|
|
551
|
+
else:
|
|
552
|
+
# Adaptive: refine per element on the *energy defect*. This stress exists so that
|
|
553
|
+
# S_quad : Δe = ΔW exactly (a discrete gradient, for energy conservation); the only error
|
|
554
|
+
# that matters is how far the quadrature of ∫ ∂W/∂e·Δe ds still is from the true ΔW, which
|
|
555
|
+
# is *known* from the endpoints — so the test is absolute (each level scored on its own).
|
|
556
|
+
# Refining element-by-element rather than the whole block spends points only where the
|
|
557
|
+
# material is nonlinear over the step (see __AdaptiveTimeQuadratureStressTensor).
|
|
558
|
+
dWde_quad, d2Wde_quad, nPts_used = __AdaptiveTimeQuadratureStressTensor(
|
|
559
|
+
material, state_n, state_np1, tol, int(maxPoints)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# not __second_piola_block: that pairs one B with itself, while here the tangent is
|
|
563
|
+
# differentiated at u_{n+1} but tested against B(ū) — hence the non-symmetry.
|
|
564
|
+
residual_e = einsum("ep,epi,epij->ej", wJ_e_pg, dWde_quad, B_mid)
|
|
565
|
+
tangent_e = einsum(
|
|
566
|
+
"ep,epji,epjk,epkl->eil", wJ_e_pg, B_mid, d2Wde_quad, B_np1
|
|
567
|
+
) + __geometric_tangent(wJ_e_pg, state_mid, dWde_quad)
|
|
568
|
+
|
|
569
|
+
if dim == 2:
|
|
570
|
+
thickness = material.thickness
|
|
571
|
+
tangent_e *= thickness
|
|
572
|
+
residual_e *= thickness
|
|
573
|
+
|
|
574
|
+
K_e, R_e = __reorder_dofs(dim, nPe, tangent_e, residual_e)
|
|
575
|
+
return K_e, R_e, nPts_used
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def ActiveStressTensor(
|
|
579
|
+
material: "_HyperElastic",
|
|
580
|
+
state: "HyperElasticState",
|
|
581
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
582
|
+
r"""Active-stress contributions ``(Kgeo_e, R_e)`` for the fiber stress :math:`\Sigma_{act} = \tau \, \hat{T} \otimes \hat{T}`.
|
|
583
|
+
|
|
584
|
+
A contractile stress of magnitude ``τ`` (``material.active_stress``) along the unit fiber direction ``T̂``, typical of cardiac mechanics. It is **not** a derivative of the stored energy ``W``, so it is assembled here instead of being folded into ``material.Compute_dWde`` — the same separation Kelvin–Voigt viscosity gets in :func:`KelvinVoigtDamping`, and for the same reason: it keeps ``Compute_dWde == ∂(Compute_W)/∂e``, which every energy-based algorithm relies on. In particular :func:`GonzalezStressTensor` builds its discrete gradient from ``ΔW`` and ``s̄``; had ``s̄`` carried the active stress while ``ΔW`` did not, ``α`` would silently cancel the active contribution and the active stress would do exactly zero work.
|
|
585
|
+
|
|
586
|
+
Since ``Σ_act`` is independent of ``e`` there is **no material tangent**; it does however stiffen the structure geometrically::
|
|
587
|
+
|
|
588
|
+
R_e = ∫ Bᵀ · Σ_act dΩ internal force
|
|
589
|
+
Kgeo_e = ∫ gradᵀ (I ⊗ Σ_act) grad dΩ = ∂R_e/∂u
|
|
590
|
+
|
|
591
|
+
The simulation adds both to the elastic ``K_e`` / residual, so ``Kgeo_e`` rides ``coefK`` exactly like the elastic geometric tangent.
|
|
592
|
+
|
|
593
|
+
Parameters
|
|
594
|
+
----------
|
|
595
|
+
material
|
|
596
|
+
Hyperelastic constitutive law — supplies ``active_stress`` and ``Compute_active_stress(state)``.
|
|
597
|
+
state
|
|
598
|
+
Hyperelastic state at the evaluation point of the time scheme (the midpoint state ``ū`` for :attr:`~EasyFEA.AlgoType.midpoint`), owning the mesh and that displacement.
|
|
599
|
+
|
|
600
|
+
Returns
|
|
601
|
+
-------
|
|
602
|
+
tuple
|
|
603
|
+
``(None, None)`` when ``material.active_stress == 0``. Otherwise ``Kgeo_e`` of shape ``(Ne, nPe·dim, nPe·dim)`` and ``R_e`` of shape ``(Ne, nPe·dim)``, reordered to ``(xi, yi, zi, ..., xn, yn, zn)``.
|
|
604
|
+
"""
|
|
605
|
+
if material.active_stress == 0.0:
|
|
606
|
+
return None, None # type: ignore [return-value]
|
|
607
|
+
|
|
608
|
+
groupElem = state.groupElem
|
|
609
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(state.matrixType)
|
|
610
|
+
nPe = groupElem.nPe
|
|
611
|
+
dim = groupElem.dim
|
|
612
|
+
|
|
613
|
+
sig_e_pg = material.Compute_active_stress(state)
|
|
614
|
+
|
|
615
|
+
_, B_e_pg = __block_grad_B(state)
|
|
616
|
+
residual_e = einsum("ep,epi,epij->ej", wJ_e_pg, sig_e_pg, B_e_pg)
|
|
617
|
+
Kgeo_e = __geometric_tangent(wJ_e_pg, state, sig_e_pg)
|
|
618
|
+
|
|
619
|
+
if dim == 2:
|
|
620
|
+
thickness = material.thickness
|
|
621
|
+
Kgeo_e *= thickness
|
|
622
|
+
residual_e *= thickness
|
|
623
|
+
|
|
624
|
+
return __reorder_dofs(dim, nPe, Kgeo_e, residual_e)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def KelvinVoigtDamping(
|
|
628
|
+
material: "_HyperElastic",
|
|
629
|
+
state: "HyperElasticState",
|
|
630
|
+
velocity: np.ndarray,
|
|
631
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
632
|
+
r"""Kelvin–Voigt viscous element contributions (C_e, Kgeo_e) for the
|
|
633
|
+
large-strain viscous force F_visco(u) = C(u)·v, with Σ_visco = η·Ė
|
|
634
|
+
(Green-Lagrange strain rate of velocity) and B = De(u)·grad.
|
|
635
|
+
|
|
636
|
+
- C_e = thickness · η · ∫ Bᵀ B dΩ — the damping matrix; the simulation puts
|
|
637
|
+
it in slot 2 of (K, C, M, F) (residual b -= C @ v_t, time-scheme history,
|
|
638
|
+
and the coefC·C tangent).
|
|
639
|
+
- Kgeo_e — the configuration tangent ∂(C·v)/∂u at fixed velocity (geometric
|
|
640
|
+
stiffening from Σ_visco plus the ∂Ė/∂u term); the simulation adds it to
|
|
641
|
+
K_e so it rides coefK.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
material
|
|
646
|
+
Hyperelastic constitutive law — supplies the viscosity eta.
|
|
647
|
+
state
|
|
648
|
+
Hyperelastic state — owns the mesh and the current displacement.
|
|
649
|
+
velocity
|
|
650
|
+
Velocity field (same (xi, yi, zi, ...) layout as the displacement), or
|
|
651
|
+
None for a quasi-static evaluation.
|
|
652
|
+
|
|
653
|
+
Returns
|
|
654
|
+
-------
|
|
655
|
+
tuple
|
|
656
|
+
(None, None) when material.eta == 0 or velocity is None. Both matrices
|
|
657
|
+
are (Ne, nPe·dim, nPe·dim) reordered to (xi, yi, zi, ..., xn, yn, zn).
|
|
658
|
+
"""
|
|
659
|
+
if material.eta == 0.0 or velocity is None:
|
|
660
|
+
return None, None # type: ignore [return-value]
|
|
661
|
+
|
|
662
|
+
groupElem = state.groupElem
|
|
663
|
+
matrixType = state.matrixType
|
|
664
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(matrixType)
|
|
665
|
+
nPe = groupElem.nPe
|
|
666
|
+
dim = groupElem.dim
|
|
667
|
+
thickness = material.thickness if dim == 2 else 1
|
|
668
|
+
|
|
669
|
+
grad_e_pg, B_e_pg = __block_grad_B(state)
|
|
670
|
+
Beta_e_pg = state.Compute_Deta(velocity) @ grad_e_pg
|
|
671
|
+
|
|
672
|
+
# damping matrix C = thickness · η · ∫ Bᵀ B (fused einsum, see SPK above)
|
|
673
|
+
subscripts = "ep,epji,epjl->eil"
|
|
674
|
+
C_e = thickness * material.eta * einsum(subscripts, wJ_e_pg, B_e_pg, B_e_pg)
|
|
675
|
+
|
|
676
|
+
# configuration tangent ∂(C·v)/∂u = geometric (∫ gradᵀ Sig grad) + material-like
|
|
677
|
+
# (η ∫ Bᵀ (∂Ė/∂u)) pieces
|
|
678
|
+
A_mat = material.eta * einsum(subscripts, wJ_e_pg, B_e_pg, Beta_e_pg)
|
|
679
|
+
A_geo = __geometric_tangent(
|
|
680
|
+
wJ_e_pg,
|
|
681
|
+
state,
|
|
682
|
+
material.eta * state.Compute_Edot_vec(velocity),
|
|
683
|
+
)
|
|
684
|
+
Kgeo_e = thickness * (A_mat + A_geo)
|
|
685
|
+
|
|
686
|
+
return __reorder_dofs(dim, nPe, C_e, Kgeo_e)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def __skew(v: np.ndarray) -> np.ndarray:
|
|
690
|
+
"""Skew-symmetric "cross-product" matrix: ``S(v) · w = v × w``.
|
|
691
|
+
|
|
692
|
+
Input ``v`` of shape ``(..., 3)`` → output ``(..., 3, 3)``.
|
|
693
|
+
|
|
694
|
+
::
|
|
695
|
+
|
|
696
|
+
S(v) = | 0 -v_2 v_1 |
|
|
697
|
+
| v_2 0 -v_0 |
|
|
698
|
+
|-v_1 v_0 0 |
|
|
699
|
+
"""
|
|
700
|
+
zero = np.zeros_like(v[..., 0])
|
|
701
|
+
return np.stack(
|
|
702
|
+
[
|
|
703
|
+
np.stack([zero, -v[..., 2], v[..., 1]], axis=-1),
|
|
704
|
+
np.stack([v[..., 2], zero, -v[..., 0]], axis=-1),
|
|
705
|
+
np.stack([-v[..., 1], v[..., 0], zero], axis=-1),
|
|
706
|
+
],
|
|
707
|
+
axis=-2,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def FollowingPressure(
|
|
712
|
+
groupElem: "_GroupElem",
|
|
713
|
+
u: np.ndarray,
|
|
714
|
+
pressure: Union[float, np.ndarray],
|
|
715
|
+
elements: Optional[np.ndarray] = None,
|
|
716
|
+
matrixType: "MatrixType" = MatrixType.rigi,
|
|
717
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
718
|
+
r"""Follower-pressure contribution on a 2D surface group in a 3D mesh.
|
|
719
|
+
|
|
720
|
+
The load tracks the deformed normal ``n = ∂x/∂r × ∂x/∂s`` with ``x = X + u``, so its Jacobian feeds a non-symmetric tangent.
|
|
721
|
+
|
|
722
|
+
Returned ``(K_e, R_e)`` are contributions to global ``K`` and ``R(u) = R_internal − F_follower`` — same convention as PK2:
|
|
723
|
+
|
|
724
|
+
```
|
|
725
|
+
K_e = -∂F_follower/∂u → slot K
|
|
726
|
+
R_e = -F_follower(u) → slot F as -R_e (= +F_follower in b)
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Outside ``elements`` the returned arrays are exact zero so the surface connectivity can scatter ``(Ne_surf, ...)`` uniformly.
|
|
730
|
+
"""
|
|
731
|
+
assert groupElem.dim in [1, 2], "groupElem must be 1D or 2D."
|
|
732
|
+
|
|
733
|
+
dim = 3
|
|
734
|
+
Ne, nPe = groupElem.Ne, groupElem.nPe
|
|
735
|
+
ndof = nPe * dim
|
|
736
|
+
|
|
737
|
+
K_e = np.zeros((Ne, ndof, ndof))
|
|
738
|
+
R_e = np.zeros((Ne, ndof))
|
|
739
|
+
|
|
740
|
+
if elements is None:
|
|
741
|
+
active = np.arange(Ne)
|
|
742
|
+
else:
|
|
743
|
+
active = np.asarray(elements, dtype=int).ravel()
|
|
744
|
+
if active.size == 0:
|
|
745
|
+
return K_e, R_e
|
|
746
|
+
Ne_a = active.size
|
|
747
|
+
|
|
748
|
+
if np.isscalar(pressure) and float(pressure) == 0.0:
|
|
749
|
+
return K_e, R_e
|
|
750
|
+
|
|
751
|
+
gauss = groupElem.Get_gauss(matrixType)
|
|
752
|
+
weights = gauss.weights
|
|
753
|
+
|
|
754
|
+
# Reference-frame shape functions and r-/s-derivatives
|
|
755
|
+
N_pg = groupElem.Get_N_pg(matrixType)[:, 0, :] # (nPg, nPe)
|
|
756
|
+
dN_pg = groupElem.Get_dN_pg(matrixType) # (nPg, 2, nPe)
|
|
757
|
+
dNr_pg = dN_pg[:, 0, :]
|
|
758
|
+
dNs_pg = dN_pg[:, 1, :]
|
|
759
|
+
|
|
760
|
+
# Deformed node coordinates x = X + u (Ne_a, nPe, 3)
|
|
761
|
+
connect = groupElem.connect
|
|
762
|
+
connect_local = groupElem._global_to_local_nodes[connect]
|
|
763
|
+
X_e = groupElem.coord[connect_local][active]
|
|
764
|
+
u_e = u.reshape(-1, dim)[connect][active]
|
|
765
|
+
x_e = X_e + u_e
|
|
766
|
+
|
|
767
|
+
# Deformed tangents dxdr_e_pg = ∂x/∂r, dxds_e_pg = ∂x/∂s at Gauss points
|
|
768
|
+
dxdr_e_pg = einsum("pn,enc->epc", dNr_pg, x_e) # (Ne_a, nPg, 3)
|
|
769
|
+
dxds_e_pg = einsum("pn,enc->epc", dNs_pg, x_e) # (Ne_a, nPg, 3)
|
|
770
|
+
n_e_pg = np.cross(dxdr_e_pg, dxds_e_pg) # area-weighted deformed normal
|
|
771
|
+
|
|
772
|
+
# F[e, i, c] = Σ_p w·p · φ_i · n_c (component-major, then reorder)
|
|
773
|
+
factor = weights[None, :] * pressure # (1, nPg)
|
|
774
|
+
F_active = einsum("ep,pn,epc->enc", factor, N_pg, n_e_pg).reshape(
|
|
775
|
+
Ne_a, dim * nPe
|
|
776
|
+
) # (xi, yi, zi, ...)
|
|
777
|
+
|
|
778
|
+
# Chain rule on n = a × b (with a = ∂x/∂r, b = ∂x/∂s) gives, at each Gauss point, the 3×3 matrix
|
|
779
|
+
# ∂n / ∂u_{j, :} = ∂φ_j/∂s · S(a) − ∂φ_j/∂r · S(b)
|
|
780
|
+
# where S(v) is the skew-symmetric "cross-product" matrix:
|
|
781
|
+
# S(v) = | 0 −v_2 v_1 | so that S(v) · w = v × w.
|
|
782
|
+
# | v_2 0 −v_0 |
|
|
783
|
+
# |−v_1 v_0 0 |
|
|
784
|
+
# The local tangent is then a Kronecker product over components × nodes:
|
|
785
|
+
# K_e_pg = factor · [ S(a) ⊗ (φ ⊗ ∂φ/∂sᵀ) − S(b) ⊗ (φ ⊗ ∂φ/∂rᵀ) ]
|
|
786
|
+
# i.e. block (c_a, c_b) carries S(a)[c_a,c_b]·φ_iᵀ∂φ_j/∂s − S(b)[c_a,c_b]·φ_iᵀ∂φ_j/∂r.
|
|
787
|
+
S_a = __skew(dxdr_e_pg) # S(a), shape (Ne_a, nPg, 3, 3)
|
|
788
|
+
S_b = __skew(dxds_e_pg) # S(b), shape (Ne_a, nPg, 3, 3)
|
|
789
|
+
K_active = (
|
|
790
|
+
einsum("ep,epcd,pi,pj->ecidj", factor, S_a, N_pg, dNs_pg)
|
|
791
|
+
- einsum("ep,epcd,pi,pj->ecidj", factor, S_b, N_pg, dNr_pg)
|
|
792
|
+
).reshape(Ne_a, dim * nPe, dim * nPe)
|
|
793
|
+
|
|
794
|
+
# component-major → interleaved (xi, yi, zi, ...)
|
|
795
|
+
(K_active,) = __reorder_dofs(dim, nPe, K_active)
|
|
796
|
+
|
|
797
|
+
K_e[active] = -K_active
|
|
798
|
+
R_e[active] = F_active
|
|
799
|
+
|
|
800
|
+
return K_e, R_e
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def PenaltyContact(
|
|
804
|
+
groupElem: "_GroupElem",
|
|
805
|
+
penalty: float,
|
|
806
|
+
gap_e_pg: FeArray,
|
|
807
|
+
normal_e_pg: FeArray,
|
|
808
|
+
elements: Optional[np.ndarray] = None,
|
|
809
|
+
matrixType: "MatrixType" = MatrixType.mass,
|
|
810
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
811
|
+
r"""Penalty-contact tangent/residual on a contact surface group.
|
|
812
|
+
|
|
813
|
+
Integrated over ``groupElem`` — the contact surface group of the deformable body, so it assembles onto the body dofs — from a precomputed signed normal gap ``gap_e_pg`` and outward unit normal ``normal_e_pg`` at its integration points. Contact is active only where ``gap < 0``.
|
|
814
|
+
|
|
815
|
+
The operator is agnostic to **how** the gap / normal are obtained — by projecting the deformed contact-surface Gauss points onto a rigid obstacle surface (:meth:`_GroupElem._Get_gap_and_normal`), by an analytic signed-distance obstacle (plane, sphere, …), etc. They only have to be sampled at the contact-surface Gauss points of ``matrixType``, the same rule used here for the surface integral.
|
|
816
|
+
|
|
817
|
+
With penalty ``εₙ``, signed gap ``gₙ``, outward normal ``n`` and test / trial fields ``v`` / ``u``, the weak-form contributions are::
|
|
818
|
+
|
|
819
|
+
R_e = εₙ ∫_Γ ⟨-gₙ⟩ (v·n) dΓ slot F: outward force, grows with penetration ⟨-gₙ⟩
|
|
820
|
+
K_e = εₙ ∫_Γc (u·n)(v·n) dΓ slot K: tangent ∂R/∂u on the active set Γc (gₙ < 0)
|
|
821
|
+
|
|
822
|
+
where ``⟨·⟩`` is the Macaulay bracket. Linearising the ramp ``⟨-gₙ⟩`` collapses it to the active-set restriction ``Γc``, so ``K_e`` is :func:`~EasyFEA.FEM.Operators.Bilinear.MassAlongNormal` scaled by ``εₙ`` where contact is active (the small change-of-normal / closest-point curvature terms are dropped).
|
|
823
|
+
|
|
824
|
+
Returned ``(K_e, R_e)`` follow the slot convention of :func:`FollowingPressure` — ``K_e`` → slot K, ``R_e`` → slot F (the force pushing the body out of the obstacle). Outside ``elements`` both are exact zero.
|
|
825
|
+
|
|
826
|
+
Parameters
|
|
827
|
+
----------
|
|
828
|
+
groupElem : _GroupElem
|
|
829
|
+
Contact surface group (1D edges in 2D, 2D faces in 3D).
|
|
830
|
+
penalty : float
|
|
831
|
+
Penalty stiffness ``εₙ``.
|
|
832
|
+
gap_e_pg : FeArray
|
|
833
|
+
Signed normal gap at the contact-surface ``matrixType`` Gauss points, shape ``(Ne_a, nPg)`` (negative under penetration).
|
|
834
|
+
normal_e_pg : FeArray
|
|
835
|
+
Outward unit normal at the same Gauss points, shape ``(Ne_a, nPg, 3)``. Must share its ``nPg`` with ``gap_e_pg``.
|
|
836
|
+
elements : np.ndarray, optional
|
|
837
|
+
Active (contact) element indices ``gap_e_pg``/``normal_e_pg`` were computed for, by default all.
|
|
838
|
+
matrixType : MatrixType, optional
|
|
839
|
+
Integration scheme for the surface integral; ``gap_e_pg`` / ``normal_e_pg`` must be sampled with the same one, by default ``MatrixType.mass``.
|
|
840
|
+
"""
|
|
841
|
+
assert groupElem.dim in [1, 2], "groupElem must be a 1D or 2D boundary group."
|
|
842
|
+
assert isinstance(gap_e_pg, FeArray) and isinstance(
|
|
843
|
+
normal_e_pg, FeArray
|
|
844
|
+
), "gap_e_pg and normal_e_pg must be FeArrays."
|
|
845
|
+
assert (
|
|
846
|
+
gap_e_pg.shape[1] == normal_e_pg.shape[1]
|
|
847
|
+
), "gap_e_pg and normal_e_pg must share the same nPg."
|
|
848
|
+
|
|
849
|
+
dim = groupElem.inDim # ambient (world) dimension
|
|
850
|
+
Ne, nPe = groupElem.Ne, groupElem.nPe
|
|
851
|
+
ndof = nPe * dim
|
|
852
|
+
|
|
853
|
+
K_e = np.zeros((Ne, ndof, ndof))
|
|
854
|
+
R_e = np.zeros((Ne, ndof))
|
|
855
|
+
|
|
856
|
+
if elements is None:
|
|
857
|
+
active = np.arange(Ne)
|
|
858
|
+
else:
|
|
859
|
+
active = np.asarray(elements, dtype=int).ravel()
|
|
860
|
+
if active.size == 0:
|
|
861
|
+
return K_e, R_e
|
|
862
|
+
|
|
863
|
+
if penalty == 0.0:
|
|
864
|
+
return K_e, R_e
|
|
865
|
+
|
|
866
|
+
# surface integration measure and shape functions
|
|
867
|
+
wJ_e_pg = groupElem.Get_weightedJacobian_e_pg(matrixType)[active] # (Ne_a, nPg)
|
|
868
|
+
N_pg = groupElem.Get_N_pg(matrixType)[:, 0, :] # (nPg, nPe)
|
|
869
|
+
assert (
|
|
870
|
+
gap_e_pg.shape[1] == wJ_e_pg.shape[1]
|
|
871
|
+
), "gap_e_pg / normal_e_pg must be sampled at the `matrixType` Gauss points."
|
|
872
|
+
|
|
873
|
+
# precomputed gap / outward normal at the active Gauss points
|
|
874
|
+
normal_e_pg = normal_e_pg[..., :dim] # (Ne_a, nPg, dim)
|
|
875
|
+
|
|
876
|
+
# active set: penetration only (gap < 0)
|
|
877
|
+
pen_e_pg = np.where(gap_e_pg < 0, -gap_e_pg, 0.0) # ⟨-gₙ⟩ ≥ 0
|
|
878
|
+
H_e_pg = (gap_e_pg < 0).astype(float) # active-set indicator
|
|
879
|
+
|
|
880
|
+
# The einsum node/component index order (...i,c...) yields the interleaved
|
|
881
|
+
# (xi, yi, zi, ...) dof layout directly, so no reorder is needed.
|
|
882
|
+
factor = penalty * wJ_e_pg # (Ne_a, nPg)
|
|
883
|
+
|
|
884
|
+
# R_e = +εₙ ∫ ⟨-gₙ⟩ Nᵢ n dΓ (force pushing the body out → slot F)
|
|
885
|
+
R_active = einsum(
|
|
886
|
+
"ep,ep,pi,epc->eic",
|
|
887
|
+
factor,
|
|
888
|
+
pen_e_pg,
|
|
889
|
+
N_pg,
|
|
890
|
+
normal_e_pg,
|
|
891
|
+
).reshape(active.size, nPe * dim)
|
|
892
|
+
|
|
893
|
+
# K_e = +εₙ ∫ H Nᵢ Nⱼ (n⊗n) dΓ (tangent → slot K)
|
|
894
|
+
K_active = einsum(
|
|
895
|
+
"ep,ep,pi,pj,epc,epd->eicjd",
|
|
896
|
+
factor,
|
|
897
|
+
H_e_pg,
|
|
898
|
+
N_pg,
|
|
899
|
+
N_pg,
|
|
900
|
+
normal_e_pg,
|
|
901
|
+
normal_e_pg,
|
|
902
|
+
).reshape(active.size, nPe * dim, nPe * dim)
|
|
903
|
+
|
|
904
|
+
K_e[active] = K_active
|
|
905
|
+
R_e[active] = R_active
|
|
906
|
+
|
|
907
|
+
return K_e, R_e
|