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.
Files changed (70) hide show
  1. anysolver/__init__.py +631 -0
  2. anysolver/anystructure_fem_mode.py +942 -0
  3. anysolver/arc_length.py +758 -0
  4. anysolver/assembly.py +950 -0
  5. anysolver/baselines.py +303 -0
  6. anysolver/beam_shell_verification.py +4276 -0
  7. anysolver/beam_validity.py +110 -0
  8. anysolver/benchmarks.py +495 -0
  9. anysolver/boundary.py +476 -0
  10. anysolver/buckling.py +442 -0
  11. anysolver/buckling_validity.py +91 -0
  12. anysolver/capacity_workflow.py +350 -0
  13. anysolver/cases.py +173 -0
  14. anysolver/composite_strip_verification.py +297 -0
  15. anysolver/contact.py +3461 -0
  16. anysolver/corotational.py +371 -0
  17. anysolver/cylinder_benchmarks.py +364 -0
  18. anysolver/dynamics.py +723 -0
  19. anysolver/element_qualification.py +432 -0
  20. anysolver/elements.py +2724 -0
  21. anysolver/external_references.py +369 -0
  22. anysolver/fe_core.py +327 -0
  23. anysolver/fracture.py +551 -0
  24. anysolver/imperfections.py +390 -0
  25. anysolver/jit_compiler.py +108 -0
  26. anysolver/kernel_warmup.py +180 -0
  27. anysolver/linalg.py +760 -0
  28. anysolver/mass_properties.py +179 -0
  29. anysolver/material_curves.py +231 -0
  30. anysolver/matrix_assembly.py +558 -0
  31. anysolver/mesh_gen.py +1065 -0
  32. anysolver/mesh_load_bc_verification.py +609 -0
  33. anysolver/modal.py +282 -0
  34. anysolver/nonlinear.py +300 -0
  35. anysolver/nonlinear_performance.py +920 -0
  36. anysolver/nonlinear_performance_batch_b.py +592 -0
  37. anysolver/nonlinear_performance_batch_c.py +506 -0
  38. anysolver/nonlinear_performance_bootstrap.py +120 -0
  39. anysolver/nonlinear_reduced_assembly.py +760 -0
  40. anysolver/nonlinear_static.py +1518 -0
  41. anysolver/plasticity.py +419 -0
  42. anysolver/plasticity_qualification.py +314 -0
  43. anysolver/production_readiness.py +304 -0
  44. anysolver/quality_control.py +1497 -0
  45. anysolver/recovery.py +505 -0
  46. anysolver/recovery_policy.py +205 -0
  47. anysolver/reference_cases.py +425 -0
  48. anysolver/results.py +503 -0
  49. anysolver/runtime.py +9030 -0
  50. anysolver/s4_validity.py +326 -0
  51. anysolver/sesam_fem/__init__.py +55 -0
  52. anysolver/sesam_fem/__main__.py +80 -0
  53. anysolver/sesam_fem/diagnostics.py +65 -0
  54. anysolver/sesam_fem/document.py +814 -0
  55. anysolver/sesam_fem/exporter.py +84 -0
  56. anysolver/sesam_fem/importer.py +365 -0
  57. anysolver/sesam_fem/records.py +257 -0
  58. anysolver/sesam_fem/schema.py +107 -0
  59. anysolver/sesam_fem/sif_importer.py +397 -0
  60. anysolver/sesam_fem/validation.py +62 -0
  61. anysolver/shell_benchmarks.py +367 -0
  62. anysolver/test_cases.py +610 -0
  63. anysolver/validation.py +416 -0
  64. anysolver/vectorized_nonlinear.py +334 -0
  65. anysolver/vectorized_stiffness.py +571 -0
  66. anysolver-0.1.0.dist-info/METADATA +165 -0
  67. anysolver-0.1.0.dist-info/RECORD +70 -0
  68. anysolver-0.1.0.dist-info/WHEEL +5 -0
  69. anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. 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)