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
|
@@ -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
|
+
]
|