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
@@ -0,0 +1,942 @@
1
+ """Normalized generated-geometry FEM mode.
2
+
3
+ The functions in this module consume geometry that a client has already
4
+ generated. They do not parse IFC or external FEM files. The workflow is:
5
+
6
+ 1. normalize generated geometry and idealize tagged member plates as beams;
7
+ 2. convert generated nodes/shells/beams into ``FEModel``;
8
+ 3. generate symmetric design loads from normalized client inputs;
9
+ 4. solve the full static model;
10
+ 5. recover prestress from the static result;
11
+ 6. solve linear buckling from that recovered prestress.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
18
+
19
+ import numpy as np
20
+
21
+ from .assembly import compute_stresses, solve_linear
22
+ from .boundary import BoundaryCondition, LoadCase
23
+ from .buckling import BucklingResult, solve_eigenvalue_buckling
24
+ from .elements import BeamElement, QuadraticBeamElement, ShellElement
25
+ from .fe_core import FEModel
26
+ from .mesh_gen import InterpolatedBeamShellMPCElement, RigidLidMPCElement
27
+ from .validation import LoadResultant, load_case_resultant
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class AnyStructureFEMConfig:
32
+ """Configuration for the generated-geometry full FEM workflow."""
33
+
34
+ geometry_scale: float = 1.0
35
+ include_shells: bool = True
36
+ include_beams: bool = True
37
+ idealize_stiffeners_as_beams: bool = True
38
+ idealize_girders_as_beams: bool = True
39
+ auto_idealize_member_plates_as_beams: bool = True
40
+ exclude_idealized_member_plates: bool = True
41
+ require_idealized_member_beams: bool = True
42
+ default_material: str = "steel"
43
+ elastic_modulus: float = 210.0e9
44
+ poisson_ratio: float = 0.3
45
+ density: float = 7850.0
46
+ yield_stress: float = 355.0e6
47
+ pressure_pa: Optional[float] = None
48
+ pressure_sign: float = 1.0
49
+ load_scale: float = 1.0
50
+ num_buckling_modes: int = 5
51
+ solver_type: str = "direct"
52
+ stress_percentile: float = 95.0
53
+ add_inplane_edge_loads: bool = True
54
+
55
+
56
+ @dataclass
57
+ class AnyStructureFEMResult:
58
+ """Result from the generated-geometry FEM mode."""
59
+
60
+ valid: bool
61
+ status: str
62
+ invalid_reason: Optional[str] = None
63
+ static_solver_status: str = "not_run"
64
+ buckling_solver_status: str = "not_run"
65
+ critical_load_factor: Optional[float] = None
66
+ buckling_load_factors: List[float] = field(default_factory=list)
67
+ max_displacement: float = 0.0
68
+ max_translation: float = 0.0
69
+ stress_max: float = 0.0
70
+ stress_percentile: float = 0.0
71
+ node_count: int = 0
72
+ element_count: int = 0
73
+ shell_element_count: int = 0
74
+ beam_element_count: int = 0
75
+ prestress_summary: Dict[str, Any] = field(default_factory=dict)
76
+ load_resultant: Optional[LoadResultant] = None
77
+ diagnostics: Dict[str, Any] = field(default_factory=dict)
78
+ displacements: Optional[np.ndarray] = None
79
+ buckling_result: Optional[BucklingResult] = None
80
+
81
+ def to_dict(self) -> Dict[str, Any]:
82
+ return {
83
+ "valid": self.valid,
84
+ "status": self.status,
85
+ "invalid_reason": self.invalid_reason,
86
+ "static_solver_status": self.static_solver_status,
87
+ "buckling_solver_status": self.buckling_solver_status,
88
+ "critical_load_factor": self.critical_load_factor,
89
+ "buckling_load_factors": self.buckling_load_factors,
90
+ "max_displacement": self.max_displacement,
91
+ "max_translation": self.max_translation,
92
+ "stress_max": self.stress_max,
93
+ "stress_percentile": self.stress_percentile,
94
+ "node_count": self.node_count,
95
+ "element_count": self.element_count,
96
+ "shell_element_count": self.shell_element_count,
97
+ "beam_element_count": self.beam_element_count,
98
+ "prestress_summary": self.prestress_summary,
99
+ "load_resultant": None if self.load_resultant is None else self.load_resultant.to_dict(),
100
+ "diagnostics": self.diagnostics,
101
+ }
102
+
103
+
104
+ def _value(obj: Any, *names: str, default: Any = None) -> Any:
105
+ if obj is None:
106
+ return default
107
+ if isinstance(obj, Mapping):
108
+ for name in names:
109
+ if name in obj:
110
+ return obj[name]
111
+ for name in names:
112
+ if hasattr(obj, name):
113
+ return getattr(obj, name)
114
+ return default
115
+
116
+
117
+ def _collection(obj: Any, *names: str) -> List[Any]:
118
+ value = _value(obj, *names, default=[])
119
+ if value is None:
120
+ return []
121
+ if isinstance(value, Mapping):
122
+ return [{"id": key, **val} if isinstance(val, Mapping) else {"id": key, "value": val} for key, val in value.items()]
123
+ return list(value)
124
+
125
+
126
+ def _collection_by_name(obj: Any, name: str) -> List[Any]:
127
+ if obj is None:
128
+ return []
129
+ missing = object()
130
+ if isinstance(obj, Mapping):
131
+ value = obj.get(name, missing)
132
+ else:
133
+ value = getattr(obj, name, missing)
134
+ if value is missing or value is None:
135
+ return []
136
+ if isinstance(value, Mapping):
137
+ return [{"id": key, **val} if isinstance(val, Mapping) else {"id": key, "value": val} for key, val in value.items()]
138
+ return list(value)
139
+
140
+
141
+ def _combined_collections(obj: Any, *names: str) -> List[Tuple[str, Any]]:
142
+ items: List[Tuple[str, Any]] = []
143
+ for name in names:
144
+ items.extend((name, item) for item in _collection_by_name(obj, name))
145
+ return items
146
+
147
+
148
+ def _as_float(value: Any, default: float = 0.0) -> float:
149
+ try:
150
+ if value is None:
151
+ return default
152
+ return float(value)
153
+ except (TypeError, ValueError):
154
+ return default
155
+
156
+
157
+ def _coords_from_node(node: Any, scale: float) -> Tuple[int, float, float, float]:
158
+ if isinstance(node, Mapping):
159
+ node_id = int(_value(node, "id", "node_id"))
160
+ if "value" in node:
161
+ coords = np.asarray(node["value"], dtype=float).reshape(-1)
162
+ if coords.size >= 3:
163
+ return node_id, float(coords[0]) * scale, float(coords[1]) * scale, float(coords[2]) * scale
164
+ if "coords" in node:
165
+ coords = np.asarray(node["coords"], dtype=float).reshape(-1)
166
+ return node_id, float(coords[0]) * scale, float(coords[1]) * scale, float(coords[2]) * scale
167
+ return (
168
+ node_id,
169
+ _as_float(_value(node, "x")) * scale,
170
+ _as_float(_value(node, "y")) * scale,
171
+ _as_float(_value(node, "z")) * scale,
172
+ )
173
+ if isinstance(node, (list, tuple)) and len(node) >= 4:
174
+ return int(node[0]), float(node[1]) * scale, float(node[2]) * scale, float(node[3]) * scale
175
+ node_id = int(_value(node, "id", "node_id"))
176
+ return node_id, _as_float(_value(node, "x")) * scale, _as_float(_value(node, "y")) * scale, _as_float(_value(node, "z")) * scale
177
+
178
+
179
+ def _node_ids(item: Any) -> List[int]:
180
+ ids = _value(item, "node_ids", "nodes", "connectivity", default=[])
181
+ return [int(node_id) for node_id in ids]
182
+
183
+
184
+ def _cross_section(item: Any) -> Dict[str, float]:
185
+ section = _value(item, "cross_section", "section", default=None)
186
+ if isinstance(section, Mapping):
187
+ source = section
188
+ else:
189
+ source = item
190
+ section = {
191
+ "area": _as_float(_value(source, "area", "A"), 0.01),
192
+ "Iy": _as_float(_value(source, "Iy", "iy"), 1.0e-8),
193
+ "Iz": _as_float(_value(source, "Iz", "iz"), 1.0e-8),
194
+ "J": _as_float(_value(source, "J", "torsion_constant"), 1.0e-8),
195
+ "shear_factor_y": _as_float(_value(source, "shear_factor_y", "ky"), 5.0 / 6.0),
196
+ "shear_factor_z": _as_float(_value(source, "shear_factor_z", "kz"), 5.0 / 6.0),
197
+ }
198
+ orientation = _value(source, "orientation", "section_orientation", "web_direction", default=None)
199
+ if orientation is not None:
200
+ vector = [float(component) for component in orientation]
201
+ if len(vector) >= 3 and any(abs(component) > 0.0 for component in vector[:3]):
202
+ section["orientation"] = tuple(vector[:3])
203
+ if bool(_value(source, "consistent_mass", default=False)):
204
+ section["consistent_mass"] = True
205
+ contact_radius = _value(source, "contact_radius", default=None)
206
+ if contact_radius is not None and float(contact_radius) > 0.0:
207
+ section["contact_radius"] = float(contact_radius)
208
+ for key, aliases in (
209
+ ("c_y", ("c_y", "cy", "fiber_distance_y")),
210
+ ("c_z", ("c_z", "cz", "fiber_distance_z")),
211
+ ("torsion_modulus", ("torsion_modulus", "Wt", "torsional_section_modulus")),
212
+ # Physical profile dimensions: the fiber-section plasticity model uses
213
+ # them to build a profile-true web/flange fiber layout.
214
+ ("web_height", ("web_height", "web_h", "hw")),
215
+ ("web_thickness", ("web_thickness", "web_thk", "tw")),
216
+ ("flange_width", ("flange_width", "flange_w", "bf")),
217
+ ("flange_thickness", ("flange_thickness", "flange_thk", "tf")),
218
+ ):
219
+ value = _value(source, *aliases, default=None)
220
+ if value is not None and float(value) > 0.0:
221
+ section[key] = float(value)
222
+ return section
223
+
224
+
225
+ def _has_cross_section(item: Any) -> bool:
226
+ section = _value(item, "cross_section", "section", default=None)
227
+ if isinstance(section, Mapping):
228
+ return True
229
+ return any(_value(item, name, default=None) is not None for name in ("area", "A", "Iy", "iy", "Iz", "iz", "J", "torsion_constant"))
230
+
231
+
232
+ def _material_name(item: Any, config: AnyStructureFEMConfig) -> str:
233
+ return str(_value(item, "material", "material_name", default=config.default_material))
234
+
235
+
236
+ def _structural_member_role(item: Any, source_name: str = "") -> Optional[str]:
237
+ raw = " ".join(
238
+ str(value)
239
+ for value in (
240
+ source_name,
241
+ _value(item, "role", "structural_role", "member_role", "kind", "category", "type", default=""),
242
+ )
243
+ if value is not None
244
+ ).lower()
245
+ if any(token in raw for token in ("stiffener", "longitudinal", "stringer")):
246
+ return "stiffener"
247
+ if any(token in raw for token in ("girder", "frame", "transverse")):
248
+ return "girder"
249
+ return None
250
+
251
+
252
+ def _is_idealized_member_plate(item: Any, config: AnyStructureFEMConfig) -> Optional[str]:
253
+ role = _structural_member_role(item)
254
+ if role == "stiffener" and config.idealize_stiffeners_as_beams:
255
+ return role
256
+ if role == "girder" and config.idealize_girders_as_beams:
257
+ return role
258
+ return None
259
+
260
+
261
+ def _add_model_element(model: FEModel, elem_id: int, element: Any) -> None:
262
+ if model.mesh.get_element(elem_id) is not None:
263
+ raise ValueError(f"duplicate-element-id-{elem_id}")
264
+ model.add_element(elem_id, element)
265
+
266
+
267
+ def _item_copy(item: Any) -> Any:
268
+ if isinstance(item, Mapping):
269
+ return dict(item)
270
+ return item
271
+
272
+
273
+ def _beam_item_copy(source_name: str, item: Any) -> Any:
274
+ copied = _item_copy(item)
275
+ role = _structural_member_role(copied, source_name)
276
+ if isinstance(copied, Mapping):
277
+ copied.setdefault("role", role or "beam")
278
+ copied.setdefault("geometry_source", source_name)
279
+ return copied
280
+
281
+
282
+ def _polygon_area(coords: np.ndarray) -> float:
283
+ if coords.shape[0] < 3:
284
+ return 0.0
285
+ centroid = np.mean(coords, axis=0)
286
+ area = 0.0
287
+ for i in range(coords.shape[0]):
288
+ a = coords[i] - centroid
289
+ b = coords[(i + 1) % coords.shape[0]] - centroid
290
+ area += 0.5 * float(np.linalg.norm(np.cross(a, b)))
291
+ return area
292
+
293
+
294
+ def _infer_member_centerline(plates: List[Any], node_coords: Dict[int, np.ndarray]) -> Tuple[np.ndarray, np.ndarray]:
295
+ coords = []
296
+ for plate in plates:
297
+ for node_id in _node_ids(plate):
298
+ if node_id in node_coords:
299
+ coords.append(node_coords[node_id])
300
+ if len(coords) < 2:
301
+ raise ValueError("member-plate-has-insufficient-nodes")
302
+ points = np.asarray(coords, dtype=float)
303
+ center = np.mean(points, axis=0)
304
+ _, singular_values, vh = np.linalg.svd(points - center, full_matrices=False)
305
+ if singular_values.size == 0 or singular_values[0] <= 1.0e-12:
306
+ raise ValueError("member-plate-centerline-has-zero-length")
307
+ direction = vh[0]
308
+ projections = (points - center) @ direction
309
+ p0 = center + float(np.min(projections)) * direction
310
+ p1 = center + float(np.max(projections)) * direction
311
+ if np.linalg.norm(p1 - p0) <= 1.0e-12:
312
+ raise ValueError("member-plate-centerline-has-zero-length")
313
+ return p0, p1
314
+
315
+
316
+ def _section_from_member_plates(
317
+ plates: List[Any],
318
+ node_coords: Dict[int, np.ndarray],
319
+ p0: np.ndarray,
320
+ p1: np.ndarray,
321
+ scale: float,
322
+ ) -> Dict[str, float]:
323
+ for plate in plates:
324
+ if _has_cross_section(plate):
325
+ return _cross_section(plate)
326
+
327
+ length = float(np.linalg.norm(p1 - p0))
328
+ if length <= 0.0:
329
+ raise ValueError("member-plate-centerline-has-zero-length")
330
+
331
+ area = 0.0
332
+ iy = 0.0
333
+ iz = 0.0
334
+ max_section_width = 0.0
335
+ max_thickness = 0.0
336
+ for plate in plates:
337
+ node_ids = _node_ids(plate)
338
+ coords = np.asarray([node_coords[node_id] for node_id in node_ids if node_id in node_coords], dtype=float)
339
+ if coords.shape[0] < 3:
340
+ continue
341
+ thickness = _as_float(_value(plate, "thickness", "t"), 0.0)
342
+ if thickness <= 0.0:
343
+ continue
344
+ face_area = _polygon_area(coords[:4] if coords.shape[0] >= 4 else coords)
345
+ section_width = face_area / length if length > 0.0 else 0.0
346
+ if section_width <= 0.0:
347
+ continue
348
+ area += thickness * section_width
349
+ iy += thickness * section_width**3 / 12.0
350
+ iz += section_width * thickness**3 / 12.0
351
+ max_section_width = max(max_section_width, section_width)
352
+ max_thickness = max(max_thickness, thickness)
353
+
354
+ if area <= 0.0:
355
+ raise ValueError("member-plate-section-could-not-be-inferred")
356
+
357
+ scaled_area = area * scale**2
358
+ scaled_iy = max(iy * scale**4, 1.0e-12)
359
+ scaled_iz = max(iz * scale**4, 1.0e-12)
360
+ section = {
361
+ "area": scaled_area,
362
+ "Iy": scaled_iy,
363
+ "Iz": scaled_iz,
364
+ "J": max((iy + iz) * scale**4, 1.0e-12),
365
+ "shear_factor_y": 5.0 / 6.0,
366
+ "shear_factor_z": 5.0 / 6.0,
367
+ }
368
+ if max_section_width > 0.0 and max_thickness > 0.0:
369
+ # Strip section: width (web) along local z, thickness along local y.
370
+ section["c_z"] = 0.5 * max_section_width * scale
371
+ section["c_y"] = 0.5 * max_thickness * scale
372
+ section["torsion_modulus"] = section["J"] / (max_thickness * scale)
373
+
374
+ # The inferred strip section has its width (web) along the in-plane
375
+ # direction transverse to the centerline. Recover that direction so the
376
+ # beam local z axis matches the Iy/Iz meaning used above.
377
+ points = []
378
+ for plate in plates:
379
+ for node_id in _node_ids(plate):
380
+ if node_id in node_coords:
381
+ points.append(node_coords[node_id])
382
+ if len(points) >= 3:
383
+ points_arr = np.asarray(points, dtype=float)
384
+ axis = (p1 - p0) / length
385
+ offsets = points_arr - np.mean(points_arr, axis=0)
386
+ offsets -= np.outer(offsets @ axis, axis)
387
+ _, singular_values, vh = np.linalg.svd(offsets, full_matrices=False)
388
+ if singular_values.size and singular_values[0] > 1.0e-9 * length:
389
+ section["orientation"] = tuple(float(component) for component in vh[0])
390
+ return section
391
+
392
+
393
+ def _member_group_key(item: Any, role: str) -> Tuple[str, str]:
394
+ raw_id = _value(item, "member_id", "parent_id", "stiffener_id", "girder_id", "name", "id", default="member")
395
+ return role, str(raw_id)
396
+
397
+
398
+ def _next_available_id(used: set, start: int) -> int:
399
+ value = int(start)
400
+ while value in used:
401
+ value += 1
402
+ used.add(value)
403
+ return value
404
+
405
+
406
+ def idealize_generated_geometry_members(
407
+ generated_geometry: Any,
408
+ config: Optional[AnyStructureFEMConfig] = None,
409
+ ) -> Dict[str, Any]:
410
+ """Return generated geometry with tagged stiffener/girder plates collapsed to beams.
411
+
412
+ A client may produce member geometry as plate surfaces. For the default
413
+ beam-shell solver idealization, tagged stiffener and girder plates are
414
+ removed from the shell set and represented by equivalent line beams.
415
+ """
416
+ config = config or AnyStructureFEMConfig()
417
+ if generated_geometry is None:
418
+ raise ValueError("missing-generated-geometry")
419
+
420
+ nodes = [_item_copy(node) for node in _collection(generated_geometry, "nodes")]
421
+ node_coords = {node_id: np.array([x, y, z], dtype=float) for node_id, x, y, z in (_coords_from_node(node, 1.0) for node in nodes)}
422
+ used_node_ids = set(node_coords)
423
+
424
+ output: Dict[str, Any] = {
425
+ "name": str(_value(generated_geometry, "name", default="ANYsolverGeneratedGeometry")),
426
+ "nodes": nodes,
427
+ "shells": [],
428
+ "beams": [_beam_item_copy(source, beam) for source, beam in _combined_collections(
429
+ generated_geometry,
430
+ "beams",
431
+ "beam_members",
432
+ "line_members",
433
+ "members",
434
+ "stiffeners",
435
+ "girders",
436
+ "frames",
437
+ "longitudinals",
438
+ "transverses",
439
+ )],
440
+ "couplings": [_item_copy(item) for _source, item in _combined_collections(generated_geometry, "couplings", "mpcs")],
441
+ "rigid_lids": [_item_copy(item) for _source, item in _combined_collections(generated_geometry, "rigid_lids", "diaphragms")],
442
+ "supports": [_item_copy(item) for _source, item in _combined_collections(generated_geometry, "supports", "boundary_conditions")],
443
+ "materials": [_item_copy(item) for item in _collection(generated_geometry, "materials")],
444
+ "idealization": {"auto_member_beams": [], "excluded_member_plates": []},
445
+ }
446
+
447
+ member_plate_groups: Dict[Tuple[str, str], List[Any]] = {}
448
+ excluded_member_roles: List[str] = []
449
+ for _source_name, shell in _combined_collections(generated_geometry, "shells", "shell_faces", "faces", "plates"):
450
+ role = _is_idealized_member_plate(shell, config)
451
+ if role and config.exclude_idealized_member_plates:
452
+ excluded_member_roles.append(role)
453
+ output["idealization"]["excluded_member_plates"].append({"id": _value(shell, "id", "element_id", default=None), "role": role})
454
+ if config.auto_idealize_member_plates_as_beams:
455
+ member_plate_groups.setdefault(_member_group_key(shell, role), []).append(shell)
456
+ continue
457
+ output["shells"].append(_item_copy(shell))
458
+
459
+ existing_member_keys = {
460
+ _member_group_key(beam, role)
461
+ for beam in output["beams"]
462
+ for role in [_structural_member_role(beam)]
463
+ if role is not None
464
+ }
465
+ existing_roles = {role for role, _member_id in existing_member_keys}
466
+ if excluded_member_roles and config.require_idealized_member_beams and not config.auto_idealize_member_plates_as_beams:
467
+ missing_roles = sorted({role for role in excluded_member_roles if role not in existing_roles})
468
+ if missing_roles:
469
+ raise ValueError(f"idealized-member-plates-require-beam-members-{','.join(missing_roles)}")
470
+
471
+ used_element_ids = {
472
+ int(element_id)
473
+ for element_id in (
474
+ _value(item, "id", "element_id", default=None)
475
+ for item in output["shells"] + output["beams"] + output["couplings"] + output["rigid_lids"]
476
+ )
477
+ if element_id is not None
478
+ }
479
+
480
+ for key, plates in member_plate_groups.items():
481
+ role, member_id = key
482
+ if key in existing_member_keys:
483
+ continue
484
+ p0, p1 = _infer_member_centerline(plates, node_coords)
485
+ section = _section_from_member_plates(plates, node_coords, p0, p1, config.geometry_scale)
486
+ n0 = _next_available_id(used_node_ids, 900_000)
487
+ n1 = _next_available_id(used_node_ids, n0 + 1)
488
+ output["nodes"].extend(
489
+ [
490
+ {"id": n0, "coords": p0.tolist()},
491
+ {"id": n1, "coords": p1.tolist()},
492
+ ]
493
+ )
494
+ node_coords[n0] = p0
495
+ node_coords[n1] = p1
496
+ element_id = _next_available_id(used_element_ids, 20_000)
497
+ beam = {
498
+ "id": element_id,
499
+ "node_ids": [n0, n1],
500
+ "cross_section": section,
501
+ "role": role,
502
+ "member_id": member_id,
503
+ "material": _material_name(plates[0], config),
504
+ "generated_from": "member_plates",
505
+ }
506
+ output["beams"].append(beam)
507
+ output["idealization"]["auto_member_beams"].append(
508
+ {"id": element_id, "role": role, "member_id": member_id, "source_plate_count": len(plates)}
509
+ )
510
+
511
+ return output
512
+
513
+
514
+ def _add_materials(model: FEModel, generated_geometry: Any, config: AnyStructureFEMConfig) -> None:
515
+ model.add_material(
516
+ config.default_material,
517
+ elastic_modulus=config.elastic_modulus,
518
+ poisson_ratio=config.poisson_ratio,
519
+ density=config.density,
520
+ yield_stress=config.yield_stress,
521
+ )
522
+ model.current_material = config.default_material
523
+ for item in _collection(generated_geometry, "materials"):
524
+ name = str(_value(item, "name", "id", default=config.default_material))
525
+ model.add_material(
526
+ name,
527
+ elastic_modulus=_as_float(_value(item, "elastic_modulus", "E"), config.elastic_modulus),
528
+ poisson_ratio=_as_float(_value(item, "poisson_ratio", "nu"), config.poisson_ratio),
529
+ density=_as_float(_value(item, "density"), config.density),
530
+ yield_stress=_as_float(_value(item, "yield_stress", "fy"), config.yield_stress),
531
+ )
532
+
533
+
534
+ def build_fe_model_from_generated_geometry(
535
+ generated_geometry: Any,
536
+ config: Optional[AnyStructureFEMConfig] = None,
537
+ ) -> FEModel:
538
+ """Convert normalized generated geometry into an ``FEModel``."""
539
+ config = config or AnyStructureFEMConfig()
540
+ if generated_geometry is None:
541
+ raise ValueError("missing-generated-geometry")
542
+ generated_geometry = idealize_generated_geometry_members(generated_geometry, config)
543
+
544
+ model = FEModel(str(_value(generated_geometry, "name", default="ANYsolverGeneratedGeometry")))
545
+ _add_materials(model, generated_geometry, config)
546
+
547
+ nodes = _collection(generated_geometry, "nodes")
548
+ if not nodes:
549
+ raise ValueError("generated-geometry-has-no-nodes")
550
+ for node in nodes:
551
+ node_id, x, y, z = _coords_from_node(node, config.geometry_scale)
552
+ model.add_node(node_id, x, y, z)
553
+
554
+ element_count = 0
555
+ skipped_member_plate_roles: List[str] = []
556
+ if config.include_shells:
557
+ for _source_name, shell in _combined_collections(generated_geometry, "shells", "shell_faces", "faces", "plates"):
558
+ member_plate_role = _is_idealized_member_plate(shell, config)
559
+ if member_plate_role and config.exclude_idealized_member_plates:
560
+ skipped_member_plate_roles.append(member_plate_role)
561
+ continue
562
+ node_ids = _node_ids(shell)
563
+ if len(node_ids) not in {3, 4, 6, 8}:
564
+ raise ValueError(f"unsupported-shell-topology-{len(node_ids)}")
565
+ elem_id = int(_value(shell, "id", "element_id", default=element_count + 1))
566
+ thickness = _as_float(_value(shell, "thickness", "t"), 0.0) * config.geometry_scale
567
+ if thickness <= 0.0:
568
+ raise ValueError("shell-thickness-must-be-positive")
569
+ elem_type = str(_value(shell, "type", default="")).upper()
570
+ _add_model_element(model, elem_id, ShellElement(elem_id, node_ids, _material_name(shell, config), thickness=thickness, reduced_integration=(elem_type == "S8R")))
571
+ element_count += 1
572
+
573
+ beam_roles: List[str] = []
574
+ if config.include_beams:
575
+ beam_collections = (
576
+ "beams",
577
+ "beam_members",
578
+ "line_members",
579
+ "members",
580
+ "stiffeners",
581
+ "girders",
582
+ "frames",
583
+ "longitudinals",
584
+ "transverses",
585
+ )
586
+ for source_name, beam in _combined_collections(generated_geometry, *beam_collections):
587
+ node_ids = _node_ids(beam)
588
+ elem_id = int(_value(beam, "id", "element_id", default=20_000 + element_count + 1))
589
+ section = _cross_section(beam)
590
+ material_name = _material_name(beam, config)
591
+ role = _structural_member_role(beam, source_name)
592
+ if len(node_ids) == 2:
593
+ element = BeamElement(elem_id, node_ids, material_name, section)
594
+ elif len(node_ids) == 3:
595
+ element = QuadraticBeamElement(elem_id, node_ids, material_name, section)
596
+ else:
597
+ raise ValueError(f"unsupported-beam-topology-{len(node_ids)}")
598
+ element.structural_role = role or "beam"
599
+ element.geometry_source = source_name
600
+ _add_model_element(model, elem_id, element)
601
+ if role:
602
+ beam_roles.append(role)
603
+ element_count += 1
604
+
605
+ if skipped_member_plate_roles and config.require_idealized_member_beams:
606
+ missing_roles = sorted({role for role in skipped_member_plate_roles if role not in beam_roles})
607
+ if missing_roles:
608
+ raise ValueError(f"idealized-member-plates-require-beam-members-{','.join(missing_roles)}")
609
+
610
+ for coupling in _collection(generated_geometry, "couplings", "mpcs"):
611
+ elem_id = int(_value(coupling, "id", "element_id", default=30_000 + element_count + 1))
612
+ beam_node_id = int(_value(coupling, "beam_node_id", "slave_node"))
613
+ shell_node_ids = [int(node_id) for node_id in _value(coupling, "shell_node_ids", "master_nodes", default=[])]
614
+ shape_weights = np.asarray(_value(coupling, "shape_weights", "weights", default=[]), dtype=float)
615
+ eccentricity = np.asarray(_value(coupling, "eccentricity", default=[0.0, 0.0, 0.0]), dtype=float)
616
+ if len(shell_node_ids) == 0 or shape_weights.size != len(shell_node_ids):
617
+ raise ValueError("invalid-beam-shell-coupling")
618
+ _add_model_element(
619
+ model,
620
+ elem_id,
621
+ InterpolatedBeamShellMPCElement(
622
+ elem_id,
623
+ beam_node_id,
624
+ shell_node_ids,
625
+ shape_weights,
626
+ eccentricity,
627
+ material_name=config.default_material,
628
+ ),
629
+ )
630
+ element_count += 1
631
+
632
+ for lid in _collection(generated_geometry, "rigid_lids", "diaphragms"):
633
+ elem_id = int(_value(lid, "id", "element_id", default=40_000 + element_count + 1))
634
+ center_node_id = int(_value(lid, "center_node_id", "reference_node", "master_node"))
635
+ ring_node_ids = [int(node_id) for node_id in _value(lid, "ring_node_ids", "node_ids", "nodes", default=[])]
636
+ if len(ring_node_ids) < 3:
637
+ raise ValueError("invalid-rigid-lid-ring")
638
+ _add_model_element(
639
+ model,
640
+ elem_id,
641
+ RigidLidMPCElement(
642
+ elem_id,
643
+ center_node_id,
644
+ ring_node_ids,
645
+ material_name=config.default_material,
646
+ ),
647
+ )
648
+ element_count += 1
649
+
650
+ for support in _collection(generated_geometry, "supports", "boundary_conditions"):
651
+ node_ids = [int(node_id) for node_id in _value(support, "node_ids", "nodes", default=[])]
652
+ constraints = dict(_value(support, "dof_constraints", "constraints", default={}))
653
+ if node_ids and constraints:
654
+ model.add_boundary_condition(BoundaryCondition(str(_value(support, "name", default="generated_support")), node_ids, constraints))
655
+
656
+ if model.mesh.num_elements == 0:
657
+ raise ValueError("generated-geometry-has-no-supported-elements")
658
+ return model
659
+
660
+
661
+ def _calc_part(calc_object: Any, name: str) -> Any:
662
+ root = calc_object[0] if isinstance(calc_object, (list, tuple)) else calc_object
663
+ return _value(root, name, default=None)
664
+
665
+
666
+ def _pressure_pa(calc_object: Any, lat_press: float, config: AnyStructureFEMConfig) -> float:
667
+ if config.pressure_pa is not None:
668
+ return float(config.pressure_pa) * float(config.load_scale)
669
+ if lat_press:
670
+ return float(lat_press) * 1000.0 * float(config.load_scale)
671
+ for part_name in ("Plate", "Stiffener"):
672
+ part = _calc_part(calc_object, part_name)
673
+ pressure = _value(part, "pressure", "lat_press", default=None)
674
+ if pressure is not None:
675
+ return float(pressure) * 1.0e6 * float(config.load_scale)
676
+ return 0.0
677
+
678
+
679
+ def _design_stress_pa(calc_object: Any, attr1: str, attr2: Optional[str] = None) -> float:
680
+ part = _calc_part(calc_object, "Stiffener") or _calc_part(calc_object, "Plate")
681
+ if part is None:
682
+ return 0.0
683
+ first = _as_float(_value(part, attr1), 0.0)
684
+ second = _as_float(_value(part, attr2), first) if attr2 else first
685
+ value = first if abs(first) >= abs(second) else second
686
+ return value * 1.0e6
687
+
688
+
689
+ def _shell_elements(model: FEModel) -> List[ShellElement]:
690
+ return [element for element in model.mesh.elements.values() if isinstance(element, ShellElement)]
691
+
692
+
693
+ def _edge_weights(nodes: List[Any], axis: int) -> Dict[int, float]:
694
+ if not nodes:
695
+ return {}
696
+ coords = np.asarray([node.coords() for node in nodes], dtype=float)
697
+ if len(nodes) == 1:
698
+ return {int(nodes[0].id): 1.0}
699
+ order = np.argsort(coords[:, axis])
700
+ sorted_nodes = [nodes[int(i)] for i in order]
701
+ sorted_coords = coords[order, axis]
702
+ weights = {}
703
+ for i, node in enumerate(sorted_nodes):
704
+ if i == 0:
705
+ length = abs(sorted_coords[1] - sorted_coords[0])
706
+ elif i == len(sorted_nodes) - 1:
707
+ length = abs(sorted_coords[-1] - sorted_coords[-2])
708
+ else:
709
+ length = 0.5 * abs(sorted_coords[i + 1] - sorted_coords[i - 1])
710
+ weights[int(node.id)] = float(length)
711
+ return weights
712
+
713
+
714
+ def _add_rectangular_edge_loads(load_case: LoadCase, calc_object: Any, model: FEModel, config: AnyStructureFEMConfig) -> Dict[str, float]:
715
+ shell_elements = _shell_elements(model)
716
+ if not shell_elements:
717
+ return {"axial_stress_pa": 0.0, "transverse_stress_pa": 0.0, "shear_stress_pa": 0.0}
718
+
719
+ shell_node_ids = sorted({node_id for element in shell_elements for node_id in element.node_ids})
720
+ nodes = [model.mesh.get_node(node_id) for node_id in shell_node_ids]
721
+ nodes = [node for node in nodes if node is not None]
722
+ coords = np.asarray([node.coords() for node in nodes], dtype=float)
723
+ span = np.ptp(coords, axis=0)
724
+ if span[0] <= 0.0 or span[1] <= 0.0:
725
+ return {"axial_stress_pa": 0.0, "transverse_stress_pa": 0.0, "shear_stress_pa": 0.0}
726
+
727
+ thickness = float(np.mean([element.thickness for element in shell_elements]))
728
+ sx = _design_stress_pa(calc_object, "sigma_x1", "sigma_x2") * config.load_scale
729
+ sy = _design_stress_pa(calc_object, "sigma_y1", "sigma_y2") * config.load_scale
730
+ txy = _design_stress_pa(calc_object, "tau_xy") * config.load_scale
731
+ xmin, xmax = float(np.min(coords[:, 0])), float(np.max(coords[:, 0]))
732
+ ymin, ymax = float(np.min(coords[:, 1])), float(np.max(coords[:, 1]))
733
+ tol = 1.0e-8 * max(float(np.max(span)), 1.0)
734
+ x0 = [node for node in nodes if abs(node.x - xmin) <= tol]
735
+ x1 = [node for node in nodes if abs(node.x - xmax) <= tol]
736
+ y0 = [node for node in nodes if abs(node.y - ymin) <= tol]
737
+ y1 = [node for node in nodes if abs(node.y - ymax) <= tol]
738
+
739
+ def add_edge(edge_nodes: List[Any], weights_axis: int, force_per_length: np.ndarray) -> None:
740
+ weights = _edge_weights(edge_nodes, weights_axis)
741
+ for node_id, length in weights.items():
742
+ load_case.add_nodal_load(node_id, forces=force_per_length * length)
743
+
744
+ # Existing normalized stress inputs are treated as compression-positive.
745
+ add_edge(x0, 1, np.array([sx * thickness, txy * thickness, 0.0]))
746
+ add_edge(x1, 1, np.array([-sx * thickness, -txy * thickness, 0.0]))
747
+ add_edge(y0, 0, np.array([txy * thickness, sy * thickness, 0.0]))
748
+ add_edge(y1, 0, np.array([-txy * thickness, -sy * thickness, 0.0]))
749
+ return {"axial_stress_pa": sx, "transverse_stress_pa": sy, "shear_stress_pa": txy}
750
+
751
+
752
+ def build_symmetric_load_case(
753
+ calc_object: Any,
754
+ model: FEModel,
755
+ config: Optional[AnyStructureFEMConfig] = None,
756
+ lat_press: float = 0.0,
757
+ ) -> LoadCase:
758
+ """Create symmetric pressure and in-plane loads from normalized inputs."""
759
+ config = config or AnyStructureFEMConfig()
760
+ load_case = LoadCase("anystructure_symmetric_load")
761
+ pressure = _pressure_pa(calc_object, lat_press, config)
762
+ if pressure != 0.0:
763
+ for element in _shell_elements(model):
764
+ load_case.add_pressure_load(int(element.element_id), float(config.pressure_sign) * pressure)
765
+ if config.add_inplane_edge_loads:
766
+ _add_rectangular_edge_loads(load_case, calc_object, model, config)
767
+ return load_case
768
+
769
+
770
+ def recover_prestress_from_static_result(model: FEModel, displacements: np.ndarray) -> Tuple[Dict[int, Dict[str, float]], Dict[str, Any]]:
771
+ """Recover element prestress states for buckling from a static solution."""
772
+ states: Dict[int, Dict[str, float]] = {}
773
+ stresses = compute_stresses(model, displacements)
774
+ shell_count = 0
775
+ beam_count = 0
776
+ shell_compression = []
777
+ beam_compression = []
778
+ for element_id, element in model.mesh.elements.items():
779
+ stress = stresses.get(element_id)
780
+ if isinstance(element, ShellElement) and stress:
781
+ sx = float(np.mean(stress.get("membrane_xx", np.zeros(1))))
782
+ sy = float(np.mean(stress.get("membrane_yy", np.zeros(1))))
783
+ txy = float(np.mean(stress.get("membrane_xy", np.zeros(1))))
784
+ states[int(element_id)] = {
785
+ "membrane_force_x": sx * element.thickness,
786
+ "membrane_force_y": sy * element.thickness,
787
+ "membrane_force_xy": txy * element.thickness,
788
+ }
789
+ shell_compression.append(max(-sx * element.thickness, -sy * element.thickness, 0.0))
790
+ shell_count += 1
791
+ elif isinstance(element, (BeamElement, QuadraticBeamElement)) and stress:
792
+ axial_force = float(stress.get("axial_stress", 0.0)) * float(getattr(element, "_A", 0.0))
793
+ states[int(element_id)] = {"axial_force": axial_force}
794
+ beam_compression.append(max(-axial_force, 0.0))
795
+ beam_count += 1
796
+ summary = {
797
+ "shell_elements": shell_count,
798
+ "beam_elements": beam_count,
799
+ "state_count": len(states),
800
+ "max_shell_compression_resultant": float(max(shell_compression) if shell_compression else 0.0),
801
+ "max_beam_compression_force": float(max(beam_compression) if beam_compression else 0.0),
802
+ }
803
+ return states, summary
804
+
805
+
806
+ def _stress_statistics(model: FEModel, displacements: np.ndarray, percentile: float) -> Dict[str, float]:
807
+ values = []
808
+ for stress in compute_stresses(model, displacements).values():
809
+ if "von_mises" not in stress:
810
+ continue
811
+ values.extend(np.asarray(stress["von_mises"], dtype=float).reshape(-1).tolist())
812
+ arr = np.asarray(values, dtype=float)
813
+ arr = arr[np.isfinite(arr)]
814
+ if arr.size == 0:
815
+ return {"count": 0, "max": 0.0, "percentile": 0.0}
816
+ return {
817
+ "count": int(arr.size),
818
+ "max": float(np.max(arr)),
819
+ "percentile": float(np.percentile(arr, percentile)),
820
+ }
821
+
822
+
823
+ def _max_translation(model: FEModel, displacements: np.ndarray) -> float:
824
+ value = 0.0
825
+ for node in model.mesh.nodes.values():
826
+ value = max(value, float(np.linalg.norm(displacements[node.dofs[:3]])))
827
+ return value
828
+
829
+
830
+ def _beam_role_counts(model: FEModel) -> Dict[str, int]:
831
+ counts: Dict[str, int] = {}
832
+ for element in model.mesh.elements.values():
833
+ if not isinstance(element, (BeamElement, QuadraticBeamElement)):
834
+ continue
835
+ role = str(getattr(element, "structural_role", "beam") or "beam")
836
+ counts[role] = counts.get(role, 0) + 1
837
+ return counts
838
+
839
+
840
+ def _invalid_result(reason: str, diagnostics: Optional[Dict[str, Any]] = None) -> AnyStructureFEMResult:
841
+ return AnyStructureFEMResult(valid=False, status="invalid", invalid_reason=reason, diagnostics=diagnostics or {})
842
+
843
+
844
+ def run_anystructure_fem_mode(
845
+ calc_object: Any,
846
+ generated_geometry: Any,
847
+ config: Optional[AnyStructureFEMConfig] = None,
848
+ lat_press: float = 0.0,
849
+ ) -> AnyStructureFEMResult:
850
+ """Run static full-geometry analysis and buckling from recovered prestress."""
851
+ config = config or AnyStructureFEMConfig()
852
+ try:
853
+ model = build_fe_model_from_generated_geometry(generated_geometry, config)
854
+ load_case = build_symmetric_load_case(calc_object, model, config, lat_press=lat_press)
855
+ except Exception as exc:
856
+ return _invalid_result(str(exc))
857
+
858
+ resultant = load_case_resultant(model, load_case)
859
+ displacements, solver_info = solve_linear(model, load_case, solver_type=config.solver_type, constraint_mode="auto")
860
+ static_status = str((solver_info.get("convergence_info") or {}).get("status", "unknown"))
861
+ if static_status != "converged":
862
+ return AnyStructureFEMResult(
863
+ valid=False,
864
+ status="static_failed",
865
+ invalid_reason=static_status,
866
+ static_solver_status=static_status,
867
+ node_count=model.mesh.num_nodes,
868
+ element_count=model.mesh.num_elements,
869
+ shell_element_count=len(_shell_elements(model)),
870
+ beam_element_count=sum(isinstance(e, (BeamElement, QuadraticBeamElement)) for e in model.mesh.elements.values()),
871
+ load_resultant=resultant,
872
+ diagnostics={"solver_info": solver_info},
873
+ displacements=displacements,
874
+ )
875
+
876
+ prestress_states, prestress_summary = recover_prestress_from_static_result(model, displacements)
877
+ if not prestress_states:
878
+ return AnyStructureFEMResult(
879
+ valid=False,
880
+ status="prestress_failed",
881
+ invalid_reason="failed-prestress-recovery",
882
+ static_solver_status=static_status,
883
+ node_count=model.mesh.num_nodes,
884
+ element_count=model.mesh.num_elements,
885
+ load_resultant=resultant,
886
+ diagnostics={"solver_info": solver_info},
887
+ displacements=displacements,
888
+ )
889
+
890
+ buckling = solve_eigenvalue_buckling(model, prestress_states, num_modes=config.num_buckling_modes)
891
+ stress_stats = _stress_statistics(model, displacements, config.stress_percentile)
892
+ mode_factors = [float(mode.load_factor) for mode in buckling.modes]
893
+ valid = buckling.solver_status == "ok" and bool(mode_factors)
894
+ status = "ok" if valid else "buckling_failed"
895
+ invalid_reason = None if valid else buckling.solver_status
896
+ return AnyStructureFEMResult(
897
+ valid=valid,
898
+ status=status,
899
+ invalid_reason=invalid_reason,
900
+ static_solver_status=static_status,
901
+ buckling_solver_status=buckling.solver_status,
902
+ critical_load_factor=buckling.critical_load_factor,
903
+ buckling_load_factors=mode_factors,
904
+ max_displacement=float(np.max(np.abs(displacements))) if displacements.size else 0.0,
905
+ max_translation=_max_translation(model, displacements),
906
+ stress_max=stress_stats["max"],
907
+ stress_percentile=stress_stats["percentile"],
908
+ node_count=model.mesh.num_nodes,
909
+ element_count=model.mesh.num_elements,
910
+ shell_element_count=len(_shell_elements(model)),
911
+ beam_element_count=sum(isinstance(e, (BeamElement, QuadraticBeamElement)) for e in model.mesh.elements.values()),
912
+ prestress_summary=prestress_summary,
913
+ load_resultant=resultant,
914
+ diagnostics={
915
+ "solver_info": solver_info,
916
+ "stress_statistics": stress_stats,
917
+ "buckling": buckling.to_dict(),
918
+ "beam_role_counts": _beam_role_counts(model),
919
+ },
920
+ displacements=displacements,
921
+ buckling_result=buckling,
922
+ )
923
+
924
+
925
+ # Generic solver-owned names. Historical names remain available for
926
+ # downstream ANYstructure compatibility.
927
+ GeneratedGeometryFEMConfig = AnyStructureFEMConfig
928
+ GeneratedGeometryFEMResult = AnyStructureFEMResult
929
+ run_generated_geometry_fem = run_anystructure_fem_mode
930
+
931
+ __all__ = [
932
+ "AnyStructureFEMConfig",
933
+ "AnyStructureFEMResult",
934
+ "GeneratedGeometryFEMConfig",
935
+ "GeneratedGeometryFEMResult",
936
+ "build_fe_model_from_generated_geometry",
937
+ "build_symmetric_load_case",
938
+ "idealize_generated_geometry_members",
939
+ "recover_prestress_from_static_result",
940
+ "run_anystructure_fem_mode",
941
+ "run_generated_geometry_fem",
942
+ ]