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/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
+ }