ANYsolver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/elements.py
ADDED
|
@@ -0,0 +1,2724 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Finite Element Implementations
|
|
3
|
+
|
|
4
|
+
This module contains element formulations for:
|
|
5
|
+
- ShellElement: 3/6-node triangular and 4/8-node quadrilateral Mindlin-Reissner shell element
|
|
6
|
+
- BeamElement: 2-node Timoshenko beam element with 6 DOF/node
|
|
7
|
+
- QuadraticBeamElement: 3-node quadratic Timoshenko beam element
|
|
8
|
+
- CoupledBeamShellElement: kinematic MPC element for eccentric beam-shell interaction
|
|
9
|
+
|
|
10
|
+
Shell convention
|
|
11
|
+
----------------
|
|
12
|
+
The shell element forms stiffness and stresses in an element-local orthonormal
|
|
13
|
+
basis at each integration point:
|
|
14
|
+
|
|
15
|
+
local x = projected xi tangent direction
|
|
16
|
+
local y = in-surface direction perpendicular to local x
|
|
17
|
+
local z = shell normal
|
|
18
|
+
|
|
19
|
+
Global nodal translations and rotations are transformed to this local basis
|
|
20
|
+
before the membrane, bending and transverse shear B-matrices are evaluated.
|
|
21
|
+
|
|
22
|
+
Shell shear treatment
|
|
23
|
+
---------------------
|
|
24
|
+
Membrane and bending always use full integration for the element topology.
|
|
25
|
+
Transverse shear depends on the topology:
|
|
26
|
+
|
|
27
|
+
* 4-node: MITC4 assumed natural shear (covariant shear sampled at the four
|
|
28
|
+
edge midpoints and interpolated), integrated at the full 2x2 rule. This
|
|
29
|
+
avoids both shear locking and the spurious zero-energy w-hourglass mode
|
|
30
|
+
of one-point reduced shear integration.
|
|
31
|
+
* 3-node: centroidal edge-compatible assumed shear. This is the constant
|
|
32
|
+
shear part of the DSG/MITC3 family: transverse shear is evaluated from
|
|
33
|
+
the element-average shear gap in the centroid frame, preserving rigid
|
|
34
|
+
body motion and constant-shear patches without the locking-prone fully
|
|
35
|
+
integrated linear Mindlin shear term.
|
|
36
|
+
* 6-node: quadratic displacement interpolation with reduced three-point
|
|
37
|
+
triangular shear integration.
|
|
38
|
+
* 8-node: reduced 2x2 shear integration (S8R style). When full element
|
|
39
|
+
reduced integration is requested, a small nullspace-projection
|
|
40
|
+
hourglass stiffness stabilizes modes outside the six rigid-body modes.
|
|
41
|
+
|
|
42
|
+
Beam section convention
|
|
43
|
+
-----------------------
|
|
44
|
+
Beam local axes are (x = member axis, y, z). ``Iy`` is the second moment of
|
|
45
|
+
area about the local y axis and governs bending that deflects the beam in
|
|
46
|
+
local z; ``Iz`` governs deflection in local y. ``shear_factor_y`` scales the
|
|
47
|
+
shear area for transverse shear force in local y, ``shear_factor_z`` for local
|
|
48
|
+
z. The optional ``cross_section["orientation"]`` vector prescribes the local
|
|
49
|
+
z direction (e.g. the stiffener web direction). Without it, a heuristic picks
|
|
50
|
+
local y close to global Y (or global Z for members nearly parallel to Y),
|
|
51
|
+
which leaves the section orientation unconstrained for asymmetric sections.
|
|
52
|
+
|
|
53
|
+
Beam-shell coupling
|
|
54
|
+
-------------------
|
|
55
|
+
Beam-shell coupling is represented as a linear multi-point constraint (MPC),
|
|
56
|
+
not as a large penalty spring. For a beam node offset from a shell node by
|
|
57
|
+
vector r, the coupling relation is:
|
|
58
|
+
|
|
59
|
+
u_beam = u_shell + theta_shell x r
|
|
60
|
+
theta_beam = theta_shell
|
|
61
|
+
|
|
62
|
+
The assembly solver eliminates these slave beam DOFs through a transformation.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
from abc import ABC, abstractmethod
|
|
68
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
69
|
+
|
|
70
|
+
import numpy as np
|
|
71
|
+
|
|
72
|
+
from .jit_compiler import njit
|
|
73
|
+
from .material_curves import FiberSectionPlasticityConfig
|
|
74
|
+
from .plasticity import lobatto_layers, plane_stress_elastic_matrix, plane_stress_return_map
|
|
75
|
+
|
|
76
|
+
if TYPE_CHECKING:
|
|
77
|
+
from .fe_core import FEMesh, Material
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SMALL = 1.0e-12
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@njit
|
|
84
|
+
def _cross3(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
85
|
+
"""Cross product of two 3-vectors without np.cross dispatch overhead."""
|
|
86
|
+
return np.array(
|
|
87
|
+
[
|
|
88
|
+
a[1] * b[2] - a[2] * b[1],
|
|
89
|
+
a[2] * b[0] - a[0] * b[2],
|
|
90
|
+
a[0] * b[1] - a[1] * b[0],
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@njit
|
|
96
|
+
def _inv2(matrix: np.ndarray) -> Tuple[np.ndarray, float]:
|
|
97
|
+
"""Inverse and determinant of a 2x2 matrix without LAPACK overhead."""
|
|
98
|
+
det = matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]
|
|
99
|
+
if abs(det) < _SMALL:
|
|
100
|
+
raise np.linalg.LinAlgError("singular 2x2 matrix")
|
|
101
|
+
inv = np.array(
|
|
102
|
+
[[matrix[1, 1], -matrix[0, 1]], [-matrix[1, 0], matrix[0, 0]]],
|
|
103
|
+
) / det
|
|
104
|
+
return inv, float(det)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _section_orientation(cross_section: Dict[str, Any]) -> Optional[np.ndarray]:
|
|
108
|
+
"""Return the prescribed local-z (web) direction from a cross-section dict."""
|
|
109
|
+
value = cross_section.get("orientation", cross_section.get("web_direction"))
|
|
110
|
+
if value is None:
|
|
111
|
+
return None
|
|
112
|
+
vector = np.asarray(value, dtype=float).reshape(-1)
|
|
113
|
+
if vector.size < 3 or float(np.linalg.norm(vector[:3])) < _SMALL:
|
|
114
|
+
return None
|
|
115
|
+
return np.array(vector[:3], dtype=float)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _beam_rotation_matrix(e1: np.ndarray, orientation: Optional[np.ndarray]) -> np.ndarray:
|
|
119
|
+
"""Build the beam local frame [e1 e2 e3] with optional prescribed local z.
|
|
120
|
+
|
|
121
|
+
``orientation`` is the requested local z direction (section web direction).
|
|
122
|
+
It is projected perpendicular to the member axis; if it is (nearly)
|
|
123
|
+
parallel to the axis the heuristic fallback is used instead.
|
|
124
|
+
"""
|
|
125
|
+
if orientation is not None:
|
|
126
|
+
candidate = orientation - np.dot(orientation, e1) * e1
|
|
127
|
+
norm = float(np.linalg.norm(candidate))
|
|
128
|
+
if norm > 1.0e-6 * float(np.linalg.norm(orientation)):
|
|
129
|
+
e3 = candidate / norm
|
|
130
|
+
e2 = _cross3(e3, e1)
|
|
131
|
+
e2 /= np.linalg.norm(e2)
|
|
132
|
+
return np.column_stack((e1, e2, e3))
|
|
133
|
+
trial = np.array([0.0, 1.0, 0.0])
|
|
134
|
+
if abs(float(np.dot(e1, trial))) > 0.95:
|
|
135
|
+
trial = np.array([0.0, 0.0, 1.0])
|
|
136
|
+
e2 = trial - np.dot(trial, e1) * e1
|
|
137
|
+
e2 /= np.linalg.norm(e2)
|
|
138
|
+
e3 = _cross3(e1, e2)
|
|
139
|
+
e3 /= np.linalg.norm(e3)
|
|
140
|
+
return np.column_stack((e1, e2, e3))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rotation_vector_from_matrix(rotation: np.ndarray) -> np.ndarray:
|
|
144
|
+
"""Return the axis-angle rotation vector for a proper 3x3 rotation matrix."""
|
|
145
|
+
R = np.asarray(rotation, dtype=float).reshape(3, 3)
|
|
146
|
+
trace_value = float(np.trace(R))
|
|
147
|
+
cos_angle = max(min((trace_value - 1.0) / 2.0, 1.0), -1.0)
|
|
148
|
+
angle = float(np.arccos(cos_angle))
|
|
149
|
+
if angle < 1.0e-12:
|
|
150
|
+
return 0.5 * np.array(
|
|
151
|
+
[R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]],
|
|
152
|
+
dtype=float,
|
|
153
|
+
)
|
|
154
|
+
if abs(np.pi - angle) < 1.0e-6:
|
|
155
|
+
axis = np.sqrt(np.maximum(np.diag(R) + 1.0, 0.0) / 2.0)
|
|
156
|
+
if R[2, 1] - R[1, 2] < 0.0:
|
|
157
|
+
axis[0] *= -1.0
|
|
158
|
+
if R[0, 2] - R[2, 0] < 0.0:
|
|
159
|
+
axis[1] *= -1.0
|
|
160
|
+
if R[1, 0] - R[0, 1] < 0.0:
|
|
161
|
+
axis[2] *= -1.0
|
|
162
|
+
norm = float(np.linalg.norm(axis))
|
|
163
|
+
if norm <= _SMALL:
|
|
164
|
+
return np.zeros(3, dtype=float)
|
|
165
|
+
return angle * axis / norm
|
|
166
|
+
axis = np.array(
|
|
167
|
+
[R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]],
|
|
168
|
+
dtype=float,
|
|
169
|
+
) / (2.0 * np.sin(angle))
|
|
170
|
+
return angle * axis
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Element(ABC):
|
|
174
|
+
"""Abstract base class for all FE elements."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, element_id: int, node_ids: List[int], material_name: str = "default"):
|
|
177
|
+
self.element_id = element_id
|
|
178
|
+
self.node_ids = node_ids
|
|
179
|
+
self.material_name = material_name
|
|
180
|
+
self._stiffness_matrix: Optional[np.ndarray] = None
|
|
181
|
+
self._mass_matrix: Optional[np.ndarray] = None
|
|
182
|
+
self._internal_forces: Optional[np.ndarray] = None
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
@abstractmethod
|
|
186
|
+
def num_nodes(self) -> int:
|
|
187
|
+
raise NotImplementedError
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def dofs_per_node(self) -> int:
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def total_dofs(self) -> int:
|
|
196
|
+
return self.num_nodes * self.dofs_per_node
|
|
197
|
+
|
|
198
|
+
@abstractmethod
|
|
199
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
200
|
+
raise NotImplementedError
|
|
201
|
+
|
|
202
|
+
@abstractmethod
|
|
203
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
204
|
+
raise NotImplementedError
|
|
205
|
+
|
|
206
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
207
|
+
return np.zeros((self.total_dofs, self.total_dofs))
|
|
208
|
+
|
|
209
|
+
def compute_geometric_stiffness_matrix(
|
|
210
|
+
self,
|
|
211
|
+
mesh: "FEMesh",
|
|
212
|
+
material: "Material",
|
|
213
|
+
state: Optional[Any] = None,
|
|
214
|
+
) -> np.ndarray:
|
|
215
|
+
return np.zeros((self.total_dofs, self.total_dofs))
|
|
216
|
+
|
|
217
|
+
def compute_internal_forces(
|
|
218
|
+
self, mesh: "FEMesh", displacements: np.ndarray, material: "Material"
|
|
219
|
+
) -> np.ndarray:
|
|
220
|
+
return np.zeros(self.total_dofs)
|
|
221
|
+
|
|
222
|
+
def compute_nonlinear_response(
|
|
223
|
+
self,
|
|
224
|
+
mesh: "FEMesh",
|
|
225
|
+
material: "Material",
|
|
226
|
+
u_elem: np.ndarray,
|
|
227
|
+
state: Optional[Any] = None,
|
|
228
|
+
num_layers: int = 5,
|
|
229
|
+
tangent: bool = True,
|
|
230
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
231
|
+
"""Internal force vector, tangent stiffness and trial state at u_elem.
|
|
232
|
+
|
|
233
|
+
The default is linear elastic: F = K u with the constant stiffness as
|
|
234
|
+
tangent. Elements supporting geometric and/or material nonlinearity
|
|
235
|
+
override this. The returned state is a trial state; the incremental
|
|
236
|
+
solver commits it only when the load step converges. With
|
|
237
|
+
``tangent=False`` the stiffness entry may be None (used by the line
|
|
238
|
+
search, which only needs residuals).
|
|
239
|
+
"""
|
|
240
|
+
K = self._stiffness_matrix
|
|
241
|
+
if K is None:
|
|
242
|
+
K = self.compute_stiffness_matrix(mesh, material)
|
|
243
|
+
return K @ u_elem, (K if tangent else None), state
|
|
244
|
+
|
|
245
|
+
def compute_stresses(
|
|
246
|
+
self,
|
|
247
|
+
mesh: "FEMesh",
|
|
248
|
+
displacements: np.ndarray,
|
|
249
|
+
material: "Material",
|
|
250
|
+
return_global: bool = False,
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
return {}
|
|
253
|
+
|
|
254
|
+
def get_dof_mapping(self, mesh: "FEMesh") -> List[int]:
|
|
255
|
+
dof_mapping: List[int] = []
|
|
256
|
+
for node_id in self.node_ids:
|
|
257
|
+
node = mesh.get_node(node_id)
|
|
258
|
+
if node:
|
|
259
|
+
dof_mapping.extend(node.dofs)
|
|
260
|
+
return dof_mapping
|
|
261
|
+
|
|
262
|
+
def _get_element_displacements(self, mesh: "FEMesh", displacements: np.ndarray) -> np.ndarray:
|
|
263
|
+
u = np.asarray(displacements, dtype=float)
|
|
264
|
+
if u.size == self.total_dofs:
|
|
265
|
+
return u.copy()
|
|
266
|
+
dof_mapping = self.get_dof_mapping(mesh)
|
|
267
|
+
if not dof_mapping:
|
|
268
|
+
return np.zeros(self.total_dofs)
|
|
269
|
+
dof_indices = np.asarray(dof_mapping, dtype=np.intp)
|
|
270
|
+
if int(dof_indices.max()) >= u.size:
|
|
271
|
+
raise IndexError(
|
|
272
|
+
f"Displacement vector has length {u.size}, but element {self.element_id} "
|
|
273
|
+
f"requires global DOF {int(dof_indices.max())}."
|
|
274
|
+
)
|
|
275
|
+
return u[dof_indices]
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
278
|
+
return {
|
|
279
|
+
"element_id": self.element_id,
|
|
280
|
+
"type": self.__class__.__name__,
|
|
281
|
+
"node_ids": self.node_ids,
|
|
282
|
+
"material_name": self.material_name,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@njit
|
|
287
|
+
def _jit_compute_4node_shape_functions(xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
288
|
+
N = np.array(
|
|
289
|
+
[
|
|
290
|
+
0.25 * (1.0 - xi) * (1.0 - eta),
|
|
291
|
+
0.25 * (1.0 + xi) * (1.0 - eta),
|
|
292
|
+
0.25 * (1.0 + xi) * (1.0 + eta),
|
|
293
|
+
0.25 * (1.0 - xi) * (1.0 + eta),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
dN_dxi = np.array(
|
|
297
|
+
[
|
|
298
|
+
-0.25 * (1.0 - eta),
|
|
299
|
+
0.25 * (1.0 - eta),
|
|
300
|
+
0.25 * (1.0 + eta),
|
|
301
|
+
-0.25 * (1.0 + eta),
|
|
302
|
+
],
|
|
303
|
+
)
|
|
304
|
+
dN_deta = np.array(
|
|
305
|
+
[
|
|
306
|
+
-0.25 * (1.0 - xi),
|
|
307
|
+
-0.25 * (1.0 + xi),
|
|
308
|
+
0.25 * (1.0 + xi),
|
|
309
|
+
0.25 * (1.0 - xi),
|
|
310
|
+
],
|
|
311
|
+
)
|
|
312
|
+
return N, dN_dxi, dN_deta
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@njit
|
|
316
|
+
def _jit_compute_8node_shape_functions(xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
317
|
+
N = np.zeros(8, dtype=float)
|
|
318
|
+
N[0] = -0.25 * (1.0 - xi) * (1.0 - eta) * (1.0 + xi + eta)
|
|
319
|
+
N[1] = -0.25 * (1.0 + xi) * (1.0 - eta) * (1.0 - xi + eta)
|
|
320
|
+
N[2] = -0.25 * (1.0 + xi) * (1.0 + eta) * (1.0 - xi - eta)
|
|
321
|
+
N[3] = -0.25 * (1.0 - xi) * (1.0 + eta) * (1.0 + xi - eta)
|
|
322
|
+
N[4] = 0.5 * (1.0 - xi**2) * (1.0 - eta)
|
|
323
|
+
N[5] = 0.5 * (1.0 + xi) * (1.0 - eta**2)
|
|
324
|
+
N[6] = 0.5 * (1.0 - xi**2) * (1.0 + eta)
|
|
325
|
+
N[7] = 0.5 * (1.0 - xi) * (1.0 - eta**2)
|
|
326
|
+
|
|
327
|
+
dN_dxi = np.zeros(8, dtype=float)
|
|
328
|
+
dN_dxi[0] = 0.25 * (1.0 - eta) * (1.0 + xi + eta) - 0.25 * (1.0 - xi) * (1.0 - eta)
|
|
329
|
+
dN_dxi[1] = -0.25 * (1.0 - eta) * (1.0 - xi + eta) + 0.25 * (1.0 + xi) * (1.0 - eta)
|
|
330
|
+
dN_dxi[2] = -0.25 * (1.0 + eta) * (1.0 - xi - eta) + 0.25 * (1.0 + xi) * (1.0 + eta)
|
|
331
|
+
dN_dxi[3] = 0.25 * (1.0 + eta) * (1.0 + xi - eta) - 0.25 * (1.0 - xi) * (1.0 + eta)
|
|
332
|
+
dN_dxi[4] = -xi * (1.0 - eta)
|
|
333
|
+
dN_dxi[5] = 0.5 * (1.0 - eta**2)
|
|
334
|
+
dN_dxi[6] = -xi * (1.0 + eta)
|
|
335
|
+
dN_dxi[7] = -0.5 * (1.0 - eta**2)
|
|
336
|
+
|
|
337
|
+
dN_deta = np.zeros(8, dtype=float)
|
|
338
|
+
dN_deta[0] = 0.25 * (1.0 - xi) * (1.0 + xi + eta) - 0.25 * (1.0 - xi) * (1.0 - eta)
|
|
339
|
+
dN_deta[1] = 0.25 * (1.0 + xi) * (1.0 - xi + eta) - 0.25 * (1.0 + xi) * (1.0 - eta)
|
|
340
|
+
dN_deta[2] = -0.25 * (1.0 + xi) * (1.0 - xi - eta) + 0.25 * (1.0 + xi) * (1.0 + eta)
|
|
341
|
+
dN_deta[3] = -0.25 * (1.0 - xi) * (1.0 + xi - eta) + 0.25 * (1.0 - xi) * (1.0 + eta)
|
|
342
|
+
dN_deta[4] = -0.5 * (1.0 - xi**2)
|
|
343
|
+
dN_deta[5] = -eta * (1.0 + xi)
|
|
344
|
+
dN_deta[6] = 0.5 * (1.0 - xi**2)
|
|
345
|
+
dN_deta[7] = -eta * (1.0 - xi)
|
|
346
|
+
return N, dN_dxi, dN_deta
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@njit
|
|
350
|
+
def _jit_compute_3node_shape_functions(r: float, s: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
351
|
+
N = np.array([1.0 - r - s, r, s])
|
|
352
|
+
dN_dr = np.array([-1.0, 1.0, 0.0])
|
|
353
|
+
dN_ds = np.array([-1.0, 0.0, 1.0])
|
|
354
|
+
return N, dN_dr, dN_ds
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@njit
|
|
358
|
+
def _jit_compute_6node_shape_functions(r: float, s: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
359
|
+
l1 = 1.0 - r - s
|
|
360
|
+
l2 = r
|
|
361
|
+
l3 = s
|
|
362
|
+
|
|
363
|
+
N = np.zeros(6, dtype=float)
|
|
364
|
+
N[0] = l1 * (2.0 * l1 - 1.0)
|
|
365
|
+
N[1] = l2 * (2.0 * l2 - 1.0)
|
|
366
|
+
N[2] = l3 * (2.0 * l3 - 1.0)
|
|
367
|
+
N[3] = 4.0 * l1 * l2
|
|
368
|
+
N[4] = 4.0 * l2 * l3
|
|
369
|
+
N[5] = 4.0 * l3 * l1
|
|
370
|
+
|
|
371
|
+
dN_dr = np.zeros(6, dtype=float)
|
|
372
|
+
dN_dr[0] = 1.0 - 4.0 * l1
|
|
373
|
+
dN_dr[1] = 4.0 * l2 - 1.0
|
|
374
|
+
dN_dr[2] = 0.0
|
|
375
|
+
dN_dr[3] = 4.0 * (l1 - l2)
|
|
376
|
+
dN_dr[4] = 4.0 * l3
|
|
377
|
+
dN_dr[5] = -4.0 * l3
|
|
378
|
+
|
|
379
|
+
dN_ds = np.zeros(6, dtype=float)
|
|
380
|
+
dN_ds[0] = 1.0 - 4.0 * l1
|
|
381
|
+
dN_ds[1] = 0.0
|
|
382
|
+
dN_ds[2] = 4.0 * l3 - 1.0
|
|
383
|
+
dN_ds[3] = -4.0 * l2
|
|
384
|
+
dN_ds[4] = 4.0 * l2
|
|
385
|
+
dN_ds[5] = 4.0 * (l1 - l3)
|
|
386
|
+
return N, dN_dr, dN_ds
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@njit
|
|
390
|
+
def _jit_integrate_nonlinear_response(
|
|
391
|
+
u_loc: np.ndarray,
|
|
392
|
+
N_res: np.ndarray,
|
|
393
|
+
M_res: np.ndarray,
|
|
394
|
+
C0: np.ndarray,
|
|
395
|
+
C1: np.ndarray,
|
|
396
|
+
C2: np.ndarray,
|
|
397
|
+
B_m_all: np.ndarray,
|
|
398
|
+
B_b_all: np.ndarray,
|
|
399
|
+
B_d_all: np.ndarray,
|
|
400
|
+
Gw_all: np.ndarray,
|
|
401
|
+
detw_all: np.ndarray,
|
|
402
|
+
B_s_all: np.ndarray,
|
|
403
|
+
detw_shear_all: np.ndarray,
|
|
404
|
+
D_shear: np.ndarray,
|
|
405
|
+
drilling_stiffness: float,
|
|
406
|
+
tangent: bool,
|
|
407
|
+
has_plasticity: bool,
|
|
408
|
+
n_dof: int,
|
|
409
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
410
|
+
F_loc = np.zeros(n_dof)
|
|
411
|
+
K_loc = np.zeros((n_dof, n_dof))
|
|
412
|
+
|
|
413
|
+
n_gp = len(detw_all)
|
|
414
|
+
for g in range(n_gp):
|
|
415
|
+
detw = detw_all[g]
|
|
416
|
+
B_m = B_m_all[g]
|
|
417
|
+
B_b = B_b_all[g]
|
|
418
|
+
B_d = B_d_all[g]
|
|
419
|
+
Gw = Gw_all[g]
|
|
420
|
+
|
|
421
|
+
# Calculate theta = Gw @ u_loc
|
|
422
|
+
theta_0 = 0.0
|
|
423
|
+
theta_1 = 0.0
|
|
424
|
+
for i in range(n_dof):
|
|
425
|
+
theta_0 += Gw[0, i] * u_loc[i]
|
|
426
|
+
theta_1 += Gw[1, i] * u_loc[i]
|
|
427
|
+
|
|
428
|
+
# Calculate B_eff = B_m + B_nl
|
|
429
|
+
B_eff = np.zeros((3, n_dof))
|
|
430
|
+
for i in range(n_dof):
|
|
431
|
+
B_eff[0, i] = B_m[0, i] + theta_0 * Gw[0, i]
|
|
432
|
+
B_eff[1, i] = B_m[1, i] + theta_1 * Gw[1, i]
|
|
433
|
+
B_eff[2, i] = B_m[2, i] + theta_0 * Gw[1, i] + theta_1 * Gw[0, i]
|
|
434
|
+
|
|
435
|
+
# B_eff.T @ N_res[g] + B_b.T @ M_res[g]
|
|
436
|
+
N_g = N_res[g]
|
|
437
|
+
M_g = M_res[g]
|
|
438
|
+
B_eff_T_N = np.zeros(n_dof)
|
|
439
|
+
B_b_T_M = np.zeros(n_dof)
|
|
440
|
+
for i in range(n_dof):
|
|
441
|
+
B_eff_T_N[i] = B_eff[0, i] * N_g[0] + B_eff[1, i] * N_g[1] + B_eff[2, i] * N_g[2]
|
|
442
|
+
B_b_T_M[i] = B_b[0, i] * M_g[0] + B_b[1, i] * M_g[1] + B_b[2, i] * M_g[2]
|
|
443
|
+
|
|
444
|
+
# B_d @ u_loc
|
|
445
|
+
Bd_u = 0.0
|
|
446
|
+
for i in range(n_dof):
|
|
447
|
+
Bd_u += B_d[0, i] * u_loc[i]
|
|
448
|
+
|
|
449
|
+
for i in range(n_dof):
|
|
450
|
+
F_loc[i] += (B_eff_T_N[i] + B_b_T_M[i]) * detw
|
|
451
|
+
F_loc[i] += B_d[0, i] * (drilling_stiffness * Bd_u) * detw
|
|
452
|
+
|
|
453
|
+
if not tangent:
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# K_loc += (B_eff.T @ C0[g] @ B_eff + B_b.T @ C2[g] @ B_b) * detw
|
|
457
|
+
C0_g = C0[g]
|
|
458
|
+
C2_g = C2[g]
|
|
459
|
+
|
|
460
|
+
C0_B_eff = np.zeros((3, n_dof))
|
|
461
|
+
C2_B_b = np.zeros((3, n_dof))
|
|
462
|
+
for r in range(3):
|
|
463
|
+
for c in range(n_dof):
|
|
464
|
+
val0 = 0.0
|
|
465
|
+
val2 = 0.0
|
|
466
|
+
for k in range(3):
|
|
467
|
+
val0 += C0_g[r, k] * B_eff[k, c]
|
|
468
|
+
val2 += C2_g[r, k] * B_b[k, c]
|
|
469
|
+
C0_B_eff[r, c] = val0
|
|
470
|
+
C2_B_b[r, c] = val2
|
|
471
|
+
|
|
472
|
+
for r in range(n_dof):
|
|
473
|
+
for c in range(n_dof):
|
|
474
|
+
val_eff = 0.0
|
|
475
|
+
val_b = 0.0
|
|
476
|
+
for k in range(3):
|
|
477
|
+
val_eff += B_eff[k, r] * C0_B_eff[k, c]
|
|
478
|
+
val_b += B_b[k, r] * C2_B_b[k, c]
|
|
479
|
+
K_loc[r, c] += (val_eff + val_b) * detw
|
|
480
|
+
|
|
481
|
+
if has_plasticity:
|
|
482
|
+
# Membrane-bending coupling:
|
|
483
|
+
# B_eff.T @ C1 @ B_b + B_b.T @ C1 @ B_eff.
|
|
484
|
+
C1_g = C1[g]
|
|
485
|
+
C1_B_b = np.zeros((3, n_dof))
|
|
486
|
+
C1_B_eff = np.zeros((3, n_dof))
|
|
487
|
+
for r in range(3):
|
|
488
|
+
for c in range(n_dof):
|
|
489
|
+
val_b = 0.0
|
|
490
|
+
val_eff = 0.0
|
|
491
|
+
for k in range(3):
|
|
492
|
+
val_b += C1_g[r, k] * B_b[k, c]
|
|
493
|
+
val_eff += C1_g[r, k] * B_eff[k, c]
|
|
494
|
+
C1_B_b[r, c] = val_b
|
|
495
|
+
C1_B_eff[r, c] = val_eff
|
|
496
|
+
|
|
497
|
+
for r in range(n_dof):
|
|
498
|
+
for c in range(n_dof):
|
|
499
|
+
forward = 0.0
|
|
500
|
+
reverse = 0.0
|
|
501
|
+
for k in range(3):
|
|
502
|
+
forward += B_eff[k, r] * C1_B_b[k, c]
|
|
503
|
+
reverse += B_b[k, r] * C1_B_eff[k, c]
|
|
504
|
+
K_loc[r, c] += (forward + reverse) * detw
|
|
505
|
+
|
|
506
|
+
# Geometric initial stress stiffness
|
|
507
|
+
N00 = N_g[0]
|
|
508
|
+
N11 = N_g[1]
|
|
509
|
+
N01 = N_g[2]
|
|
510
|
+
|
|
511
|
+
N_Gw_0 = N00 * Gw[0] + N01 * Gw[1]
|
|
512
|
+
N_Gw_1 = N01 * Gw[0] + N11 * Gw[1]
|
|
513
|
+
for r in range(n_dof):
|
|
514
|
+
for c in range(n_dof):
|
|
515
|
+
K_loc[r, c] += (Gw[0, r] * N_Gw_0[c] + Gw[1, r] * N_Gw_1[c]) * detw
|
|
516
|
+
K_loc[r, c] += B_d[0, r] * (drilling_stiffness * B_d[0, c]) * detw
|
|
517
|
+
|
|
518
|
+
# Shear stiffness and force contribution
|
|
519
|
+
for g in range(len(detw_shear_all)):
|
|
520
|
+
detw_s = detw_shear_all[g]
|
|
521
|
+
B_s = B_s_all[g]
|
|
522
|
+
|
|
523
|
+
# F_loc += K_s @ u_loc
|
|
524
|
+
Bs_u_0 = 0.0
|
|
525
|
+
Bs_u_1 = 0.0
|
|
526
|
+
for i in range(n_dof):
|
|
527
|
+
Bs_u_0 += B_s[0, i] * u_loc[i]
|
|
528
|
+
Bs_u_1 += B_s[1, i] * u_loc[i]
|
|
529
|
+
|
|
530
|
+
f_s_0 = D_shear[0, 0] * Bs_u_0 + D_shear[0, 1] * Bs_u_1
|
|
531
|
+
f_s_1 = D_shear[1, 0] * Bs_u_0 + D_shear[1, 1] * Bs_u_1
|
|
532
|
+
|
|
533
|
+
for i in range(n_dof):
|
|
534
|
+
F_loc[i] += (B_s[0, i] * f_s_0 + B_s[1, i] * f_s_1) * detw_s
|
|
535
|
+
|
|
536
|
+
if not tangent:
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
# K_loc += K_s
|
|
540
|
+
C_s = np.zeros((2, n_dof))
|
|
541
|
+
for r in range(2):
|
|
542
|
+
for c in range(n_dof):
|
|
543
|
+
C_s[r, c] = D_shear[r, 0] * B_s[0, c] + D_shear[r, 1] * B_s[1, c]
|
|
544
|
+
|
|
545
|
+
for r in range(n_dof):
|
|
546
|
+
for c in range(n_dof):
|
|
547
|
+
K_loc[r, c] += (B_s[0, r] * C_s[0, c] + B_s[1, r] * C_s[1, c]) * detw_s
|
|
548
|
+
|
|
549
|
+
return F_loc, K_loc
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class ShellElement(Element):
|
|
553
|
+
"""3/6-node triangular and 4/8-node quadrilateral Mindlin-Reissner shell element."""
|
|
554
|
+
|
|
555
|
+
TRI_GAUSS_POINTS_1 = np.array([[1.0 / 3.0, 1.0 / 3.0]], dtype=float)
|
|
556
|
+
TRI_GAUSS_WEIGHTS_1 = np.array([0.5], dtype=float)
|
|
557
|
+
|
|
558
|
+
TRI_GAUSS_POINTS_3 = np.array(
|
|
559
|
+
[[1.0 / 6.0, 1.0 / 6.0], [2.0 / 3.0, 1.0 / 6.0], [1.0 / 6.0, 2.0 / 3.0]],
|
|
560
|
+
dtype=float,
|
|
561
|
+
)
|
|
562
|
+
TRI_GAUSS_WEIGHTS_3 = np.array([1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0], dtype=float)
|
|
563
|
+
|
|
564
|
+
_DUNAVANT_A1 = 0.059715871789770
|
|
565
|
+
_DUNAVANT_B1 = 0.470142064105115
|
|
566
|
+
_DUNAVANT_A2 = 0.797426985353087
|
|
567
|
+
_DUNAVANT_B2 = 0.101286507323456
|
|
568
|
+
TRI_GAUSS_POINTS_7 = np.array(
|
|
569
|
+
[
|
|
570
|
+
[1.0 / 3.0, 1.0 / 3.0],
|
|
571
|
+
[_DUNAVANT_B1, _DUNAVANT_B1],
|
|
572
|
+
[_DUNAVANT_A1, _DUNAVANT_B1],
|
|
573
|
+
[_DUNAVANT_B1, _DUNAVANT_A1],
|
|
574
|
+
[_DUNAVANT_B2, _DUNAVANT_B2],
|
|
575
|
+
[_DUNAVANT_A2, _DUNAVANT_B2],
|
|
576
|
+
[_DUNAVANT_B2, _DUNAVANT_A2],
|
|
577
|
+
],
|
|
578
|
+
dtype=float,
|
|
579
|
+
)
|
|
580
|
+
TRI_GAUSS_WEIGHTS_7 = np.array(
|
|
581
|
+
[
|
|
582
|
+
0.1125,
|
|
583
|
+
0.066197076394253,
|
|
584
|
+
0.066197076394253,
|
|
585
|
+
0.066197076394253,
|
|
586
|
+
0.062969590272414,
|
|
587
|
+
0.062969590272414,
|
|
588
|
+
0.062969590272414,
|
|
589
|
+
],
|
|
590
|
+
dtype=float,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
GAUSS_POINTS_1x1 = np.array([[0.0, 0.0]], dtype=float)
|
|
594
|
+
GAUSS_WEIGHTS_1x1 = np.array([4.0], dtype=float)
|
|
595
|
+
|
|
596
|
+
GAUSS_POINTS_2x2 = np.array(
|
|
597
|
+
[[-1.0, -1.0], [1.0, -1.0], [-1.0, 1.0], [1.0, 1.0]], dtype=float
|
|
598
|
+
) / np.sqrt(3.0)
|
|
599
|
+
GAUSS_WEIGHTS_2x2 = np.ones(4, dtype=float)
|
|
600
|
+
|
|
601
|
+
GAUSS_POINTS_3x3 = np.array(
|
|
602
|
+
[
|
|
603
|
+
[-np.sqrt(3.0 / 5.0), -np.sqrt(3.0 / 5.0)],
|
|
604
|
+
[0.0, -np.sqrt(3.0 / 5.0)],
|
|
605
|
+
[np.sqrt(3.0 / 5.0), -np.sqrt(3.0 / 5.0)],
|
|
606
|
+
[-np.sqrt(3.0 / 5.0), 0.0],
|
|
607
|
+
[0.0, 0.0],
|
|
608
|
+
[np.sqrt(3.0 / 5.0), 0.0],
|
|
609
|
+
[-np.sqrt(3.0 / 5.0), np.sqrt(3.0 / 5.0)],
|
|
610
|
+
[0.0, np.sqrt(3.0 / 5.0)],
|
|
611
|
+
[np.sqrt(3.0 / 5.0), np.sqrt(3.0 / 5.0)],
|
|
612
|
+
],
|
|
613
|
+
dtype=float,
|
|
614
|
+
)
|
|
615
|
+
GAUSS_WEIGHTS_3x3 = np.array(
|
|
616
|
+
[
|
|
617
|
+
25.0 / 81.0,
|
|
618
|
+
40.0 / 81.0,
|
|
619
|
+
25.0 / 81.0,
|
|
620
|
+
40.0 / 81.0,
|
|
621
|
+
64.0 / 81.0,
|
|
622
|
+
40.0 / 81.0,
|
|
623
|
+
25.0 / 81.0,
|
|
624
|
+
40.0 / 81.0,
|
|
625
|
+
25.0 / 81.0,
|
|
626
|
+
],
|
|
627
|
+
dtype=float,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def __init__(
|
|
631
|
+
self,
|
|
632
|
+
element_id: int,
|
|
633
|
+
node_ids: List[int],
|
|
634
|
+
material_name: str = "default",
|
|
635
|
+
thickness: float = 0.01,
|
|
636
|
+
drilling_stabilization: float = 1.0e-3,
|
|
637
|
+
reduced_integration: bool = False,
|
|
638
|
+
hourglass_stabilization: float = 1.0e-8,
|
|
639
|
+
):
|
|
640
|
+
super().__init__(element_id, node_ids, material_name)
|
|
641
|
+
if len(set(node_ids)) != len(node_ids):
|
|
642
|
+
raise ValueError(f"Shell element {element_id} has repeated node ids")
|
|
643
|
+
self.thickness = float(thickness)
|
|
644
|
+
self.drilling_stabilization = float(drilling_stabilization)
|
|
645
|
+
self.reduced_integration = reduced_integration
|
|
646
|
+
self.hourglass_stabilization = float(hourglass_stabilization)
|
|
647
|
+
self._is_3node = len(node_ids) == 3
|
|
648
|
+
self._is_6node = len(node_ids) == 6
|
|
649
|
+
self._is_8node = len(node_ids) == 8
|
|
650
|
+
self._is_4node = len(node_ids) == 4
|
|
651
|
+
self._is_triangular = self._is_3node or self._is_6node
|
|
652
|
+
self._is_quadrilateral = self._is_4node or self._is_8node
|
|
653
|
+
if not (self._is_triangular or self._is_quadrilateral):
|
|
654
|
+
raise ValueError(f"ShellElement requires 3, 4, 6 or 8 nodes, got {len(node_ids)}")
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def num_nodes(self) -> int:
|
|
658
|
+
return len(self.node_ids)
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def dofs_per_node(self) -> int:
|
|
662
|
+
return 6
|
|
663
|
+
|
|
664
|
+
@property
|
|
665
|
+
def gauss_points(self) -> np.ndarray:
|
|
666
|
+
if self._is_3node:
|
|
667
|
+
return self.TRI_GAUSS_POINTS_3
|
|
668
|
+
if self._is_6node:
|
|
669
|
+
return self.TRI_GAUSS_POINTS_7
|
|
670
|
+
if self._is_8node and self.reduced_integration:
|
|
671
|
+
return self.GAUSS_POINTS_2x2
|
|
672
|
+
return self.GAUSS_POINTS_3x3 if self._is_8node else self.GAUSS_POINTS_2x2
|
|
673
|
+
|
|
674
|
+
@property
|
|
675
|
+
def gauss_weights(self) -> np.ndarray:
|
|
676
|
+
if self._is_3node:
|
|
677
|
+
return self.TRI_GAUSS_WEIGHTS_3
|
|
678
|
+
if self._is_6node:
|
|
679
|
+
return self.TRI_GAUSS_WEIGHTS_7
|
|
680
|
+
if self._is_8node and self.reduced_integration:
|
|
681
|
+
return self.GAUSS_WEIGHTS_2x2
|
|
682
|
+
return self.GAUSS_WEIGHTS_3x3 if self._is_8node else self.GAUSS_WEIGHTS_2x2
|
|
683
|
+
|
|
684
|
+
@property
|
|
685
|
+
def shear_gauss_points(self) -> np.ndarray:
|
|
686
|
+
if self._is_3node:
|
|
687
|
+
return self.TRI_GAUSS_POINTS_1
|
|
688
|
+
if self._is_6node:
|
|
689
|
+
return self.TRI_GAUSS_POINTS_3
|
|
690
|
+
return self.GAUSS_POINTS_2x2 if self._is_8node else self.GAUSS_POINTS_1x1
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def shear_gauss_weights(self) -> np.ndarray:
|
|
694
|
+
if self._is_3node:
|
|
695
|
+
return self.TRI_GAUSS_WEIGHTS_1
|
|
696
|
+
if self._is_6node:
|
|
697
|
+
return self.TRI_GAUSS_WEIGHTS_3
|
|
698
|
+
return self.GAUSS_WEIGHTS_2x2 if self._is_8node else self.GAUSS_WEIGHTS_1x1
|
|
699
|
+
|
|
700
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
701
|
+
coords = np.zeros((self.num_nodes, 3), dtype=float)
|
|
702
|
+
for i, node_id in enumerate(self.node_ids):
|
|
703
|
+
node = mesh.get_node(node_id)
|
|
704
|
+
if node is None:
|
|
705
|
+
raise ValueError(f"Shell element {self.element_id} references missing node {node_id}")
|
|
706
|
+
coords[i] = node.coords()
|
|
707
|
+
return coords
|
|
708
|
+
|
|
709
|
+
def compute_shape_functions(self, xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
710
|
+
if self._is_3node:
|
|
711
|
+
return self._compute_3node_shape_functions(xi, eta)
|
|
712
|
+
if self._is_6node:
|
|
713
|
+
return self._compute_6node_shape_functions(xi, eta)
|
|
714
|
+
if self._is_4node:
|
|
715
|
+
return self._compute_4node_shape_functions(xi, eta)
|
|
716
|
+
return self._compute_8node_shape_functions(xi, eta)
|
|
717
|
+
|
|
718
|
+
def _compute_3node_shape_functions(self, xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
719
|
+
return _jit_compute_3node_shape_functions(xi, eta)
|
|
720
|
+
|
|
721
|
+
def _compute_4node_shape_functions(self, xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
722
|
+
return _jit_compute_4node_shape_functions(xi, eta)
|
|
723
|
+
|
|
724
|
+
def _compute_6node_shape_functions(self, xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
725
|
+
return _jit_compute_6node_shape_functions(xi, eta)
|
|
726
|
+
|
|
727
|
+
def _compute_8node_shape_functions(self, xi: float, eta: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
728
|
+
return _jit_compute_8node_shape_functions(xi, eta)
|
|
729
|
+
|
|
730
|
+
def compute_jacobian(self, coords: np.ndarray, dN_dxi: np.ndarray, dN_deta: np.ndarray) -> np.ndarray:
|
|
731
|
+
return np.array([coords.T @ dN_dxi, coords.T @ dN_deta], dtype=float)
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _normalize(vector: np.ndarray) -> Tuple[np.ndarray, float]:
|
|
735
|
+
norm = float(np.sqrt(vector @ vector))
|
|
736
|
+
if norm < _SMALL:
|
|
737
|
+
return np.zeros(3, dtype=float), norm
|
|
738
|
+
return vector / norm, norm
|
|
739
|
+
|
|
740
|
+
def _fallback_edge_direction(self, coords: np.ndarray, normal: np.ndarray) -> np.ndarray:
|
|
741
|
+
candidate_edges = []
|
|
742
|
+
if coords.shape[0] >= 2:
|
|
743
|
+
candidate_edges.append(coords[1] - coords[0])
|
|
744
|
+
if coords.shape[0] >= 3:
|
|
745
|
+
candidate_edges.append(coords[2] - coords[0])
|
|
746
|
+
candidate_edges.append(coords[2] - coords[1])
|
|
747
|
+
if coords.shape[0] >= 4:
|
|
748
|
+
candidate_edges.append(coords[2] - coords[3])
|
|
749
|
+
candidate_edges.append(coords[3] - coords[0])
|
|
750
|
+
candidate_edges.append(coords[2] - coords[1])
|
|
751
|
+
for edge in candidate_edges:
|
|
752
|
+
projected = edge - np.dot(edge, normal) * normal
|
|
753
|
+
e1, n = self._normalize(projected)
|
|
754
|
+
if n > _SMALL:
|
|
755
|
+
return e1
|
|
756
|
+
raise ValueError(f"Shell element {self.element_id} has no valid in-plane direction")
|
|
757
|
+
|
|
758
|
+
def _local_frame_and_derivatives(
|
|
759
|
+
self,
|
|
760
|
+
coords: np.ndarray,
|
|
761
|
+
dN_dxi: np.ndarray,
|
|
762
|
+
dN_deta: np.ndarray,
|
|
763
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
|
|
764
|
+
J = self.compute_jacobian(coords, dN_dxi, dN_deta)
|
|
765
|
+
tangent_xi = J[0]
|
|
766
|
+
tangent_eta = J[1]
|
|
767
|
+
e3, det_j = self._normalize(_cross3(tangent_xi, tangent_eta))
|
|
768
|
+
if det_j < _SMALL:
|
|
769
|
+
raise ValueError(f"Shell element {self.element_id} has a near-zero surface Jacobian")
|
|
770
|
+
|
|
771
|
+
e1_raw = tangent_xi - np.dot(tangent_xi, e3) * e3
|
|
772
|
+
e1, e1_norm = self._normalize(e1_raw)
|
|
773
|
+
if e1_norm < _SMALL:
|
|
774
|
+
e1 = self._fallback_edge_direction(coords, e3)
|
|
775
|
+
e2, e2_norm = self._normalize(_cross3(e3, e1))
|
|
776
|
+
if e2_norm < _SMALL:
|
|
777
|
+
raise ValueError(f"Shell element {self.element_id} has an invalid local y direction")
|
|
778
|
+
e1, _ = self._normalize(_cross3(e2, e3))
|
|
779
|
+
R = np.column_stack((e1, e2, e3))
|
|
780
|
+
|
|
781
|
+
J_local = np.array(
|
|
782
|
+
[
|
|
783
|
+
[np.dot(tangent_xi, e1), np.dot(tangent_xi, e2)],
|
|
784
|
+
[np.dot(tangent_eta, e1), np.dot(tangent_eta, e2)],
|
|
785
|
+
],
|
|
786
|
+
dtype=float,
|
|
787
|
+
)
|
|
788
|
+
try:
|
|
789
|
+
inv_j_local, _ = _inv2(J_local)
|
|
790
|
+
except np.linalg.LinAlgError as exc:
|
|
791
|
+
raise ValueError(f"Shell element {self.element_id} has a singular local Jacobian") from exc
|
|
792
|
+
|
|
793
|
+
dN_dx = inv_j_local[0, 0] * dN_dxi + inv_j_local[0, 1] * dN_deta
|
|
794
|
+
dN_dy = inv_j_local[1, 0] * dN_dxi + inv_j_local[1, 1] * dN_deta
|
|
795
|
+
return R, dN_dx, dN_dy, det_j
|
|
796
|
+
|
|
797
|
+
def _local_dof_transform(self, R: np.ndarray) -> np.ndarray:
|
|
798
|
+
n_blocks = 2 * self.num_nodes
|
|
799
|
+
T = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
800
|
+
blocks = T.reshape(n_blocks, 3, n_blocks, 3)
|
|
801
|
+
indices = np.arange(n_blocks)
|
|
802
|
+
blocks[indices, :, indices, :] = R.T
|
|
803
|
+
return T
|
|
804
|
+
|
|
805
|
+
def _build_shell_b_matrices(
|
|
806
|
+
self,
|
|
807
|
+
N: np.ndarray,
|
|
808
|
+
dN_dx: np.ndarray,
|
|
809
|
+
dN_dy: np.ndarray,
|
|
810
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
811
|
+
B_m = np.zeros((3, self.total_dofs), dtype=float)
|
|
812
|
+
B_b = np.zeros((3, self.total_dofs), dtype=float)
|
|
813
|
+
B_s = np.zeros((2, self.total_dofs), dtype=float)
|
|
814
|
+
|
|
815
|
+
B_m[0, 0::6] = dN_dx
|
|
816
|
+
B_m[1, 1::6] = dN_dy
|
|
817
|
+
B_m[2, 0::6] = dN_dy
|
|
818
|
+
B_m[2, 1::6] = dN_dx
|
|
819
|
+
|
|
820
|
+
B_b[0, 4::6] = dN_dx
|
|
821
|
+
B_b[1, 3::6] = -dN_dy
|
|
822
|
+
B_b[2, 4::6] = dN_dy
|
|
823
|
+
B_b[2, 3::6] = -dN_dx
|
|
824
|
+
|
|
825
|
+
B_s[0, 2::6] = dN_dx
|
|
826
|
+
B_s[0, 4::6] = N
|
|
827
|
+
B_s[1, 2::6] = dN_dy
|
|
828
|
+
B_s[1, 3::6] = -N
|
|
829
|
+
return B_m, B_b, B_s
|
|
830
|
+
|
|
831
|
+
def _build_drilling_b_matrix(
|
|
832
|
+
self,
|
|
833
|
+
N: np.ndarray,
|
|
834
|
+
dN_dx: np.ndarray,
|
|
835
|
+
dN_dy: np.ndarray,
|
|
836
|
+
) -> np.ndarray:
|
|
837
|
+
"""
|
|
838
|
+
Build a small drilling stabilization strain.
|
|
839
|
+
|
|
840
|
+
The stabilized quantity is theta_z - 0.5 * (dv/dx - du/dy), so a
|
|
841
|
+
physical rigid rotation about the shell normal has exactly zero energy.
|
|
842
|
+
"""
|
|
843
|
+
B_d = np.zeros((1, self.total_dofs), dtype=float)
|
|
844
|
+
B_d[0, 0::6] = 0.5 * dN_dy
|
|
845
|
+
B_d[0, 1::6] = -0.5 * dN_dx
|
|
846
|
+
B_d[0, 5::6] = N
|
|
847
|
+
return B_d
|
|
848
|
+
|
|
849
|
+
def _reference_center(self) -> Tuple[float, float]:
|
|
850
|
+
if self._is_triangular:
|
|
851
|
+
return 1.0 / 3.0, 1.0 / 3.0
|
|
852
|
+
return 0.0, 0.0
|
|
853
|
+
|
|
854
|
+
# MITC4 assumed natural transverse shear (4-node elements only).
|
|
855
|
+
#
|
|
856
|
+
# Covariant shear strains are sampled where they are exact for pure
|
|
857
|
+
# bending, at the edge midpoints A(0,-1), B(1,0), C(0,1), D(-1,0):
|
|
858
|
+
#
|
|
859
|
+
# gamma_xi(xi, eta) = (1-eta)/2 * gamma_xi|A + (1+eta)/2 * gamma_xi|C
|
|
860
|
+
# gamma_eta(xi, eta) = (1-xi)/2 * gamma_eta|D + (1+xi)/2 * gamma_eta|B
|
|
861
|
+
#
|
|
862
|
+
# with gamma_xi = dw/dxi + x_,xi * theta_y - y_,xi * theta_x in a fixed
|
|
863
|
+
# element-plane frame, then mapped to Cartesian shear through the inverse
|
|
864
|
+
# in-plane Jacobian at the integration point. The element geometry is
|
|
865
|
+
# treated as a flat facet in the frame evaluated at the element centre.
|
|
866
|
+
_MITC4_SAMPLE_POINTS = {"A": (0.0, -1.0), "B": (1.0, 0.0), "C": (0.0, 1.0), "D": (-1.0, 0.0)}
|
|
867
|
+
|
|
868
|
+
def _center_frame(self, coords: np.ndarray) -> np.ndarray:
|
|
869
|
+
xi, eta = self._reference_center()
|
|
870
|
+
_, dN_dxi, dN_deta = self.compute_shape_functions(xi, eta)
|
|
871
|
+
R, _, _, _ = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
872
|
+
return R
|
|
873
|
+
|
|
874
|
+
def _tri3_assumed_shear_b_matrix(self, coords: np.ndarray, R: np.ndarray) -> Tuple[np.ndarray, float]:
|
|
875
|
+
"""
|
|
876
|
+
Constant assumed-shear field for the 3-node triangle.
|
|
877
|
+
|
|
878
|
+
The row space is evaluated in the centroid frame and integrated once
|
|
879
|
+
over the triangular area. This is the constant shear-gap part used by
|
|
880
|
+
DSG3/MITC3-style triangles: rigid rotations satisfy
|
|
881
|
+
``dw/dx + theta_y = 0`` and ``dw/dy - theta_x = 0`` exactly, while a
|
|
882
|
+
constant transverse-shear patch is reproduced without the locking-prone
|
|
883
|
+
fully integrated linear Mindlin shear interpolation.
|
|
884
|
+
"""
|
|
885
|
+
if not self._is_3node:
|
|
886
|
+
raise ValueError("_tri3_assumed_shear_b_matrix is only valid for 3-node shells")
|
|
887
|
+
planar = coords @ R[:, :2]
|
|
888
|
+
r, s = self._reference_center()
|
|
889
|
+
N, dN_dr, dN_ds = self.compute_shape_functions(r, s)
|
|
890
|
+
J2 = np.array(
|
|
891
|
+
[
|
|
892
|
+
[float(dN_dr @ planar[:, 0]), float(dN_dr @ planar[:, 1])],
|
|
893
|
+
[float(dN_ds @ planar[:, 0]), float(dN_ds @ planar[:, 1])],
|
|
894
|
+
],
|
|
895
|
+
dtype=float,
|
|
896
|
+
)
|
|
897
|
+
try:
|
|
898
|
+
inv_j2, det_j = _inv2(J2)
|
|
899
|
+
except np.linalg.LinAlgError as exc:
|
|
900
|
+
raise ValueError(f"Shell element {self.element_id} has a singular triangular shear Jacobian") from exc
|
|
901
|
+
dN_dx = inv_j2[0, 0] * dN_dr + inv_j2[0, 1] * dN_ds
|
|
902
|
+
dN_dy = inv_j2[1, 0] * dN_dr + inv_j2[1, 1] * dN_ds
|
|
903
|
+
_, _, B_s = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
904
|
+
return B_s, abs(float(det_j))
|
|
905
|
+
|
|
906
|
+
def _mitc4_shear_samples(self, coords: np.ndarray, R: np.ndarray) -> Tuple[np.ndarray, Dict[str, Tuple[np.ndarray, np.ndarray]]]:
|
|
907
|
+
"""Return in-plane node coordinates and covariant shear rows at A-D."""
|
|
908
|
+
planar = coords @ R[:, :2]
|
|
909
|
+
samples: Dict[str, Tuple[np.ndarray, np.ndarray]] = {}
|
|
910
|
+
for name, (xi, eta) in self._MITC4_SAMPLE_POINTS.items():
|
|
911
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(xi, eta)
|
|
912
|
+
x_xi = float(dN_dxi @ planar[:, 0])
|
|
913
|
+
y_xi = float(dN_dxi @ planar[:, 1])
|
|
914
|
+
x_eta = float(dN_deta @ planar[:, 0])
|
|
915
|
+
y_eta = float(dN_deta @ planar[:, 1])
|
|
916
|
+
row_xi = np.zeros(self.total_dofs, dtype=float)
|
|
917
|
+
row_eta = np.zeros(self.total_dofs, dtype=float)
|
|
918
|
+
row_xi[2::6] = dN_dxi
|
|
919
|
+
row_xi[3::6] = -N * y_xi
|
|
920
|
+
row_xi[4::6] = N * x_xi
|
|
921
|
+
row_eta[2::6] = dN_deta
|
|
922
|
+
row_eta[3::6] = -N * y_eta
|
|
923
|
+
row_eta[4::6] = N * x_eta
|
|
924
|
+
samples[name] = (row_xi, row_eta)
|
|
925
|
+
return planar, samples
|
|
926
|
+
|
|
927
|
+
def _mitc4_shear_b_matrix(
|
|
928
|
+
self,
|
|
929
|
+
planar: np.ndarray,
|
|
930
|
+
samples: Dict[str, Tuple[np.ndarray, np.ndarray]],
|
|
931
|
+
xi: float,
|
|
932
|
+
eta: float,
|
|
933
|
+
) -> Tuple[np.ndarray, float]:
|
|
934
|
+
"""Assumed-shear B matrix (local Cartesian) and in-plane Jacobian det."""
|
|
935
|
+
_, dN_dxi, dN_deta = self.compute_shape_functions(xi, eta)
|
|
936
|
+
J2 = np.array(
|
|
937
|
+
[
|
|
938
|
+
[float(dN_dxi @ planar[:, 0]), float(dN_dxi @ planar[:, 1])],
|
|
939
|
+
[float(dN_deta @ planar[:, 0]), float(dN_deta @ planar[:, 1])],
|
|
940
|
+
],
|
|
941
|
+
dtype=float,
|
|
942
|
+
)
|
|
943
|
+
try:
|
|
944
|
+
inv_j2, det_j = _inv2(J2)
|
|
945
|
+
except np.linalg.LinAlgError as exc:
|
|
946
|
+
raise ValueError(f"Shell element {self.element_id} has a singular in-plane Jacobian") from exc
|
|
947
|
+
B_covariant = np.vstack(
|
|
948
|
+
[
|
|
949
|
+
0.5 * (1.0 - eta) * samples["A"][0] + 0.5 * (1.0 + eta) * samples["C"][0],
|
|
950
|
+
0.5 * (1.0 - xi) * samples["D"][1] + 0.5 * (1.0 + xi) * samples["B"][1],
|
|
951
|
+
]
|
|
952
|
+
)
|
|
953
|
+
return inv_j2 @ B_covariant, det_j
|
|
954
|
+
|
|
955
|
+
def _rigid_body_mode_matrix(self, coords: np.ndarray) -> np.ndarray:
|
|
956
|
+
modes = np.zeros((self.total_dofs, 6), dtype=float)
|
|
957
|
+
centroid = np.mean(coords, axis=0)
|
|
958
|
+
axes = np.eye(3, dtype=float)
|
|
959
|
+
|
|
960
|
+
for local, coord in enumerate(coords):
|
|
961
|
+
base = local * 6
|
|
962
|
+
r = coord - centroid
|
|
963
|
+
modes[base + 0, 0] = 1.0
|
|
964
|
+
modes[base + 1, 1] = 1.0
|
|
965
|
+
modes[base + 2, 2] = 1.0
|
|
966
|
+
for axis_index, axis in enumerate(axes):
|
|
967
|
+
displacement = _cross3(axis, r)
|
|
968
|
+
modes[base : base + 3, 3 + axis_index] = displacement
|
|
969
|
+
modes[base + 3 : base + 6, 3 + axis_index] = axis
|
|
970
|
+
|
|
971
|
+
q, rmat = np.linalg.qr(modes)
|
|
972
|
+
diag = np.abs(np.diag(rmat))
|
|
973
|
+
if diag.size == 0:
|
|
974
|
+
return np.zeros((self.total_dofs, 0), dtype=float)
|
|
975
|
+
keep = diag > max(float(np.max(diag)) * 1.0e-10, _SMALL)
|
|
976
|
+
return q[:, keep]
|
|
977
|
+
|
|
978
|
+
def _hourglass_stabilization_matrix(self, K_base: np.ndarray, coords: np.ndarray) -> np.ndarray:
|
|
979
|
+
"""Small stiffness on non-rigid zero modes outside rigid motion."""
|
|
980
|
+
coefficient = float(getattr(self, "hourglass_stabilization", 0.0))
|
|
981
|
+
stabilized_topology = (self._is_8node and self.reduced_integration) or self._is_triangular
|
|
982
|
+
if not stabilized_topology or coefficient <= 0.0:
|
|
983
|
+
return np.zeros_like(K_base)
|
|
984
|
+
|
|
985
|
+
K_sym = 0.5 * (K_base + K_base.T)
|
|
986
|
+
eigvals, eigvecs = np.linalg.eigh(K_sym)
|
|
987
|
+
max_eig = max(float(np.max(np.abs(eigvals))), 1.0)
|
|
988
|
+
zero_tol = 1.0e-9 * max_eig
|
|
989
|
+
zero_mask = np.abs(eigvals) < zero_tol
|
|
990
|
+
if int(np.sum(zero_mask)) <= 6:
|
|
991
|
+
return np.zeros_like(K_base)
|
|
992
|
+
|
|
993
|
+
zero_space = eigvecs[:, zero_mask]
|
|
994
|
+
rigid = self._rigid_body_mode_matrix(coords)
|
|
995
|
+
if rigid.size:
|
|
996
|
+
zero_space = zero_space - rigid @ (rigid.T @ zero_space)
|
|
997
|
+
|
|
998
|
+
u, singular_values, _ = np.linalg.svd(zero_space, full_matrices=False)
|
|
999
|
+
if singular_values.size == 0:
|
|
1000
|
+
return np.zeros_like(K_base)
|
|
1001
|
+
keep = singular_values > 1.0e-8
|
|
1002
|
+
if not np.any(keep):
|
|
1003
|
+
return np.zeros_like(K_base)
|
|
1004
|
+
|
|
1005
|
+
hourglass_modes = u[:, keep]
|
|
1006
|
+
stiffness_scale = coefficient * max_eig
|
|
1007
|
+
K_hg = stiffness_scale * (hourglass_modes @ hourglass_modes.T)
|
|
1008
|
+
return 0.5 * (K_hg + K_hg.T)
|
|
1009
|
+
|
|
1010
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
1011
|
+
coords = self.get_node_coordinates(mesh)
|
|
1012
|
+
E = material.elastic_modulus
|
|
1013
|
+
nu = material.poisson_ratio
|
|
1014
|
+
G = material.shear_modulus
|
|
1015
|
+
h = self.thickness
|
|
1016
|
+
kappa = 5.0 / 6.0
|
|
1017
|
+
|
|
1018
|
+
shell_plane = np.array(
|
|
1019
|
+
[[1.0, nu, 0.0], [nu, 1.0, 0.0], [0.0, 0.0, (1.0 - nu) / 2.0]],
|
|
1020
|
+
dtype=float,
|
|
1021
|
+
)
|
|
1022
|
+
D_membrane = E * h / (1.0 - nu**2) * shell_plane
|
|
1023
|
+
D_bending = E * h**3 / (12.0 * (1.0 - nu**2)) * shell_plane
|
|
1024
|
+
D_shear = G * kappa * h * np.eye(2, dtype=float)
|
|
1025
|
+
|
|
1026
|
+
K = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1027
|
+
|
|
1028
|
+
for (xi, eta), weight in zip(self.gauss_points, self.gauss_weights):
|
|
1029
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1030
|
+
R, dN_dx, dN_dy, det_j = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1031
|
+
T = self._local_dof_transform(R)
|
|
1032
|
+
B_m, B_b, _ = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
1033
|
+
B_d = self._build_drilling_b_matrix(N, dN_dx, dN_dy)
|
|
1034
|
+
K_local = (B_m.T @ D_membrane @ B_m + B_b.T @ D_bending @ B_b) * det_j * weight
|
|
1035
|
+
|
|
1036
|
+
drilling_stiffness = G * h * getattr(self, "drilling_stabilization", 1.0e-3)
|
|
1037
|
+
K_local += (B_d.T @ (drilling_stiffness * np.eye(1)) @ B_d) * det_j * weight
|
|
1038
|
+
K += T.T @ K_local @ T
|
|
1039
|
+
|
|
1040
|
+
if self._is_4node:
|
|
1041
|
+
R = self._center_frame(coords)
|
|
1042
|
+
T = self._local_dof_transform(R)
|
|
1043
|
+
planar, samples = self._mitc4_shear_samples(coords, R)
|
|
1044
|
+
for (xi, eta), weight in zip(self.GAUSS_POINTS_2x2, self.GAUSS_WEIGHTS_2x2):
|
|
1045
|
+
B_s, det_j = self._mitc4_shear_b_matrix(planar, samples, float(xi), float(eta))
|
|
1046
|
+
K_local = (B_s.T @ D_shear @ B_s) * det_j * weight
|
|
1047
|
+
K += T.T @ K_local @ T
|
|
1048
|
+
elif self._is_3node:
|
|
1049
|
+
R = self._center_frame(coords)
|
|
1050
|
+
T = self._local_dof_transform(R)
|
|
1051
|
+
B_s, det_j = self._tri3_assumed_shear_b_matrix(coords, R)
|
|
1052
|
+
K_local = (B_s.T @ D_shear @ B_s) * det_j * float(np.sum(self.shear_gauss_weights))
|
|
1053
|
+
K += T.T @ K_local @ T
|
|
1054
|
+
else:
|
|
1055
|
+
for (xi, eta), weight in zip(self.shear_gauss_points, self.shear_gauss_weights):
|
|
1056
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1057
|
+
R, dN_dx, dN_dy, det_j = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1058
|
+
T = self._local_dof_transform(R)
|
|
1059
|
+
_, _, B_s = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
1060
|
+
K_local = (B_s.T @ D_shear @ B_s) * det_j * weight
|
|
1061
|
+
K += T.T @ K_local @ T
|
|
1062
|
+
|
|
1063
|
+
self._hourglass_stiffness_matrix = self._hourglass_stabilization_matrix(K, coords)
|
|
1064
|
+
K += self._hourglass_stiffness_matrix
|
|
1065
|
+
|
|
1066
|
+
self._stiffness_matrix = K
|
|
1067
|
+
return K
|
|
1068
|
+
|
|
1069
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
1070
|
+
coords = self.get_node_coordinates(mesh)
|
|
1071
|
+
rho = material.density
|
|
1072
|
+
h = self.thickness
|
|
1073
|
+
M = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1074
|
+
if self._is_8node and self.reduced_integration:
|
|
1075
|
+
area = 0.0
|
|
1076
|
+
for (xi, eta), weight in zip(self.gauss_points, self.gauss_weights):
|
|
1077
|
+
_N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1078
|
+
_R, _dN_dx, _dN_dy, det_j = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1079
|
+
area += float(det_j) * float(weight)
|
|
1080
|
+
translational = float(rho) * float(h) * max(area, 0.0) / max(self.num_nodes, 1)
|
|
1081
|
+
rotational = float(rho) * float(h) ** 3 / 12.0 * max(area, 0.0) / max(self.num_nodes, 1)
|
|
1082
|
+
for i in range(self.num_nodes):
|
|
1083
|
+
base = 6 * i
|
|
1084
|
+
M[base + 0, base + 0] = translational
|
|
1085
|
+
M[base + 1, base + 1] = translational
|
|
1086
|
+
M[base + 2, base + 2] = translational
|
|
1087
|
+
M[base + 3, base + 3] = rotational
|
|
1088
|
+
M[base + 4, base + 4] = rotational
|
|
1089
|
+
M[base + 5, base + 5] = rotational
|
|
1090
|
+
self._mass_matrix = M
|
|
1091
|
+
return M
|
|
1092
|
+
for (xi, eta), weight in zip(self.gauss_points, self.gauss_weights):
|
|
1093
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1094
|
+
R, _, _, det_j = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1095
|
+
T = self._local_dof_transform(R)
|
|
1096
|
+
M_local = np.zeros_like(M)
|
|
1097
|
+
outer_n = np.outer(N, N) * det_j * weight
|
|
1098
|
+
translational = rho * h * outer_n
|
|
1099
|
+
rotational = rho * h**3 / 12.0 * outer_n
|
|
1100
|
+
for d in range(3):
|
|
1101
|
+
M_local[d::6, d::6] += translational
|
|
1102
|
+
M_local[3 + d::6, 3 + d::6] += rotational
|
|
1103
|
+
M += T.T @ M_local @ T
|
|
1104
|
+
self._mass_matrix = M
|
|
1105
|
+
return M
|
|
1106
|
+
|
|
1107
|
+
@staticmethod
|
|
1108
|
+
def _membrane_compression_from_state(state: Optional[Any]) -> Tuple[float, float, float]:
|
|
1109
|
+
"""Return local membrane resultants with compression-positive convention."""
|
|
1110
|
+
if state is None or not isinstance(state, dict):
|
|
1111
|
+
return 0.0, 0.0, 0.0
|
|
1112
|
+
|
|
1113
|
+
if "membrane_compression" in state:
|
|
1114
|
+
values = np.asarray(state["membrane_compression"], dtype=float).reshape(-1)
|
|
1115
|
+
if values.size >= 3:
|
|
1116
|
+
return float(values[0]), float(values[1]), float(values[2])
|
|
1117
|
+
if "membrane_forces" in state:
|
|
1118
|
+
values = np.asarray(state["membrane_forces"], dtype=float).reshape(-1)
|
|
1119
|
+
if values.size >= 3:
|
|
1120
|
+
return -float(values[0]), -float(values[1]), -float(values[2])
|
|
1121
|
+
|
|
1122
|
+
compression_x = state.get("membrane_compression_x", state.get("Nx_compression"))
|
|
1123
|
+
compression_y = state.get("membrane_compression_y", state.get("Ny_compression"))
|
|
1124
|
+
compression_xy = state.get("membrane_compression_xy", state.get("Nxy_compression"))
|
|
1125
|
+
if compression_x is not None or compression_y is not None or compression_xy is not None:
|
|
1126
|
+
return (
|
|
1127
|
+
float(compression_x or 0.0),
|
|
1128
|
+
float(compression_y or 0.0),
|
|
1129
|
+
float(compression_xy or 0.0),
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
force_x = state.get("membrane_force_x", state.get("Nx"))
|
|
1133
|
+
force_y = state.get("membrane_force_y", state.get("Ny"))
|
|
1134
|
+
force_xy = state.get("membrane_force_xy", state.get("Nxy"))
|
|
1135
|
+
return (
|
|
1136
|
+
-float(force_x or 0.0),
|
|
1137
|
+
-float(force_y or 0.0),
|
|
1138
|
+
-float(force_xy or 0.0),
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
def compute_geometric_stiffness_matrix(
|
|
1142
|
+
self,
|
|
1143
|
+
mesh: "FEMesh",
|
|
1144
|
+
material: "Material",
|
|
1145
|
+
state: Optional[Any] = None,
|
|
1146
|
+
) -> np.ndarray:
|
|
1147
|
+
"""
|
|
1148
|
+
Shell stress-stiffness matrix from membrane resultants.
|
|
1149
|
+
|
|
1150
|
+
The state can supply either tension-positive membrane forces
|
|
1151
|
+
(``membrane_force_x/y/xy`` or ``membrane_forces``) or
|
|
1152
|
+
compression-positive values (``membrane_compression_x/y/xy``). The
|
|
1153
|
+
returned matrix follows the package convention
|
|
1154
|
+
``K phi = lambda KG phi`` with compression destabilizing.
|
|
1155
|
+
"""
|
|
1156
|
+
Nx, Ny, Nxy = self._membrane_compression_from_state(state)
|
|
1157
|
+
if Nx == 0.0 and Ny == 0.0 and Nxy == 0.0:
|
|
1158
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1159
|
+
|
|
1160
|
+
coords = self.get_node_coordinates(mesh)
|
|
1161
|
+
KG = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1162
|
+
|
|
1163
|
+
for (xi, eta), weight in zip(self.gauss_points, self.gauss_weights):
|
|
1164
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1165
|
+
R, dN_dx, dN_dy, det_j = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1166
|
+
T = self._local_dof_transform(R)
|
|
1167
|
+
G = np.zeros((2, self.total_dofs), dtype=float)
|
|
1168
|
+
G[0, 2::6] = dN_dx
|
|
1169
|
+
G[1, 2::6] = dN_dy
|
|
1170
|
+
N_matrix = np.array([[Nx, Nxy], [Nxy, Ny]], dtype=float)
|
|
1171
|
+
KG_local = (G.T @ N_matrix @ G) * det_j * float(weight)
|
|
1172
|
+
KG += T.T @ KG_local @ T
|
|
1173
|
+
return KG
|
|
1174
|
+
|
|
1175
|
+
# ------------------------------------------------------------------
|
|
1176
|
+
# Incremental nonlinear response: total-Lagrangian von Karman membrane
|
|
1177
|
+
# kinematics in the flat-facet centre frame, layered J2 plane-stress
|
|
1178
|
+
# plasticity through the thickness, elastic transverse shear (MITC4 for
|
|
1179
|
+
# 4-node, reduced for 8-node) and elastic drilling stabilization.
|
|
1180
|
+
# ------------------------------------------------------------------
|
|
1181
|
+
|
|
1182
|
+
def _nonlinear_geometry(self, mesh: "FEMesh") -> Dict[str, Any]:
|
|
1183
|
+
"""Reference-configuration data, computed once per element."""
|
|
1184
|
+
cache = getattr(self, "_nl_cache", None)
|
|
1185
|
+
if cache is not None:
|
|
1186
|
+
return cache
|
|
1187
|
+
coords = self.get_node_coordinates(mesh)
|
|
1188
|
+
R0 = self._center_frame(coords)
|
|
1189
|
+
T0 = self._local_dof_transform(R0)
|
|
1190
|
+
planar = coords @ R0[:, :2]
|
|
1191
|
+
|
|
1192
|
+
gp_data = []
|
|
1193
|
+
for (xi, eta), weight in zip(self.gauss_points, self.gauss_weights):
|
|
1194
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1195
|
+
J2 = np.array(
|
|
1196
|
+
[
|
|
1197
|
+
[float(dN_dxi @ planar[:, 0]), float(dN_dxi @ planar[:, 1])],
|
|
1198
|
+
[float(dN_deta @ planar[:, 0]), float(dN_deta @ planar[:, 1])],
|
|
1199
|
+
],
|
|
1200
|
+
dtype=float,
|
|
1201
|
+
)
|
|
1202
|
+
inv_j2, det_j = _inv2(J2)
|
|
1203
|
+
dN_dx = inv_j2[0, 0] * dN_dxi + inv_j2[0, 1] * dN_deta
|
|
1204
|
+
dN_dy = inv_j2[1, 0] * dN_dxi + inv_j2[1, 1] * dN_deta
|
|
1205
|
+
B_m, B_b, B_s = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
1206
|
+
B_d = self._build_drilling_b_matrix(N, dN_dx, dN_dy)
|
|
1207
|
+
Gw = np.zeros((2, self.total_dofs), dtype=float)
|
|
1208
|
+
Gw[0, 2::6] = dN_dx
|
|
1209
|
+
Gw[1, 2::6] = dN_dy
|
|
1210
|
+
gp_data.append(
|
|
1211
|
+
{
|
|
1212
|
+
"B_m": B_m,
|
|
1213
|
+
"B_b": B_b,
|
|
1214
|
+
"B_d": B_d,
|
|
1215
|
+
"Gw": Gw,
|
|
1216
|
+
"detw": abs(det_j) * float(weight),
|
|
1217
|
+
}
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
shear_data = []
|
|
1221
|
+
if self._is_4node:
|
|
1222
|
+
_, samples = self._mitc4_shear_samples(coords, R0)
|
|
1223
|
+
for (xi, eta), weight in zip(self.GAUSS_POINTS_2x2, self.GAUSS_WEIGHTS_2x2):
|
|
1224
|
+
B_s, det_j = self._mitc4_shear_b_matrix(planar, samples, float(xi), float(eta))
|
|
1225
|
+
shear_data.append({"B_s": B_s, "detw": abs(det_j) * float(weight)})
|
|
1226
|
+
elif self._is_3node:
|
|
1227
|
+
B_s, det_j = self._tri3_assumed_shear_b_matrix(coords, R0)
|
|
1228
|
+
shear_data.append({"B_s": B_s, "detw": det_j * float(np.sum(self.shear_gauss_weights))})
|
|
1229
|
+
else:
|
|
1230
|
+
for (xi, eta), weight in zip(self.shear_gauss_points, self.shear_gauss_weights):
|
|
1231
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1232
|
+
J2 = np.array(
|
|
1233
|
+
[
|
|
1234
|
+
[float(dN_dxi @ planar[:, 0]), float(dN_dxi @ planar[:, 1])],
|
|
1235
|
+
[float(dN_deta @ planar[:, 0]), float(dN_deta @ planar[:, 1])],
|
|
1236
|
+
],
|
|
1237
|
+
dtype=float,
|
|
1238
|
+
)
|
|
1239
|
+
inv_j2, det_j = _inv2(J2)
|
|
1240
|
+
dN_dx = inv_j2[0, 0] * dN_dxi + inv_j2[0, 1] * dN_deta
|
|
1241
|
+
dN_dy = inv_j2[1, 0] * dN_dxi + inv_j2[1, 1] * dN_deta
|
|
1242
|
+
_, _, B_s = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
1243
|
+
shear_data.append({"B_s": B_s, "detw": abs(det_j) * float(weight)})
|
|
1244
|
+
|
|
1245
|
+
n_gp = len(gp_data)
|
|
1246
|
+
B_m_all = np.zeros((n_gp, 3, self.total_dofs))
|
|
1247
|
+
B_b_all = np.zeros((n_gp, 3, self.total_dofs))
|
|
1248
|
+
B_d_all = np.zeros((n_gp, 1, self.total_dofs))
|
|
1249
|
+
Gw_all = np.zeros((n_gp, 2, self.total_dofs))
|
|
1250
|
+
detw_all = np.zeros(n_gp)
|
|
1251
|
+
for g, gp in enumerate(gp_data):
|
|
1252
|
+
B_m_all[g] = gp["B_m"]
|
|
1253
|
+
B_b_all[g] = gp["B_b"]
|
|
1254
|
+
B_d_all[g] = gp["B_d"]
|
|
1255
|
+
Gw_all[g] = gp["Gw"]
|
|
1256
|
+
detw_all[g] = gp["detw"]
|
|
1257
|
+
|
|
1258
|
+
n_shear = len(shear_data)
|
|
1259
|
+
B_s_all = np.zeros((n_shear, 2, self.total_dofs))
|
|
1260
|
+
detw_shear_all = np.zeros(n_shear)
|
|
1261
|
+
for g, sh in enumerate(shear_data):
|
|
1262
|
+
B_s_all[g] = sh["B_s"]
|
|
1263
|
+
detw_shear_all[g] = sh["detw"]
|
|
1264
|
+
|
|
1265
|
+
cache = {
|
|
1266
|
+
"T0": T0,
|
|
1267
|
+
"gp": gp_data,
|
|
1268
|
+
"shear": shear_data,
|
|
1269
|
+
"B_m_all": B_m_all,
|
|
1270
|
+
"B_b_all": B_b_all,
|
|
1271
|
+
"B_d_all": B_d_all,
|
|
1272
|
+
"Gw_all": Gw_all,
|
|
1273
|
+
"detw_all": detw_all,
|
|
1274
|
+
"B_s_all": B_s_all,
|
|
1275
|
+
"detw_shear_all": detw_shear_all,
|
|
1276
|
+
}
|
|
1277
|
+
self._nl_cache = cache
|
|
1278
|
+
return cache
|
|
1279
|
+
|
|
1280
|
+
def init_nonlinear_state(self, num_layers: int) -> Dict[str, np.ndarray]:
|
|
1281
|
+
n_points = len(self.gauss_points) * num_layers
|
|
1282
|
+
return {
|
|
1283
|
+
"plastic_strain": np.zeros((n_points, 3), dtype=float),
|
|
1284
|
+
"alpha": np.zeros(n_points, dtype=float),
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
def compute_nonlinear_response(
|
|
1288
|
+
self,
|
|
1289
|
+
mesh: "FEMesh",
|
|
1290
|
+
material: "Material",
|
|
1291
|
+
u_elem: np.ndarray,
|
|
1292
|
+
state: Optional[Any] = None,
|
|
1293
|
+
num_layers: int = 5,
|
|
1294
|
+
tangent: bool = True,
|
|
1295
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
1296
|
+
cache = self._nonlinear_geometry(mesh)
|
|
1297
|
+
T0 = cache["T0"]
|
|
1298
|
+
u_loc = T0 @ np.asarray(u_elem, dtype=float)
|
|
1299
|
+
|
|
1300
|
+
E = material.elastic_modulus
|
|
1301
|
+
nu = material.poisson_ratio
|
|
1302
|
+
G_mod = material.shear_modulus
|
|
1303
|
+
h = self.thickness
|
|
1304
|
+
curve = getattr(material, "hardening_curve", None)
|
|
1305
|
+
C_el = plane_stress_elastic_matrix(E, nu)
|
|
1306
|
+
D_shear = G_mod * (5.0 / 6.0) * h * np.eye(2, dtype=float)
|
|
1307
|
+
drilling_stiffness = G_mod * h * getattr(self, "drilling_stabilization", 1.0e-3)
|
|
1308
|
+
|
|
1309
|
+
n_gp = len(cache["gp"])
|
|
1310
|
+
z_layers, w_layers = lobatto_layers(num_layers, h)
|
|
1311
|
+
|
|
1312
|
+
if state is None:
|
|
1313
|
+
state = self.init_nonlinear_state(num_layers)
|
|
1314
|
+
|
|
1315
|
+
# Membrane and bending strains at every integration point.
|
|
1316
|
+
memb_strain = np.zeros((n_gp, 3), dtype=float)
|
|
1317
|
+
curvature = np.zeros((n_gp, 3), dtype=float)
|
|
1318
|
+
B_eff_list = []
|
|
1319
|
+
theta_list = []
|
|
1320
|
+
for g, gp in enumerate(cache["gp"]):
|
|
1321
|
+
theta = gp["Gw"] @ u_loc # (2,) transverse deflection gradients
|
|
1322
|
+
B_nl = np.vstack(
|
|
1323
|
+
[
|
|
1324
|
+
theta[0] * gp["Gw"][0],
|
|
1325
|
+
theta[1] * gp["Gw"][1],
|
|
1326
|
+
theta[0] * gp["Gw"][1] + theta[1] * gp["Gw"][0],
|
|
1327
|
+
]
|
|
1328
|
+
)
|
|
1329
|
+
B_eff = gp["B_m"] + B_nl
|
|
1330
|
+
memb_strain[g] = gp["B_m"] @ u_loc + np.array(
|
|
1331
|
+
[0.5 * theta[0] ** 2, 0.5 * theta[1] ** 2, theta[0] * theta[1]]
|
|
1332
|
+
)
|
|
1333
|
+
curvature[g] = gp["B_b"] @ u_loc
|
|
1334
|
+
B_eff_list.append(B_eff)
|
|
1335
|
+
theta_list.append(theta)
|
|
1336
|
+
|
|
1337
|
+
if curve is None:
|
|
1338
|
+
# Elastic shortcut: resultants and integrated moduli in closed form.
|
|
1339
|
+
trial_state = state
|
|
1340
|
+
N_res = memb_strain @ (h * C_el).T
|
|
1341
|
+
M_res = curvature @ (h**3 / 12.0 * C_el).T
|
|
1342
|
+
C0 = np.broadcast_to(h * C_el, (n_gp, 3, 3))
|
|
1343
|
+
C1 = np.zeros((n_gp, 3, 3), dtype=float)
|
|
1344
|
+
C2 = np.broadcast_to(h**3 / 12.0 * C_el, (n_gp, 3, 3))
|
|
1345
|
+
else:
|
|
1346
|
+
# Layer strains for all (gp, layer) points at once, then one
|
|
1347
|
+
# vectorized return-map call for the whole element.
|
|
1348
|
+
layer_strain = (
|
|
1349
|
+
memb_strain[:, None, :] + z_layers[None, :, None] * curvature[:, None, :]
|
|
1350
|
+
).reshape(n_gp * num_layers, 3)
|
|
1351
|
+
sigma, C_tan, ep_new, alpha_new = plane_stress_return_map(
|
|
1352
|
+
layer_strain,
|
|
1353
|
+
state["plastic_strain"],
|
|
1354
|
+
state["alpha"],
|
|
1355
|
+
E,
|
|
1356
|
+
nu,
|
|
1357
|
+
curve,
|
|
1358
|
+
compute_tangent=tangent,
|
|
1359
|
+
)
|
|
1360
|
+
trial_state = {"plastic_strain": ep_new, "alpha": alpha_new, "layer_strain": layer_strain.copy()}
|
|
1361
|
+
|
|
1362
|
+
sigma = sigma.reshape(n_gp, num_layers, 3)
|
|
1363
|
+
C_tan = C_tan.reshape(n_gp, num_layers, 3, 3)
|
|
1364
|
+
trial_state["layer_stress"] = sigma.reshape(n_gp * num_layers, 3).copy()
|
|
1365
|
+
|
|
1366
|
+
# Through-thickness resultants and integrated tangent moduli.
|
|
1367
|
+
N_res = np.einsum("l,gli->gi", w_layers, sigma)
|
|
1368
|
+
M_res = np.einsum("l,l,gli->gi", w_layers, z_layers, sigma)
|
|
1369
|
+
C0 = np.einsum("l,glij->gij", w_layers, C_tan)
|
|
1370
|
+
C1 = np.einsum("l,l,glij->gij", w_layers, z_layers, C_tan)
|
|
1371
|
+
C2 = np.einsum("l,l,l,glij->gij", w_layers, z_layers, z_layers, C_tan)
|
|
1372
|
+
|
|
1373
|
+
n_dof = self.total_dofs
|
|
1374
|
+
F_loc, K_loc_temp = _jit_integrate_nonlinear_response(
|
|
1375
|
+
u_loc,
|
|
1376
|
+
N_res,
|
|
1377
|
+
M_res,
|
|
1378
|
+
C0,
|
|
1379
|
+
C1,
|
|
1380
|
+
C2,
|
|
1381
|
+
cache["B_m_all"],
|
|
1382
|
+
cache["B_b_all"],
|
|
1383
|
+
cache["B_d_all"],
|
|
1384
|
+
cache["Gw_all"],
|
|
1385
|
+
cache["detw_all"],
|
|
1386
|
+
cache["B_s_all"],
|
|
1387
|
+
cache["detw_shear_all"],
|
|
1388
|
+
D_shear,
|
|
1389
|
+
drilling_stiffness,
|
|
1390
|
+
tangent,
|
|
1391
|
+
curve is not None,
|
|
1392
|
+
n_dof,
|
|
1393
|
+
)
|
|
1394
|
+
K_loc = K_loc_temp if tangent else None
|
|
1395
|
+
|
|
1396
|
+
K_hg = getattr(self, "_hourglass_stiffness_matrix", None)
|
|
1397
|
+
if self._is_8node and self.reduced_integration and float(getattr(self, "hourglass_stabilization", 0.0)) > 0.0:
|
|
1398
|
+
if K_hg is None:
|
|
1399
|
+
self.compute_stiffness_matrix(mesh, material)
|
|
1400
|
+
K_hg = getattr(self, "_hourglass_stiffness_matrix", None)
|
|
1401
|
+
|
|
1402
|
+
F_global = T0.T @ F_loc
|
|
1403
|
+
if K_hg is not None:
|
|
1404
|
+
u_global = np.asarray(u_elem, dtype=float)
|
|
1405
|
+
F_global = F_global + K_hg @ u_global
|
|
1406
|
+
|
|
1407
|
+
if not tangent:
|
|
1408
|
+
return F_global, None, trial_state
|
|
1409
|
+
K_global = T0.T @ K_loc @ T0
|
|
1410
|
+
if K_hg is not None:
|
|
1411
|
+
K_global = K_global + K_hg
|
|
1412
|
+
return F_global, K_global, trial_state
|
|
1413
|
+
|
|
1414
|
+
def compute_stresses(
|
|
1415
|
+
self,
|
|
1416
|
+
mesh: "FEMesh",
|
|
1417
|
+
displacements: np.ndarray,
|
|
1418
|
+
material: "Material",
|
|
1419
|
+
return_global: bool = False,
|
|
1420
|
+
) -> Dict[str, np.ndarray]:
|
|
1421
|
+
coords = self.get_node_coordinates(mesh)
|
|
1422
|
+
u_elem_global = self._get_element_displacements(mesh, displacements)
|
|
1423
|
+
E = material.elastic_modulus
|
|
1424
|
+
nu = material.poisson_ratio
|
|
1425
|
+
G = material.shear_modulus
|
|
1426
|
+
h = self.thickness
|
|
1427
|
+
kappa = 5.0 / 6.0
|
|
1428
|
+
|
|
1429
|
+
shell_plane = np.array(
|
|
1430
|
+
[[1.0, nu, 0.0], [nu, 1.0, 0.0], [0.0, 0.0, (1.0 - nu) / 2.0]],
|
|
1431
|
+
dtype=float,
|
|
1432
|
+
)
|
|
1433
|
+
D_stress = E / (1.0 - nu**2) * shell_plane
|
|
1434
|
+
D_bending_moment = E * h**3 / (12.0 * (1.0 - nu**2)) * shell_plane
|
|
1435
|
+
|
|
1436
|
+
num_ip = len(self.gauss_points)
|
|
1437
|
+
stresses: Dict[str, np.ndarray] = {
|
|
1438
|
+
"membrane_xx": np.zeros(num_ip),
|
|
1439
|
+
"membrane_yy": np.zeros(num_ip),
|
|
1440
|
+
"membrane_xy": np.zeros(num_ip),
|
|
1441
|
+
"bending_xx": np.zeros(num_ip),
|
|
1442
|
+
"bending_yy": np.zeros(num_ip),
|
|
1443
|
+
"bending_xy": np.zeros(num_ip),
|
|
1444
|
+
"shear_xz": np.zeros(num_ip),
|
|
1445
|
+
"shear_yz": np.zeros(num_ip),
|
|
1446
|
+
"von_mises": np.zeros(num_ip),
|
|
1447
|
+
}
|
|
1448
|
+
if return_global:
|
|
1449
|
+
stresses.update({
|
|
1450
|
+
"local_xx_top": np.zeros(num_ip),
|
|
1451
|
+
"local_yy_top": np.zeros(num_ip),
|
|
1452
|
+
"local_zz_top": np.zeros(num_ip),
|
|
1453
|
+
"local_xy_top": np.zeros(num_ip),
|
|
1454
|
+
"local_yz_top": np.zeros(num_ip),
|
|
1455
|
+
"local_xz_top": np.zeros(num_ip),
|
|
1456
|
+
"local_xx_bot": np.zeros(num_ip),
|
|
1457
|
+
"local_yy_bot": np.zeros(num_ip),
|
|
1458
|
+
"local_zz_bot": np.zeros(num_ip),
|
|
1459
|
+
"local_xy_bot": np.zeros(num_ip),
|
|
1460
|
+
"local_yz_bot": np.zeros(num_ip),
|
|
1461
|
+
"local_xz_bot": np.zeros(num_ip),
|
|
1462
|
+
"global_xx_top": np.zeros(num_ip),
|
|
1463
|
+
"global_yy_top": np.zeros(num_ip),
|
|
1464
|
+
"global_zz_top": np.zeros(num_ip),
|
|
1465
|
+
"global_xy_top": np.zeros(num_ip),
|
|
1466
|
+
"global_yz_top": np.zeros(num_ip),
|
|
1467
|
+
"global_xz_top": np.zeros(num_ip),
|
|
1468
|
+
"global_xx_bot": np.zeros(num_ip),
|
|
1469
|
+
"global_yy_bot": np.zeros(num_ip),
|
|
1470
|
+
"global_zz_bot": np.zeros(num_ip),
|
|
1471
|
+
"global_xy_bot": np.zeros(num_ip),
|
|
1472
|
+
"global_yz_bot": np.zeros(num_ip),
|
|
1473
|
+
"global_xz_bot": np.zeros(num_ip),
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
mitc_planar = None
|
|
1477
|
+
mitc_samples = None
|
|
1478
|
+
mitc_u_local = None
|
|
1479
|
+
tri3_B_s = None
|
|
1480
|
+
tri3_u_local = None
|
|
1481
|
+
if self._is_4node:
|
|
1482
|
+
R_center = self._center_frame(coords)
|
|
1483
|
+
mitc_planar, mitc_samples = self._mitc4_shear_samples(coords, R_center)
|
|
1484
|
+
mitc_u_local = self._local_dof_transform(R_center) @ u_elem_global
|
|
1485
|
+
elif self._is_3node:
|
|
1486
|
+
R_center = self._center_frame(coords)
|
|
1487
|
+
tri3_B_s, _ = self._tri3_assumed_shear_b_matrix(coords, R_center)
|
|
1488
|
+
tri3_u_local = self._local_dof_transform(R_center) @ u_elem_global
|
|
1489
|
+
|
|
1490
|
+
for idx, (xi, eta) in enumerate(self.gauss_points):
|
|
1491
|
+
N, dN_dxi, dN_deta = self.compute_shape_functions(float(xi), float(eta))
|
|
1492
|
+
R, dN_dx, dN_dy, _ = self._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
|
|
1493
|
+
T = self._local_dof_transform(R)
|
|
1494
|
+
u_local = T @ u_elem_global
|
|
1495
|
+
B_m, B_b, B_s = self._build_shell_b_matrices(N, dN_dx, dN_dy)
|
|
1496
|
+
membrane_strain = B_m @ u_local
|
|
1497
|
+
curvature = B_b @ u_local
|
|
1498
|
+
if self._is_4node:
|
|
1499
|
+
B_s_mitc, _ = self._mitc4_shear_b_matrix(mitc_planar, mitc_samples, float(xi), float(eta))
|
|
1500
|
+
shear_strain = B_s_mitc @ mitc_u_local
|
|
1501
|
+
elif self._is_3node:
|
|
1502
|
+
shear_strain = tri3_B_s @ tri3_u_local
|
|
1503
|
+
else:
|
|
1504
|
+
shear_strain = B_s @ u_local
|
|
1505
|
+
|
|
1506
|
+
sigma_m = D_stress @ membrane_strain
|
|
1507
|
+
moments = D_bending_moment @ curvature
|
|
1508
|
+
sigma_b = 6.0 * moments / max(h**2, _SMALL)
|
|
1509
|
+
tau_s = G * kappa * shear_strain
|
|
1510
|
+
|
|
1511
|
+
stresses["membrane_xx"][idx] = sigma_m[0]
|
|
1512
|
+
stresses["membrane_yy"][idx] = sigma_m[1]
|
|
1513
|
+
stresses["membrane_xy"][idx] = sigma_m[2]
|
|
1514
|
+
stresses["bending_xx"][idx] = sigma_b[0]
|
|
1515
|
+
stresses["bending_yy"][idx] = sigma_b[1]
|
|
1516
|
+
stresses["bending_xy"][idx] = sigma_b[2]
|
|
1517
|
+
stresses["shear_xz"][idx] = tau_s[0]
|
|
1518
|
+
stresses["shear_yz"][idx] = tau_s[1]
|
|
1519
|
+
|
|
1520
|
+
# Top surface (z = +h/2)
|
|
1521
|
+
sigma_x_top = sigma_m[0] + sigma_b[0]
|
|
1522
|
+
sigma_y_top = sigma_m[1] + sigma_b[1]
|
|
1523
|
+
tau_xy_top = sigma_m[2] + sigma_b[2]
|
|
1524
|
+
vm_top = np.sqrt(
|
|
1525
|
+
sigma_x_top**2 + sigma_y_top**2 - sigma_x_top * sigma_y_top + 3.0 * (tau_xy_top**2 + tau_s[0]**2 + tau_s[1]**2)
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
# Bottom surface (z = -h/2)
|
|
1529
|
+
sigma_x_bot = sigma_m[0] - sigma_b[0]
|
|
1530
|
+
sigma_y_bot = sigma_m[1] - sigma_b[1]
|
|
1531
|
+
tau_xy_bot = sigma_m[2] - sigma_b[2]
|
|
1532
|
+
vm_bot = np.sqrt(
|
|
1533
|
+
sigma_x_bot**2 + sigma_y_bot**2 - sigma_x_bot * sigma_y_bot + 3.0 * (tau_xy_bot**2 + tau_s[0]**2 + tau_s[1]**2)
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
stresses["von_mises"][idx] = max(vm_top, vm_bot)
|
|
1537
|
+
|
|
1538
|
+
if return_global:
|
|
1539
|
+
# Top local stress tensor
|
|
1540
|
+
sigma_loc_top = np.array([
|
|
1541
|
+
[sigma_x_top, tau_xy_top, tau_s[0]],
|
|
1542
|
+
[tau_xy_top, sigma_y_top, tau_s[1]],
|
|
1543
|
+
[tau_s[0], tau_s[1], 0.0]
|
|
1544
|
+
], dtype=float)
|
|
1545
|
+
sigma_glob_top = R @ sigma_loc_top @ R.T
|
|
1546
|
+
|
|
1547
|
+
# Bottom local stress tensor
|
|
1548
|
+
sigma_loc_bot = np.array([
|
|
1549
|
+
[sigma_x_bot, tau_xy_bot, tau_s[0]],
|
|
1550
|
+
[tau_xy_bot, sigma_y_bot, tau_s[1]],
|
|
1551
|
+
[tau_s[0], tau_s[1], 0.0]
|
|
1552
|
+
], dtype=float)
|
|
1553
|
+
sigma_glob_bot = R @ sigma_loc_bot @ R.T
|
|
1554
|
+
|
|
1555
|
+
# Store local components
|
|
1556
|
+
stresses["local_xx_top"][idx] = sigma_x_top
|
|
1557
|
+
stresses["local_yy_top"][idx] = sigma_y_top
|
|
1558
|
+
stresses["local_zz_top"][idx] = 0.0
|
|
1559
|
+
stresses["local_xy_top"][idx] = tau_xy_top
|
|
1560
|
+
stresses["local_xz_top"][idx] = tau_s[0]
|
|
1561
|
+
stresses["local_yz_top"][idx] = tau_s[1]
|
|
1562
|
+
|
|
1563
|
+
stresses["local_xx_bot"][idx] = sigma_x_bot
|
|
1564
|
+
stresses["local_yy_bot"][idx] = sigma_y_bot
|
|
1565
|
+
stresses["local_zz_bot"][idx] = 0.0
|
|
1566
|
+
stresses["local_xy_bot"][idx] = tau_xy_bot
|
|
1567
|
+
stresses["local_xz_bot"][idx] = tau_s[0]
|
|
1568
|
+
stresses["local_yz_bot"][idx] = tau_s[1]
|
|
1569
|
+
|
|
1570
|
+
# Store global components
|
|
1571
|
+
stresses["global_xx_top"][idx] = sigma_glob_top[0, 0]
|
|
1572
|
+
stresses["global_yy_top"][idx] = sigma_glob_top[1, 1]
|
|
1573
|
+
stresses["global_zz_top"][idx] = sigma_glob_top[2, 2]
|
|
1574
|
+
stresses["global_xy_top"][idx] = sigma_glob_top[0, 1]
|
|
1575
|
+
stresses["global_xz_top"][idx] = sigma_glob_top[0, 2]
|
|
1576
|
+
stresses["global_yz_top"][idx] = sigma_glob_top[1, 2]
|
|
1577
|
+
|
|
1578
|
+
stresses["global_xx_bot"][idx] = sigma_glob_bot[0, 0]
|
|
1579
|
+
stresses["global_yy_bot"][idx] = sigma_glob_bot[1, 1]
|
|
1580
|
+
stresses["global_zz_bot"][idx] = sigma_glob_bot[2, 2]
|
|
1581
|
+
stresses["global_xy_bot"][idx] = sigma_glob_bot[0, 1]
|
|
1582
|
+
stresses["global_xz_bot"][idx] = sigma_glob_bot[0, 2]
|
|
1583
|
+
stresses["global_yz_bot"][idx] = sigma_glob_bot[1, 2]
|
|
1584
|
+
return stresses
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
class BeamElement(Element):
|
|
1588
|
+
"""2-node Timoshenko beam element with 6 DOF per node."""
|
|
1589
|
+
|
|
1590
|
+
def __init__(
|
|
1591
|
+
self,
|
|
1592
|
+
element_id: int,
|
|
1593
|
+
node_ids: List[int],
|
|
1594
|
+
material_name: str = "default",
|
|
1595
|
+
cross_section: Optional[Dict[str, float]] = None,
|
|
1596
|
+
):
|
|
1597
|
+
super().__init__(element_id, node_ids, material_name)
|
|
1598
|
+
if len(node_ids) != 2:
|
|
1599
|
+
raise ValueError(f"BeamElement requires 2 nodes, got {len(node_ids)}")
|
|
1600
|
+
self.cross_section = cross_section or {}
|
|
1601
|
+
self._A = self.cross_section.get("area", 0.01)
|
|
1602
|
+
self._Iy = self.cross_section.get("Iy", 1.0e-8)
|
|
1603
|
+
self._Iz = self.cross_section.get("Iz", 1.0e-8)
|
|
1604
|
+
self._J = self.cross_section.get("J", 1.0e-8)
|
|
1605
|
+
self._ky = self.cross_section.get("shear_factor_y", 5.0 / 6.0)
|
|
1606
|
+
self._kz = self.cross_section.get("shear_factor_z", 5.0 / 6.0)
|
|
1607
|
+
self._orientation = _section_orientation(self.cross_section)
|
|
1608
|
+
self._fiber_plasticity = self.cross_section.get("fiber_plasticity")
|
|
1609
|
+
self._geometric_nonlinearity = str(
|
|
1610
|
+
self.cross_section.get("geometric_nonlinearity", self.cross_section.get("geometry", "von_karman"))
|
|
1611
|
+
).lower()
|
|
1612
|
+
# Optional exact stress-recovery data; estimated from A and I if absent.
|
|
1613
|
+
self._c_y = self.cross_section.get("c_y")
|
|
1614
|
+
self._c_z = self.cross_section.get("c_z")
|
|
1615
|
+
self._torsion_modulus = self.cross_section.get("torsion_modulus")
|
|
1616
|
+
|
|
1617
|
+
def _fiber_distances(self) -> Tuple[float, float]:
|
|
1618
|
+
"""Extreme fiber distances (local y, local z), estimated when not given."""
|
|
1619
|
+
c_y = self._c_y
|
|
1620
|
+
c_z = self._c_z
|
|
1621
|
+
if c_y is None or c_y <= 0.0:
|
|
1622
|
+
c_y = np.sqrt(abs(self._Iz) / max(self._A, _SMALL)) * 2.0
|
|
1623
|
+
if c_z is None or c_z <= 0.0:
|
|
1624
|
+
c_z = np.sqrt(abs(self._Iy) / max(self._A, _SMALL)) * 2.0
|
|
1625
|
+
return max(float(c_y), _SMALL), max(float(c_z), _SMALL)
|
|
1626
|
+
|
|
1627
|
+
def _torsion_section_modulus(self) -> float:
|
|
1628
|
+
"""Torsional section modulus Wt with tau = T / Wt, estimated when not given."""
|
|
1629
|
+
wt = self._torsion_modulus
|
|
1630
|
+
if wt is None or wt <= 0.0:
|
|
1631
|
+
wt = 2.0 * self._A
|
|
1632
|
+
return max(float(wt), _SMALL)
|
|
1633
|
+
|
|
1634
|
+
@property
|
|
1635
|
+
def num_nodes(self) -> int:
|
|
1636
|
+
return 2
|
|
1637
|
+
|
|
1638
|
+
@property
|
|
1639
|
+
def dofs_per_node(self) -> int:
|
|
1640
|
+
return 6
|
|
1641
|
+
|
|
1642
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
1643
|
+
coords = np.zeros((2, 3), dtype=float)
|
|
1644
|
+
for i, node_id in enumerate(self.node_ids):
|
|
1645
|
+
node = mesh.get_node(node_id)
|
|
1646
|
+
if node is None:
|
|
1647
|
+
raise ValueError(f"Beam element {self.element_id} references missing node {node_id}")
|
|
1648
|
+
coords[i] = node.coords()
|
|
1649
|
+
return coords
|
|
1650
|
+
|
|
1651
|
+
def _beam_frame_and_transform(self, coords: np.ndarray) -> Tuple[float, np.ndarray]:
|
|
1652
|
+
length = float(np.linalg.norm(coords[1] - coords[0]))
|
|
1653
|
+
if length < _SMALL:
|
|
1654
|
+
raise ValueError(f"Beam element {self.element_id} has near-zero length")
|
|
1655
|
+
e1 = (coords[1] - coords[0]) / length
|
|
1656
|
+
R = _beam_rotation_matrix(e1, self._orientation)
|
|
1657
|
+
T = np.zeros((12, 12), dtype=float)
|
|
1658
|
+
Rt = R.T
|
|
1659
|
+
for i in range(2):
|
|
1660
|
+
b = i * 6
|
|
1661
|
+
T[b:b + 3, b:b + 3] = Rt
|
|
1662
|
+
T[b + 3:b + 6, b + 3:b + 6] = Rt
|
|
1663
|
+
return length, T
|
|
1664
|
+
|
|
1665
|
+
def _local_linear_stiffness(self, length: float, material: "Material", include_axial: bool = True) -> np.ndarray:
|
|
1666
|
+
L = float(length)
|
|
1667
|
+
E = material.elastic_modulus
|
|
1668
|
+
G = material.shear_modulus
|
|
1669
|
+
EA = E * self._A
|
|
1670
|
+
EIy = E * self._Iy
|
|
1671
|
+
EIz = E * self._Iz
|
|
1672
|
+
GJ = G * self._J
|
|
1673
|
+
K = np.zeros((12, 12), dtype=float)
|
|
1674
|
+
if include_axial:
|
|
1675
|
+
K[0, 0] = K[6, 6] = EA / L
|
|
1676
|
+
K[0, 6] = K[6, 0] = -EA / L
|
|
1677
|
+
|
|
1678
|
+
# Bending about local y: deflection w (local z), rotation ry, EIy,
|
|
1679
|
+
# Timoshenko shear parameter from the local-z shear area.
|
|
1680
|
+
phi_w = 12.0 * EIy / max(G * self._A * self._kz * L**2, _SMALL)
|
|
1681
|
+
K[2, 2] = K[8, 8] = 12.0 * EIy / (L**3 * (1.0 + phi_w))
|
|
1682
|
+
K[2, 8] = K[8, 2] = -K[2, 2]
|
|
1683
|
+
K[2, 4] = K[4, 2] = -6.0 * EIy / (L**2 * (1.0 + phi_w))
|
|
1684
|
+
K[2, 10] = K[10, 2] = K[2, 4]
|
|
1685
|
+
K[8, 4] = K[4, 8] = -K[2, 4]
|
|
1686
|
+
K[8, 10] = K[10, 8] = -K[2, 4]
|
|
1687
|
+
K[4, 4] = K[10, 10] = (4.0 + phi_w) * EIy / (L * (1.0 + phi_w))
|
|
1688
|
+
K[4, 10] = K[10, 4] = (2.0 - phi_w) * EIy / (L * (1.0 + phi_w))
|
|
1689
|
+
|
|
1690
|
+
# Bending about local z: deflection v (local y), rotation rz, EIz,
|
|
1691
|
+
# Timoshenko shear parameter from the local-y shear area.
|
|
1692
|
+
phi_v = 12.0 * EIz / max(G * self._A * self._ky * L**2, _SMALL)
|
|
1693
|
+
K[1, 1] = K[7, 7] = 12.0 * EIz / (L**3 * (1.0 + phi_v))
|
|
1694
|
+
K[1, 7] = K[7, 1] = -K[1, 1]
|
|
1695
|
+
K[1, 5] = K[5, 1] = 6.0 * EIz / (L**2 * (1.0 + phi_v))
|
|
1696
|
+
K[1, 11] = K[11, 1] = 6.0 * EIz / (L**2 * (1.0 + phi_v))
|
|
1697
|
+
K[7, 5] = K[5, 7] = -6.0 * EIz / (L**2 * (1.0 + phi_v))
|
|
1698
|
+
K[7, 11] = K[11, 7] = -6.0 * EIz / (L**2 * (1.0 + phi_v))
|
|
1699
|
+
K[5, 5] = K[11, 11] = (4.0 + phi_v) * EIz / (L * (1.0 + phi_v))
|
|
1700
|
+
K[5, 11] = K[11, 5] = (2.0 - phi_v) * EIz / (L * (1.0 + phi_v))
|
|
1701
|
+
|
|
1702
|
+
K[3, 3] = K[9, 9] = GJ / L
|
|
1703
|
+
K[3, 9] = K[9, 3] = -GJ / L
|
|
1704
|
+
return K
|
|
1705
|
+
|
|
1706
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
1707
|
+
coords = self.get_node_coordinates(mesh)
|
|
1708
|
+
try:
|
|
1709
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
1710
|
+
except ValueError:
|
|
1711
|
+
return np.zeros((self.total_dofs, self.total_dofs))
|
|
1712
|
+
K = self._local_linear_stiffness(L, material)
|
|
1713
|
+
K_global = T.T @ K @ T
|
|
1714
|
+
self._stiffness_matrix = K_global
|
|
1715
|
+
return K_global
|
|
1716
|
+
|
|
1717
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
1718
|
+
coords = self.get_node_coordinates(mesh)
|
|
1719
|
+
try:
|
|
1720
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
1721
|
+
except ValueError:
|
|
1722
|
+
return np.zeros((self.total_dofs, self.total_dofs))
|
|
1723
|
+
rho = material.density
|
|
1724
|
+
M = np.zeros((12, 12), dtype=float)
|
|
1725
|
+
if bool(self.cross_section.get("consistent_mass", False)):
|
|
1726
|
+
# Consistent mass on the element's own linear interpolation,
|
|
1727
|
+
# mirroring the 3-node quadratic beam: N_i N_j blocks for the
|
|
1728
|
+
# translations (rho A) and for each rotation with its matching
|
|
1729
|
+
# section inertia (polar Iy+Iz for torsion). Rigid-body
|
|
1730
|
+
# translational and rotational inertia are exact, so no lumped
|
|
1731
|
+
# bar-length correction terms are needed.
|
|
1732
|
+
coupling = np.array([[1.0 / 3.0, 1.0 / 6.0], [1.0 / 6.0, 1.0 / 3.0]], dtype=float)
|
|
1733
|
+
rotary_density = (
|
|
1734
|
+
rho * (self._Iy + self._Iz), # rx (torsion)
|
|
1735
|
+
rho * self._Iy, # ry
|
|
1736
|
+
rho * self._Iz, # rz
|
|
1737
|
+
)
|
|
1738
|
+
for i in range(2):
|
|
1739
|
+
for j in range(2):
|
|
1740
|
+
factor = coupling[i, j] * L
|
|
1741
|
+
for d in range(3):
|
|
1742
|
+
M[i * 6 + d, j * 6 + d] = rho * self._A * factor
|
|
1743
|
+
M[i * 6 + 3 + d, j * 6 + 3 + d] = rotary_density[d] * factor
|
|
1744
|
+
M_global = T.T @ M @ T
|
|
1745
|
+
self._mass_matrix = M_global
|
|
1746
|
+
return M_global
|
|
1747
|
+
mass_per_node = rho * self._A * L / 2.0
|
|
1748
|
+
# Lumped rotary inertia: bending rotations carry the rigid-bar term
|
|
1749
|
+
# rho*A*L^3/24 plus the section rotatory term rho*I*L/2; the torsion
|
|
1750
|
+
# DOF carries the polar section inertia rho*(Iy+Iz)*L/2, not a
|
|
1751
|
+
# bar-length term, because spinning about the member axis does not
|
|
1752
|
+
# translate the distributed mass.
|
|
1753
|
+
rotary_bar = mass_per_node * L**2 / 12.0
|
|
1754
|
+
rotary = (
|
|
1755
|
+
rho * (self._Iy + self._Iz) * L / 2.0, # rx (torsion)
|
|
1756
|
+
rotary_bar + rho * self._Iy * L / 2.0, # ry
|
|
1757
|
+
rotary_bar + rho * self._Iz * L / 2.0, # rz
|
|
1758
|
+
)
|
|
1759
|
+
for i in range(2):
|
|
1760
|
+
b = i * 6
|
|
1761
|
+
for d in range(3):
|
|
1762
|
+
M[b + d, b + d] = mass_per_node
|
|
1763
|
+
M[b + 3 + d, b + 3 + d] = rotary[d]
|
|
1764
|
+
M_global = T.T @ M @ T
|
|
1765
|
+
self._mass_matrix = M_global
|
|
1766
|
+
return M_global
|
|
1767
|
+
|
|
1768
|
+
@staticmethod
|
|
1769
|
+
def _axial_compression_from_state(state: Optional[Any]) -> float:
|
|
1770
|
+
"""Return positive compression from a user/reference element state."""
|
|
1771
|
+
if state is None:
|
|
1772
|
+
return 0.0
|
|
1773
|
+
if isinstance(state, (int, float, np.number)):
|
|
1774
|
+
return float(state)
|
|
1775
|
+
if not isinstance(state, dict):
|
|
1776
|
+
return 0.0
|
|
1777
|
+
|
|
1778
|
+
for key in ("axial_compression", "compression", "N_compression"):
|
|
1779
|
+
if key in state:
|
|
1780
|
+
return float(state[key])
|
|
1781
|
+
|
|
1782
|
+
for key in ("axial_force", "N", "reference_axial_force"):
|
|
1783
|
+
if key in state:
|
|
1784
|
+
# Stress recovery uses positive axial strain/stress in tension,
|
|
1785
|
+
# so a negative axial force is destabilizing compression.
|
|
1786
|
+
return -float(state[key])
|
|
1787
|
+
return 0.0
|
|
1788
|
+
|
|
1789
|
+
def compute_geometric_stiffness_matrix(
|
|
1790
|
+
self,
|
|
1791
|
+
mesh: "FEMesh",
|
|
1792
|
+
material: "Material",
|
|
1793
|
+
state: Optional[Any] = None,
|
|
1794
|
+
) -> np.ndarray:
|
|
1795
|
+
"""
|
|
1796
|
+
Beam-column geometric stiffness for an axial reference compression.
|
|
1797
|
+
|
|
1798
|
+
The returned matrix follows ``K phi = lambda KG phi``. A positive
|
|
1799
|
+
``axial_compression`` in ``state`` therefore produces a positive
|
|
1800
|
+
destabilizing ``KG``.
|
|
1801
|
+
"""
|
|
1802
|
+
axial_compression = self._axial_compression_from_state(state)
|
|
1803
|
+
if axial_compression == 0.0:
|
|
1804
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1805
|
+
|
|
1806
|
+
coords = self.get_node_coordinates(mesh)
|
|
1807
|
+
try:
|
|
1808
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
1809
|
+
except ValueError:
|
|
1810
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
1811
|
+
|
|
1812
|
+
factor = axial_compression / (30.0 * L)
|
|
1813
|
+
L2 = L * L
|
|
1814
|
+
g_standard = factor * np.array(
|
|
1815
|
+
[
|
|
1816
|
+
[36.0, 3.0 * L, -36.0, 3.0 * L],
|
|
1817
|
+
[3.0 * L, 4.0 * L2, -3.0 * L, -L2],
|
|
1818
|
+
[-36.0, -3.0 * L, 36.0, -3.0 * L],
|
|
1819
|
+
[3.0 * L, -L2, -3.0 * L, 4.0 * L2],
|
|
1820
|
+
],
|
|
1821
|
+
dtype=float,
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
K_geo = np.zeros((12, 12), dtype=float)
|
|
1825
|
+
v_rz = [1, 5, 7, 11]
|
|
1826
|
+
w_ry = [2, 4, 8, 10]
|
|
1827
|
+
sign_w = np.diag([1.0, -1.0, 1.0, -1.0])
|
|
1828
|
+
g_w = sign_w @ g_standard @ sign_w
|
|
1829
|
+
|
|
1830
|
+
for i, row in enumerate(v_rz):
|
|
1831
|
+
for j, col in enumerate(v_rz):
|
|
1832
|
+
K_geo[row, col] += g_standard[i, j]
|
|
1833
|
+
for i, row in enumerate(w_ry):
|
|
1834
|
+
for j, col in enumerate(w_ry):
|
|
1835
|
+
K_geo[row, col] += g_w[i, j]
|
|
1836
|
+
|
|
1837
|
+
# Wagner term: axial compression destabilizes twist about the shear
|
|
1838
|
+
# center, enabling torsional and flexural-torsional column modes
|
|
1839
|
+
# (stiffener tripping under axial stress). The shear center is taken
|
|
1840
|
+
# at the centroid and warping is neglected, consistent with the
|
|
1841
|
+
# element's St. Venant torsion treatment, so the torsional critical
|
|
1842
|
+
# load is G*J*A/Ip.
|
|
1843
|
+
polar_ratio = (self._Iy + self._Iz) / max(self._A, 1.0e-30)
|
|
1844
|
+
g_torsion = axial_compression * polar_ratio / L
|
|
1845
|
+
K_geo[3, 3] += g_torsion
|
|
1846
|
+
K_geo[3, 9] -= g_torsion
|
|
1847
|
+
K_geo[9, 3] -= g_torsion
|
|
1848
|
+
K_geo[9, 9] += g_torsion
|
|
1849
|
+
|
|
1850
|
+
return T.T @ K_geo @ T
|
|
1851
|
+
|
|
1852
|
+
def _fiber_plasticity_config(self, material: "Material") -> Optional[FiberSectionPlasticityConfig]:
|
|
1853
|
+
config = self._fiber_plasticity
|
|
1854
|
+
if config is None:
|
|
1855
|
+
return None
|
|
1856
|
+
if isinstance(config, dict):
|
|
1857
|
+
config = FiberSectionPlasticityConfig(**config)
|
|
1858
|
+
elif config is True:
|
|
1859
|
+
config = FiberSectionPlasticityConfig()
|
|
1860
|
+
elif not isinstance(config, FiberSectionPlasticityConfig):
|
|
1861
|
+
raise TypeError("cross_section['fiber_plasticity'] must be a FiberSectionPlasticityConfig, dict or True")
|
|
1862
|
+
curve = config.material_curve or getattr(material, "hardening_curve", None)
|
|
1863
|
+
if curve is None:
|
|
1864
|
+
return None
|
|
1865
|
+
if config.material_curve is curve:
|
|
1866
|
+
return config
|
|
1867
|
+
return FiberSectionPlasticityConfig(config.num_y, config.num_z, curve)
|
|
1868
|
+
|
|
1869
|
+
def _fiber_section_grid(self, config: FiberSectionPlasticityConfig) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
1870
|
+
cross = self.cross_section if isinstance(getattr(self, "cross_section", None), dict) else {}
|
|
1871
|
+
web_height = float(cross.get("web_height") or 0.0)
|
|
1872
|
+
web_thickness = float(cross.get("web_thickness") or 0.0)
|
|
1873
|
+
flange_width = float(cross.get("flange_width") or 0.0)
|
|
1874
|
+
flange_thickness = float(cross.get("flange_thickness") or 0.0)
|
|
1875
|
+
shaped = (
|
|
1876
|
+
web_height > 0.0
|
|
1877
|
+
and web_thickness > 0.0
|
|
1878
|
+
and flange_width > 0.0
|
|
1879
|
+
and flange_thickness > 0.0
|
|
1880
|
+
)
|
|
1881
|
+
key = (
|
|
1882
|
+
"fiber_grid",
|
|
1883
|
+
int(config.num_y),
|
|
1884
|
+
int(config.num_z),
|
|
1885
|
+
float(self._A),
|
|
1886
|
+
float(self._Iy),
|
|
1887
|
+
float(self._Iz),
|
|
1888
|
+
round(web_height, 12),
|
|
1889
|
+
round(web_thickness, 12),
|
|
1890
|
+
round(flange_width, 12),
|
|
1891
|
+
round(flange_thickness, 12),
|
|
1892
|
+
)
|
|
1893
|
+
cache = getattr(self, "_fiber_grid_cache", None)
|
|
1894
|
+
if cache is not None and cache.get("key") == key:
|
|
1895
|
+
return cache["y"], cache["z"], cache["weights"]
|
|
1896
|
+
|
|
1897
|
+
if shaped:
|
|
1898
|
+
# Profile-true fiber layout: web and flange strips follow the real
|
|
1899
|
+
# T/L section geometry so the plastification order (flange tips vs
|
|
1900
|
+
# web) is captured. The coordinates and weights are afterwards
|
|
1901
|
+
# recentred and rescaled to reproduce the section constants
|
|
1902
|
+
# A/Iy/Iz exactly, keeping the elastic response identical to the
|
|
1903
|
+
# section-property idealization. Flanges are laid out symmetric
|
|
1904
|
+
# in y also for L profiles (the section constants carry no product
|
|
1905
|
+
# of inertia).
|
|
1906
|
+
n_web = max(int(config.num_z), 3)
|
|
1907
|
+
n_flange = max(int(config.num_y), 3)
|
|
1908
|
+
web_z = (np.arange(n_web, dtype=float) + 0.5) * (web_height / n_web)
|
|
1909
|
+
y_parts = [np.zeros(n_web)]
|
|
1910
|
+
z_parts = [web_z]
|
|
1911
|
+
weight_parts = [np.full(n_web, web_height * web_thickness / n_web)]
|
|
1912
|
+
flange_y = (np.arange(n_flange, dtype=float) + 0.5) * (flange_width / n_flange) - 0.5 * flange_width
|
|
1913
|
+
for z_level in (web_height + 0.25 * flange_thickness, web_height + 0.75 * flange_thickness):
|
|
1914
|
+
y_parts.append(flange_y.copy())
|
|
1915
|
+
z_parts.append(np.full(n_flange, z_level))
|
|
1916
|
+
weight_parts.append(np.full(n_flange, flange_width * flange_thickness / (2 * n_flange)))
|
|
1917
|
+
y = np.concatenate(y_parts)
|
|
1918
|
+
z = np.concatenate(z_parts)
|
|
1919
|
+
weights = np.concatenate(weight_parts)
|
|
1920
|
+
total = float(np.sum(weights))
|
|
1921
|
+
if total > _SMALL and self._A > 0.0:
|
|
1922
|
+
weights *= float(self._A) / total
|
|
1923
|
+
area = float(np.sum(weights))
|
|
1924
|
+
if area > _SMALL:
|
|
1925
|
+
y = y - float(np.sum(weights * y)) / area
|
|
1926
|
+
z = z - float(np.sum(weights * z)) / area
|
|
1927
|
+
else:
|
|
1928
|
+
raw_y = np.linspace(-1.0, 1.0, int(config.num_y)) if config.num_y > 1 else np.zeros(1)
|
|
1929
|
+
raw_z = np.linspace(-1.0, 1.0, int(config.num_z)) if config.num_z > 1 else np.zeros(1)
|
|
1930
|
+
yy, zz = np.meshgrid(raw_y, raw_z, indexing="ij")
|
|
1931
|
+
y = yy.reshape(-1)
|
|
1932
|
+
z = zz.reshape(-1)
|
|
1933
|
+
weights = np.full(y.size, float(self._A) / max(y.size, 1), dtype=float)
|
|
1934
|
+
|
|
1935
|
+
denom_y = float(np.sum(weights * y * y))
|
|
1936
|
+
denom_z = float(np.sum(weights * z * z))
|
|
1937
|
+
if denom_y > _SMALL and self._Iz > 0.0:
|
|
1938
|
+
y *= np.sqrt(float(self._Iz) / denom_y)
|
|
1939
|
+
else:
|
|
1940
|
+
y *= 0.0
|
|
1941
|
+
if denom_z > _SMALL and self._Iy > 0.0:
|
|
1942
|
+
z *= np.sqrt(float(self._Iy) / denom_z)
|
|
1943
|
+
else:
|
|
1944
|
+
z *= 0.0
|
|
1945
|
+
|
|
1946
|
+
self._fiber_grid_cache = {"key": key, "y": y, "z": z, "weights": weights}
|
|
1947
|
+
return y, z, weights
|
|
1948
|
+
|
|
1949
|
+
@staticmethod
|
|
1950
|
+
def _uniaxial_return_map(
|
|
1951
|
+
strain: np.ndarray,
|
|
1952
|
+
state: Optional[Any],
|
|
1953
|
+
E: float,
|
|
1954
|
+
curve: Any,
|
|
1955
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
1956
|
+
strain = np.asarray(strain, dtype=float).reshape(-1)
|
|
1957
|
+
n = strain.size
|
|
1958
|
+
if isinstance(state, dict) and np.asarray(state.get("plastic_strain", [])).size == n:
|
|
1959
|
+
plastic_old = np.asarray(state["plastic_strain"], dtype=float).reshape(-1)
|
|
1960
|
+
alpha_old = np.asarray(state.get("alpha", np.zeros(n)), dtype=float).reshape(-1)
|
|
1961
|
+
else:
|
|
1962
|
+
plastic_old = np.zeros(n, dtype=float)
|
|
1963
|
+
alpha_old = np.zeros(n, dtype=float)
|
|
1964
|
+
|
|
1965
|
+
trial = E * (strain - plastic_old)
|
|
1966
|
+
abs_trial = np.abs(trial)
|
|
1967
|
+
flow_old = curve.flow_stress(alpha_old)
|
|
1968
|
+
yielding = abs_trial > flow_old + 1.0e-9 * np.maximum(flow_old, 1.0)
|
|
1969
|
+
|
|
1970
|
+
stress = trial.copy()
|
|
1971
|
+
tangent = np.full(n, E, dtype=float)
|
|
1972
|
+
plastic_new = plastic_old.copy()
|
|
1973
|
+
alpha_new = alpha_old.copy()
|
|
1974
|
+
if not np.any(yielding):
|
|
1975
|
+
return stress, tangent, plastic_new, alpha_new
|
|
1976
|
+
|
|
1977
|
+
indices = np.where(yielding)[0]
|
|
1978
|
+
for idx in indices:
|
|
1979
|
+
sign = 1.0 if trial[idx] >= 0.0 else -1.0
|
|
1980
|
+
dgamma = 0.0
|
|
1981
|
+
H = float(curve.hardening_modulus(np.array([alpha_old[idx]]))[0])
|
|
1982
|
+
for _ in range(30):
|
|
1983
|
+
alpha_trial = alpha_old[idx] + dgamma
|
|
1984
|
+
sy = float(curve.flow_stress(np.array([alpha_trial]))[0])
|
|
1985
|
+
H = float(curve.hardening_modulus(np.array([alpha_trial]))[0])
|
|
1986
|
+
residual = abs_trial[idx] - E * dgamma - sy
|
|
1987
|
+
if abs(residual) <= 1.0e-8 * max(sy, 1.0):
|
|
1988
|
+
break
|
|
1989
|
+
dgamma = max(0.0, dgamma + residual / max(E + H, _SMALL))
|
|
1990
|
+
stress[idx] = sign * max(abs_trial[idx] - E * dgamma, 0.0)
|
|
1991
|
+
plastic_new[idx] = plastic_old[idx] + sign * dgamma
|
|
1992
|
+
alpha_new[idx] = alpha_old[idx] + dgamma
|
|
1993
|
+
tangent[idx] = E * H / max(E + H, _SMALL)
|
|
1994
|
+
return stress, tangent, plastic_new, alpha_new
|
|
1995
|
+
|
|
1996
|
+
def _compute_fiber_nonlinear_response(
|
|
1997
|
+
self,
|
|
1998
|
+
mesh: "FEMesh",
|
|
1999
|
+
material: "Material",
|
|
2000
|
+
u_elem: np.ndarray,
|
|
2001
|
+
state: Optional[Any],
|
|
2002
|
+
config: FiberSectionPlasticityConfig,
|
|
2003
|
+
tangent: bool,
|
|
2004
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
2005
|
+
coords = self.get_node_coordinates(mesh)
|
|
2006
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2007
|
+
u_loc = T @ np.asarray(u_elem, dtype=float)
|
|
2008
|
+
E = material.elastic_modulus
|
|
2009
|
+
G = material.shear_modulus
|
|
2010
|
+
y, z, weights = self._fiber_section_grid(config)
|
|
2011
|
+
|
|
2012
|
+
du = u_loc[6] - u_loc[0]
|
|
2013
|
+
dv = u_loc[7] - u_loc[1]
|
|
2014
|
+
dw = u_loc[8] - u_loc[2]
|
|
2015
|
+
eps0 = du / L + (dv**2 + dw**2) / (2.0 * L**2)
|
|
2016
|
+
kappa_y = (u_loc[10] - u_loc[4]) / L
|
|
2017
|
+
kappa_z = (u_loc[11] - u_loc[5]) / L
|
|
2018
|
+
fiber_strain = eps0 + z * kappa_y + y * kappa_z
|
|
2019
|
+
stress, Et, plastic_new, alpha_new = self._uniaxial_return_map(
|
|
2020
|
+
fiber_strain, state, E, config.material_curve
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
B = np.zeros((fiber_strain.size, 12), dtype=float)
|
|
2024
|
+
B[:, 0] = -1.0 / L
|
|
2025
|
+
B[:, 6] = 1.0 / L
|
|
2026
|
+
B[:, 1] = -dv / L**2
|
|
2027
|
+
B[:, 7] = dv / L**2
|
|
2028
|
+
B[:, 2] = -dw / L**2
|
|
2029
|
+
B[:, 8] = dw / L**2
|
|
2030
|
+
B[:, 4] = -z / L
|
|
2031
|
+
B[:, 10] = z / L
|
|
2032
|
+
B[:, 5] = -y / L
|
|
2033
|
+
B[:, 11] = y / L
|
|
2034
|
+
|
|
2035
|
+
F_loc = L * np.einsum("i,i,ij->j", weights, stress, B)
|
|
2036
|
+
K_loc = None
|
|
2037
|
+
if tangent:
|
|
2038
|
+
K_loc = L * np.einsum("i,i,ij,ik->jk", weights, Et, B, B)
|
|
2039
|
+
|
|
2040
|
+
N_force = float(np.sum(weights * stress))
|
|
2041
|
+
if tangent:
|
|
2042
|
+
string = N_force / L
|
|
2043
|
+
for a, b in ((1, 7), (2, 8)):
|
|
2044
|
+
K_loc[a, a] += string
|
|
2045
|
+
K_loc[b, b] += string
|
|
2046
|
+
K_loc[a, b] -= string
|
|
2047
|
+
K_loc[b, a] -= string
|
|
2048
|
+
|
|
2049
|
+
B_shear_y = np.zeros(12, dtype=float)
|
|
2050
|
+
B_shear_y[1], B_shear_y[7] = -1.0 / L, 1.0 / L
|
|
2051
|
+
B_shear_y[5], B_shear_y[11] = -0.5, -0.5
|
|
2052
|
+
B_shear_z = np.zeros(12, dtype=float)
|
|
2053
|
+
B_shear_z[2], B_shear_z[8] = -1.0 / L, 1.0 / L
|
|
2054
|
+
B_shear_z[4], B_shear_z[10] = 0.5, 0.5
|
|
2055
|
+
B_torsion = np.zeros(12, dtype=float)
|
|
2056
|
+
B_torsion[3], B_torsion[9] = -1.0 / L, 1.0 / L
|
|
2057
|
+
K_aux = L * (
|
|
2058
|
+
G * self._A * self._ky * np.outer(B_shear_y, B_shear_y)
|
|
2059
|
+
+ G * self._A * self._kz * np.outer(B_shear_z, B_shear_z)
|
|
2060
|
+
+ G * self._J * np.outer(B_torsion, B_torsion)
|
|
2061
|
+
)
|
|
2062
|
+
F_loc += K_aux @ u_loc
|
|
2063
|
+
if tangent:
|
|
2064
|
+
K_loc += K_aux
|
|
2065
|
+
|
|
2066
|
+
trial_state = {
|
|
2067
|
+
"plastic_strain": plastic_new,
|
|
2068
|
+
"alpha": alpha_new,
|
|
2069
|
+
"fiber_strain": fiber_strain.copy(),
|
|
2070
|
+
"fiber_stress": stress.copy(),
|
|
2071
|
+
"axial_force": N_force,
|
|
2072
|
+
}
|
|
2073
|
+
if not tangent:
|
|
2074
|
+
return T.T @ F_loc, None, trial_state
|
|
2075
|
+
return T.T @ F_loc, T.T @ K_loc @ T, trial_state
|
|
2076
|
+
|
|
2077
|
+
def _current_beam_frame_and_transform(self, coords: np.ndarray, u_elem: np.ndarray) -> Tuple[float, np.ndarray, np.ndarray, np.ndarray]:
|
|
2078
|
+
current = coords.copy()
|
|
2079
|
+
current[0] += np.asarray(u_elem[0:3], dtype=float)
|
|
2080
|
+
current[1] += np.asarray(u_elem[6:9], dtype=float)
|
|
2081
|
+
length = float(np.linalg.norm(current[1] - current[0]))
|
|
2082
|
+
if length < _SMALL:
|
|
2083
|
+
raise ValueError(f"Beam element {self.element_id} has near-zero current length")
|
|
2084
|
+
e1 = (current[1] - current[0]) / length
|
|
2085
|
+
R = _beam_rotation_matrix(e1, self._orientation)
|
|
2086
|
+
T = np.zeros((12, 12), dtype=float)
|
|
2087
|
+
Rt = R.T
|
|
2088
|
+
for i in range(2):
|
|
2089
|
+
b = i * 6
|
|
2090
|
+
T[b:b + 3, b:b + 3] = Rt
|
|
2091
|
+
T[b + 3:b + 6, b + 3:b + 6] = Rt
|
|
2092
|
+
return length, T, R, current
|
|
2093
|
+
|
|
2094
|
+
def _compute_corotational_nonlinear_response(
|
|
2095
|
+
self,
|
|
2096
|
+
mesh: "FEMesh",
|
|
2097
|
+
material: "Material",
|
|
2098
|
+
u_elem: np.ndarray,
|
|
2099
|
+
state: Optional[Any],
|
|
2100
|
+
tangent: bool,
|
|
2101
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
2102
|
+
"""Elastic 2-node corotational beam response.
|
|
2103
|
+
|
|
2104
|
+
The element frame follows the current chord. Rigid-body chord rotation
|
|
2105
|
+
is subtracted from nodal rotations, so a finite rigid rotation with
|
|
2106
|
+
matching nodal rotation vectors produces near-zero internal force.
|
|
2107
|
+
"""
|
|
2108
|
+
coords = self.get_node_coordinates(mesh)
|
|
2109
|
+
L0, _T0 = self._beam_frame_and_transform(coords)
|
|
2110
|
+
Lc, Tc, Rc, _current = self._current_beam_frame_and_transform(coords, np.asarray(u_elem, dtype=float))
|
|
2111
|
+
R0 = _beam_rotation_matrix((coords[1] - coords[0]) / L0, self._orientation)
|
|
2112
|
+
rigid_rotation = _rotation_vector_from_matrix(Rc @ R0.T)
|
|
2113
|
+
|
|
2114
|
+
u_global = np.asarray(u_elem, dtype=float).reshape(12)
|
|
2115
|
+
q = np.zeros(12, dtype=float)
|
|
2116
|
+
q[6] = Lc - L0
|
|
2117
|
+
q[3:6] = Rc.T @ (u_global[3:6] - rigid_rotation)
|
|
2118
|
+
q[9:12] = Rc.T @ (u_global[9:12] - rigid_rotation)
|
|
2119
|
+
|
|
2120
|
+
K_loc = self._local_linear_stiffness(L0, material)
|
|
2121
|
+
F_loc = K_loc @ q
|
|
2122
|
+
F_global = Tc.T @ F_loc
|
|
2123
|
+
trial_state = {
|
|
2124
|
+
"geometric_nonlinearity": "corotational",
|
|
2125
|
+
"initial_length": float(L0),
|
|
2126
|
+
"current_length": float(Lc),
|
|
2127
|
+
"axial_extension": float(Lc - L0),
|
|
2128
|
+
"rigid_rotation_vector": rigid_rotation.copy(),
|
|
2129
|
+
"basic_deformation_norm": float(np.linalg.norm(q)),
|
|
2130
|
+
}
|
|
2131
|
+
if not tangent:
|
|
2132
|
+
return F_global, None, trial_state
|
|
2133
|
+
K_global = Tc.T @ K_loc @ Tc
|
|
2134
|
+
return F_global, K_global, trial_state
|
|
2135
|
+
|
|
2136
|
+
def compute_nonlinear_response(
|
|
2137
|
+
self,
|
|
2138
|
+
mesh: "FEMesh",
|
|
2139
|
+
material: "Material",
|
|
2140
|
+
u_elem: np.ndarray,
|
|
2141
|
+
state: Optional[Any] = None,
|
|
2142
|
+
num_layers: int = 5,
|
|
2143
|
+
tangent: bool = True,
|
|
2144
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
2145
|
+
"""Elastic beam-column response with von Karman axial coupling.
|
|
2146
|
+
|
|
2147
|
+
The axial strain includes the transverse-displacement rotation terms
|
|
2148
|
+
|
|
2149
|
+
eps = (u2-u1)/L + ((v2-v1)^2 + (w2-w1)^2) / (2 L^2)
|
|
2150
|
+
|
|
2151
|
+
which gives the P-delta string effect consistently (internal force
|
|
2152
|
+
and tangent from the same potential). Bending, shear and torsion
|
|
2153
|
+
remain linear elastic unless ``cross_section["fiber_plasticity"]`` is
|
|
2154
|
+
provided, in which case axial/bending response is integrated over a
|
|
2155
|
+
uniaxial fiber section using the material hardening curve.
|
|
2156
|
+
"""
|
|
2157
|
+
if self._geometric_nonlinearity in {"corotational", "co_rotational", "corot"}:
|
|
2158
|
+
return self._compute_corotational_nonlinear_response(
|
|
2159
|
+
mesh, material, u_elem, state, tangent
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
fiber_config = self._fiber_plasticity_config(material)
|
|
2163
|
+
if fiber_config is not None:
|
|
2164
|
+
return self._compute_fiber_nonlinear_response(
|
|
2165
|
+
mesh, material, u_elem, state, fiber_config, tangent
|
|
2166
|
+
)
|
|
2167
|
+
|
|
2168
|
+
cache = getattr(self, "_nl_cache", None)
|
|
2169
|
+
if cache is None:
|
|
2170
|
+
coords = self.get_node_coordinates(mesh)
|
|
2171
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2172
|
+
K_global = self.compute_stiffness_matrix(mesh, material)
|
|
2173
|
+
K_loc = T @ K_global @ T.T
|
|
2174
|
+
# Remove the linear axial block; the von Karman axial response
|
|
2175
|
+
# replaces it entirely.
|
|
2176
|
+
for i in (0, 6):
|
|
2177
|
+
K_loc[i, :] = 0.0
|
|
2178
|
+
K_loc[:, i] = 0.0
|
|
2179
|
+
cache = {"L": L, "T": T, "K_noax": K_loc, "EA": material.elastic_modulus * self._A}
|
|
2180
|
+
self._nl_cache = cache
|
|
2181
|
+
|
|
2182
|
+
L = cache["L"]
|
|
2183
|
+
T = cache["T"]
|
|
2184
|
+
EA = cache["EA"]
|
|
2185
|
+
u_loc = T @ np.asarray(u_elem, dtype=float)
|
|
2186
|
+
|
|
2187
|
+
du = u_loc[6] - u_loc[0]
|
|
2188
|
+
dv = u_loc[7] - u_loc[1]
|
|
2189
|
+
dw = u_loc[8] - u_loc[2]
|
|
2190
|
+
eps = du / L + (dv**2 + dw**2) / (2.0 * L**2)
|
|
2191
|
+
N_force = EA * eps
|
|
2192
|
+
|
|
2193
|
+
d_eps = np.zeros(12, dtype=float)
|
|
2194
|
+
d_eps[0], d_eps[6] = -1.0 / L, 1.0 / L
|
|
2195
|
+
d_eps[1], d_eps[7] = -dv / L**2, dv / L**2
|
|
2196
|
+
d_eps[2], d_eps[8] = -dw / L**2, dw / L**2
|
|
2197
|
+
|
|
2198
|
+
F_loc = cache["K_noax"] @ u_loc + N_force * L * d_eps
|
|
2199
|
+
if not tangent:
|
|
2200
|
+
return T.T @ F_loc, None, state
|
|
2201
|
+
K_loc = cache["K_noax"] + EA * L * np.outer(d_eps, d_eps)
|
|
2202
|
+
string = N_force / L
|
|
2203
|
+
for a, b in ((1, 7), (2, 8)):
|
|
2204
|
+
K_loc[a, a] += string
|
|
2205
|
+
K_loc[b, b] += string
|
|
2206
|
+
K_loc[a, b] -= string
|
|
2207
|
+
K_loc[b, a] -= string
|
|
2208
|
+
|
|
2209
|
+
return T.T @ F_loc, T.T @ K_loc @ T, state
|
|
2210
|
+
|
|
2211
|
+
def _end_displacements(self, u_local: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
2212
|
+
"""Local DOF vectors of the two geometric end nodes."""
|
|
2213
|
+
return u_local[0:6], u_local[6:12]
|
|
2214
|
+
|
|
2215
|
+
def compute_stresses(
|
|
2216
|
+
self,
|
|
2217
|
+
mesh: "FEMesh",
|
|
2218
|
+
displacements: np.ndarray,
|
|
2219
|
+
material: "Material",
|
|
2220
|
+
return_global: bool = False,
|
|
2221
|
+
) -> Dict[str, Any]:
|
|
2222
|
+
coords = self.get_node_coordinates(mesh)
|
|
2223
|
+
try:
|
|
2224
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2225
|
+
except ValueError:
|
|
2226
|
+
return {}
|
|
2227
|
+
u_local = T @ self._get_element_displacements(mesh, displacements)
|
|
2228
|
+
E = material.elastic_modulus
|
|
2229
|
+
G = material.shear_modulus
|
|
2230
|
+
end_a, end_b = self._end_displacements(u_local)
|
|
2231
|
+
u1, v1, w1, rx1, ry1, rz1 = end_a
|
|
2232
|
+
u2, v2, w2, rx2, ry2, rz2 = end_b
|
|
2233
|
+
sigma_axial = E * (u2 - u1) / L
|
|
2234
|
+
kappa_z = (rz2 - rz1) / L
|
|
2235
|
+
kappa_y = (ry2 - ry1) / L
|
|
2236
|
+
M_z = E * self._Iz * kappa_z
|
|
2237
|
+
M_y = E * self._Iy * kappa_y
|
|
2238
|
+
c_y, c_z = self._fiber_distances()
|
|
2239
|
+
sigma_bending_y = M_y * c_z / max(self._Iy, _SMALL)
|
|
2240
|
+
sigma_bending_z = M_z * c_y / max(self._Iz, _SMALL)
|
|
2241
|
+
gamma_xy = (v2 - v1) / L - 0.5 * (rz1 + rz2)
|
|
2242
|
+
gamma_xz = (w2 - w1) / L + 0.5 * (ry1 + ry2)
|
|
2243
|
+
tau_y = G * self._ky * gamma_xy
|
|
2244
|
+
tau_z = G * self._kz * gamma_xz
|
|
2245
|
+
tau_torsion = G * self._J * (rx2 - rx1) / L / self._torsion_section_modulus()
|
|
2246
|
+
sigma_x = sigma_axial + sigma_bending_y + sigma_bending_z
|
|
2247
|
+
von_mises = np.sqrt(sigma_x**2 + 3.0 * (tau_y**2 + tau_z**2 + tau_torsion**2))
|
|
2248
|
+
return {
|
|
2249
|
+
"axial_stress": sigma_axial,
|
|
2250
|
+
"bending_stress_y": sigma_bending_y,
|
|
2251
|
+
"bending_stress_z": sigma_bending_z,
|
|
2252
|
+
"shear_stress_y": tau_y,
|
|
2253
|
+
"shear_stress_z": tau_z,
|
|
2254
|
+
"torsional_stress": tau_torsion,
|
|
2255
|
+
"von_mises": von_mises,
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
class QuadraticBeamElement(BeamElement):
|
|
2260
|
+
"""3-node quadratic Timoshenko beam element with 6 DOF per node."""
|
|
2261
|
+
|
|
2262
|
+
GAUSS_POINTS = np.array([-np.sqrt(3.0 / 5.0), 0.0, np.sqrt(3.0 / 5.0)], dtype=float)
|
|
2263
|
+
GAUSS_WEIGHTS = np.array([5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0], dtype=float)
|
|
2264
|
+
|
|
2265
|
+
def __init__(
|
|
2266
|
+
self,
|
|
2267
|
+
element_id: int,
|
|
2268
|
+
node_ids: List[int],
|
|
2269
|
+
material_name: str = "default",
|
|
2270
|
+
cross_section: Optional[Dict[str, float]] = None,
|
|
2271
|
+
eccentricity: Optional[np.ndarray] = None,
|
|
2272
|
+
):
|
|
2273
|
+
Element.__init__(self, element_id, node_ids, material_name)
|
|
2274
|
+
if len(node_ids) != 3:
|
|
2275
|
+
raise ValueError(f"QuadraticBeamElement requires 3 nodes, got {len(node_ids)}")
|
|
2276
|
+
self.cross_section = cross_section or {}
|
|
2277
|
+
self._A = self.cross_section.get("area", 0.01)
|
|
2278
|
+
self._Iy = self.cross_section.get("Iy", 1.0e-8)
|
|
2279
|
+
self._Iz = self.cross_section.get("Iz", 1.0e-8)
|
|
2280
|
+
self._J = self.cross_section.get("J", 1.0e-8)
|
|
2281
|
+
self._ky = self.cross_section.get("shear_factor_y", 5.0 / 6.0)
|
|
2282
|
+
self._kz = self.cross_section.get("shear_factor_z", 5.0 / 6.0)
|
|
2283
|
+
self._orientation = _section_orientation(self.cross_section)
|
|
2284
|
+
self._c_y = self.cross_section.get("c_y")
|
|
2285
|
+
self._c_z = self.cross_section.get("c_z")
|
|
2286
|
+
self._torsion_modulus = self.cross_section.get("torsion_modulus")
|
|
2287
|
+
self._fiber_plasticity = self.cross_section.get("fiber_plasticity")
|
|
2288
|
+
self.eccentricity = np.zeros(3, dtype=float) if eccentricity is None else np.asarray(eccentricity, dtype=float)
|
|
2289
|
+
|
|
2290
|
+
def _end_displacements(self, u_local: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
2291
|
+
# Geometric end nodes are 0 and 2; node 1 is the midside node. The
|
|
2292
|
+
# end-difference over the full length equals the quadratic shape
|
|
2293
|
+
# function derivative evaluated at the element centre.
|
|
2294
|
+
return u_local[0:6], u_local[12:18]
|
|
2295
|
+
|
|
2296
|
+
def compute_nonlinear_response(
|
|
2297
|
+
self,
|
|
2298
|
+
mesh: "FEMesh",
|
|
2299
|
+
material: "Material",
|
|
2300
|
+
u_elem: np.ndarray,
|
|
2301
|
+
state: Optional[Any] = None,
|
|
2302
|
+
num_layers: int = 5,
|
|
2303
|
+
tangent: bool = True,
|
|
2304
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[Any]]:
|
|
2305
|
+
"""Gauss-point von Karman beam-column response on the quadratic interpolation.
|
|
2306
|
+
|
|
2307
|
+
At each integration point the axial strain includes the transverse
|
|
2308
|
+
gradient coupling
|
|
2309
|
+
|
|
2310
|
+
eps = u' + (v'^2 + w'^2) / 2
|
|
2311
|
+
|
|
2312
|
+
with all gradients from the quadratic shape functions, giving internal
|
|
2313
|
+
force and consistent tangent (including the string/geometric term) from
|
|
2314
|
+
the same potential. With ``cross_section["fiber_plasticity"]`` the
|
|
2315
|
+
axial/bending response is integrated over the uniaxial fiber grid per
|
|
2316
|
+
Gauss point using the material hardening curve; shear and torsion stay
|
|
2317
|
+
linear elastic, matching the 2-node beam formulation.
|
|
2318
|
+
"""
|
|
2319
|
+
coords = self.get_node_coordinates(mesh)
|
|
2320
|
+
try:
|
|
2321
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2322
|
+
except ValueError:
|
|
2323
|
+
return Element.compute_nonlinear_response(
|
|
2324
|
+
self, mesh, material, u_elem, state, num_layers, tangent
|
|
2325
|
+
)
|
|
2326
|
+
u_loc = T @ np.asarray(u_elem, dtype=float)
|
|
2327
|
+
E = material.elastic_modulus
|
|
2328
|
+
G = material.shear_modulus
|
|
2329
|
+
EA = E * self._A
|
|
2330
|
+
EIy = E * self._Iy
|
|
2331
|
+
EIz = E * self._Iz
|
|
2332
|
+
GJ = G * self._J
|
|
2333
|
+
GA_y = G * self._A * self._ky
|
|
2334
|
+
GA_z = G * self._A * self._kz
|
|
2335
|
+
|
|
2336
|
+
fiber_config = self._fiber_plasticity_config(material)
|
|
2337
|
+
num_gp = len(self.GAUSS_POINTS)
|
|
2338
|
+
if fiber_config is not None:
|
|
2339
|
+
y_f, z_f, w_f = self._fiber_section_grid(fiber_config)
|
|
2340
|
+
n_fibers = y_f.size
|
|
2341
|
+
fiber_strain_all = np.zeros(num_gp * n_fibers, dtype=float)
|
|
2342
|
+
B_fiber_all = np.zeros((num_gp * n_fibers, 18), dtype=float)
|
|
2343
|
+
|
|
2344
|
+
F_loc = np.zeros(18, dtype=float)
|
|
2345
|
+
K_loc = np.zeros((18, 18), dtype=float) if tangent else None
|
|
2346
|
+
gp_data = []
|
|
2347
|
+
axial_forces = []
|
|
2348
|
+
for gp_index, (xi, weight) in enumerate(zip(self.GAUSS_POINTS, self.GAUSS_WEIGHTS)):
|
|
2349
|
+
N, dN_dxi = self.compute_shape_functions(float(xi))
|
|
2350
|
+
dN_dx = dN_dxi * 2.0 / L
|
|
2351
|
+
jac = L / 2.0 * float(weight)
|
|
2352
|
+
|
|
2353
|
+
B_ax = np.zeros(18, dtype=float)
|
|
2354
|
+
B_v = np.zeros(18, dtype=float)
|
|
2355
|
+
B_w = np.zeros(18, dtype=float)
|
|
2356
|
+
B_ry = np.zeros(18, dtype=float)
|
|
2357
|
+
B_rz = np.zeros(18, dtype=float)
|
|
2358
|
+
B_torsion = np.zeros(18, dtype=float)
|
|
2359
|
+
B_shear_xy = np.zeros(18, dtype=float)
|
|
2360
|
+
B_shear_xz = np.zeros(18, dtype=float)
|
|
2361
|
+
for i in range(3):
|
|
2362
|
+
b = i * 6
|
|
2363
|
+
B_ax[b + 0] = dN_dx[i]
|
|
2364
|
+
B_v[b + 1] = dN_dx[i]
|
|
2365
|
+
B_w[b + 2] = dN_dx[i]
|
|
2366
|
+
B_torsion[b + 3] = dN_dx[i]
|
|
2367
|
+
B_ry[b + 4] = dN_dx[i]
|
|
2368
|
+
B_rz[b + 5] = dN_dx[i]
|
|
2369
|
+
B_shear_xy[b + 1] = dN_dx[i]
|
|
2370
|
+
B_shear_xy[b + 5] = -N[i]
|
|
2371
|
+
B_shear_xz[b + 2] = dN_dx[i]
|
|
2372
|
+
B_shear_xz[b + 4] = N[i]
|
|
2373
|
+
|
|
2374
|
+
v_grad = float(B_v @ u_loc)
|
|
2375
|
+
w_grad = float(B_w @ u_loc)
|
|
2376
|
+
eps0 = float(B_ax @ u_loc) + 0.5 * (v_grad**2 + w_grad**2)
|
|
2377
|
+
kappa_y = float(B_ry @ u_loc)
|
|
2378
|
+
kappa_z = float(B_rz @ u_loc)
|
|
2379
|
+
B_membrane = B_ax + v_grad * B_v + w_grad * B_w
|
|
2380
|
+
|
|
2381
|
+
# linear shear and torsion at every integration point
|
|
2382
|
+
gamma_xy = float(B_shear_xy @ u_loc)
|
|
2383
|
+
gamma_xz = float(B_shear_xz @ u_loc)
|
|
2384
|
+
twist = float(B_torsion @ u_loc)
|
|
2385
|
+
F_loc += jac * (GA_y * gamma_xy * B_shear_xy + GA_z * gamma_xz * B_shear_xz + GJ * twist * B_torsion)
|
|
2386
|
+
if tangent:
|
|
2387
|
+
K_loc += jac * (
|
|
2388
|
+
GA_y * np.outer(B_shear_xy, B_shear_xy)
|
|
2389
|
+
+ GA_z * np.outer(B_shear_xz, B_shear_xz)
|
|
2390
|
+
+ GJ * np.outer(B_torsion, B_torsion)
|
|
2391
|
+
)
|
|
2392
|
+
|
|
2393
|
+
if fiber_config is not None:
|
|
2394
|
+
rows = slice(gp_index * n_fibers, (gp_index + 1) * n_fibers)
|
|
2395
|
+
fiber_strain_all[rows] = eps0 + z_f * kappa_y + y_f * kappa_z
|
|
2396
|
+
B_fiber_all[rows] = (
|
|
2397
|
+
B_membrane[None, :] + z_f[:, None] * B_ry[None, :] + y_f[:, None] * B_rz[None, :]
|
|
2398
|
+
)
|
|
2399
|
+
gp_data.append((jac, B_v, B_w, rows))
|
|
2400
|
+
else:
|
|
2401
|
+
axial_force = EA * eps0
|
|
2402
|
+
axial_forces.append(axial_force)
|
|
2403
|
+
F_loc += jac * (axial_force * B_membrane + EIy * kappa_y * B_ry + EIz * kappa_z * B_rz)
|
|
2404
|
+
if tangent:
|
|
2405
|
+
K_loc += jac * (
|
|
2406
|
+
EA * np.outer(B_membrane, B_membrane)
|
|
2407
|
+
+ axial_force * (np.outer(B_v, B_v) + np.outer(B_w, B_w))
|
|
2408
|
+
+ EIy * np.outer(B_ry, B_ry)
|
|
2409
|
+
+ EIz * np.outer(B_rz, B_rz)
|
|
2410
|
+
)
|
|
2411
|
+
|
|
2412
|
+
trial_state: Optional[Any] = state
|
|
2413
|
+
if fiber_config is not None:
|
|
2414
|
+
stress, Et, plastic_new, alpha_new = self._uniaxial_return_map(
|
|
2415
|
+
fiber_strain_all, state, E, fiber_config.material_curve
|
|
2416
|
+
)
|
|
2417
|
+
for jac, B_v, B_w, rows in gp_data:
|
|
2418
|
+
weights_gp = w_f
|
|
2419
|
+
stress_gp = stress[rows]
|
|
2420
|
+
F_loc += jac * np.einsum("f,f,fj->j", weights_gp, stress_gp, B_fiber_all[rows])
|
|
2421
|
+
axial_force = float(np.sum(weights_gp * stress_gp))
|
|
2422
|
+
axial_forces.append(axial_force)
|
|
2423
|
+
if tangent:
|
|
2424
|
+
K_loc += jac * np.einsum("f,f,fj,fk->jk", weights_gp, Et[rows], B_fiber_all[rows], B_fiber_all[rows])
|
|
2425
|
+
K_loc += jac * axial_force * (np.outer(B_v, B_v) + np.outer(B_w, B_w))
|
|
2426
|
+
trial_state = {
|
|
2427
|
+
"plastic_strain": plastic_new,
|
|
2428
|
+
"alpha": alpha_new,
|
|
2429
|
+
"fiber_strain": fiber_strain_all.copy(),
|
|
2430
|
+
"fiber_stress": stress.copy(),
|
|
2431
|
+
"axial_force": float(np.mean(axial_forces)) if axial_forces else 0.0,
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
if not tangent:
|
|
2435
|
+
return T.T @ F_loc, None, trial_state
|
|
2436
|
+
return T.T @ F_loc, T.T @ K_loc @ T, trial_state
|
|
2437
|
+
|
|
2438
|
+
@property
|
|
2439
|
+
def num_nodes(self) -> int:
|
|
2440
|
+
return 3
|
|
2441
|
+
|
|
2442
|
+
@property
|
|
2443
|
+
def dofs_per_node(self) -> int:
|
|
2444
|
+
return 6
|
|
2445
|
+
|
|
2446
|
+
@property
|
|
2447
|
+
def total_dofs(self) -> int:
|
|
2448
|
+
return 18
|
|
2449
|
+
|
|
2450
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
2451
|
+
coords = np.zeros((3, 3), dtype=float)
|
|
2452
|
+
for i, node_id in enumerate(self.node_ids):
|
|
2453
|
+
node = mesh.get_node(node_id)
|
|
2454
|
+
if node is None:
|
|
2455
|
+
raise ValueError(f"Quadratic beam element {self.element_id} references missing node {node_id}")
|
|
2456
|
+
coords[i] = node.coords()
|
|
2457
|
+
chord = coords[2] - coords[0]
|
|
2458
|
+
length = float(np.linalg.norm(chord))
|
|
2459
|
+
if length > _SMALL:
|
|
2460
|
+
midpoint_error = float(np.linalg.norm(coords[1] - 0.5 * (coords[0] + coords[2])))
|
|
2461
|
+
if midpoint_error > max(1.0e-8 * length, 1.0e-12):
|
|
2462
|
+
raise ValueError(
|
|
2463
|
+
f"Quadratic beam element {self.element_id} is curved or has a non-midpoint middle node. "
|
|
2464
|
+
"The current B3 formulation is straight-sided; use two-node beam segments for curved geometry."
|
|
2465
|
+
)
|
|
2466
|
+
return coords
|
|
2467
|
+
|
|
2468
|
+
def compute_shape_functions(self, xi: float) -> Tuple[np.ndarray, np.ndarray]:
|
|
2469
|
+
N = np.array([xi * (xi - 1.0) / 2.0, 1.0 - xi**2, xi * (xi + 1.0) / 2.0], dtype=float)
|
|
2470
|
+
dN_dxi = np.array([xi - 0.5, -2.0 * xi, xi + 0.5], dtype=float)
|
|
2471
|
+
return N, dN_dxi
|
|
2472
|
+
|
|
2473
|
+
def _beam_frame_and_transform(self, coords: np.ndarray) -> Tuple[float, np.ndarray]:
|
|
2474
|
+
length = float(np.linalg.norm(coords[2] - coords[0]))
|
|
2475
|
+
if length < _SMALL:
|
|
2476
|
+
raise ValueError(f"Quadratic beam element {self.element_id} has near-zero length")
|
|
2477
|
+
e1 = (coords[2] - coords[0]) / length
|
|
2478
|
+
R = _beam_rotation_matrix(e1, self._orientation)
|
|
2479
|
+
T = np.zeros((18, 18), dtype=float)
|
|
2480
|
+
Rt = R.T
|
|
2481
|
+
for i in range(3):
|
|
2482
|
+
b = i * 6
|
|
2483
|
+
T[b:b + 3, b:b + 3] = Rt
|
|
2484
|
+
T[b + 3:b + 6, b + 3:b + 6] = Rt
|
|
2485
|
+
return length, T
|
|
2486
|
+
|
|
2487
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
2488
|
+
coords = self.get_node_coordinates(mesh)
|
|
2489
|
+
try:
|
|
2490
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2491
|
+
except ValueError:
|
|
2492
|
+
return np.zeros((18, 18), dtype=float)
|
|
2493
|
+
E = material.elastic_modulus
|
|
2494
|
+
G = material.shear_modulus
|
|
2495
|
+
EA = E * self._A
|
|
2496
|
+
EIy = E * self._Iy
|
|
2497
|
+
EIz = E * self._Iz
|
|
2498
|
+
GJ = G * self._J
|
|
2499
|
+
GA_y = G * self._A * self._ky
|
|
2500
|
+
GA_z = G * self._A * self._kz
|
|
2501
|
+
K = np.zeros((18, 18), dtype=float)
|
|
2502
|
+
for xi, weight in zip(self.GAUSS_POINTS, self.GAUSS_WEIGHTS):
|
|
2503
|
+
N, dN_dxi = self.compute_shape_functions(float(xi))
|
|
2504
|
+
dN_dx = dN_dxi * 2.0 / L
|
|
2505
|
+
B_axial = np.zeros((1, 18), dtype=float)
|
|
2506
|
+
B_torsion = np.zeros((1, 18), dtype=float)
|
|
2507
|
+
B_shear_xz = np.zeros((1, 18), dtype=float)
|
|
2508
|
+
B_shear_xy = np.zeros((1, 18), dtype=float)
|
|
2509
|
+
B_bend_y = np.zeros((1, 18), dtype=float)
|
|
2510
|
+
B_bend_z = np.zeros((1, 18), dtype=float)
|
|
2511
|
+
for i in range(3):
|
|
2512
|
+
b = i * 6
|
|
2513
|
+
B_axial[0, b + 0] = dN_dx[i]
|
|
2514
|
+
B_torsion[0, b + 3] = dN_dx[i]
|
|
2515
|
+
B_shear_xz[0, b + 2] = dN_dx[i]
|
|
2516
|
+
B_shear_xz[0, b + 4] = N[i]
|
|
2517
|
+
B_shear_xy[0, b + 1] = dN_dx[i]
|
|
2518
|
+
B_shear_xy[0, b + 5] = -N[i]
|
|
2519
|
+
B_bend_y[0, b + 4] = dN_dx[i]
|
|
2520
|
+
B_bend_z[0, b + 5] = dN_dx[i]
|
|
2521
|
+
jac = L / 2.0 * weight
|
|
2522
|
+
K += B_axial.T @ (EA * np.eye(1)) @ B_axial * jac
|
|
2523
|
+
K += B_torsion.T @ (GJ * np.eye(1)) @ B_torsion * jac
|
|
2524
|
+
K += B_shear_xz.T @ (GA_z * np.eye(1)) @ B_shear_xz * jac
|
|
2525
|
+
K += B_shear_xy.T @ (GA_y * np.eye(1)) @ B_shear_xy * jac
|
|
2526
|
+
K += B_bend_y.T @ (EIy * np.eye(1)) @ B_bend_y * jac
|
|
2527
|
+
K += B_bend_z.T @ (EIz * np.eye(1)) @ B_bend_z * jac
|
|
2528
|
+
K_global = T.T @ K @ T
|
|
2529
|
+
self._stiffness_matrix = K_global
|
|
2530
|
+
return K_global
|
|
2531
|
+
|
|
2532
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
2533
|
+
coords = self.get_node_coordinates(mesh)
|
|
2534
|
+
try:
|
|
2535
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2536
|
+
except ValueError:
|
|
2537
|
+
return np.zeros((18, 18), dtype=float)
|
|
2538
|
+
M = np.zeros((18, 18), dtype=float)
|
|
2539
|
+
rho = material.density
|
|
2540
|
+
# Consistent rotary inertia per rotation axis: polar (Iy+Iz) for
|
|
2541
|
+
# torsion, the matching section inertia for each bending rotation.
|
|
2542
|
+
rotary_inertia = (rho * (self._Iy + self._Iz), rho * self._Iy, rho * self._Iz)
|
|
2543
|
+
for xi, weight in zip(self.GAUSS_POINTS, self.GAUSS_WEIGHTS):
|
|
2544
|
+
N, _ = self.compute_shape_functions(float(xi))
|
|
2545
|
+
for i in range(3):
|
|
2546
|
+
for j in range(3):
|
|
2547
|
+
translational = N[i] * N[j] * rho * self._A * weight * L / 2.0
|
|
2548
|
+
for d in range(3):
|
|
2549
|
+
M[i * 6 + d, j * 6 + d] += translational
|
|
2550
|
+
M[i * 6 + 3 + d, j * 6 + 3 + d] += N[i] * N[j] * rotary_inertia[d] * weight * L / 2.0
|
|
2551
|
+
M_global = T.T @ M @ T
|
|
2552
|
+
self._mass_matrix = M_global
|
|
2553
|
+
return M_global
|
|
2554
|
+
|
|
2555
|
+
def compute_geometric_stiffness_matrix(
|
|
2556
|
+
self,
|
|
2557
|
+
mesh: "FEMesh",
|
|
2558
|
+
material: "Material",
|
|
2559
|
+
state: Optional[Any] = None,
|
|
2560
|
+
) -> np.ndarray:
|
|
2561
|
+
"""
|
|
2562
|
+
Beam-column stress stiffness from the lateral displacement gradient:
|
|
2563
|
+
|
|
2564
|
+
KG = N_compression * integral (dN/dx)^T (dN/dx) dx
|
|
2565
|
+
|
|
2566
|
+
applied to both transverse deflections. This is the same
|
|
2567
|
+
destabilizing-gradient theory as the shell membrane KG and follows the
|
|
2568
|
+
package convention ``K phi = lambda KG phi`` with compression positive.
|
|
2569
|
+
The higher-order rotation-gradient term is omitted, which makes the
|
|
2570
|
+
predicted critical loads slightly conservative on coarse meshes.
|
|
2571
|
+
"""
|
|
2572
|
+
axial_compression = self._axial_compression_from_state(state)
|
|
2573
|
+
if axial_compression == 0.0:
|
|
2574
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
2575
|
+
|
|
2576
|
+
coords = self.get_node_coordinates(mesh)
|
|
2577
|
+
try:
|
|
2578
|
+
L, T = self._beam_frame_and_transform(coords)
|
|
2579
|
+
except ValueError:
|
|
2580
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
2581
|
+
|
|
2582
|
+
KG = np.zeros((18, 18), dtype=float)
|
|
2583
|
+
# Wagner term factor: see BeamElement.compute_geometric_stiffness_matrix.
|
|
2584
|
+
polar_ratio = (self._Iy + self._Iz) / max(self._A, 1.0e-30)
|
|
2585
|
+
for xi, weight in zip(self.GAUSS_POINTS, self.GAUSS_WEIGHTS):
|
|
2586
|
+
_, dN_dxi = self.compute_shape_functions(float(xi))
|
|
2587
|
+
dN_dx = dN_dxi * 2.0 / L
|
|
2588
|
+
G_matrix = np.zeros((2, 18), dtype=float)
|
|
2589
|
+
twist_gradient = np.zeros((1, 18), dtype=float)
|
|
2590
|
+
for i in range(3):
|
|
2591
|
+
b = i * 6
|
|
2592
|
+
G_matrix[0, b + 1] = dN_dx[i]
|
|
2593
|
+
G_matrix[1, b + 2] = dN_dx[i]
|
|
2594
|
+
twist_gradient[0, b + 3] = dN_dx[i]
|
|
2595
|
+
KG += axial_compression * (G_matrix.T @ G_matrix) * (L / 2.0 * weight)
|
|
2596
|
+
KG += axial_compression * polar_ratio * (twist_gradient.T @ twist_gradient) * (L / 2.0 * weight)
|
|
2597
|
+
return T.T @ KG @ T
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
class CoupledBeamShellElement(Element):
|
|
2601
|
+
"""Kinematic MPC coupling between one eccentric beam node and one shell node."""
|
|
2602
|
+
|
|
2603
|
+
def __init__(
|
|
2604
|
+
self,
|
|
2605
|
+
element_id: int,
|
|
2606
|
+
beam_node_id: int,
|
|
2607
|
+
shell_node_id: int,
|
|
2608
|
+
material_name: str = "default",
|
|
2609
|
+
coupling_stiffness: float = 0.0,
|
|
2610
|
+
eccentricity: Optional[np.ndarray] = None,
|
|
2611
|
+
):
|
|
2612
|
+
super().__init__(element_id, [beam_node_id, shell_node_id], material_name)
|
|
2613
|
+
self.beam_node_id = beam_node_id
|
|
2614
|
+
self.shell_node_id = shell_node_id
|
|
2615
|
+
self.coupling_stiffness = coupling_stiffness
|
|
2616
|
+
self.eccentricity = None if eccentricity is None else np.asarray(eccentricity, dtype=float)
|
|
2617
|
+
|
|
2618
|
+
@property
|
|
2619
|
+
def num_nodes(self) -> int:
|
|
2620
|
+
return 2
|
|
2621
|
+
|
|
2622
|
+
@property
|
|
2623
|
+
def dofs_per_node(self) -> int:
|
|
2624
|
+
return 6
|
|
2625
|
+
|
|
2626
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
2627
|
+
beam_node = mesh.get_node(self.beam_node_id)
|
|
2628
|
+
shell_node = mesh.get_node(self.shell_node_id)
|
|
2629
|
+
if beam_node is None or shell_node is None:
|
|
2630
|
+
raise ValueError(f"Coupling element {self.element_id} references a missing node")
|
|
2631
|
+
return np.array([beam_node.coords(), shell_node.coords()], dtype=float)
|
|
2632
|
+
|
|
2633
|
+
def _eccentricity_vector(self, mesh: "FEMesh") -> np.ndarray:
|
|
2634
|
+
if self.eccentricity is not None:
|
|
2635
|
+
return np.asarray(self.eccentricity, dtype=float)
|
|
2636
|
+
coords = self.get_node_coordinates(mesh)
|
|
2637
|
+
return coords[0] - coords[1]
|
|
2638
|
+
|
|
2639
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
2640
|
+
# Coupling is enforced exactly by assembly.build_constraint_transformation().
|
|
2641
|
+
# Returning zero avoids penalty-stiffness pollution.
|
|
2642
|
+
K = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
2643
|
+
self._stiffness_matrix = K
|
|
2644
|
+
return K
|
|
2645
|
+
|
|
2646
|
+
def get_mpc_constraints(self, mesh: "FEMesh") -> List[Dict[str, Any]]:
|
|
2647
|
+
"""
|
|
2648
|
+
Return slave/master constraints for the eccentric beam-shell relation.
|
|
2649
|
+
|
|
2650
|
+
Beam node DOFs are slaves. Shell node DOFs are masters:
|
|
2651
|
+
u_b = u_s + theta_s x r
|
|
2652
|
+
theta_b = theta_s
|
|
2653
|
+
where r points from shell node to beam node.
|
|
2654
|
+
"""
|
|
2655
|
+
beam_node = mesh.get_node(self.beam_node_id)
|
|
2656
|
+
shell_node = mesh.get_node(self.shell_node_id)
|
|
2657
|
+
if beam_node is None or shell_node is None:
|
|
2658
|
+
return []
|
|
2659
|
+
|
|
2660
|
+
b = beam_node.dofs
|
|
2661
|
+
s = shell_node.dofs
|
|
2662
|
+
rx, ry, rz = self._eccentricity_vector(mesh)
|
|
2663
|
+
|
|
2664
|
+
return [
|
|
2665
|
+
{
|
|
2666
|
+
"slave": b[0],
|
|
2667
|
+
"masters": {s[0]: 1.0, s[4]: rz, s[5]: -ry},
|
|
2668
|
+
"value": 0.0,
|
|
2669
|
+
"label": f"beam_shell_ecc_u_x_{self.element_id}",
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
"slave": b[1],
|
|
2673
|
+
"masters": {s[1]: 1.0, s[3]: -rz, s[5]: rx},
|
|
2674
|
+
"value": 0.0,
|
|
2675
|
+
"label": f"beam_shell_ecc_u_y_{self.element_id}",
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
"slave": b[2],
|
|
2679
|
+
"masters": {s[2]: 1.0, s[3]: ry, s[4]: -rx},
|
|
2680
|
+
"value": 0.0,
|
|
2681
|
+
"label": f"beam_shell_ecc_u_z_{self.element_id}",
|
|
2682
|
+
},
|
|
2683
|
+
{"slave": b[3], "masters": {s[3]: 1.0}, "value": 0.0, "label": f"beam_shell_ecc_rx_{self.element_id}"},
|
|
2684
|
+
{"slave": b[4], "masters": {s[4]: 1.0}, "value": 0.0, "label": f"beam_shell_ecc_ry_{self.element_id}"},
|
|
2685
|
+
{"slave": b[5], "masters": {s[5]: 1.0}, "value": 0.0, "label": f"beam_shell_ecc_rz_{self.element_id}"},
|
|
2686
|
+
]
|
|
2687
|
+
|
|
2688
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
2689
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
ELEMENT_TYPES = {
|
|
2693
|
+
"shell": ShellElement,
|
|
2694
|
+
"shell3": ShellElement,
|
|
2695
|
+
"tri3": ShellElement,
|
|
2696
|
+
"tria3": ShellElement,
|
|
2697
|
+
"t3": ShellElement,
|
|
2698
|
+
"s3": ShellElement,
|
|
2699
|
+
"shell6": ShellElement,
|
|
2700
|
+
"tri6": ShellElement,
|
|
2701
|
+
"tria6": ShellElement,
|
|
2702
|
+
"t6": ShellElement,
|
|
2703
|
+
"s6": ShellElement,
|
|
2704
|
+
"beam": BeamElement,
|
|
2705
|
+
"quadratic_beam": QuadraticBeamElement,
|
|
2706
|
+
"coupled": CoupledBeamShellElement,
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
|
|
2710
|
+
def create_element(
|
|
2711
|
+
element_type: str,
|
|
2712
|
+
element_id: int,
|
|
2713
|
+
node_ids: List[int],
|
|
2714
|
+
material_name: str = "default",
|
|
2715
|
+
**kwargs: Any,
|
|
2716
|
+
) -> Element:
|
|
2717
|
+
normalized_type = str(element_type).lower()
|
|
2718
|
+
if normalized_type not in ELEMENT_TYPES:
|
|
2719
|
+
raise ValueError(f"Unknown element type: {element_type}")
|
|
2720
|
+
if normalized_type == "coupled":
|
|
2721
|
+
if len(node_ids) != 2:
|
|
2722
|
+
raise ValueError("CoupledBeamShellElement factory requires [beam_node_id, shell_node_id]")
|
|
2723
|
+
return CoupledBeamShellElement(element_id, node_ids[0], node_ids[1], material_name, **kwargs)
|
|
2724
|
+
return ELEMENT_TYPES[normalized_type](element_id, node_ids, material_name, **kwargs)
|