ANYsolver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/mesh_gen.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mesh generation from normalized stiffened-panel geometry.
|
|
3
|
+
|
|
4
|
+
The generated meshes are intentionally limited to the rectangular panel/stiffener
|
|
5
|
+
layout supported by the current FE solver. The mesh builder applies support
|
|
6
|
+
conditions to full shell edges and uses explicit eccentric beam-shell MPC
|
|
7
|
+
couplings instead of shared-node or penalty placeholders.
|
|
8
|
+
|
|
9
|
+
Beam-shell coupling is shell-interpolated: each eccentric beam node is constrained
|
|
10
|
+
to the shell element underneath it using the shell shape functions. This avoids
|
|
11
|
+
the earlier brittle requirement that each beam node must lie exactly on a shell
|
|
12
|
+
node row/column.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .fe_core import FEModel, FEMesh, Material
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MeshConfig:
|
|
28
|
+
"""Configuration for mesh generation."""
|
|
29
|
+
|
|
30
|
+
shell_num_divisions_x: int = 4
|
|
31
|
+
shell_num_divisions_y: int = 4
|
|
32
|
+
beam_num_divisions: int = 1
|
|
33
|
+
|
|
34
|
+
use_coupling_elements: bool = True
|
|
35
|
+
coupling_stiffness: float = 0.0
|
|
36
|
+
use_shared_nodes: bool = False
|
|
37
|
+
|
|
38
|
+
tolerance: float = 1.0e-6
|
|
39
|
+
|
|
40
|
+
default_material: str = "steel"
|
|
41
|
+
plate_material: str = "steel"
|
|
42
|
+
stiffener_material: str = "steel"
|
|
43
|
+
|
|
44
|
+
use_8node_shells: bool = False
|
|
45
|
+
align_mesh_to_stiffeners: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PanelGeometry:
|
|
50
|
+
"""Geometry and load metadata for a rectangular stiffened panel."""
|
|
51
|
+
|
|
52
|
+
length: float = 0.0
|
|
53
|
+
width: float = 0.0
|
|
54
|
+
|
|
55
|
+
plate_thickness: float = 0.0
|
|
56
|
+
plate_material: str = "steel"
|
|
57
|
+
|
|
58
|
+
stiffener_type: str = "T-bar"
|
|
59
|
+
stiffener_spacing: float = 0.0
|
|
60
|
+
stiffener_height: float = 0.0
|
|
61
|
+
stiffener_web_thickness: float = 0.0
|
|
62
|
+
stiffener_flange_width: float = 0.0
|
|
63
|
+
stiffener_flange_thickness: float = 0.0
|
|
64
|
+
stiffener_material: str = "steel"
|
|
65
|
+
num_stiffeners: int = 1
|
|
66
|
+
|
|
67
|
+
in_plane_support: str = "Integrated"
|
|
68
|
+
rotational_support: str = "SS"
|
|
69
|
+
|
|
70
|
+
axial_stress: float = 0.0
|
|
71
|
+
transverse_stress: float = 0.0
|
|
72
|
+
shear_stress: float = 0.0
|
|
73
|
+
pressure: float = 0.0
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"length": self.length,
|
|
78
|
+
"width": self.width,
|
|
79
|
+
"plate_thickness": self.plate_thickness,
|
|
80
|
+
"plate_material": self.plate_material,
|
|
81
|
+
"stiffener_type": self.stiffener_type,
|
|
82
|
+
"stiffener_spacing": self.stiffener_spacing,
|
|
83
|
+
"stiffener_height": self.stiffener_height,
|
|
84
|
+
"stiffener_web_thickness": self.stiffener_web_thickness,
|
|
85
|
+
"stiffener_flange_width": self.stiffener_flange_width,
|
|
86
|
+
"stiffener_flange_thickness": self.stiffener_flange_thickness,
|
|
87
|
+
"stiffener_material": self.stiffener_material,
|
|
88
|
+
"num_stiffeners": self.num_stiffeners,
|
|
89
|
+
"in_plane_support": self.in_plane_support,
|
|
90
|
+
"rotational_support": self.rotational_support,
|
|
91
|
+
"axial_stress": self.axial_stress,
|
|
92
|
+
"transverse_stress": self.transverse_stress,
|
|
93
|
+
"shear_stress": self.shear_stress,
|
|
94
|
+
"pressure": self.pressure,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_anystructure(cls, anystructure_data: Any) -> "PanelGeometry":
|
|
99
|
+
geometry = cls()
|
|
100
|
+
if hasattr(anystructure_data, "Plate"):
|
|
101
|
+
plate = anystructure_data.Plate
|
|
102
|
+
geometry.length = getattr(plate, "span", 0.0)
|
|
103
|
+
geometry.width = getattr(plate, "spacing", 0.0)
|
|
104
|
+
geometry.plate_thickness = getattr(plate, "t", 0.0)
|
|
105
|
+
geometry.plate_material = "steel"
|
|
106
|
+
if hasattr(anystructure_data, "Stiffener"):
|
|
107
|
+
stiffener = anystructure_data.Stiffener
|
|
108
|
+
geometry.stiffener_type = getattr(stiffener, "stiffener_type", "T-bar")
|
|
109
|
+
geometry.stiffener_spacing = getattr(stiffener, "spacing", 0.0)
|
|
110
|
+
geometry.stiffener_height = getattr(stiffener, "hw", 0.0)
|
|
111
|
+
geometry.stiffener_web_thickness = getattr(stiffener, "tw", 0.0)
|
|
112
|
+
geometry.stiffener_flange_width = getattr(stiffener, "b", 0.0)
|
|
113
|
+
geometry.stiffener_flange_thickness = getattr(stiffener, "tf", 0.0)
|
|
114
|
+
geometry.stiffener_material = "steel"
|
|
115
|
+
if hasattr(anystructure_data, "sigma_x1"):
|
|
116
|
+
geometry.axial_stress = anystructure_data.sigma_x1
|
|
117
|
+
if hasattr(anystructure_data, "sigma_y1"):
|
|
118
|
+
geometry.transverse_stress = anystructure_data.sigma_y1
|
|
119
|
+
if hasattr(anystructure_data, "tau_xy"):
|
|
120
|
+
geometry.shear_stress = anystructure_data.tau_xy
|
|
121
|
+
if hasattr(anystructure_data, "pressure"):
|
|
122
|
+
geometry.pressure = anystructure_data.pressure
|
|
123
|
+
return geometry
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class StiffenerCrossSection:
|
|
128
|
+
"""Cross-section properties for a line stiffener."""
|
|
129
|
+
|
|
130
|
+
area: float
|
|
131
|
+
Iy: float
|
|
132
|
+
Iz: float
|
|
133
|
+
J: float
|
|
134
|
+
shear_factor_y: float = 5.0 / 6.0
|
|
135
|
+
shear_factor_z: float = 5.0 / 6.0
|
|
136
|
+
c_y: float = 0.0
|
|
137
|
+
c_z: float = 0.0
|
|
138
|
+
torsion_modulus: float = 0.0
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _composite_rectangles(
|
|
142
|
+
rectangles: List[Tuple[float, float, float, float]],
|
|
143
|
+
) -> Tuple[float, float, float, float, float]:
|
|
144
|
+
"""
|
|
145
|
+
Return A, Iy, Iz, c_y, c_z for rectangles described by
|
|
146
|
+
y, z, width_y, height_z, with c_y/c_z the extreme fiber distances
|
|
147
|
+
from the centroid.
|
|
148
|
+
"""
|
|
149
|
+
areas = np.asarray([width * height for _y, _z, width, height in rectangles], dtype=float)
|
|
150
|
+
total_area = max(float(np.sum(areas)), 1.0e-30)
|
|
151
|
+
y_centroid = float(
|
|
152
|
+
np.sum([area * rect[0] for area, rect in zip(areas, rectangles)]) / total_area
|
|
153
|
+
)
|
|
154
|
+
z_centroid = float(
|
|
155
|
+
np.sum([area * rect[1] for area, rect in zip(areas, rectangles)]) / total_area
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
Iy = 0.0
|
|
159
|
+
Iz = 0.0
|
|
160
|
+
c_y = 0.0
|
|
161
|
+
c_z = 0.0
|
|
162
|
+
for area, (y, z, width, height) in zip(areas, rectangles):
|
|
163
|
+
Iy += width * height**3 / 12.0 + area * (z - z_centroid) ** 2
|
|
164
|
+
Iz += height * width**3 / 12.0 + area * (y - y_centroid) ** 2
|
|
165
|
+
c_y = max(c_y, abs(y - y_centroid) + width / 2.0)
|
|
166
|
+
c_z = max(c_z, abs(z - z_centroid) + height / 2.0)
|
|
167
|
+
return total_area, Iy, Iz, c_y, c_z
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_geometry(cls, stiffener_type: str, hw: float, tw: float, b: float, tf: float) -> "StiffenerCrossSection":
|
|
171
|
+
# Open thin-walled torsion: J = sum(l*t^3)/3 and tau_max = T*t_max/J,
|
|
172
|
+
# so the torsional section modulus is Wt = J / t_max.
|
|
173
|
+
if stiffener_type == "T-bar":
|
|
174
|
+
A, Iy, Iz, c_y, c_z = cls._composite_rectangles(
|
|
175
|
+
[
|
|
176
|
+
(0.0, hw / 2.0, tw, hw),
|
|
177
|
+
(0.0, hw + tf / 2.0, b, tf),
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
J = (hw * tw**3 + b * tf**3) / 3.0
|
|
181
|
+
t_max = max(tw, tf)
|
|
182
|
+
elif stiffener_type in ("L-bulb", "Angle"):
|
|
183
|
+
A, Iy, Iz, c_y, c_z = cls._composite_rectangles(
|
|
184
|
+
[
|
|
185
|
+
(tw / 2.0, hw / 2.0, tw, hw),
|
|
186
|
+
(b / 2.0, hw + tf / 2.0, b, tf),
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
J = (hw * tw**3 + b * tf**3) / 3.0
|
|
190
|
+
t_max = max(tw, tf)
|
|
191
|
+
elif stiffener_type == "Flatbar":
|
|
192
|
+
A, Iy, Iz, c_y, c_z = cls._composite_rectangles([(0.0, 0.0, b, tf)])
|
|
193
|
+
J = b * tf**3 / 3.0
|
|
194
|
+
t_max = min(b, tf)
|
|
195
|
+
else:
|
|
196
|
+
A, Iy, Iz, c_y, c_z = cls._composite_rectangles([(0.0, hw / 2.0, tw, hw)])
|
|
197
|
+
J = hw * tw**3 / 3.0
|
|
198
|
+
t_max = tw
|
|
199
|
+
torsion_modulus = J / max(t_max, 1.0e-30)
|
|
200
|
+
return cls(area=A, Iy=Iy, Iz=Iz, J=J, c_y=c_y, c_z=c_z, torsion_modulus=torsion_modulus)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class InterpolatedBeamShellMPCElement:
|
|
204
|
+
"""
|
|
205
|
+
Duck-typed MPC-only element for eccentric beam-to-shell coupling.
|
|
206
|
+
|
|
207
|
+
The first node is the beam slave node. The remaining nodes are the shell
|
|
208
|
+
master nodes of the shell element underneath the beam node. Shape weights
|
|
209
|
+
are evaluated at the projected beam-node location in the shell element.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
element_id: int,
|
|
215
|
+
beam_node_id: int,
|
|
216
|
+
shell_node_ids: List[int],
|
|
217
|
+
shape_weights: np.ndarray,
|
|
218
|
+
eccentricity: np.ndarray,
|
|
219
|
+
material_name: str = "steel",
|
|
220
|
+
):
|
|
221
|
+
self.element_id = element_id
|
|
222
|
+
self.beam_node_id = beam_node_id
|
|
223
|
+
self.shell_node_ids = list(shell_node_ids)
|
|
224
|
+
self.shape_weights = np.asarray(shape_weights, dtype=float)
|
|
225
|
+
self.eccentricity = np.asarray(eccentricity, dtype=float)
|
|
226
|
+
self.material_name = material_name
|
|
227
|
+
self.node_ids = [beam_node_id] + self.shell_node_ids
|
|
228
|
+
self._stiffness_matrix = None
|
|
229
|
+
self._mass_matrix = None
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def num_nodes(self) -> int:
|
|
233
|
+
return 1 + len(self.shell_node_ids)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def dofs_per_node(self) -> int:
|
|
237
|
+
return 6
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def total_dofs(self) -> int:
|
|
241
|
+
return self.num_nodes * self.dofs_per_node
|
|
242
|
+
|
|
243
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
244
|
+
coords = []
|
|
245
|
+
for node_id in self.node_ids:
|
|
246
|
+
node = mesh.get_node(node_id)
|
|
247
|
+
if node is None:
|
|
248
|
+
raise ValueError(f"MPC element {self.element_id} references missing node {node_id}")
|
|
249
|
+
coords.append(node.coords())
|
|
250
|
+
return np.asarray(coords, dtype=float)
|
|
251
|
+
|
|
252
|
+
def get_dof_mapping(self, mesh: "FEMesh") -> List[int]:
|
|
253
|
+
dofs: List[int] = []
|
|
254
|
+
for node_id in self.node_ids:
|
|
255
|
+
node = mesh.get_node(node_id)
|
|
256
|
+
if node is not None:
|
|
257
|
+
dofs.extend(node.dofs)
|
|
258
|
+
return dofs
|
|
259
|
+
|
|
260
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
261
|
+
# Constraint is enforced exactly by assembly.build_constraint_transformation().
|
|
262
|
+
K = np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
263
|
+
self._stiffness_matrix = K
|
|
264
|
+
return K
|
|
265
|
+
|
|
266
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
267
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
268
|
+
|
|
269
|
+
def compute_geometric_stiffness_matrix(self, mesh: "FEMesh", material: "Material", state: Any = None) -> np.ndarray:
|
|
270
|
+
return np.zeros((self.total_dofs, self.total_dofs), dtype=float)
|
|
271
|
+
|
|
272
|
+
def compute_nonlinear_response(
|
|
273
|
+
self,
|
|
274
|
+
mesh: "FEMesh",
|
|
275
|
+
material: "Material",
|
|
276
|
+
u_elem: np.ndarray,
|
|
277
|
+
state: Any = None,
|
|
278
|
+
num_layers: int = 5,
|
|
279
|
+
tangent: bool = True,
|
|
280
|
+
) -> Tuple[np.ndarray, Optional[np.ndarray], Any]:
|
|
281
|
+
force = np.zeros(self.total_dofs, dtype=float)
|
|
282
|
+
stiffness = np.zeros((self.total_dofs, self.total_dofs), dtype=float) if tangent else None
|
|
283
|
+
return force, stiffness, state
|
|
284
|
+
|
|
285
|
+
def compute_stresses(
|
|
286
|
+
self,
|
|
287
|
+
mesh: "FEMesh",
|
|
288
|
+
displacements: np.ndarray,
|
|
289
|
+
material: "Material",
|
|
290
|
+
return_global: bool = False,
|
|
291
|
+
) -> Dict[str, np.ndarray]:
|
|
292
|
+
return {}
|
|
293
|
+
|
|
294
|
+
def get_mpc_constraints(self, mesh: "FEMesh") -> List[Dict[str, Any]]:
|
|
295
|
+
beam_node = mesh.get_node(self.beam_node_id)
|
|
296
|
+
if beam_node is None:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
beam_dofs = beam_node.dofs
|
|
300
|
+
rx, ry, rz = self.eccentricity
|
|
301
|
+
translational_masters = [{}, {}, {}]
|
|
302
|
+
rotational_masters = [{}, {}, {}]
|
|
303
|
+
|
|
304
|
+
for shell_node_id, weight in zip(self.shell_node_ids, self.shape_weights):
|
|
305
|
+
shell_node = mesh.get_node(shell_node_id)
|
|
306
|
+
if shell_node is None or abs(float(weight)) == 0.0:
|
|
307
|
+
continue
|
|
308
|
+
s = shell_node.dofs
|
|
309
|
+
w = float(weight)
|
|
310
|
+
|
|
311
|
+
translational_masters[0][s[0]] = translational_masters[0].get(s[0], 0.0) + w
|
|
312
|
+
translational_masters[0][s[4]] = translational_masters[0].get(s[4], 0.0) + w * rz
|
|
313
|
+
translational_masters[0][s[5]] = translational_masters[0].get(s[5], 0.0) - w * ry
|
|
314
|
+
|
|
315
|
+
translational_masters[1][s[1]] = translational_masters[1].get(s[1], 0.0) + w
|
|
316
|
+
translational_masters[1][s[3]] = translational_masters[1].get(s[3], 0.0) - w * rz
|
|
317
|
+
translational_masters[1][s[5]] = translational_masters[1].get(s[5], 0.0) + w * rx
|
|
318
|
+
|
|
319
|
+
translational_masters[2][s[2]] = translational_masters[2].get(s[2], 0.0) + w
|
|
320
|
+
translational_masters[2][s[3]] = translational_masters[2].get(s[3], 0.0) + w * ry
|
|
321
|
+
translational_masters[2][s[4]] = translational_masters[2].get(s[4], 0.0) - w * rx
|
|
322
|
+
|
|
323
|
+
rotational_masters[0][s[3]] = rotational_masters[0].get(s[3], 0.0) + w
|
|
324
|
+
rotational_masters[1][s[4]] = rotational_masters[1].get(s[4], 0.0) + w
|
|
325
|
+
rotational_masters[2][s[5]] = rotational_masters[2].get(s[5], 0.0) + w
|
|
326
|
+
|
|
327
|
+
return [
|
|
328
|
+
{"slave": beam_dofs[0], "masters": translational_masters[0], "value": 0.0, "label": f"interp_beam_shell_ux_{self.element_id}"},
|
|
329
|
+
{"slave": beam_dofs[1], "masters": translational_masters[1], "value": 0.0, "label": f"interp_beam_shell_uy_{self.element_id}"},
|
|
330
|
+
{"slave": beam_dofs[2], "masters": translational_masters[2], "value": 0.0, "label": f"interp_beam_shell_uz_{self.element_id}"},
|
|
331
|
+
{"slave": beam_dofs[3], "masters": rotational_masters[0], "value": 0.0, "label": f"interp_beam_shell_rx_{self.element_id}"},
|
|
332
|
+
{"slave": beam_dofs[4], "masters": rotational_masters[1], "value": 0.0, "label": f"interp_beam_shell_ry_{self.element_id}"},
|
|
333
|
+
{"slave": beam_dofs[5], "masters": rotational_masters[2], "value": 0.0, "label": f"interp_beam_shell_rz_{self.element_id}"},
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class RigidLidMPCElement:
|
|
338
|
+
"""
|
|
339
|
+
Constraint-only rigid end diaphragm.
|
|
340
|
+
|
|
341
|
+
The element ties an end ring to a free center reference node using rigid-body
|
|
342
|
+
kinematics. It adds end-ring coupling without adding lid shell elements, so
|
|
343
|
+
lid stresses and lid pressure loads are not recovered.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
def __init__(
|
|
347
|
+
self,
|
|
348
|
+
element_id: int,
|
|
349
|
+
center_node_id: int,
|
|
350
|
+
ring_node_ids: List[int],
|
|
351
|
+
material_name: str = "steel",
|
|
352
|
+
):
|
|
353
|
+
self.element_id = int(element_id)
|
|
354
|
+
self.center_node_id = int(center_node_id)
|
|
355
|
+
self.ring_node_ids = [int(node_id) for node_id in ring_node_ids if int(node_id) != int(center_node_id)]
|
|
356
|
+
self.material_name = material_name
|
|
357
|
+
self.node_ids = [self.center_node_id] + self.ring_node_ids
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def num_nodes(self) -> int:
|
|
361
|
+
return len(self.node_ids)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def dofs_per_node(self) -> int:
|
|
365
|
+
return 6
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def total_dofs(self) -> int:
|
|
369
|
+
return self.num_nodes * self.dofs_per_node
|
|
370
|
+
|
|
371
|
+
def get_node_coordinates(self, mesh: "FEMesh") -> np.ndarray:
|
|
372
|
+
coords = []
|
|
373
|
+
for node_id in self.node_ids:
|
|
374
|
+
node = mesh.get_node(node_id)
|
|
375
|
+
if node is None:
|
|
376
|
+
raise ValueError(f"Rigid lid element {self.element_id} references missing node {node_id}")
|
|
377
|
+
coords.append(node.coords())
|
|
378
|
+
return np.asarray(coords, dtype=float)
|
|
379
|
+
|
|
380
|
+
def get_dof_mapping(self, mesh: "FEMesh") -> List[int]:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
def compute_stiffness_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
384
|
+
return np.zeros((0, 0), dtype=float)
|
|
385
|
+
|
|
386
|
+
def compute_mass_matrix(self, mesh: "FEMesh", material: "Material") -> np.ndarray:
|
|
387
|
+
return np.zeros((0, 0), dtype=float)
|
|
388
|
+
|
|
389
|
+
def compute_geometric_stiffness_matrix(self, mesh: "FEMesh", material: "Material", state: Any = None) -> np.ndarray:
|
|
390
|
+
return np.zeros((0, 0), dtype=float)
|
|
391
|
+
|
|
392
|
+
def compute_stresses(
|
|
393
|
+
self,
|
|
394
|
+
mesh: "FEMesh",
|
|
395
|
+
displacements: np.ndarray,
|
|
396
|
+
material: "Material",
|
|
397
|
+
return_global: bool = False,
|
|
398
|
+
) -> Dict[str, np.ndarray]:
|
|
399
|
+
return {}
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _nonzero_masters(items: Dict[int, float]) -> Dict[int, float]:
|
|
403
|
+
return {int(dof): float(value) for dof, value in items.items() if abs(float(value)) > 0.0}
|
|
404
|
+
|
|
405
|
+
def get_mpc_constraints(self, mesh: "FEMesh") -> List[Dict[str, Any]]:
|
|
406
|
+
center = mesh.get_node(self.center_node_id)
|
|
407
|
+
if center is None:
|
|
408
|
+
return []
|
|
409
|
+
center_dofs = center.dofs
|
|
410
|
+
constraints: List[Dict[str, Any]] = []
|
|
411
|
+
for ring_node_id in self.ring_node_ids:
|
|
412
|
+
node = mesh.get_node(ring_node_id)
|
|
413
|
+
if node is None:
|
|
414
|
+
continue
|
|
415
|
+
rx, ry, rz = (node.coords() - center.coords()).tolist()
|
|
416
|
+
node_dofs = node.dofs
|
|
417
|
+
translation_masters = (
|
|
418
|
+
{center_dofs[0]: 1.0, center_dofs[4]: rz, center_dofs[5]: -ry},
|
|
419
|
+
{center_dofs[1]: 1.0, center_dofs[3]: -rz, center_dofs[5]: rx},
|
|
420
|
+
{center_dofs[2]: 1.0, center_dofs[3]: ry, center_dofs[4]: -rx},
|
|
421
|
+
)
|
|
422
|
+
for local_index, masters in enumerate(translation_masters):
|
|
423
|
+
constraints.append(
|
|
424
|
+
{
|
|
425
|
+
"slave": node_dofs[local_index],
|
|
426
|
+
"masters": self._nonzero_masters(masters),
|
|
427
|
+
"value": 0.0,
|
|
428
|
+
"label": f"rigid_lid_{self.element_id}_u{local_index + 1}",
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
for local_index in range(3, 6):
|
|
432
|
+
constraints.append(
|
|
433
|
+
{
|
|
434
|
+
"slave": node_dofs[local_index],
|
|
435
|
+
"masters": {center_dofs[local_index]: 1.0},
|
|
436
|
+
"value": 0.0,
|
|
437
|
+
"label": f"rigid_lid_{self.element_id}_r{local_index - 2}",
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
return constraints
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _safe_divisions(value: int) -> int:
|
|
444
|
+
return max(int(value), 1)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def generate_stiffened_panel_mesh(panel: PanelGeometry, config: Optional[MeshConfig] = None) -> "FEModel":
|
|
448
|
+
"""Generate a rectangular stiffened panel mesh."""
|
|
449
|
+
from .elements import BeamElement, QuadraticBeamElement, ShellElement
|
|
450
|
+
from .fe_core import FEModel
|
|
451
|
+
|
|
452
|
+
config = config or MeshConfig()
|
|
453
|
+
if config.use_shared_nodes:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"use_shared_nodes=True is no longer supported for eccentric stiffeners. "
|
|
456
|
+
"Use separate beam nodes with interpolated beam-shell MPC constraints instead."
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
model = FEModel(name=f"StiffenedPanel_{panel.length}x{panel.width}")
|
|
460
|
+
model.add_material(
|
|
461
|
+
name="steel",
|
|
462
|
+
elastic_modulus=210.0e9,
|
|
463
|
+
poisson_ratio=0.3,
|
|
464
|
+
density=7850.0,
|
|
465
|
+
yield_stress=235.0e6,
|
|
466
|
+
)
|
|
467
|
+
model.current_material = "steel"
|
|
468
|
+
|
|
469
|
+
shell_nodes, shell_elements = _generate_shell_mesh(panel, config)
|
|
470
|
+
beam_nodes, beam_elements = _generate_beam_mesh(panel, config)
|
|
471
|
+
|
|
472
|
+
all_nodes = {**shell_nodes, **beam_nodes}
|
|
473
|
+
for node_id, coords in all_nodes.items():
|
|
474
|
+
model.add_node(node_id, coords[0], coords[1], coords[2])
|
|
475
|
+
|
|
476
|
+
for elem_id, (node_ids, thickness) in shell_elements.items():
|
|
477
|
+
model.add_element(elem_id, ShellElement(elem_id, node_ids, material_name="steel", thickness=thickness))
|
|
478
|
+
|
|
479
|
+
for elem_id, (node_ids, element_data) in beam_elements.items():
|
|
480
|
+
if len(node_ids) == 3:
|
|
481
|
+
eccentricity = element_data.get("eccentricity", None)
|
|
482
|
+
cross_section = {k: v for k, v in element_data.items() if k != "eccentricity"}
|
|
483
|
+
element = QuadraticBeamElement(elem_id, node_ids, material_name="steel", cross_section=cross_section, eccentricity=eccentricity)
|
|
484
|
+
else:
|
|
485
|
+
cross_section = {k: v for k, v in element_data.items() if k != "eccentricity"}
|
|
486
|
+
element = BeamElement(elem_id, node_ids, material_name="steel", cross_section=cross_section)
|
|
487
|
+
model.add_element(elem_id, element)
|
|
488
|
+
|
|
489
|
+
if config.use_coupling_elements:
|
|
490
|
+
coupling_elements = _generate_coupling_elements(panel, config, shell_nodes, shell_elements, beam_nodes)
|
|
491
|
+
if len(coupling_elements) != len(beam_nodes):
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f"Only generated {len(coupling_elements)} beam-shell MPC couplings for {len(beam_nodes)} beam nodes. "
|
|
494
|
+
"Check stiffener positions and panel dimensions."
|
|
495
|
+
)
|
|
496
|
+
for elem_id, data in coupling_elements.items():
|
|
497
|
+
beam_node_id, shell_node_ids, shape_weights, eccentricity = data
|
|
498
|
+
model.add_element(
|
|
499
|
+
elem_id,
|
|
500
|
+
InterpolatedBeamShellMPCElement(
|
|
501
|
+
elem_id,
|
|
502
|
+
beam_node_id=beam_node_id,
|
|
503
|
+
shell_node_ids=shell_node_ids,
|
|
504
|
+
shape_weights=shape_weights,
|
|
505
|
+
eccentricity=eccentricity,
|
|
506
|
+
material_name="steel",
|
|
507
|
+
),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
_add_boundary_conditions(model, panel, shell_nodes, config)
|
|
511
|
+
return model
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _generate_shell_mesh(
|
|
516
|
+
panel: PanelGeometry,
|
|
517
|
+
config: MeshConfig,
|
|
518
|
+
) -> Tuple[Dict[int, Tuple[float, float, float]], Dict[int, Tuple[List[int], float]]]:
|
|
519
|
+
"""Generate 4-node or 8-node quadrilateral shell mesh for the plating."""
|
|
520
|
+
nodes: Dict[int, Tuple[float, float, float]] = {}
|
|
521
|
+
elements: Dict[int, Tuple[List[int], float]] = {}
|
|
522
|
+
nx = _safe_divisions(config.shell_num_divisions_x)
|
|
523
|
+
ny = _safe_divisions(config.shell_num_divisions_y)
|
|
524
|
+
L = panel.length
|
|
525
|
+
W = panel.width
|
|
526
|
+
t = panel.plate_thickness
|
|
527
|
+
|
|
528
|
+
if config.align_mesh_to_stiffeners:
|
|
529
|
+
num_stiffeners = max(int(panel.num_stiffeners), 1)
|
|
530
|
+
spacing = panel.stiffener_spacing if panel.stiffener_spacing > 0.0 else panel.width / (num_stiffeners + 1)
|
|
531
|
+
y_stiffeners = [(s + 1) * spacing for s in range(num_stiffeners)]
|
|
532
|
+
key_ys = [0.0] + y_stiffeners + [W]
|
|
533
|
+
n_segments = len(key_ys) - 1
|
|
534
|
+
|
|
535
|
+
if ny <= n_segments:
|
|
536
|
+
segment_divs = [1] * n_segments
|
|
537
|
+
ny = n_segments
|
|
538
|
+
else:
|
|
539
|
+
segment_divs = [1] * n_segments
|
|
540
|
+
remaining = ny - n_segments
|
|
541
|
+
widths = np.array([key_ys[k+1] - key_ys[k] for k in range(n_segments)], dtype=float)
|
|
542
|
+
shares = widths / np.sum(widths) * remaining
|
|
543
|
+
floored = np.floor(shares).astype(int)
|
|
544
|
+
segment_divs = [divs + f for divs, f in zip(segment_divs, floored)]
|
|
545
|
+
remaining -= np.sum(floored)
|
|
546
|
+
fractional = shares - floored
|
|
547
|
+
for idx in np.argsort(fractional)[::-1][:remaining]:
|
|
548
|
+
segment_divs[idx] += 1
|
|
549
|
+
ny = sum(segment_divs)
|
|
550
|
+
|
|
551
|
+
y_grid = []
|
|
552
|
+
for k in range(n_segments):
|
|
553
|
+
y0_seg = key_ys[k]
|
|
554
|
+
y1_seg = key_ys[k+1]
|
|
555
|
+
divs = segment_divs[k]
|
|
556
|
+
for d in range(divs):
|
|
557
|
+
y_grid.append(y0_seg + d * (y1_seg - y0_seg) / divs)
|
|
558
|
+
y_grid.append(W)
|
|
559
|
+
else:
|
|
560
|
+
y_grid = [j * W / ny for j in range(ny + 1)]
|
|
561
|
+
|
|
562
|
+
if config.use_8node_shells:
|
|
563
|
+
node_id = 1
|
|
564
|
+
corner_nodes: Dict[Tuple[int, int], int] = {}
|
|
565
|
+
for i in range(nx + 1):
|
|
566
|
+
for j in range(ny + 1):
|
|
567
|
+
corner_nodes[(i, j)] = node_id
|
|
568
|
+
nodes[node_id] = (i * L / nx, y_grid[j], 0.0)
|
|
569
|
+
node_id += 1
|
|
570
|
+
h_mid_nodes: Dict[Tuple[int, int], int] = {}
|
|
571
|
+
for i in range(nx):
|
|
572
|
+
for j in range(ny + 1):
|
|
573
|
+
h_mid_nodes[(i, j)] = node_id
|
|
574
|
+
nodes[node_id] = ((i + 0.5) * L / nx, y_grid[j], 0.0)
|
|
575
|
+
node_id += 1
|
|
576
|
+
v_mid_nodes: Dict[Tuple[int, int], int] = {}
|
|
577
|
+
for i in range(nx + 1):
|
|
578
|
+
for j in range(ny):
|
|
579
|
+
v_mid_nodes[(i, j)] = node_id
|
|
580
|
+
nodes[node_id] = (i * L / nx, 0.5 * (y_grid[j] + y_grid[j + 1]), 0.0)
|
|
581
|
+
node_id += 1
|
|
582
|
+
elem_id = 1
|
|
583
|
+
for i in range(nx):
|
|
584
|
+
for j in range(ny):
|
|
585
|
+
elements[elem_id] = (
|
|
586
|
+
[
|
|
587
|
+
corner_nodes[(i, j)],
|
|
588
|
+
corner_nodes[(i + 1, j)],
|
|
589
|
+
corner_nodes[(i + 1, j + 1)],
|
|
590
|
+
corner_nodes[(i, j + 1)],
|
|
591
|
+
h_mid_nodes[(i, j)],
|
|
592
|
+
v_mid_nodes[(i + 1, j)],
|
|
593
|
+
h_mid_nodes[(i, j + 1)],
|
|
594
|
+
v_mid_nodes[(i, j)],
|
|
595
|
+
],
|
|
596
|
+
t,
|
|
597
|
+
)
|
|
598
|
+
elem_id += 1
|
|
599
|
+
else:
|
|
600
|
+
node_id = 1
|
|
601
|
+
node_grid: Dict[Tuple[int, int], int] = {}
|
|
602
|
+
for i in range(nx + 1):
|
|
603
|
+
for j in range(ny + 1):
|
|
604
|
+
node_grid[(i, j)] = node_id
|
|
605
|
+
nodes[node_id] = (i * L / nx, y_grid[j], 0.0)
|
|
606
|
+
node_id += 1
|
|
607
|
+
elem_id = 1
|
|
608
|
+
for i in range(nx):
|
|
609
|
+
for j in range(ny):
|
|
610
|
+
elements[elem_id] = (
|
|
611
|
+
[node_grid[(i, j)], node_grid[(i + 1, j)], node_grid[(i + 1, j + 1)], node_grid[(i, j + 1)]],
|
|
612
|
+
t,
|
|
613
|
+
)
|
|
614
|
+
elem_id += 1
|
|
615
|
+
return nodes, elements
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _stiffener_cross_section_dict(panel: PanelGeometry) -> Dict[str, float]:
|
|
619
|
+
cross_section = StiffenerCrossSection.from_geometry(
|
|
620
|
+
panel.stiffener_type,
|
|
621
|
+
panel.stiffener_height,
|
|
622
|
+
panel.stiffener_web_thickness,
|
|
623
|
+
panel.stiffener_flange_width,
|
|
624
|
+
panel.stiffener_flange_thickness,
|
|
625
|
+
)
|
|
626
|
+
return {
|
|
627
|
+
"area": cross_section.area,
|
|
628
|
+
"Iy": cross_section.Iy,
|
|
629
|
+
"Iz": cross_section.Iz,
|
|
630
|
+
"J": cross_section.J,
|
|
631
|
+
"shear_factor_y": cross_section.shear_factor_y,
|
|
632
|
+
"shear_factor_z": cross_section.shear_factor_z,
|
|
633
|
+
"c_y": cross_section.c_y,
|
|
634
|
+
"c_z": cross_section.c_z,
|
|
635
|
+
"torsion_modulus": cross_section.torsion_modulus,
|
|
636
|
+
# Section local z (web direction): the panel lies in the global z=0
|
|
637
|
+
# plane with stiffener webs pointing in +Z. This pins the beam local
|
|
638
|
+
# frame so Iy/Iz keep the meaning used by StiffenerCrossSection.
|
|
639
|
+
"orientation": (0.0, 0.0, 1.0),
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _generate_beam_mesh(
|
|
644
|
+
panel: PanelGeometry,
|
|
645
|
+
config: MeshConfig,
|
|
646
|
+
) -> Tuple[Dict[int, Tuple[float, float, float]], Dict[int, Tuple[List[int], Dict[str, float]]]]:
|
|
647
|
+
"""Generate separate beam nodes/elements for longitudinal stiffeners."""
|
|
648
|
+
nodes: Dict[int, Tuple[float, float, float]] = {}
|
|
649
|
+
elements: Dict[int, Tuple[List[int], Dict[str, float]]] = {}
|
|
650
|
+
cross_section = _stiffener_cross_section_dict(panel)
|
|
651
|
+
n_div = _safe_divisions(config.beam_num_divisions)
|
|
652
|
+
num_stiffeners = max(int(panel.num_stiffeners), 1)
|
|
653
|
+
spacing = panel.stiffener_spacing if panel.stiffener_spacing > 0.0 else panel.width / (num_stiffeners + 1)
|
|
654
|
+
eccentricity = np.array([0.0, 0.0, panel.stiffener_height], dtype=float)
|
|
655
|
+
|
|
656
|
+
start_node_id = 10000
|
|
657
|
+
start_elem_id = 20000
|
|
658
|
+
for s in range(num_stiffeners):
|
|
659
|
+
y_pos = (s + 1) * spacing
|
|
660
|
+
for i in range(n_div + 1):
|
|
661
|
+
node_id = start_node_id + s * (n_div + 1) + i
|
|
662
|
+
nodes[node_id] = (i * panel.length / n_div, y_pos, panel.stiffener_height)
|
|
663
|
+
for i in range(n_div):
|
|
664
|
+
n1 = start_node_id + s * (n_div + 1) + i
|
|
665
|
+
n2 = n1 + 1
|
|
666
|
+
elem_id = start_elem_id + s * n_div + i
|
|
667
|
+
data = dict(cross_section)
|
|
668
|
+
data["eccentricity"] = eccentricity
|
|
669
|
+
elements[elem_id] = ([n1, n2], data)
|
|
670
|
+
return nodes, elements
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _generate_beam_mesh_shared_nodes(
|
|
674
|
+
panel: PanelGeometry,
|
|
675
|
+
config: MeshConfig,
|
|
676
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
677
|
+
) -> Tuple[Dict[int, Tuple[float, float, float]], Dict[int, Tuple[List[int], Dict[str, Any]]]]:
|
|
678
|
+
"""Deprecated helper kept only for import compatibility."""
|
|
679
|
+
raise ValueError("Shared-node stiffener mesh is disabled; use interpolated eccentric MPC coupling instead.")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _shape_functions_4node(xi: float, eta: float) -> np.ndarray:
|
|
683
|
+
return np.array(
|
|
684
|
+
[
|
|
685
|
+
0.25 * (1.0 - xi) * (1.0 - eta),
|
|
686
|
+
0.25 * (1.0 + xi) * (1.0 - eta),
|
|
687
|
+
0.25 * (1.0 + xi) * (1.0 + eta),
|
|
688
|
+
0.25 * (1.0 - xi) * (1.0 + eta),
|
|
689
|
+
],
|
|
690
|
+
dtype=float,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _shape_functions_8node(xi: float, eta: float) -> np.ndarray:
|
|
695
|
+
return np.array(
|
|
696
|
+
[
|
|
697
|
+
-0.25 * (1.0 - xi) * (1.0 - eta) * (1.0 + xi + eta),
|
|
698
|
+
-0.25 * (1.0 + xi) * (1.0 - eta) * (1.0 - xi + eta),
|
|
699
|
+
-0.25 * (1.0 + xi) * (1.0 + eta) * (1.0 - xi - eta),
|
|
700
|
+
-0.25 * (1.0 - xi) * (1.0 + eta) * (1.0 + xi - eta),
|
|
701
|
+
0.5 * (1.0 - xi**2) * (1.0 - eta),
|
|
702
|
+
0.5 * (1.0 + xi) * (1.0 - eta**2),
|
|
703
|
+
0.5 * (1.0 - xi**2) * (1.0 + eta),
|
|
704
|
+
0.5 * (1.0 - xi) * (1.0 - eta**2),
|
|
705
|
+
],
|
|
706
|
+
dtype=float,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@dataclass(frozen=True)
|
|
711
|
+
class _StructuredShellGrid:
|
|
712
|
+
"""Reusable axis-aligned shell-cell index for coupling-point lookup."""
|
|
713
|
+
|
|
714
|
+
x_edges: np.ndarray
|
|
715
|
+
y_edges: np.ndarray
|
|
716
|
+
cells: Dict[Tuple[int, int], Tuple[List[int], float]]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _build_structured_shell_grid_index(
|
|
720
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
721
|
+
shell_elements: Dict[int, Tuple[List[int], float]],
|
|
722
|
+
tolerance: float,
|
|
723
|
+
) -> Optional[_StructuredShellGrid]:
|
|
724
|
+
"""Build a structured-cell index once, or return ``None`` for irregular meshes."""
|
|
725
|
+
|
|
726
|
+
tol = max(float(tolerance), 1.0e-10)
|
|
727
|
+
records: List[Tuple[float, float, float, float, List[int], float]] = []
|
|
728
|
+
x_edges: set[float] = set()
|
|
729
|
+
y_edges: set[float] = set()
|
|
730
|
+
try:
|
|
731
|
+
for node_ids, thickness in shell_elements.values():
|
|
732
|
+
corner_coords = np.asarray([shell_nodes[node_id] for node_id in node_ids[:4]], dtype=float)
|
|
733
|
+
xmin = float(np.min(corner_coords[:, 0]))
|
|
734
|
+
xmax = float(np.max(corner_coords[:, 0]))
|
|
735
|
+
ymin = float(np.min(corner_coords[:, 1]))
|
|
736
|
+
ymax = float(np.max(corner_coords[:, 1]))
|
|
737
|
+
if xmax - xmin <= tol or ymax - ymin <= tol:
|
|
738
|
+
return None
|
|
739
|
+
# The fast index is intentionally limited to axis-aligned cells.
|
|
740
|
+
for x_value, y_value in corner_coords[:, :2]:
|
|
741
|
+
if min(abs(float(x_value) - xmin), abs(float(x_value) - xmax)) > tol:
|
|
742
|
+
return None
|
|
743
|
+
if min(abs(float(y_value) - ymin), abs(float(y_value) - ymax)) > tol:
|
|
744
|
+
return None
|
|
745
|
+
qxmin = round(xmin / tol) * tol
|
|
746
|
+
qxmax = round(xmax / tol) * tol
|
|
747
|
+
qymin = round(ymin / tol) * tol
|
|
748
|
+
qymax = round(ymax / tol) * tol
|
|
749
|
+
x_edges.update((qxmin, qxmax))
|
|
750
|
+
y_edges.update((qymin, qymax))
|
|
751
|
+
records.append((qxmin, qxmax, qymin, qymax, list(node_ids), float(thickness)))
|
|
752
|
+
except (KeyError, TypeError, ValueError):
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
xs = np.asarray(sorted(x_edges), dtype=float)
|
|
756
|
+
ys = np.asarray(sorted(y_edges), dtype=float)
|
|
757
|
+
nx = int(xs.size - 1)
|
|
758
|
+
ny = int(ys.size - 1)
|
|
759
|
+
if nx <= 0 or ny <= 0 or nx * ny != len(records):
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
x_lookup = {float(value): index for index, value in enumerate(xs[:-1])}
|
|
763
|
+
y_lookup = {float(value): index for index, value in enumerate(ys[:-1])}
|
|
764
|
+
cells: Dict[Tuple[int, int], Tuple[List[int], float]] = {}
|
|
765
|
+
for xmin, xmax, ymin, ymax, node_ids, thickness in records:
|
|
766
|
+
i = x_lookup.get(float(xmin))
|
|
767
|
+
j = y_lookup.get(float(ymin))
|
|
768
|
+
if i is None or j is None:
|
|
769
|
+
return None
|
|
770
|
+
if abs(float(xs[i + 1]) - xmax) > tol or abs(float(ys[j + 1]) - ymax) > tol:
|
|
771
|
+
return None
|
|
772
|
+
if (i, j) in cells:
|
|
773
|
+
return None
|
|
774
|
+
cells[(i, j)] = (node_ids, thickness)
|
|
775
|
+
if len(cells) != nx * ny:
|
|
776
|
+
return None
|
|
777
|
+
return _StructuredShellGrid(xs, ys, cells)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _interpolate_shell_point(
|
|
781
|
+
x: float,
|
|
782
|
+
y: float,
|
|
783
|
+
node_ids: List[int],
|
|
784
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
785
|
+
tolerance: float,
|
|
786
|
+
) -> Optional[Tuple[List[int], np.ndarray, np.ndarray]]:
|
|
787
|
+
"""Return interpolation weights and point for one axis-aligned shell cell."""
|
|
788
|
+
|
|
789
|
+
tol = max(float(tolerance), 1.0e-10)
|
|
790
|
+
corner_coords = np.asarray([shell_nodes[node_id] for node_id in node_ids[:4]], dtype=float)
|
|
791
|
+
xmin, xmax = float(np.min(corner_coords[:, 0])), float(np.max(corner_coords[:, 0]))
|
|
792
|
+
ymin, ymax = float(np.min(corner_coords[:, 1])), float(np.max(corner_coords[:, 1]))
|
|
793
|
+
if x < xmin - tol or x > xmax + tol or y < ymin - tol or y > ymax + tol:
|
|
794
|
+
return None
|
|
795
|
+
dx = xmax - xmin
|
|
796
|
+
dy = ymax - ymin
|
|
797
|
+
if dx <= tol or dy <= tol:
|
|
798
|
+
return None
|
|
799
|
+
xi = float(np.clip(2.0 * (x - xmin) / dx - 1.0, -1.0, 1.0))
|
|
800
|
+
eta = float(np.clip(2.0 * (y - ymin) / dy - 1.0, -1.0, 1.0))
|
|
801
|
+
weights = _shape_functions_8node(xi, eta) if len(node_ids) == 8 else _shape_functions_4node(xi, eta)
|
|
802
|
+
shell_coords = np.asarray([shell_nodes[node_id] for node_id in node_ids], dtype=float)
|
|
803
|
+
return list(node_ids), weights, weights @ shell_coords
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _locate_shell_element_at_xy(
|
|
807
|
+
x: float,
|
|
808
|
+
y: float,
|
|
809
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
810
|
+
shell_elements: Dict[int, Tuple[List[int], float]],
|
|
811
|
+
tolerance: float,
|
|
812
|
+
grid_index: Optional[_StructuredShellGrid] = None,
|
|
813
|
+
) -> Optional[Tuple[List[int], np.ndarray, np.ndarray]]:
|
|
814
|
+
"""Find the shell element containing an x/y point and return node ids, weights and shell point."""
|
|
815
|
+
tol = max(float(tolerance), 1.0e-10)
|
|
816
|
+
|
|
817
|
+
# Build on demand for direct callers; mesh generation passes a shared
|
|
818
|
+
# index so hundreds of beam nodes do not rebuild the same grid.
|
|
819
|
+
index = grid_index or _build_structured_shell_grid_index(shell_nodes, shell_elements, tol)
|
|
820
|
+
if index is not None:
|
|
821
|
+
i = int(np.searchsorted(index.x_edges, x) - 1)
|
|
822
|
+
j = int(np.searchsorted(index.y_edges, y) - 1)
|
|
823
|
+
i = max(0, min(i, len(index.x_edges) - 2))
|
|
824
|
+
j = max(0, min(j, len(index.y_edges) - 2))
|
|
825
|
+
candidate = index.cells.get((i, j))
|
|
826
|
+
if candidate is not None:
|
|
827
|
+
located = _interpolate_shell_point(x, y, candidate[0], shell_nodes, tol)
|
|
828
|
+
if located is not None:
|
|
829
|
+
return located
|
|
830
|
+
|
|
831
|
+
# Fallback to sequential search
|
|
832
|
+
for node_ids, _thickness in shell_elements.values():
|
|
833
|
+
located = _interpolate_shell_point(x, y, list(node_ids), shell_nodes, tol)
|
|
834
|
+
if located is not None:
|
|
835
|
+
return located
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _generate_coupling_elements(
|
|
840
|
+
panel: PanelGeometry,
|
|
841
|
+
config: MeshConfig,
|
|
842
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
843
|
+
shell_elements: Dict[int, Tuple[List[int], float]],
|
|
844
|
+
beam_nodes: Dict[int, Tuple[float, float, float]],
|
|
845
|
+
) -> Dict[int, Tuple[int, List[int], np.ndarray, np.ndarray]]:
|
|
846
|
+
"""Generate interpolated eccentric beam-shell MPC coupling elements."""
|
|
847
|
+
coupling_elements: Dict[int, Tuple[int, List[int], np.ndarray, np.ndarray]] = {}
|
|
848
|
+
elem_id = 30000
|
|
849
|
+
grid_index = _build_structured_shell_grid_index(shell_nodes, shell_elements, config.tolerance)
|
|
850
|
+
for beam_node_id, beam_coords_tuple in beam_nodes.items():
|
|
851
|
+
beam_coords = np.asarray(beam_coords_tuple, dtype=float)
|
|
852
|
+
located = _locate_shell_element_at_xy(
|
|
853
|
+
beam_coords[0],
|
|
854
|
+
beam_coords[1],
|
|
855
|
+
shell_nodes,
|
|
856
|
+
shell_elements,
|
|
857
|
+
config.tolerance,
|
|
858
|
+
grid_index,
|
|
859
|
+
)
|
|
860
|
+
if located is None:
|
|
861
|
+
continue
|
|
862
|
+
shell_node_ids, shape_weights, shell_point = located
|
|
863
|
+
eccentricity = beam_coords - shell_point
|
|
864
|
+
coupling_elements[elem_id] = (beam_node_id, shell_node_ids, shape_weights, eccentricity)
|
|
865
|
+
elem_id += 1
|
|
866
|
+
return coupling_elements
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _edge_node_sets(
|
|
870
|
+
panel: PanelGeometry,
|
|
871
|
+
nodes: Dict[int, Tuple[float, float, float]],
|
|
872
|
+
tolerance: float,
|
|
873
|
+
) -> Dict[str, List[int]]:
|
|
874
|
+
"""Return all shell nodes lying on the four rectangular panel edges."""
|
|
875
|
+
L = panel.length
|
|
876
|
+
W = panel.width
|
|
877
|
+
tol = max(float(tolerance), 1.0e-9, 1.0e-8 * max(abs(L), abs(W), 1.0))
|
|
878
|
+
edge_nodes = {"x0": [], "xL": [], "y0": [], "yW": []}
|
|
879
|
+
for node_id, coords in nodes.items():
|
|
880
|
+
x, y, _ = coords
|
|
881
|
+
if abs(x) <= tol:
|
|
882
|
+
edge_nodes["x0"].append(node_id)
|
|
883
|
+
if abs(x - L) <= tol:
|
|
884
|
+
edge_nodes["xL"].append(node_id)
|
|
885
|
+
if abs(y) <= tol:
|
|
886
|
+
edge_nodes["y0"].append(node_id)
|
|
887
|
+
if abs(y - W) <= tol:
|
|
888
|
+
edge_nodes["yW"].append(node_id)
|
|
889
|
+
edge_nodes["x0"].sort(key=lambda nid: nodes[nid][1])
|
|
890
|
+
edge_nodes["xL"].sort(key=lambda nid: nodes[nid][1])
|
|
891
|
+
edge_nodes["y0"].sort(key=lambda nid: nodes[nid][0])
|
|
892
|
+
edge_nodes["yW"].sort(key=lambda nid: nodes[nid][0])
|
|
893
|
+
edge_nodes["all"] = sorted(set(edge_nodes["x0"] + edge_nodes["xL"] + edge_nodes["y0"] + edge_nodes["yW"]))
|
|
894
|
+
return edge_nodes
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _unique_node_ids(*node_lists: List[int]) -> List[int]:
|
|
898
|
+
seen = set()
|
|
899
|
+
ordered: List[int] = []
|
|
900
|
+
for node_list in node_lists:
|
|
901
|
+
for node_id in node_list:
|
|
902
|
+
if node_id not in seen:
|
|
903
|
+
seen.add(node_id)
|
|
904
|
+
ordered.append(node_id)
|
|
905
|
+
return ordered
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _add_custom_support(model: "FEModel", name: str, node_ids: List[int], dof_constraints: Dict[str, float]) -> None:
|
|
909
|
+
from .boundary import BoundaryCondition
|
|
910
|
+
|
|
911
|
+
node_ids = _unique_node_ids(node_ids)
|
|
912
|
+
if node_ids:
|
|
913
|
+
model.add_boundary_condition(BoundaryCondition(name, node_ids, dof_constraints))
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _add_boundary_conditions(
|
|
917
|
+
model: "FEModel",
|
|
918
|
+
panel: PanelGeometry,
|
|
919
|
+
shell_nodes: Dict[int, Tuple[float, float, float]],
|
|
920
|
+
config: Optional[MeshConfig] = None,
|
|
921
|
+
) -> None:
|
|
922
|
+
"""Add edge-based support conditions to the model."""
|
|
923
|
+
config = config or MeshConfig()
|
|
924
|
+
edges = _edge_node_sets(panel, shell_nodes, config.tolerance)
|
|
925
|
+
support = (panel.in_plane_support or "").strip().lower()
|
|
926
|
+
rotational = (panel.rotational_support or "").strip().upper()
|
|
927
|
+
|
|
928
|
+
longitudinal_edges = _unique_node_ids(edges["y0"], edges["yW"])
|
|
929
|
+
transverse_edges = _unique_node_ids(edges["x0"], edges["xL"])
|
|
930
|
+
all_edge_nodes = edges["all"]
|
|
931
|
+
|
|
932
|
+
if support == "integrated":
|
|
933
|
+
_add_custom_support(model, "Integrated_edge_translations", all_edge_nodes, {"ux": 0.0, "uy": 0.0, "uz": 0.0})
|
|
934
|
+
elif support == "girder - long":
|
|
935
|
+
_add_custom_support(model, "Longitudinal_girder_edges", longitudinal_edges, {"ux": 0.0, "uy": 0.0, "uz": 0.0})
|
|
936
|
+
_add_custom_support(model, "Transverse_reference_ux", edges["x0"][:1], {"ux": 0.0})
|
|
937
|
+
elif support == "girder - trans":
|
|
938
|
+
_add_custom_support(model, "Transverse_girder_edges", transverse_edges, {"ux": 0.0, "uy": 0.0, "uz": 0.0})
|
|
939
|
+
_add_custom_support(model, "Longitudinal_reference_uy", edges["y0"][:1], {"uy": 0.0})
|
|
940
|
+
else:
|
|
941
|
+
_add_custom_support(model, "Reference_out_of_plane", all_edge_nodes[:1], {"uz": 0.0})
|
|
942
|
+
|
|
943
|
+
if rotational == "CL":
|
|
944
|
+
_add_custom_support(model, "Clamped_edge_rotations", all_edge_nodes, {"rx": 0.0, "ry": 0.0, "rz": 0.0})
|
|
945
|
+
elif rotational == "FS":
|
|
946
|
+
_add_custom_support(model, "Fixed_simple_longitudinal_rotations", longitudinal_edges, {"rx": 0.0, "ry": 0.0, "rz": 0.0})
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def generate_simple_panel_mesh(
|
|
950
|
+
length: float,
|
|
951
|
+
width: float,
|
|
952
|
+
thickness: float,
|
|
953
|
+
num_divisions_x: int = 4,
|
|
954
|
+
num_divisions_y: int = 4,
|
|
955
|
+
use_8node_elements: bool = False,
|
|
956
|
+
) -> "FEModel":
|
|
957
|
+
"""Generate a simple rectangular shell panel mesh for testing."""
|
|
958
|
+
from .elements import ShellElement
|
|
959
|
+
from .fe_core import FEModel
|
|
960
|
+
|
|
961
|
+
config = MeshConfig(shell_num_divisions_x=num_divisions_x, shell_num_divisions_y=num_divisions_y, use_8node_shells=use_8node_elements)
|
|
962
|
+
panel = PanelGeometry(length=length, width=width, plate_thickness=thickness)
|
|
963
|
+
shell_nodes, shell_elements = _generate_shell_mesh(panel, config)
|
|
964
|
+
model = FEModel(name=f"SimplePanel_{length}x{width}")
|
|
965
|
+
model.add_material("steel", 210.0e9, 0.3)
|
|
966
|
+
model.current_material = "steel"
|
|
967
|
+
for node_id, coords in shell_nodes.items():
|
|
968
|
+
model.add_node(node_id, coords[0], coords[1], coords[2])
|
|
969
|
+
for elem_id, (node_ids, elem_thickness) in shell_elements.items():
|
|
970
|
+
model.add_element(elem_id, ShellElement(elem_id, node_ids, material_name="steel", thickness=elem_thickness))
|
|
971
|
+
_add_boundary_conditions(model, panel, shell_nodes, config)
|
|
972
|
+
return model
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def generate_beam_mesh(
|
|
976
|
+
length: float,
|
|
977
|
+
num_divisions: int = 10,
|
|
978
|
+
cross_section: Optional[Dict[str, float]] = None,
|
|
979
|
+
) -> "FEModel":
|
|
980
|
+
"""Generate a simple cantilever beam mesh."""
|
|
981
|
+
from .boundary import FixedSupport
|
|
982
|
+
from .elements import BeamElement
|
|
983
|
+
from .fe_core import FEModel
|
|
984
|
+
|
|
985
|
+
model = FEModel(name=f"SimpleBeam_{length}")
|
|
986
|
+
model.add_material("steel", 210.0e9, 0.3)
|
|
987
|
+
model.current_material = "steel"
|
|
988
|
+
if cross_section is None:
|
|
989
|
+
cross_section = {"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}
|
|
990
|
+
n_div = _safe_divisions(num_divisions)
|
|
991
|
+
for i in range(n_div + 1):
|
|
992
|
+
model.add_node(i + 1, i * length / n_div, 0.0, 0.0)
|
|
993
|
+
for i in range(n_div):
|
|
994
|
+
model.add_element(i + 1, BeamElement(i + 1, [i + 1, i + 2], material_name="steel", cross_section=cross_section))
|
|
995
|
+
model.add_boundary_condition(FixedSupport("Fixed_1", [1]))
|
|
996
|
+
return model
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def verify_mesh_quality(model: "FEModel") -> Dict[str, Any]:
|
|
1000
|
+
"""Analyze and verify mesh quality metrics (aspect ratio, warping)."""
|
|
1001
|
+
from .elements import ShellElement
|
|
1002
|
+
|
|
1003
|
+
aspect_ratios = []
|
|
1004
|
+
warps = []
|
|
1005
|
+
shell_count = 0
|
|
1006
|
+
|
|
1007
|
+
for elem in model.mesh.elements.values():
|
|
1008
|
+
if not isinstance(elem, ShellElement):
|
|
1009
|
+
continue
|
|
1010
|
+
shell_count += 1
|
|
1011
|
+
coords = elem.get_node_coordinates(model.mesh)
|
|
1012
|
+
corner_coords = coords[:4]
|
|
1013
|
+
|
|
1014
|
+
e1 = corner_coords[1] - corner_coords[0]
|
|
1015
|
+
e2 = corner_coords[2] - corner_coords[1]
|
|
1016
|
+
e3 = corner_coords[3] - corner_coords[2]
|
|
1017
|
+
e4 = corner_coords[0] - corner_coords[3]
|
|
1018
|
+
|
|
1019
|
+
l1 = float(np.linalg.norm(e1))
|
|
1020
|
+
l2 = float(np.linalg.norm(e2))
|
|
1021
|
+
l3 = float(np.linalg.norm(e3))
|
|
1022
|
+
l4 = float(np.linalg.norm(e4))
|
|
1023
|
+
|
|
1024
|
+
lengths = [l1, l2, l3, l4]
|
|
1025
|
+
max_l = max(lengths)
|
|
1026
|
+
min_l = min(lengths)
|
|
1027
|
+
|
|
1028
|
+
ar = max_l / max(min_l, 1.0e-15)
|
|
1029
|
+
aspect_ratios.append(ar)
|
|
1030
|
+
|
|
1031
|
+
# Calculate warp
|
|
1032
|
+
n_raw = np.cross(e1, corner_coords[2] - corner_coords[0])
|
|
1033
|
+
n_norm = np.linalg.norm(n_raw)
|
|
1034
|
+
if n_norm > 1.0e-15:
|
|
1035
|
+
n = n_raw / n_norm
|
|
1036
|
+
d = abs(float(np.dot(corner_coords[3] - corner_coords[0], n)))
|
|
1037
|
+
avg_l = sum(lengths) / 4.0
|
|
1038
|
+
warp = d / max(avg_l, 1.0e-15)
|
|
1039
|
+
else:
|
|
1040
|
+
warp = 0.0
|
|
1041
|
+
warps.append(warp)
|
|
1042
|
+
|
|
1043
|
+
warnings = []
|
|
1044
|
+
max_ar = float(np.max(aspect_ratios)) if aspect_ratios else 1.0
|
|
1045
|
+
mean_ar = float(np.mean(aspect_ratios)) if aspect_ratios else 1.0
|
|
1046
|
+
max_warp = float(np.max(warps)) if warps else 0.0
|
|
1047
|
+
|
|
1048
|
+
if max_ar > 5.0:
|
|
1049
|
+
warnings.append(
|
|
1050
|
+
f"High aspect ratio detected (max AR = {max_ar:.2f}). "
|
|
1051
|
+
"Highly stretched elements can reduce solver accuracy. Consider refining the mesh divisions."
|
|
1052
|
+
)
|
|
1053
|
+
if max_warp > 0.05:
|
|
1054
|
+
warnings.append(
|
|
1055
|
+
f"Significant element warp detected (max warp = {max_warp:.4f}). "
|
|
1056
|
+
"Warped shell elements can lose accuracy. Ensure plate geometries are flat or sufficiently refined."
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
"num_shell_elements": shell_count,
|
|
1061
|
+
"max_aspect_ratio": max_ar,
|
|
1062
|
+
"mean_aspect_ratio": mean_ar,
|
|
1063
|
+
"max_warp": max_warp,
|
|
1064
|
+
"warnings": warnings,
|
|
1065
|
+
}
|