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.
Files changed (85) hide show
  1. easyfea-3.2.0/EasyFEA/FEM/Operators/NonLinear.py +907 -0
  2. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_group_elem.py +96 -12
  3. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_linalg.py +9 -0
  4. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_mesher.py +32 -26
  5. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/_laws.py +40 -58
  6. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_hyperelastic.py +186 -15
  7. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_simu.py +41 -124
  8. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_weakforms.py +1 -1
  9. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Matplotlib.py +1 -1
  10. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/PyVista.py +13 -0
  11. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_tic.py +11 -5
  12. {easyfea-3.0.0/easyfea.egg-info → easyfea-3.2.0}/PKG-INFO +10 -9
  13. {easyfea-3.0.0 → easyfea-3.2.0}/README.md +9 -8
  14. {easyfea-3.0.0 → easyfea-3.2.0/easyfea.egg-info}/PKG-INFO +10 -9
  15. {easyfea-3.0.0 → easyfea-3.2.0}/pyproject.toml +1 -1
  16. easyfea-3.0.0/EasyFEA/FEM/Operators/NonLinear.py +0 -350
  17. {easyfea-3.0.0 → easyfea-3.2.0}/AUTHORS.md +0 -0
  18. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/__init__.py +0 -0
  19. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_beam.py +0 -0
  20. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_hexa.py +0 -0
  21. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_point.py +0 -0
  22. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_prism.py +0 -0
  23. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_quad.py +0 -0
  24. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_seg.py +0 -0
  25. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_tetra.py +0 -0
  26. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Elems/_tri.py +0 -0
  27. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/Bilinear.py +0 -0
  28. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/Linear.py +0 -0
  29. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/Operators/__init__.py +0 -0
  30. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/__init__.py +0 -0
  31. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_boundary_conditions.py +0 -0
  32. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_field.py +0 -0
  33. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_forms.py +0 -0
  34. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_gauss.py +0 -0
  35. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_mesh.py +0 -0
  36. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/FEM/_utils.py +0 -0
  37. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/__init__.py +0 -0
  38. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_circle.py +0 -0
  39. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_contour.py +0 -0
  40. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_domain.py +0 -0
  41. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_geom.py +0 -0
  42. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_line.py +0 -0
  43. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_points.py +0 -0
  44. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Geoms/_utils.py +0 -0
  45. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Beam/__init__.py +0 -0
  46. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Beam/_beam.py +0 -0
  47. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Elastic/__init__.py +0 -0
  48. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/Elastic/_laws.py +0 -0
  49. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/__init__.py +0 -0
  50. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/HyperElastic/_state.py +0 -0
  51. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/__init__.py +0 -0
  52. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_phasefield.py +0 -0
  53. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_thermal.py +0 -0
  54. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_utils.py +0 -0
  55. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Models/_weakforms.py +0 -0
  56. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/Solvers.py +0 -0
  57. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/__init__.py +0 -0
  58. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_beam.py +0 -0
  59. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_dic.py +0 -0
  60. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_elastic.py +0 -0
  61. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_phasefield.py +0 -0
  62. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_thermal.py +0 -0
  63. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Simulations/_utils.py +0 -0
  64. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Folder.py +0 -0
  65. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/GLTF.py +0 -0
  66. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/MeshIO.py +0 -0
  67. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Paraview.py +0 -0
  68. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Terminal.py +0 -0
  69. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/USD.py +0 -0
  70. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/Vizir.py +0 -0
  71. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/__init__.py +0 -0
  72. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_cache.py +0 -0
  73. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_mpi.py +0 -0
  74. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_observers.py +0 -0
  75. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_params.py +0 -0
  76. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_requires.py +0 -0
  77. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/Utilities/_types.py +0 -0
  78. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/__about__.py +0 -0
  79. {easyfea-3.0.0 → easyfea-3.2.0}/EasyFEA/__init__.py +0 -0
  80. {easyfea-3.0.0 → easyfea-3.2.0}/LICENSE.txt +0 -0
  81. {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/SOURCES.txt +0 -0
  82. {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/dependency_links.txt +0 -0
  83. {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/requires.txt +0 -0
  84. {easyfea-3.0.0 → easyfea-3.2.0}/easyfea.egg-info/top_level.txt +0 -0
  85. {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