ANYsolver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/results.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Results Module
|
|
3
|
+
|
|
4
|
+
This module provides classes for storing and processing FE analysis results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import TYPE_CHECKING, List, Dict, Tuple, Optional, Any
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .fe_core import FEModel, FEMesh
|
|
15
|
+
from .recovery import RecoveryConfig, ResourceConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FEResult:
|
|
20
|
+
"""
|
|
21
|
+
Complete FE analysis result.
|
|
22
|
+
|
|
23
|
+
Stores displacements, stresses, reactions, and solver information.
|
|
24
|
+
"""
|
|
25
|
+
model_name: str
|
|
26
|
+
displacements: np.ndarray # Global displacement vector
|
|
27
|
+
node_displacements: Dict[int, np.ndarray] = field(default_factory=dict)
|
|
28
|
+
element_stresses: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
|
|
29
|
+
reactions: Dict[int, np.ndarray] = field(default_factory=dict)
|
|
30
|
+
solver_info: Dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
assembly_info: Dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
result_case: Optional[Dict[str, Any]] = None
|
|
33
|
+
|
|
34
|
+
# Additional results
|
|
35
|
+
strains: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
|
|
36
|
+
forces: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
if self.displacements is not None:
|
|
40
|
+
self.max_displacement = np.max(np.abs(self.displacements))
|
|
41
|
+
self.max_displacement_node = np.argmax(np.abs(self.displacements))
|
|
42
|
+
else:
|
|
43
|
+
self.max_displacement = 0.0
|
|
44
|
+
self.max_displacement_node = -1
|
|
45
|
+
|
|
46
|
+
def get_node_displacement(self, node_id: int) -> Optional[np.ndarray]:
|
|
47
|
+
"""Get displacement for a specific node."""
|
|
48
|
+
return self.node_displacements.get(node_id)
|
|
49
|
+
|
|
50
|
+
def get_element_stress(self, element_id: int) -> Optional[Dict[str, np.ndarray]]:
|
|
51
|
+
"""Get stresses for a specific element."""
|
|
52
|
+
return self.element_stresses.get(element_id)
|
|
53
|
+
|
|
54
|
+
def get_reaction(self, node_id: int) -> Optional[np.ndarray]:
|
|
55
|
+
"""Get reaction forces for a specific node."""
|
|
56
|
+
return self.reactions.get(node_id)
|
|
57
|
+
|
|
58
|
+
def get_max_displacement(self) -> Tuple[float, int]:
|
|
59
|
+
"""Get maximum displacement and its node ID."""
|
|
60
|
+
return self.max_displacement, self.max_displacement_node
|
|
61
|
+
|
|
62
|
+
def get_displacement_norm(self) -> float:
|
|
63
|
+
"""Get the norm of the displacement vector."""
|
|
64
|
+
return np.linalg.norm(self.displacements)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
67
|
+
"""Convert result to dictionary."""
|
|
68
|
+
return {
|
|
69
|
+
'model_name': self.model_name,
|
|
70
|
+
'max_displacement': self.max_displacement,
|
|
71
|
+
'displacement_norm': self.get_displacement_norm(),
|
|
72
|
+
'num_nodes': len(self.node_displacements),
|
|
73
|
+
'num_elements': len(self.element_stresses),
|
|
74
|
+
'solver_info': self.solver_info,
|
|
75
|
+
'assembly_info': self.assembly_info,
|
|
76
|
+
'result_case': self.result_case,
|
|
77
|
+
'node_displacements': {k: v.tolist() for k, v in self.node_displacements.items()},
|
|
78
|
+
'reactions': {k: v.tolist() for k, v in self.reactions.items()}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def summary(self) -> str:
|
|
82
|
+
"""Generate a summary string."""
|
|
83
|
+
lines = [
|
|
84
|
+
f"FE Analysis Result: {self.model_name}",
|
|
85
|
+
f"Max Displacement: {self.max_displacement:.6e} m",
|
|
86
|
+
f"Displacement Norm: {self.get_displacement_norm():.6e} m",
|
|
87
|
+
f"Number of Nodes: {len(self.node_displacements)}",
|
|
88
|
+
f"Number of Elements: {len(self.element_stresses)}",
|
|
89
|
+
f"Solver: {self.solver_info.get('solver_type', 'unknown')}",
|
|
90
|
+
f"Convergence: {self.solver_info.get('convergence_info', {}).get('status', 'unknown')}"
|
|
91
|
+
]
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class StressResult:
|
|
97
|
+
"""Stress results for a single element."""
|
|
98
|
+
element_id: int
|
|
99
|
+
element_type: str
|
|
100
|
+
|
|
101
|
+
# Shell stresses (if applicable)
|
|
102
|
+
membrane_stress_xx: Optional[np.ndarray] = None
|
|
103
|
+
membrane_stress_yy: Optional[np.ndarray] = None
|
|
104
|
+
membrane_stress_xy: Optional[np.ndarray] = None
|
|
105
|
+
bending_stress_xx: Optional[np.ndarray] = None
|
|
106
|
+
bending_stress_yy: Optional[np.ndarray] = None
|
|
107
|
+
bending_stress_xy: Optional[np.ndarray] = None
|
|
108
|
+
shear_stress_xz: Optional[np.ndarray] = None
|
|
109
|
+
shear_stress_yz: Optional[np.ndarray] = None
|
|
110
|
+
von_mises_stress: Optional[np.ndarray] = None
|
|
111
|
+
|
|
112
|
+
# Beam stresses (if applicable)
|
|
113
|
+
axial_stress: Optional[float] = None
|
|
114
|
+
bending_stress_y: Optional[float] = None
|
|
115
|
+
bending_stress_z: Optional[float] = None
|
|
116
|
+
shear_stress_y: Optional[float] = None
|
|
117
|
+
shear_stress_z: Optional[float] = None
|
|
118
|
+
torsional_stress: Optional[float] = None
|
|
119
|
+
|
|
120
|
+
def get_max_von_mises(self) -> float:
|
|
121
|
+
"""Get maximum von Mises stress."""
|
|
122
|
+
if self.von_mises_stress is not None:
|
|
123
|
+
return np.max(np.abs(self.von_mises_stress))
|
|
124
|
+
return 0.0
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
127
|
+
"""Convert to dictionary."""
|
|
128
|
+
result = {
|
|
129
|
+
'element_id': self.element_id,
|
|
130
|
+
'element_type': self.element_type,
|
|
131
|
+
'max_von_mises': self.get_max_von_mises()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if self.axial_stress is not None:
|
|
135
|
+
result['axial_stress'] = self.axial_stress
|
|
136
|
+
if self.bending_stress_y is not None:
|
|
137
|
+
result['bending_stress_y'] = self.bending_stress_y
|
|
138
|
+
if self.bending_stress_z is not None:
|
|
139
|
+
result['bending_stress_z'] = self.bending_stress_z
|
|
140
|
+
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class DisplacementResult:
|
|
146
|
+
"""Displacement results for a single node."""
|
|
147
|
+
node_id: int
|
|
148
|
+
ux: float = 0.0
|
|
149
|
+
uy: float = 0.0
|
|
150
|
+
uz: float = 0.0
|
|
151
|
+
rx: float = 0.0
|
|
152
|
+
ry: float = 0.0
|
|
153
|
+
rz: float = 0.0
|
|
154
|
+
|
|
155
|
+
def to_array(self) -> np.ndarray:
|
|
156
|
+
"""Convert to numpy array."""
|
|
157
|
+
return np.array([self.ux, self.uy, self.uz, self.rx, self.ry, self.rz])
|
|
158
|
+
|
|
159
|
+
def to_dict(self) -> Dict[str, float]:
|
|
160
|
+
"""Convert to dictionary."""
|
|
161
|
+
return {
|
|
162
|
+
'node_id': self.node_id,
|
|
163
|
+
'ux': self.ux,
|
|
164
|
+
'uy': self.uy,
|
|
165
|
+
'uz': self.uz,
|
|
166
|
+
'rx': self.rx,
|
|
167
|
+
'ry': self.ry,
|
|
168
|
+
'rz': self.rz
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def create_fe_result(
|
|
173
|
+
model: 'FEModel',
|
|
174
|
+
displacements: np.ndarray,
|
|
175
|
+
solver_info: Dict[str, Any],
|
|
176
|
+
assembly_info: Dict[str, Any] = None,
|
|
177
|
+
recovery_config: Optional["RecoveryConfig"] = None,
|
|
178
|
+
resource_config: Optional["ResourceConfig"] = None,
|
|
179
|
+
) -> FEResult:
|
|
180
|
+
"""
|
|
181
|
+
Create a complete FE result from solver output.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
model: The FE model
|
|
185
|
+
displacements: Global displacement vector
|
|
186
|
+
solver_info: Solver information dictionary
|
|
187
|
+
assembly_info: Assembly information dictionary
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
FEResult object with all results
|
|
191
|
+
"""
|
|
192
|
+
from .assembly import compute_reactions
|
|
193
|
+
from .recovery import (
|
|
194
|
+
default_recovery_config,
|
|
195
|
+
enforce_memory_limit,
|
|
196
|
+
estimate_model_memory,
|
|
197
|
+
filter_reactions,
|
|
198
|
+
recover_element_stresses,
|
|
199
|
+
recover_element_stresses_with_report,
|
|
200
|
+
recovery_metadata,
|
|
201
|
+
select_node_displacements,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
policy_requested = recovery_config is not None or resource_config is not None
|
|
205
|
+
recovery = default_recovery_config(recovery_config)
|
|
206
|
+
memory_estimate = estimate_model_memory(model, recovery_config=recovery) if policy_requested else None
|
|
207
|
+
if memory_estimate is not None:
|
|
208
|
+
enforce_memory_limit(memory_estimate, resource_config, context="create_fe_result")
|
|
209
|
+
policy_metadata = recovery_metadata(recovery, resource_config, memory_estimate) if policy_requested else {}
|
|
210
|
+
result_solver_info = dict(solver_info)
|
|
211
|
+
if policy_requested:
|
|
212
|
+
result_solver_info["recovery_policy"] = policy_metadata
|
|
213
|
+
|
|
214
|
+
# Extract node displacements
|
|
215
|
+
node_displacements = select_node_displacements(model, displacements, recovery)
|
|
216
|
+
|
|
217
|
+
# Compute reactions (need a load case)
|
|
218
|
+
reactions = {}
|
|
219
|
+
if 'load_case' in solver_info:
|
|
220
|
+
load_case = solver_info['load_case']
|
|
221
|
+
reactions = filter_reactions(compute_reactions(model, displacements, load_case), recovery, model)
|
|
222
|
+
|
|
223
|
+
# Compute stresses
|
|
224
|
+
if policy_requested:
|
|
225
|
+
element_stresses, stress_report = recover_element_stresses_with_report(
|
|
226
|
+
model,
|
|
227
|
+
displacements,
|
|
228
|
+
recovery,
|
|
229
|
+
resource_config=resource_config,
|
|
230
|
+
)
|
|
231
|
+
result_solver_info["recovery_policy"]["execution"] = {"element_stress_recovery": stress_report.to_dict()}
|
|
232
|
+
else:
|
|
233
|
+
element_stresses = recover_element_stresses(model, displacements, recovery)
|
|
234
|
+
|
|
235
|
+
result_case = solver_info.get("result_case")
|
|
236
|
+
if policy_requested and isinstance(result_case, dict):
|
|
237
|
+
result_case = dict(result_case)
|
|
238
|
+
result_case["recovery"] = {**dict(result_case.get("recovery", {})), **recovery.to_dict()}
|
|
239
|
+
metadata = dict(result_case.get("metadata", {}))
|
|
240
|
+
metadata.update({key: value for key, value in policy_metadata.items() if key != "recovery"})
|
|
241
|
+
result_case["metadata"] = metadata
|
|
242
|
+
|
|
243
|
+
return FEResult(
|
|
244
|
+
model_name=model.name,
|
|
245
|
+
displacements=displacements,
|
|
246
|
+
node_displacements=node_displacements,
|
|
247
|
+
element_stresses=element_stresses,
|
|
248
|
+
reactions=reactions,
|
|
249
|
+
solver_info=result_solver_info,
|
|
250
|
+
assembly_info=assembly_info or {},
|
|
251
|
+
result_case=result_case,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def post_process_results(result: FEResult) -> Dict[str, Any]:
|
|
256
|
+
"""
|
|
257
|
+
Perform additional post-processing on FE results.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
result: The FE result to post-process
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Dictionary with post-processed results
|
|
264
|
+
"""
|
|
265
|
+
post_processed = {
|
|
266
|
+
'global': {
|
|
267
|
+
'max_displacement': result.max_displacement,
|
|
268
|
+
'displacement_norm': result.get_displacement_norm(),
|
|
269
|
+
'total_strain_energy': 0.0 # Would need stress-strain integration
|
|
270
|
+
},
|
|
271
|
+
'nodes': {},
|
|
272
|
+
'elements': {}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Process node results
|
|
276
|
+
for node_id, disp in result.node_displacements.items():
|
|
277
|
+
post_processed['nodes'][node_id] = {
|
|
278
|
+
'displacement_magnitude': np.linalg.norm(disp[:3]),
|
|
279
|
+
'rotation_magnitude': np.linalg.norm(disp[3:])
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Process element results
|
|
283
|
+
for elem_id, stresses in result.element_stresses.items():
|
|
284
|
+
elem_result = {'max_stress': 0.0}
|
|
285
|
+
|
|
286
|
+
# Find maximum stress component
|
|
287
|
+
for stress_name, stress_values in stresses.items():
|
|
288
|
+
if isinstance(stress_values, np.ndarray):
|
|
289
|
+
max_stress = np.max(np.abs(stress_values))
|
|
290
|
+
else:
|
|
291
|
+
max_stress = abs(stress_values)
|
|
292
|
+
|
|
293
|
+
if max_stress > elem_result['max_stress']:
|
|
294
|
+
elem_result['max_stress'] = max_stress
|
|
295
|
+
|
|
296
|
+
post_processed['elements'][elem_id] = elem_result
|
|
297
|
+
|
|
298
|
+
return post_processed
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def compare_with_analytical(result: FEResult, analytical_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
302
|
+
"""
|
|
303
|
+
Compare FE results with analytical/semi-analytical results.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
result: FE result
|
|
307
|
+
analytical_result: Analytical result dictionary
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Dictionary with comparison metrics
|
|
311
|
+
"""
|
|
312
|
+
comparison = {
|
|
313
|
+
'displacement': {},
|
|
314
|
+
'stress': {},
|
|
315
|
+
'overall': {}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Compare displacements if available
|
|
319
|
+
if 'max_displacement' in analytical_result:
|
|
320
|
+
fe_max_disp = result.max_displacement
|
|
321
|
+
analytical_max_disp = analytical_result['max_displacement']
|
|
322
|
+
comparison['displacement']['fe'] = fe_max_disp
|
|
323
|
+
comparison['displacement']['analytical'] = analytical_max_disp
|
|
324
|
+
comparison['displacement']['ratio'] = fe_max_disp / analytical_max_disp if analytical_max_disp > 0 else float('inf')
|
|
325
|
+
comparison['displacement']['error_percent'] = abs(fe_max_disp - analytical_max_disp) / analytical_max_disp * 100 if analytical_max_disp > 0 else float('inf')
|
|
326
|
+
|
|
327
|
+
# Compare stresses if available
|
|
328
|
+
if 'max_stress' in analytical_result:
|
|
329
|
+
fe_max_stress = 0.0
|
|
330
|
+
for elem_stresses in result.element_stresses.values():
|
|
331
|
+
for stresses in elem_stresses.values():
|
|
332
|
+
if isinstance(stresses, np.ndarray):
|
|
333
|
+
max_s = np.max(np.abs(stresses))
|
|
334
|
+
else:
|
|
335
|
+
max_s = abs(stresses)
|
|
336
|
+
if max_s > fe_max_stress:
|
|
337
|
+
fe_max_stress = max_s
|
|
338
|
+
|
|
339
|
+
analytical_max_stress = analytical_result['max_stress']
|
|
340
|
+
comparison['stress']['fe'] = fe_max_stress
|
|
341
|
+
comparison['stress']['analytical'] = analytical_max_stress
|
|
342
|
+
comparison['stress']['ratio'] = fe_max_stress / analytical_max_stress if analytical_max_stress > 0 else float('inf')
|
|
343
|
+
comparison['stress']['error_percent'] = abs(fe_max_stress - analytical_max_stress) / analytical_max_stress * 100 if analytical_max_stress > 0 else float('inf')
|
|
344
|
+
|
|
345
|
+
return comparison
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_QUAD4_NODE_NATURAL = np.array([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]], dtype=float)
|
|
349
|
+
_QUAD8_NODE_NATURAL = np.array(
|
|
350
|
+
[
|
|
351
|
+
[-1.0, -1.0],
|
|
352
|
+
[1.0, -1.0],
|
|
353
|
+
[1.0, 1.0],
|
|
354
|
+
[-1.0, 1.0],
|
|
355
|
+
[0.0, -1.0],
|
|
356
|
+
[1.0, 0.0],
|
|
357
|
+
[0.0, 1.0],
|
|
358
|
+
[-1.0, 0.0],
|
|
359
|
+
],
|
|
360
|
+
dtype=float,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
_RECOVERED_STRESS_COMPONENTS = ("xx", "yy", "zz", "xy", "yz", "xz")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _polynomial_basis(points: np.ndarray, num_gauss: int) -> np.ndarray:
|
|
367
|
+
"""Polynomial basis matched to the Gauss rule for stress extrapolation."""
|
|
368
|
+
xi = points[:, 0]
|
|
369
|
+
eta = points[:, 1]
|
|
370
|
+
if num_gauss >= 9:
|
|
371
|
+
return np.column_stack(
|
|
372
|
+
[np.ones_like(xi), xi, eta, xi * eta, xi**2, eta**2, xi**2 * eta, xi * eta**2, xi**2 * eta**2]
|
|
373
|
+
)
|
|
374
|
+
if num_gauss >= 4:
|
|
375
|
+
return np.column_stack([np.ones_like(xi), xi, eta, xi * eta])
|
|
376
|
+
return np.ones((points.shape[0], 1), dtype=float)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@lru_cache(maxsize=16)
|
|
380
|
+
def _cached_gauss_to_node_extrapolation(
|
|
381
|
+
num_nodes: int,
|
|
382
|
+
gauss_points_flat: Tuple[float, ...],
|
|
383
|
+
) -> Optional[np.ndarray]:
|
|
384
|
+
"""Build one operator per shell topology and Gauss rule."""
|
|
385
|
+
|
|
386
|
+
gauss_points = np.asarray(gauss_points_flat, dtype=float).reshape(-1, 2)
|
|
387
|
+
num_gauss = gauss_points.shape[0]
|
|
388
|
+
if num_gauss == 0:
|
|
389
|
+
return None
|
|
390
|
+
if num_nodes == 4:
|
|
391
|
+
node_natural = _QUAD4_NODE_NATURAL
|
|
392
|
+
elif num_nodes == 8:
|
|
393
|
+
node_natural = _QUAD8_NODE_NATURAL
|
|
394
|
+
else:
|
|
395
|
+
return None
|
|
396
|
+
P_gauss = _polynomial_basis(gauss_points, num_gauss)
|
|
397
|
+
P_nodes = _polynomial_basis(node_natural, num_gauss)
|
|
398
|
+
coefficients, *_ = np.linalg.lstsq(P_gauss, np.eye(num_gauss), rcond=None)
|
|
399
|
+
operator = P_nodes @ coefficients
|
|
400
|
+
operator.setflags(write=False)
|
|
401
|
+
return operator
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _gauss_to_node_extrapolation(element: Any) -> Optional[np.ndarray]:
|
|
405
|
+
"""Return the cached (num_nodes, num_gauss) operator for one shell."""
|
|
406
|
+
|
|
407
|
+
gauss_points = np.asarray(getattr(element, "gauss_points", ()), dtype=float).reshape(-1, 2)
|
|
408
|
+
return _cached_gauss_to_node_extrapolation(
|
|
409
|
+
int(getattr(element, "num_nodes", 0)),
|
|
410
|
+
tuple(float(value) for value in gauss_points.reshape(-1)),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _von_mises_surface(components: Dict[str, float], suffix: str) -> float:
|
|
415
|
+
sxx = components[f"global_xx_{suffix}"]
|
|
416
|
+
syy = components[f"global_yy_{suffix}"]
|
|
417
|
+
szz = components[f"global_zz_{suffix}"]
|
|
418
|
+
sxy = components[f"global_xy_{suffix}"]
|
|
419
|
+
syz = components[f"global_yz_{suffix}"]
|
|
420
|
+
sxz = components[f"global_xz_{suffix}"]
|
|
421
|
+
return float(
|
|
422
|
+
np.sqrt(
|
|
423
|
+
0.5 * ((sxx - syy) ** 2 + (syy - szz) ** 2 + (szz - sxx) ** 2)
|
|
424
|
+
+ 3.0 * (sxy**2 + syz**2 + sxz**2)
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def recover_nodal_stresses(
|
|
430
|
+
model: "FEModel",
|
|
431
|
+
displacements: np.ndarray,
|
|
432
|
+
element_ids: Optional[Any] = None,
|
|
433
|
+
) -> Dict[str, Any]:
|
|
434
|
+
"""Gauss-to-node extrapolated, patch-averaged shell surface stresses.
|
|
435
|
+
|
|
436
|
+
Shell integration-point stresses are second-order accurate inside the
|
|
437
|
+
element but underestimate surface peaks on coarse meshes, especially for
|
|
438
|
+
the 2x2-point S4. This recovery evaluates the global-frame top/bottom
|
|
439
|
+
surface stresses at the integration points, extrapolates them to the
|
|
440
|
+
element nodes with the polynomial basis matched to the Gauss rule, and
|
|
441
|
+
averages contributions from all shell elements sharing each node. Nodal
|
|
442
|
+
von Mises values are computed from the averaged global components, so the
|
|
443
|
+
result is frame-consistent on distorted meshes.
|
|
444
|
+
|
|
445
|
+
Supported for 4-node and 8-node quadrilateral shells; other element types
|
|
446
|
+
are skipped. Returns per-node averaged components/von Mises, the
|
|
447
|
+
per-element nodal (unaveraged) values, and the peak recovered von Mises.
|
|
448
|
+
"""
|
|
449
|
+
from .elements import ShellElement
|
|
450
|
+
|
|
451
|
+
keys = [f"global_{component}_{surface}" for surface in ("top", "bot") for component in _RECOVERED_STRESS_COMPONENTS]
|
|
452
|
+
selected = None if element_ids is None else {int(element_id) for element_id in element_ids}
|
|
453
|
+
node_sums: Dict[int, Dict[str, float]] = {}
|
|
454
|
+
node_counts: Dict[int, int] = {}
|
|
455
|
+
element_nodal: Dict[int, Dict[str, np.ndarray]] = {}
|
|
456
|
+
skipped: List[int] = []
|
|
457
|
+
for element_id, element in model.mesh.elements.items():
|
|
458
|
+
if selected is not None and int(element_id) not in selected:
|
|
459
|
+
continue
|
|
460
|
+
if not isinstance(element, ShellElement):
|
|
461
|
+
continue
|
|
462
|
+
operator = _gauss_to_node_extrapolation(element)
|
|
463
|
+
if operator is None:
|
|
464
|
+
skipped.append(int(element_id))
|
|
465
|
+
continue
|
|
466
|
+
material = model.get_material(element.material_name)
|
|
467
|
+
stresses = element.compute_stresses(model.mesh, displacements, material, return_global=True)
|
|
468
|
+
if any(key not in stresses for key in keys):
|
|
469
|
+
skipped.append(int(element_id))
|
|
470
|
+
continue
|
|
471
|
+
nodal_values = {key: operator @ np.asarray(stresses[key], dtype=float).reshape(-1) for key in keys}
|
|
472
|
+
element_nodal[int(element_id)] = nodal_values
|
|
473
|
+
for local_index, node_id in enumerate(element.node_ids):
|
|
474
|
+
entry = node_sums.setdefault(int(node_id), {key: 0.0 for key in keys})
|
|
475
|
+
for key in keys:
|
|
476
|
+
entry[key] += float(nodal_values[key][local_index])
|
|
477
|
+
node_counts[int(node_id)] = node_counts.get(int(node_id), 0) + 1
|
|
478
|
+
|
|
479
|
+
nodal: Dict[int, Dict[str, float]] = {}
|
|
480
|
+
max_von_mises = 0.0
|
|
481
|
+
max_von_mises_node: Optional[int] = None
|
|
482
|
+
for node_id, sums in node_sums.items():
|
|
483
|
+
count = max(node_counts.get(node_id, 1), 1)
|
|
484
|
+
averaged = {key: value / count for key, value in sums.items()}
|
|
485
|
+
vm_top = _von_mises_surface(averaged, "top")
|
|
486
|
+
vm_bot = _von_mises_surface(averaged, "bot")
|
|
487
|
+
averaged["von_mises_top"] = vm_top
|
|
488
|
+
averaged["von_mises_bot"] = vm_bot
|
|
489
|
+
averaged["von_mises"] = max(vm_top, vm_bot)
|
|
490
|
+
nodal[node_id] = averaged
|
|
491
|
+
if averaged["von_mises"] > max_von_mises:
|
|
492
|
+
max_von_mises = averaged["von_mises"]
|
|
493
|
+
max_von_mises_node = int(node_id)
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"method": "gauss_extrapolation_nodal_average",
|
|
497
|
+
"stress_frame": "global",
|
|
498
|
+
"nodal": nodal,
|
|
499
|
+
"element_nodal": element_nodal,
|
|
500
|
+
"max_von_mises": float(max_von_mises),
|
|
501
|
+
"max_von_mises_node": max_von_mises_node,
|
|
502
|
+
"skipped_element_ids": sorted(skipped),
|
|
503
|
+
}
|