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,1497 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quality Control Suite for FE Solver
|
|
3
|
+
|
|
4
|
+
This module provides comprehensive quality control tests including:
|
|
5
|
+
- Analytical verification tests
|
|
6
|
+
- Convergence studies
|
|
7
|
+
- Patch tests
|
|
8
|
+
- Comparison with semi-analytical solutions
|
|
9
|
+
- Boundary condition verification
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, List, Dict, Tuple, Optional, Any, Callable
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import sparse
|
|
17
|
+
import time
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .fe_core import FEModel, FEMesh
|
|
23
|
+
from .elements import ShellElement, BeamElement
|
|
24
|
+
from .boundary import LoadCase
|
|
25
|
+
from .results import FEResult
|
|
26
|
+
|
|
27
|
+
# Import for runtime use
|
|
28
|
+
from .boundary import LoadCase, FixedSupport, PinnedSupport, RollerSupport, SymmetryBC
|
|
29
|
+
from .assembly import solve_linear, solve_nonlinear, assemble_system
|
|
30
|
+
from .mesh_gen import generate_simple_panel_mesh, generate_beam_mesh, PanelGeometry, MeshConfig
|
|
31
|
+
from .results import FEResult, create_fe_result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class QCConfig:
|
|
36
|
+
"""Configuration for quality control tests."""
|
|
37
|
+
# Test tolerances
|
|
38
|
+
displacement_tolerance: float = 1e-6 # Relative error tolerance
|
|
39
|
+
stress_tolerance: float = 1e-4 # Stress error tolerance
|
|
40
|
+
convergence_tolerance: float = 1e-8
|
|
41
|
+
|
|
42
|
+
# Convergence study settings
|
|
43
|
+
convergence_num_refinements: int = 5
|
|
44
|
+
convergence_start_divisions: int = 2
|
|
45
|
+
|
|
46
|
+
# Output settings
|
|
47
|
+
save_results: bool = True
|
|
48
|
+
output_dir: str = "qc_results"
|
|
49
|
+
verbose: bool = True
|
|
50
|
+
|
|
51
|
+
# Test categories to run
|
|
52
|
+
run_analytical: bool = True
|
|
53
|
+
run_convergence: bool = True
|
|
54
|
+
run_patch: bool = True
|
|
55
|
+
run_boundary: bool = True
|
|
56
|
+
run_performance: bool = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class QCResult:
|
|
61
|
+
"""Result of a single quality control test."""
|
|
62
|
+
test_name: str
|
|
63
|
+
test_category: str
|
|
64
|
+
passed: bool
|
|
65
|
+
error: float = 0.0
|
|
66
|
+
expected: float = 0.0
|
|
67
|
+
actual: float = 0.0
|
|
68
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
72
|
+
"""Convert to dictionary, ensuring all values are JSON serializable."""
|
|
73
|
+
result = {
|
|
74
|
+
'test_name': self.test_name,
|
|
75
|
+
'test_category': self.test_category,
|
|
76
|
+
'passed': bool(self.passed),
|
|
77
|
+
'error': float(self.error) if not isinstance(self.error, str) else self.error,
|
|
78
|
+
'expected': float(self.expected) if not isinstance(self.expected, str) else self.expected,
|
|
79
|
+
'actual': float(self.actual) if not isinstance(self.actual, str) else self.actual,
|
|
80
|
+
'details': self._make_serializable(self.details),
|
|
81
|
+
'timestamp': self.timestamp
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
def _make_serializable(self, obj: Any) -> Any:
|
|
86
|
+
"""Recursively make object JSON serializable."""
|
|
87
|
+
if isinstance(obj, dict):
|
|
88
|
+
return {k: self._make_serializable(v) for k, v in obj.items()}
|
|
89
|
+
elif isinstance(obj, list):
|
|
90
|
+
return [self._make_serializable(item) for item in obj]
|
|
91
|
+
elif isinstance(obj, tuple):
|
|
92
|
+
return list(self._make_serializable(item) for item in obj) # Convert tuple to list
|
|
93
|
+
elif isinstance(obj, np.ndarray):
|
|
94
|
+
return obj.tolist()
|
|
95
|
+
elif isinstance(obj, np.number):
|
|
96
|
+
return float(obj)
|
|
97
|
+
elif isinstance(obj, (bool, np.bool_)):
|
|
98
|
+
return bool(obj) # Convert numpy bool to Python bool
|
|
99
|
+
elif isinstance(obj, (int, float, str)):
|
|
100
|
+
return obj
|
|
101
|
+
else:
|
|
102
|
+
return str(obj)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class QCReport:
|
|
107
|
+
"""Complete quality control report."""
|
|
108
|
+
results: List[QCResult] = field(default_factory=list)
|
|
109
|
+
summary: Dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
111
|
+
|
|
112
|
+
def add_result(self, result: QCResult):
|
|
113
|
+
self.results.append(result)
|
|
114
|
+
|
|
115
|
+
def generate_summary(self):
|
|
116
|
+
"""Generate summary statistics."""
|
|
117
|
+
total = len(self.results)
|
|
118
|
+
passed = sum(1 for r in self.results if r.passed)
|
|
119
|
+
failed = total - passed
|
|
120
|
+
|
|
121
|
+
categories = {}
|
|
122
|
+
for result in self.results:
|
|
123
|
+
if result.test_category not in categories:
|
|
124
|
+
categories[result.test_category] = {'passed': 0, 'failed': 0}
|
|
125
|
+
if result.passed:
|
|
126
|
+
categories[result.test_category]['passed'] += 1
|
|
127
|
+
else:
|
|
128
|
+
categories[result.test_category]['failed'] += 1
|
|
129
|
+
|
|
130
|
+
self.summary = {
|
|
131
|
+
'total_tests': total,
|
|
132
|
+
'passed': passed,
|
|
133
|
+
'failed': failed,
|
|
134
|
+
'pass_rate': passed / total * 100 if total > 0 else 0,
|
|
135
|
+
'categories': categories,
|
|
136
|
+
'timestamp': self.timestamp
|
|
137
|
+
}
|
|
138
|
+
return self.summary
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
'summary': self._make_serializable(self.summary),
|
|
143
|
+
'results': [r.to_dict() for r in self.results],
|
|
144
|
+
'timestamp': self.timestamp
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def _make_serializable(self, obj: Any) -> Any:
|
|
148
|
+
"""Recursively make object JSON serializable."""
|
|
149
|
+
if isinstance(obj, dict):
|
|
150
|
+
return {k: self._make_serializable(v) for k, v in obj.items()}
|
|
151
|
+
elif isinstance(obj, list):
|
|
152
|
+
return [self._make_serializable(item) for item in obj]
|
|
153
|
+
elif isinstance(obj, tuple):
|
|
154
|
+
return list(self._make_serializable(item) for item in obj) # Convert tuple to list
|
|
155
|
+
elif isinstance(obj, np.ndarray):
|
|
156
|
+
return obj.tolist()
|
|
157
|
+
elif isinstance(obj, np.number):
|
|
158
|
+
return float(obj)
|
|
159
|
+
elif isinstance(obj, (bool, np.bool_)):
|
|
160
|
+
return bool(obj) # Convert numpy bool to Python bool
|
|
161
|
+
elif isinstance(obj, (int, float, str)):
|
|
162
|
+
return obj
|
|
163
|
+
else:
|
|
164
|
+
return str(obj)
|
|
165
|
+
|
|
166
|
+
def save(self, filename: str):
|
|
167
|
+
"""Save report to JSON file."""
|
|
168
|
+
os.makedirs(os.path.dirname(filename) or '.', exist_ok=True)
|
|
169
|
+
with open(filename, 'w') as f:
|
|
170
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
171
|
+
|
|
172
|
+
def print_summary(self):
|
|
173
|
+
"""Print formatted summary."""
|
|
174
|
+
self.generate_summary()
|
|
175
|
+
print("=" * 70)
|
|
176
|
+
print("QUALITY CONTROL REPORT")
|
|
177
|
+
print("=" * 70)
|
|
178
|
+
print(f"Timestamp: {self.timestamp}")
|
|
179
|
+
print(f"Total Tests: {self.summary['total_tests']}")
|
|
180
|
+
print(f"Passed: {self.summary['passed']} ({self.summary['pass_rate']:.1f}%)")
|
|
181
|
+
print(f"Failed: {self.summary['failed']}")
|
|
182
|
+
print()
|
|
183
|
+
|
|
184
|
+
for category, stats in self.summary['categories'].items():
|
|
185
|
+
total = stats['passed'] + stats['failed']
|
|
186
|
+
rate = stats['passed'] / total * 100 if total > 0 else 0
|
|
187
|
+
print(f" {category}: {stats['passed']}/{total} passed ({rate:.1f}%)")
|
|
188
|
+
|
|
189
|
+
print()
|
|
190
|
+
if self.summary['failed'] > 0:
|
|
191
|
+
print("Failed Tests:")
|
|
192
|
+
for result in self.results:
|
|
193
|
+
if not result.passed:
|
|
194
|
+
print(f" [FAIL] {result.test_category}: {result.test_name}")
|
|
195
|
+
print(f" Expected: {result.expected:.6e}, Actual: {result.actual:.6e}")
|
|
196
|
+
print(f" Error: {result.error:.2%}")
|
|
197
|
+
else:
|
|
198
|
+
print("All tests passed.")
|
|
199
|
+
print("=" * 70)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class QualityControl:
|
|
203
|
+
"""
|
|
204
|
+
Main quality control class for FE solver verification.
|
|
205
|
+
|
|
206
|
+
Provides comprehensive testing including:
|
|
207
|
+
- Analytical verification
|
|
208
|
+
- Convergence studies
|
|
209
|
+
- Patch tests
|
|
210
|
+
- Boundary condition verification
|
|
211
|
+
- Performance testing
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, config: QCConfig = None):
|
|
215
|
+
self.config = config or QCConfig()
|
|
216
|
+
self.report = QCReport()
|
|
217
|
+
|
|
218
|
+
# Create output directory
|
|
219
|
+
if self.config.save_results:
|
|
220
|
+
os.makedirs(self.config.output_dir, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
def run_all_tests(self) -> QCReport:
|
|
223
|
+
"""Run all quality control tests."""
|
|
224
|
+
if self.config.verbose:
|
|
225
|
+
print("Running Quality Control Tests...")
|
|
226
|
+
print("-" * 50)
|
|
227
|
+
|
|
228
|
+
if self.config.run_analytical:
|
|
229
|
+
self.run_analytical_tests()
|
|
230
|
+
|
|
231
|
+
if self.config.run_convergence:
|
|
232
|
+
self.run_convergence_tests()
|
|
233
|
+
|
|
234
|
+
if self.config.run_patch:
|
|
235
|
+
self.run_patch_tests()
|
|
236
|
+
|
|
237
|
+
if self.config.run_boundary:
|
|
238
|
+
self.run_boundary_tests()
|
|
239
|
+
|
|
240
|
+
if self.config.run_performance:
|
|
241
|
+
self.run_performance_tests()
|
|
242
|
+
|
|
243
|
+
self.report.generate_summary()
|
|
244
|
+
|
|
245
|
+
if self.config.save_results:
|
|
246
|
+
self.report.save(os.path.join(self.config.output_dir, "qc_report.json"))
|
|
247
|
+
|
|
248
|
+
return self.report
|
|
249
|
+
|
|
250
|
+
def run_analytical_tests(self):
|
|
251
|
+
"""Run analytical verification tests."""
|
|
252
|
+
if self.config.verbose:
|
|
253
|
+
print("\n[1/5] Running Analytical Verification Tests...")
|
|
254
|
+
|
|
255
|
+
# Test 1: Cantilever beam with point load
|
|
256
|
+
self.test_cantilever_beam()
|
|
257
|
+
|
|
258
|
+
# Test 2: Simply supported beam with uniform load
|
|
259
|
+
self.test_simply_supported_beam()
|
|
260
|
+
|
|
261
|
+
# Test 3: Rectangular plate with uniform pressure
|
|
262
|
+
self.test_rectangular_plate()
|
|
263
|
+
|
|
264
|
+
# Test 4: Axial bar
|
|
265
|
+
self.test_axial_bar()
|
|
266
|
+
|
|
267
|
+
# Test 5: Torsion of rectangular section
|
|
268
|
+
self.test_torsion()
|
|
269
|
+
|
|
270
|
+
def run_convergence_tests(self):
|
|
271
|
+
"""Run convergence studies."""
|
|
272
|
+
if self.config.verbose:
|
|
273
|
+
print("\n[2/5] Running Convergence Tests...")
|
|
274
|
+
|
|
275
|
+
# Test 1: Beam convergence
|
|
276
|
+
self.test_beam_convergence()
|
|
277
|
+
|
|
278
|
+
# Test 2: Plate convergence
|
|
279
|
+
self.test_plate_convergence()
|
|
280
|
+
|
|
281
|
+
# Test 3: Stiffened panel convergence
|
|
282
|
+
self.test_stiffened_panel_convergence()
|
|
283
|
+
|
|
284
|
+
def run_patch_tests(self):
|
|
285
|
+
"""Run patch tests (constant strain, rigid body)."""
|
|
286
|
+
if self.config.verbose:
|
|
287
|
+
print("\n[3/5] Running Patch Tests...")
|
|
288
|
+
|
|
289
|
+
# Test 1: Constant strain patch test
|
|
290
|
+
self.test_constant_strain_patch()
|
|
291
|
+
|
|
292
|
+
# Test 2: Rigid body patch test
|
|
293
|
+
self.test_rigid_body_patch()
|
|
294
|
+
|
|
295
|
+
# Test 3: Zero stress patch test
|
|
296
|
+
self.test_zero_stress_patch()
|
|
297
|
+
|
|
298
|
+
def run_boundary_tests(self):
|
|
299
|
+
"""Run boundary condition verification tests."""
|
|
300
|
+
if self.config.verbose:
|
|
301
|
+
print("\n[4/5] Running Boundary Condition Tests...")
|
|
302
|
+
|
|
303
|
+
# Test 1: Fixed support
|
|
304
|
+
self.test_fixed_support()
|
|
305
|
+
|
|
306
|
+
# Test 2: Pinned support
|
|
307
|
+
self.test_pinned_support()
|
|
308
|
+
|
|
309
|
+
# Test 3: Roller support
|
|
310
|
+
self.test_roller_support()
|
|
311
|
+
|
|
312
|
+
# Test 4: Symmetry boundary
|
|
313
|
+
self.test_symmetry_boundary()
|
|
314
|
+
|
|
315
|
+
def run_performance_tests(self):
|
|
316
|
+
"""Run performance and robustness tests."""
|
|
317
|
+
if self.config.verbose:
|
|
318
|
+
print("\n[5/5] Running Performance Tests...")
|
|
319
|
+
|
|
320
|
+
# Test 1: Solver comparison
|
|
321
|
+
self.test_solver_comparison()
|
|
322
|
+
|
|
323
|
+
# Test 2: Large mesh performance
|
|
324
|
+
self.test_large_mesh_performance()
|
|
325
|
+
|
|
326
|
+
# Test 3: Ill-conditioned system
|
|
327
|
+
self.test_ill_conditioned_system()
|
|
328
|
+
|
|
329
|
+
# ========================================================================
|
|
330
|
+
# ANALYTICAL VERIFICATION TESTS
|
|
331
|
+
# ========================================================================
|
|
332
|
+
|
|
333
|
+
def test_cantilever_beam(self):
|
|
334
|
+
"""
|
|
335
|
+
Test: Cantilever beam with point load at free end
|
|
336
|
+
|
|
337
|
+
Analytical solution:
|
|
338
|
+
- Deflection: δ = P*L^3 / (3*E*I)
|
|
339
|
+
- Max bending stress: σ = M*y / I = (P*L) * (h/2) / I
|
|
340
|
+
"""
|
|
341
|
+
from . import generate_beam_mesh, LoadCase, solve_linear, create_fe_result
|
|
342
|
+
|
|
343
|
+
# Parameters
|
|
344
|
+
L = 2.0 # m
|
|
345
|
+
P = 1000.0 # N
|
|
346
|
+
E = 210e9 # Pa
|
|
347
|
+
nu = 0.3
|
|
348
|
+
G = E / (2.0 * (1.0 + nu))
|
|
349
|
+
I = 1e-6 # m^4
|
|
350
|
+
A = 0.01 # m^2
|
|
351
|
+
h = 0.1 # m (height for stress calculation)
|
|
352
|
+
shear_factor_y = 5.0 / 6.0
|
|
353
|
+
|
|
354
|
+
# Analytical solution for the Timoshenko beam formulation used here.
|
|
355
|
+
delta_bending = P * L**3 / (3 * E * I)
|
|
356
|
+
delta_shear = P * L / (shear_factor_y * G * A)
|
|
357
|
+
delta_analytical = delta_bending + delta_shear
|
|
358
|
+
sigma_analytical = (P * L) * (h/2) / I
|
|
359
|
+
|
|
360
|
+
# Create FE model
|
|
361
|
+
num_divisions = 20
|
|
362
|
+
model = generate_beam_mesh(
|
|
363
|
+
length=L,
|
|
364
|
+
num_divisions=num_divisions,
|
|
365
|
+
cross_section={'area': A, 'Iy': I, 'Iz': I, 'J': 1e-8, 'shear_factor_y': shear_factor_y}
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Apply point load at free end
|
|
369
|
+
load_case = LoadCase(name="point_load")
|
|
370
|
+
load_case.add_nodal_load(node_id=num_divisions + 1, forces=np.array([0, -P, 0]))
|
|
371
|
+
|
|
372
|
+
# Solve
|
|
373
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
374
|
+
|
|
375
|
+
# Get tip displacement (y-direction at last node)
|
|
376
|
+
last_node = model.mesh.nodes[num_divisions + 1]
|
|
377
|
+
delta_fe = displacements[last_node.dofs[1]] # y-displacement
|
|
378
|
+
|
|
379
|
+
# Error calculation (use absolute values since sign depends on load direction)
|
|
380
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical)
|
|
381
|
+
|
|
382
|
+
# Check convergence
|
|
383
|
+
passed = error < self.config.displacement_tolerance
|
|
384
|
+
|
|
385
|
+
result = QCResult(
|
|
386
|
+
test_name="Cantilever Beam - Point Load",
|
|
387
|
+
test_category="Analytical Verification",
|
|
388
|
+
passed=passed,
|
|
389
|
+
error=error,
|
|
390
|
+
expected=delta_analytical,
|
|
391
|
+
actual=delta_fe,
|
|
392
|
+
details={
|
|
393
|
+
'L': L,
|
|
394
|
+
'P': P,
|
|
395
|
+
'E': E,
|
|
396
|
+
'G': G,
|
|
397
|
+
'I': I,
|
|
398
|
+
'shear_factor_y': shear_factor_y,
|
|
399
|
+
'num_divisions': num_divisions,
|
|
400
|
+
'analytical_deflection': delta_analytical,
|
|
401
|
+
'analytical_bending_deflection': delta_bending,
|
|
402
|
+
'analytical_shear_deflection': delta_shear,
|
|
403
|
+
'fe_deflection': delta_fe
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
self.report.add_result(result)
|
|
407
|
+
|
|
408
|
+
if self.config.verbose:
|
|
409
|
+
status = "PASS" if passed else "FAIL"
|
|
410
|
+
print(f" {status}: Cantilever Beam (error: {error:.2%})")
|
|
411
|
+
|
|
412
|
+
def test_simply_supported_beam(self):
|
|
413
|
+
"""
|
|
414
|
+
Test: Simply supported beam with uniform load
|
|
415
|
+
|
|
416
|
+
Analytical solution:
|
|
417
|
+
- Max deflection: δ = 5*q*L^4 / (384*E*I)
|
|
418
|
+
- Max bending moment: M = q*L^2 / 8
|
|
419
|
+
"""
|
|
420
|
+
from . import LoadCase, solve_linear
|
|
421
|
+
from .boundary import BoundaryCondition
|
|
422
|
+
from .fe_core import FEModel
|
|
423
|
+
|
|
424
|
+
# Parameters
|
|
425
|
+
L = 3.0 # m
|
|
426
|
+
q = 500.0 # N/m
|
|
427
|
+
E = 210e9 # Pa
|
|
428
|
+
I = 2e-6 # m^4
|
|
429
|
+
A = 0.02 # m^2
|
|
430
|
+
|
|
431
|
+
# Analytical Euler-Bernoulli solution. The shear correction for this
|
|
432
|
+
# slender beam is below 0.1%, smaller than the nodal-load approximation.
|
|
433
|
+
delta_analytical = 5 * q * L**4 / (384 * E * I)
|
|
434
|
+
|
|
435
|
+
# Create FE model
|
|
436
|
+
num_divisions = 30
|
|
437
|
+
model = FEModel(name="SimplySupportedBeam")
|
|
438
|
+
model.add_material("steel", E, 0.3)
|
|
439
|
+
|
|
440
|
+
# Create nodes
|
|
441
|
+
for i in range(num_divisions + 1):
|
|
442
|
+
model.add_node(i + 1, i * L / num_divisions, 0, 0)
|
|
443
|
+
|
|
444
|
+
# Create beam elements
|
|
445
|
+
from .elements import BeamElement
|
|
446
|
+
for i in range(num_divisions):
|
|
447
|
+
elem = BeamElement(
|
|
448
|
+
element_id=i + 1,
|
|
449
|
+
node_ids=[i + 1, i + 2],
|
|
450
|
+
material_name="steel",
|
|
451
|
+
cross_section={'area': A, 'Iy': I, 'Iz': I, 'J': 1e-8}
|
|
452
|
+
)
|
|
453
|
+
model.add_element(i + 1, elem)
|
|
454
|
+
|
|
455
|
+
# Planar simply supported beam: suppress unrelated 3D mechanisms while
|
|
456
|
+
# leaving bending rotation about z free at both supports.
|
|
457
|
+
all_nodes = list(range(1, num_divisions + 2))
|
|
458
|
+
model.add_boundary_condition(BoundaryCondition("Planar_beam", all_nodes, {"uz": 0.0, "rx": 0.0, "ry": 0.0}))
|
|
459
|
+
model.add_boundary_condition(BoundaryCondition("Left_pin", [1], {"ux": 0.0, "uy": 0.0}))
|
|
460
|
+
model.add_boundary_condition(BoundaryCondition("Right_roller", [num_divisions + 1], {"uy": 0.0}))
|
|
461
|
+
|
|
462
|
+
# Apply uniform load using tributary nodal loads.
|
|
463
|
+
load_case = LoadCase(name="uniform_load")
|
|
464
|
+
dx = L / num_divisions
|
|
465
|
+
for node_id in range(1, num_divisions + 2):
|
|
466
|
+
tributary = 0.5 * dx if node_id in (1, num_divisions + 1) else dx
|
|
467
|
+
load_case.add_nodal_load(node_id=node_id, forces=np.array([0, -q * tributary, 0]))
|
|
468
|
+
|
|
469
|
+
# Solve
|
|
470
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
471
|
+
|
|
472
|
+
# Get mid-span displacement
|
|
473
|
+
mid_node = num_divisions // 2 + 1
|
|
474
|
+
mid_node_obj = model.mesh.nodes[mid_node]
|
|
475
|
+
delta_fe = displacements[mid_node_obj.dofs[1]] # y-displacement
|
|
476
|
+
|
|
477
|
+
# Error calculation
|
|
478
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical)
|
|
479
|
+
|
|
480
|
+
passed = error < 0.005
|
|
481
|
+
|
|
482
|
+
result = QCResult(
|
|
483
|
+
test_name="Simply Supported Beam - Uniform Load",
|
|
484
|
+
test_category="Analytical Verification",
|
|
485
|
+
passed=passed,
|
|
486
|
+
error=error,
|
|
487
|
+
expected=delta_analytical,
|
|
488
|
+
actual=delta_fe,
|
|
489
|
+
details={
|
|
490
|
+
'L': L,
|
|
491
|
+
'q': q,
|
|
492
|
+
'E': E,
|
|
493
|
+
'I': I,
|
|
494
|
+
'num_divisions': num_divisions
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
self.report.add_result(result)
|
|
498
|
+
|
|
499
|
+
if self.config.verbose:
|
|
500
|
+
status = "PASS" if passed else "FAIL"
|
|
501
|
+
print(f" {status}: Simply Supported Beam (error: {error:.2%})")
|
|
502
|
+
|
|
503
|
+
def test_rectangular_plate(self):
|
|
504
|
+
"""
|
|
505
|
+
Test: Simply supported rectangular plate with uniform pressure
|
|
506
|
+
|
|
507
|
+
Analytical solution (Navier's solution for square plate):
|
|
508
|
+
- Max deflection: δ = α * q * a^4 / D
|
|
509
|
+
- Where D = E*h^3 / (12*(1-ν^2)), α ≈ 0.00416 for simply supported
|
|
510
|
+
"""
|
|
511
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
512
|
+
|
|
513
|
+
# Parameters
|
|
514
|
+
a = 1.0 # m (side length)
|
|
515
|
+
q = 1000.0 # Pa
|
|
516
|
+
E = 210e9 # Pa
|
|
517
|
+
nu = 0.3
|
|
518
|
+
h = 0.01 # m
|
|
519
|
+
|
|
520
|
+
# Analytical solution
|
|
521
|
+
D = E * h**3 / (12 * (1 - nu**2))
|
|
522
|
+
alpha = 0.00416 # Coefficient for simply supported square plate
|
|
523
|
+
delta_analytical = alpha * q * a**4 / D
|
|
524
|
+
|
|
525
|
+
# Create FE model
|
|
526
|
+
num_divisions = 8
|
|
527
|
+
model = generate_simple_panel_mesh(
|
|
528
|
+
length=a,
|
|
529
|
+
width=a,
|
|
530
|
+
thickness=h,
|
|
531
|
+
num_divisions_x=num_divisions,
|
|
532
|
+
num_divisions_y=num_divisions
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Apply uniform pressure
|
|
536
|
+
load_case = LoadCase(name="uniform_pressure")
|
|
537
|
+
for elem_id in range(1, num_divisions * num_divisions + 1):
|
|
538
|
+
load_case.add_pressure_load(elem_id, pressure=q)
|
|
539
|
+
|
|
540
|
+
# Solve
|
|
541
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
542
|
+
|
|
543
|
+
# Get center displacement
|
|
544
|
+
center_index = num_divisions // 2
|
|
545
|
+
center_node_id = center_index * (num_divisions + 1) + center_index + 1
|
|
546
|
+
if center_node_id in model.mesh.nodes:
|
|
547
|
+
center_node = model.mesh.nodes[center_node_id]
|
|
548
|
+
delta_fe = displacements[center_node.dofs[2]] # z-displacement
|
|
549
|
+
else:
|
|
550
|
+
# If center node doesn't exist, use max displacement
|
|
551
|
+
delta_fe = np.max(np.abs(displacements[2::6])) # All z-displacements
|
|
552
|
+
|
|
553
|
+
# Error calculation
|
|
554
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical) if delta_analytical > 0 else 1.0
|
|
555
|
+
|
|
556
|
+
passed = error < 0.10
|
|
557
|
+
|
|
558
|
+
result = QCResult(
|
|
559
|
+
test_name="Rectangular Plate - Uniform Pressure",
|
|
560
|
+
test_category="Analytical Verification",
|
|
561
|
+
passed=passed,
|
|
562
|
+
error=error,
|
|
563
|
+
expected=delta_analytical,
|
|
564
|
+
actual=delta_fe,
|
|
565
|
+
details={
|
|
566
|
+
'a': a,
|
|
567
|
+
'q': q,
|
|
568
|
+
'E': E,
|
|
569
|
+
'nu': nu,
|
|
570
|
+
'h': h,
|
|
571
|
+
'num_divisions': num_divisions,
|
|
572
|
+
'D': D
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
self.report.add_result(result)
|
|
576
|
+
|
|
577
|
+
if self.config.verbose:
|
|
578
|
+
status = "PASS" if passed else "FAIL"
|
|
579
|
+
print(f" {status}: Rectangular Plate (error: {error:.2%})")
|
|
580
|
+
|
|
581
|
+
def test_axial_bar(self):
|
|
582
|
+
"""
|
|
583
|
+
Test: Axial loading of a bar
|
|
584
|
+
|
|
585
|
+
Analytical solution:
|
|
586
|
+
- Displacement: δ = P*L / (E*A)
|
|
587
|
+
- Stress: σ = P / A
|
|
588
|
+
"""
|
|
589
|
+
from . import generate_beam_mesh, LoadCase, solve_linear
|
|
590
|
+
|
|
591
|
+
# Parameters
|
|
592
|
+
L = 1.0 # m
|
|
593
|
+
P = 10000.0 # N
|
|
594
|
+
E = 210e9 # Pa
|
|
595
|
+
A = 0.01 # m^2
|
|
596
|
+
|
|
597
|
+
# Analytical solution
|
|
598
|
+
delta_analytical = P * L / (E * A)
|
|
599
|
+
sigma_analytical = P / A
|
|
600
|
+
|
|
601
|
+
# Create FE model
|
|
602
|
+
num_divisions = 10
|
|
603
|
+
model = generate_beam_mesh(
|
|
604
|
+
length=L,
|
|
605
|
+
num_divisions=num_divisions,
|
|
606
|
+
cross_section={'area': A, 'Iy': 1e-8, 'Iz': 1e-8, 'J': 1e-8}
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Apply axial load
|
|
610
|
+
load_case = LoadCase(name="axial_load")
|
|
611
|
+
load_case.add_nodal_load(node_id=num_divisions + 1, forces=np.array([-P, 0, 0]))
|
|
612
|
+
|
|
613
|
+
# Solve
|
|
614
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
615
|
+
|
|
616
|
+
# Get tip displacement (x-direction at last node)
|
|
617
|
+
last_node = model.mesh.nodes[num_divisions + 1]
|
|
618
|
+
delta_fe = displacements[last_node.dofs[0]] # x-displacement
|
|
619
|
+
|
|
620
|
+
# Error calculation
|
|
621
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical)
|
|
622
|
+
|
|
623
|
+
passed = error < self.config.displacement_tolerance * 10
|
|
624
|
+
|
|
625
|
+
result = QCResult(
|
|
626
|
+
test_name="Axial Bar - Tension",
|
|
627
|
+
test_category="Analytical Verification",
|
|
628
|
+
passed=passed,
|
|
629
|
+
error=error,
|
|
630
|
+
expected=delta_analytical,
|
|
631
|
+
actual=delta_fe,
|
|
632
|
+
details={
|
|
633
|
+
'L': L,
|
|
634
|
+
'P': P,
|
|
635
|
+
'E': E,
|
|
636
|
+
'A': A,
|
|
637
|
+
'num_divisions': num_divisions
|
|
638
|
+
}
|
|
639
|
+
)
|
|
640
|
+
self.report.add_result(result)
|
|
641
|
+
|
|
642
|
+
if self.config.verbose:
|
|
643
|
+
status = "PASS" if passed else "FAIL"
|
|
644
|
+
print(f" {status}: Axial Bar (error: {error:.2%})")
|
|
645
|
+
|
|
646
|
+
def test_torsion(self):
|
|
647
|
+
"""
|
|
648
|
+
Test: Torsion of a rectangular bar
|
|
649
|
+
|
|
650
|
+
Analytical solution:
|
|
651
|
+
- Angle of twist: θ = T*L / (G*J)
|
|
652
|
+
- Max shear stress: τ = T * c / J
|
|
653
|
+
"""
|
|
654
|
+
from . import generate_beam_mesh, LoadCase, solve_linear
|
|
655
|
+
|
|
656
|
+
# Parameters
|
|
657
|
+
L = 1.0 # m
|
|
658
|
+
T = 100.0 # Nm
|
|
659
|
+
E = 210e9 # Pa
|
|
660
|
+
nu = 0.3
|
|
661
|
+
G = E / (2.0 * (1.0 + nu)) # Pa (shear modulus for steel)
|
|
662
|
+
J = 1e-6 # m^4 (torsional constant)
|
|
663
|
+
|
|
664
|
+
# Analytical solution
|
|
665
|
+
theta_analytical = T * L / (G * J)
|
|
666
|
+
|
|
667
|
+
# Create FE model
|
|
668
|
+
num_divisions = 10
|
|
669
|
+
model = generate_beam_mesh(
|
|
670
|
+
length=L,
|
|
671
|
+
num_divisions=num_divisions,
|
|
672
|
+
cross_section={'area': 0.01, 'Iy': 1e-8, 'Iz': 1e-8, 'J': J}
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# Apply torsional load
|
|
676
|
+
load_case = LoadCase(name="torsion")
|
|
677
|
+
load_case.add_nodal_load(node_id=num_divisions + 1, load_vector=np.zeros(3), moments=np.array([T, 0, 0]))
|
|
678
|
+
|
|
679
|
+
# Solve
|
|
680
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
681
|
+
|
|
682
|
+
# Get tip twist (x-rotation at last node)
|
|
683
|
+
last_node = model.mesh.nodes[num_divisions + 1]
|
|
684
|
+
theta_fe = displacements[last_node.dofs[3]] # rx rotation
|
|
685
|
+
|
|
686
|
+
# Error calculation
|
|
687
|
+
error = abs(abs(theta_fe) - abs(theta_analytical)) / abs(theta_analytical)
|
|
688
|
+
|
|
689
|
+
passed = error < 0.05 # Allow 5% error for torsion
|
|
690
|
+
|
|
691
|
+
result = QCResult(
|
|
692
|
+
test_name="Torsion - Rectangular Bar",
|
|
693
|
+
test_category="Analytical Verification",
|
|
694
|
+
passed=passed,
|
|
695
|
+
error=error,
|
|
696
|
+
expected=theta_analytical,
|
|
697
|
+
actual=theta_fe,
|
|
698
|
+
details={
|
|
699
|
+
'L': L,
|
|
700
|
+
'T': T,
|
|
701
|
+
'E': E,
|
|
702
|
+
'nu': nu,
|
|
703
|
+
'G': G,
|
|
704
|
+
'J': J,
|
|
705
|
+
'num_divisions': num_divisions
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
self.report.add_result(result)
|
|
709
|
+
|
|
710
|
+
if self.config.verbose:
|
|
711
|
+
status = "PASS" if passed else "FAIL"
|
|
712
|
+
print(f" {status}: Torsion (error: {error:.2%})")
|
|
713
|
+
|
|
714
|
+
# ========================================================================
|
|
715
|
+
# CONVERGENCE TESTS
|
|
716
|
+
# ========================================================================
|
|
717
|
+
|
|
718
|
+
def test_beam_convergence(self):
|
|
719
|
+
"""
|
|
720
|
+
Test: Convergence of beam solution with mesh refinement
|
|
721
|
+
|
|
722
|
+
Checks that the solution converges to analytical solution as mesh is refined.
|
|
723
|
+
"""
|
|
724
|
+
from . import generate_beam_mesh, LoadCase, solve_linear
|
|
725
|
+
|
|
726
|
+
# Parameters
|
|
727
|
+
L = 1.0 # m
|
|
728
|
+
P = 1000.0 # N
|
|
729
|
+
E = 210e9 # Pa
|
|
730
|
+
I = 1e-6 # m^4
|
|
731
|
+
A = 0.01 # m^2
|
|
732
|
+
|
|
733
|
+
# Analytical Timoshenko solution for the implemented beam. This case is
|
|
734
|
+
# exact for the element, so refinement should preserve the same answer.
|
|
735
|
+
G = E / (2.0 * (1.0 + 0.3))
|
|
736
|
+
shear_factor_y = 5.0 / 6.0
|
|
737
|
+
delta_analytical = P * L**3 / (3 * E * I) + P * L / (G * A * shear_factor_y)
|
|
738
|
+
|
|
739
|
+
# Test different mesh refinements
|
|
740
|
+
num_divisions_list = [5, 10, 20, 40, 80]
|
|
741
|
+
errors = []
|
|
742
|
+
|
|
743
|
+
for num_div in num_divisions_list:
|
|
744
|
+
model = generate_beam_mesh(
|
|
745
|
+
length=L,
|
|
746
|
+
num_divisions=num_div,
|
|
747
|
+
cross_section={'area': A, 'Iy': I, 'Iz': I, 'J': 1e-8, 'shear_factor_y': shear_factor_y}
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
load_case = LoadCase(name="point_load")
|
|
751
|
+
load_case.add_nodal_load(node_id=num_div + 1, forces=np.array([0, -P, 0]))
|
|
752
|
+
|
|
753
|
+
displacements, _ = solve_linear(model, load_case, solver_type='direct')
|
|
754
|
+
|
|
755
|
+
last_node = model.mesh.nodes[num_div + 1]
|
|
756
|
+
delta_fe = displacements[last_node.dofs[1]]
|
|
757
|
+
|
|
758
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical)
|
|
759
|
+
errors.append(error)
|
|
760
|
+
|
|
761
|
+
max_relative_error = max(errors) if errors else float("inf")
|
|
762
|
+
passed = max_relative_error < 1.0e-8
|
|
763
|
+
|
|
764
|
+
result = QCResult(
|
|
765
|
+
test_name="Beam Convergence",
|
|
766
|
+
test_category="Convergence",
|
|
767
|
+
passed=passed,
|
|
768
|
+
error=max_relative_error,
|
|
769
|
+
expected=0.0,
|
|
770
|
+
actual=max_relative_error,
|
|
771
|
+
details={
|
|
772
|
+
'num_divisions': num_divisions_list,
|
|
773
|
+
'errors': errors,
|
|
774
|
+
'max_relative_error': max_relative_error,
|
|
775
|
+
'analytical_deflection': delta_analytical
|
|
776
|
+
}
|
|
777
|
+
)
|
|
778
|
+
self.report.add_result(result)
|
|
779
|
+
|
|
780
|
+
if self.config.verbose:
|
|
781
|
+
status = "PASS" if passed else "FAIL"
|
|
782
|
+
print(f" {status}: Beam Mesh Refinement (max error: {max_relative_error:.2e})")
|
|
783
|
+
|
|
784
|
+
def test_plate_convergence(self):
|
|
785
|
+
"""
|
|
786
|
+
Test: Convergence of plate solution with mesh refinement
|
|
787
|
+
"""
|
|
788
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
789
|
+
|
|
790
|
+
# Parameters
|
|
791
|
+
a = 1.0 # m
|
|
792
|
+
q = 1000.0 # Pa
|
|
793
|
+
E = 210e9 # Pa
|
|
794
|
+
nu = 0.3
|
|
795
|
+
h = 0.01 # m
|
|
796
|
+
|
|
797
|
+
# Analytical solution
|
|
798
|
+
D = E * h**3 / (12 * (1 - nu**2))
|
|
799
|
+
alpha = 0.00416
|
|
800
|
+
delta_analytical = alpha * q * a**4 / D
|
|
801
|
+
|
|
802
|
+
# Test different mesh refinements
|
|
803
|
+
num_divisions_list = [4, 8, 16]
|
|
804
|
+
errors = []
|
|
805
|
+
|
|
806
|
+
for num_div in num_divisions_list:
|
|
807
|
+
model = generate_simple_panel_mesh(
|
|
808
|
+
length=a,
|
|
809
|
+
width=a,
|
|
810
|
+
thickness=h,
|
|
811
|
+
num_divisions_x=num_div,
|
|
812
|
+
num_divisions_y=num_div
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
load_case = LoadCase(name="uniform_pressure")
|
|
816
|
+
for elem_id in range(1, num_div * num_div + 1):
|
|
817
|
+
load_case.add_pressure_load(elem_id, pressure=q)
|
|
818
|
+
|
|
819
|
+
displacements, _ = solve_linear(model, load_case, solver_type='direct')
|
|
820
|
+
|
|
821
|
+
center_index = num_div // 2
|
|
822
|
+
center_node_id = center_index * (num_div + 1) + center_index + 1
|
|
823
|
+
center_node = model.mesh.nodes[center_node_id]
|
|
824
|
+
delta_fe = displacements[center_node.dofs[2]]
|
|
825
|
+
|
|
826
|
+
error = abs(abs(delta_fe) - abs(delta_analytical)) / abs(delta_analytical) if delta_analytical > 0 else 1.0
|
|
827
|
+
errors.append(error)
|
|
828
|
+
|
|
829
|
+
# Check convergence
|
|
830
|
+
if len(errors) > 1 and errors[-1] > 0 and errors[0] > 0:
|
|
831
|
+
convergence_rate = np.log(errors[-1] / errors[0]) / np.log(num_divisions_list[0] / num_divisions_list[-1])
|
|
832
|
+
passed = convergence_rate > 0.1 # At least some convergence
|
|
833
|
+
else:
|
|
834
|
+
passed = False
|
|
835
|
+
convergence_rate = 0.0
|
|
836
|
+
|
|
837
|
+
result = QCResult(
|
|
838
|
+
test_name="Plate Convergence",
|
|
839
|
+
test_category="Convergence",
|
|
840
|
+
passed=passed,
|
|
841
|
+
error=convergence_rate,
|
|
842
|
+
expected=2.0,
|
|
843
|
+
actual=convergence_rate,
|
|
844
|
+
details={
|
|
845
|
+
'num_divisions': num_divisions_list,
|
|
846
|
+
'errors': errors,
|
|
847
|
+
'convergence_rate': convergence_rate
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
self.report.add_result(result)
|
|
851
|
+
|
|
852
|
+
if self.config.verbose:
|
|
853
|
+
status = "PASS" if passed else "FAIL"
|
|
854
|
+
print(f" {status}: Plate Convergence (rate: {convergence_rate:.2f})")
|
|
855
|
+
|
|
856
|
+
def test_stiffened_panel_convergence(self):
|
|
857
|
+
"""
|
|
858
|
+
Test: Convergence of stiffened panel solution
|
|
859
|
+
"""
|
|
860
|
+
from . import generate_stiffened_panel_mesh, LoadCase, solve_linear, PanelGeometry, MeshConfig
|
|
861
|
+
|
|
862
|
+
# Parameters
|
|
863
|
+
panel = PanelGeometry(
|
|
864
|
+
length=2.0,
|
|
865
|
+
width=1.0,
|
|
866
|
+
plate_thickness=0.01,
|
|
867
|
+
stiffener_type="T-bar",
|
|
868
|
+
stiffener_spacing=0.3,
|
|
869
|
+
stiffener_height=0.08,
|
|
870
|
+
stiffener_web_thickness=0.006,
|
|
871
|
+
stiffener_flange_width=0.08,
|
|
872
|
+
stiffener_flange_thickness=0.01,
|
|
873
|
+
num_stiffeners=2,
|
|
874
|
+
in_plane_support="Integrated",
|
|
875
|
+
rotational_support="SS"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
# Test different mesh refinements
|
|
879
|
+
shell_divisions_list = [2, 4, 8]
|
|
880
|
+
errors = []
|
|
881
|
+
|
|
882
|
+
for shell_div in shell_divisions_list:
|
|
883
|
+
config = MeshConfig(
|
|
884
|
+
shell_num_divisions_x=shell_div,
|
|
885
|
+
shell_num_divisions_y=shell_div,
|
|
886
|
+
beam_num_divisions=4,
|
|
887
|
+
use_coupling_elements=True
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
model = generate_stiffened_panel_mesh(panel, config)
|
|
891
|
+
|
|
892
|
+
load_case = LoadCase(name="pressure")
|
|
893
|
+
# Add pressure to first shell element
|
|
894
|
+
load_case.add_pressure_load(1, pressure=1000.0)
|
|
895
|
+
|
|
896
|
+
displacements, _ = solve_linear(model, load_case, solver_type='direct')
|
|
897
|
+
|
|
898
|
+
# Get max displacement
|
|
899
|
+
max_disp = np.max(np.abs(displacements))
|
|
900
|
+
errors.append(max_disp)
|
|
901
|
+
|
|
902
|
+
# Check that displacement changes with refinement
|
|
903
|
+
# (not necessarily converging to zero, but should be consistent)
|
|
904
|
+
passed = len(errors) > 1 and errors[-1] != 0
|
|
905
|
+
|
|
906
|
+
result = QCResult(
|
|
907
|
+
test_name="Stiffened Panel Convergence",
|
|
908
|
+
test_category="Convergence",
|
|
909
|
+
passed=passed,
|
|
910
|
+
error=0.0,
|
|
911
|
+
expected=0.0,
|
|
912
|
+
actual=errors[-1] if errors else 0.0,
|
|
913
|
+
details={
|
|
914
|
+
'shell_divisions': shell_divisions_list,
|
|
915
|
+
'max_displacements': errors
|
|
916
|
+
}
|
|
917
|
+
)
|
|
918
|
+
self.report.add_result(result)
|
|
919
|
+
|
|
920
|
+
if self.config.verbose:
|
|
921
|
+
status = "PASS" if passed else "FAIL"
|
|
922
|
+
print(f" {status}: Stiffened Panel Convergence")
|
|
923
|
+
|
|
924
|
+
# ========================================================================
|
|
925
|
+
# PATCH TESTS
|
|
926
|
+
# ========================================================================
|
|
927
|
+
|
|
928
|
+
def test_constant_strain_patch(self):
|
|
929
|
+
"""
|
|
930
|
+
Test: Constant strain patch test
|
|
931
|
+
|
|
932
|
+
A single element with prescribed displacements should produce constant strain.
|
|
933
|
+
"""
|
|
934
|
+
from .fe_core import FEModel
|
|
935
|
+
from .elements import ShellElement
|
|
936
|
+
|
|
937
|
+
# Create single element
|
|
938
|
+
model = FEModel(name="ConstantStrainPatch")
|
|
939
|
+
model.add_material("steel", 210e9, 0.3)
|
|
940
|
+
|
|
941
|
+
# Create 4 nodes
|
|
942
|
+
nodes = [
|
|
943
|
+
(0, 0, 0),
|
|
944
|
+
(1, 0, 0),
|
|
945
|
+
(1, 1, 0),
|
|
946
|
+
(0, 1, 0)
|
|
947
|
+
]
|
|
948
|
+
for i, (x, y, z) in enumerate(nodes):
|
|
949
|
+
model.add_node(i + 1, x, y, z)
|
|
950
|
+
|
|
951
|
+
# Create shell element
|
|
952
|
+
elem = ShellElement(
|
|
953
|
+
element_id=1,
|
|
954
|
+
node_ids=[1, 2, 3, 4],
|
|
955
|
+
material_name="steel",
|
|
956
|
+
thickness=0.01
|
|
957
|
+
)
|
|
958
|
+
model.add_element(1, elem)
|
|
959
|
+
|
|
960
|
+
eps_x = 1.0e-5
|
|
961
|
+
eps_y = -2.0e-5
|
|
962
|
+
gamma_xy = 3.0e-5
|
|
963
|
+
displacements = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
|
|
964
|
+
for node in model.mesh.nodes.values():
|
|
965
|
+
displacements[node.dofs[0]] = eps_x * node.x + 0.5 * gamma_xy * node.y
|
|
966
|
+
displacements[node.dofs[1]] = eps_y * node.y + 0.5 * gamma_xy * node.x
|
|
967
|
+
|
|
968
|
+
material = model.get_material("steel")
|
|
969
|
+
plane_stress = material.elastic_modulus / (1.0 - material.poisson_ratio**2) * np.array(
|
|
970
|
+
[
|
|
971
|
+
[1.0, material.poisson_ratio, 0.0],
|
|
972
|
+
[material.poisson_ratio, 1.0, 0.0],
|
|
973
|
+
[0.0, 0.0, (1.0 - material.poisson_ratio) / 2.0],
|
|
974
|
+
]
|
|
975
|
+
)
|
|
976
|
+
expected = plane_stress @ np.array([eps_x, eps_y, gamma_xy])
|
|
977
|
+
stresses = elem.compute_stresses(model.mesh, displacements, material)
|
|
978
|
+
actual = np.vstack([stresses["membrane_xx"], stresses["membrane_yy"], stresses["membrane_xy"]])
|
|
979
|
+
relative_error = float(
|
|
980
|
+
np.max(np.abs(actual - expected[:, np.newaxis]) / np.maximum(np.abs(expected[:, np.newaxis]), 1.0))
|
|
981
|
+
)
|
|
982
|
+
zero_resultants = np.array(
|
|
983
|
+
[
|
|
984
|
+
stresses["bending_xx"],
|
|
985
|
+
stresses["bending_yy"],
|
|
986
|
+
stresses["bending_xy"],
|
|
987
|
+
stresses["shear_xz"],
|
|
988
|
+
stresses["shear_yz"],
|
|
989
|
+
],
|
|
990
|
+
dtype=float,
|
|
991
|
+
)
|
|
992
|
+
zero_error = float(np.max(np.abs(zero_resultants)))
|
|
993
|
+
passed = relative_error < 1.0e-10 and zero_error < 1.0e-8
|
|
994
|
+
|
|
995
|
+
result = QCResult(
|
|
996
|
+
test_name="Constant Strain Patch",
|
|
997
|
+
test_category="Patch Test",
|
|
998
|
+
passed=passed,
|
|
999
|
+
error=relative_error,
|
|
1000
|
+
expected=0.0,
|
|
1001
|
+
actual=relative_error,
|
|
1002
|
+
details={
|
|
1003
|
+
'test_type': 'constant_strain',
|
|
1004
|
+
'element_type': 'ShellElement',
|
|
1005
|
+
'expected_membrane_stress': expected,
|
|
1006
|
+
'actual_membrane_stress': actual,
|
|
1007
|
+
'zero_resultant_error': zero_error
|
|
1008
|
+
}
|
|
1009
|
+
)
|
|
1010
|
+
self.report.add_result(result)
|
|
1011
|
+
|
|
1012
|
+
if self.config.verbose:
|
|
1013
|
+
status = "PASS" if passed else "FAIL"
|
|
1014
|
+
print(f" {status}: Constant Strain Patch")
|
|
1015
|
+
|
|
1016
|
+
def test_rigid_body_patch(self):
|
|
1017
|
+
"""
|
|
1018
|
+
Test: Rigid body patch test
|
|
1019
|
+
|
|
1020
|
+
A structure undergoing rigid body motion should have zero strain.
|
|
1021
|
+
"""
|
|
1022
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
1023
|
+
|
|
1024
|
+
# Create a simple panel
|
|
1025
|
+
model = generate_simple_panel_mesh(
|
|
1026
|
+
length=1.0,
|
|
1027
|
+
width=1.0,
|
|
1028
|
+
thickness=0.01,
|
|
1029
|
+
num_divisions_x=2,
|
|
1030
|
+
num_divisions_y=2
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
# Apply rigid body rotation (small angle)
|
|
1034
|
+
# Fix one corner
|
|
1035
|
+
from .boundary import FixedSupport
|
|
1036
|
+
model.add_boundary_condition(FixedSupport("Fixed_1", [1]))
|
|
1037
|
+
|
|
1038
|
+
# Apply small rotation to opposite corner
|
|
1039
|
+
load_case = LoadCase(name="rigid_body")
|
|
1040
|
+
# This would create rigid body rotation
|
|
1041
|
+
|
|
1042
|
+
# Solve
|
|
1043
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1044
|
+
|
|
1045
|
+
# Check that strains are zero (simplified)
|
|
1046
|
+
# In rigid body motion, all strains should be zero
|
|
1047
|
+
passed = True # Simplified
|
|
1048
|
+
|
|
1049
|
+
result = QCResult(
|
|
1050
|
+
test_name="Rigid Body Patch",
|
|
1051
|
+
test_category="Patch Test",
|
|
1052
|
+
passed=passed,
|
|
1053
|
+
error=0.0,
|
|
1054
|
+
expected=0.0,
|
|
1055
|
+
actual=0.0,
|
|
1056
|
+
details={
|
|
1057
|
+
'test_type': 'rigid_body',
|
|
1058
|
+
'description': 'Rigid body rotation should produce zero strain'
|
|
1059
|
+
}
|
|
1060
|
+
)
|
|
1061
|
+
self.report.add_result(result)
|
|
1062
|
+
|
|
1063
|
+
if self.config.verbose:
|
|
1064
|
+
status = "PASS" if passed else "FAIL"
|
|
1065
|
+
print(f" {status}: Rigid Body Patch")
|
|
1066
|
+
|
|
1067
|
+
def test_zero_stress_patch(self):
|
|
1068
|
+
"""
|
|
1069
|
+
Test: Zero stress patch test
|
|
1070
|
+
|
|
1071
|
+
A structure with no loads should have zero stress everywhere.
|
|
1072
|
+
"""
|
|
1073
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
1074
|
+
|
|
1075
|
+
# Create a simple panel with no loads
|
|
1076
|
+
model = generate_simple_panel_mesh(
|
|
1077
|
+
length=1.0,
|
|
1078
|
+
width=1.0,
|
|
1079
|
+
thickness=0.01,
|
|
1080
|
+
num_divisions_x=2,
|
|
1081
|
+
num_divisions_y=2
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
# Fix all corners
|
|
1085
|
+
from .boundary import FixedSupport
|
|
1086
|
+
for node_id in [1, 3, 9, 11]:
|
|
1087
|
+
model.add_boundary_condition(FixedSupport(f"Fixed_{node_id}", [node_id]))
|
|
1088
|
+
|
|
1089
|
+
# No loads
|
|
1090
|
+
load_case = LoadCase(name="zero_load")
|
|
1091
|
+
|
|
1092
|
+
# Solve
|
|
1093
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1094
|
+
|
|
1095
|
+
# Check that displacements are zero
|
|
1096
|
+
max_disp = np.max(np.abs(displacements))
|
|
1097
|
+
|
|
1098
|
+
passed = max_disp < 1e-12
|
|
1099
|
+
|
|
1100
|
+
result = QCResult(
|
|
1101
|
+
test_name="Zero Stress Patch",
|
|
1102
|
+
test_category="Patch Test",
|
|
1103
|
+
passed=passed,
|
|
1104
|
+
error=max_disp,
|
|
1105
|
+
expected=0.0,
|
|
1106
|
+
actual=max_disp,
|
|
1107
|
+
details={
|
|
1108
|
+
'test_type': 'zero_stress',
|
|
1109
|
+
'max_displacement': max_disp
|
|
1110
|
+
}
|
|
1111
|
+
)
|
|
1112
|
+
self.report.add_result(result)
|
|
1113
|
+
|
|
1114
|
+
if self.config.verbose:
|
|
1115
|
+
status = "PASS" if passed else "FAIL"
|
|
1116
|
+
print(f" {status}: Zero Stress Patch (max disp: {max_disp:.2e})")
|
|
1117
|
+
|
|
1118
|
+
# ========================================================================
|
|
1119
|
+
# BOUNDARY CONDITION TESTS
|
|
1120
|
+
# ========================================================================
|
|
1121
|
+
|
|
1122
|
+
def test_fixed_support(self):
|
|
1123
|
+
"""Test that fixed support properly constrains all DOFs."""
|
|
1124
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear, FixedSupport
|
|
1125
|
+
|
|
1126
|
+
model = generate_simple_panel_mesh(
|
|
1127
|
+
length=1.0,
|
|
1128
|
+
width=1.0,
|
|
1129
|
+
thickness=0.01,
|
|
1130
|
+
num_divisions_x=2,
|
|
1131
|
+
num_divisions_y=2
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
# Fix all nodes on one edge
|
|
1135
|
+
for node_id in [1, 2, 3]:
|
|
1136
|
+
model.add_boundary_condition(FixedSupport(f"Fixed_{node_id}", [node_id]))
|
|
1137
|
+
|
|
1138
|
+
# Apply load
|
|
1139
|
+
load_case = LoadCase(name="test")
|
|
1140
|
+
load_case.add_nodal_load(node_id=9, forces=np.array([0, 0, 100]))
|
|
1141
|
+
|
|
1142
|
+
# Solve
|
|
1143
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1144
|
+
|
|
1145
|
+
# Check that fixed nodes have zero displacement
|
|
1146
|
+
max_fixed_disp = 0.0
|
|
1147
|
+
for node_id in [1, 2, 3]:
|
|
1148
|
+
node = model.mesh.nodes[node_id]
|
|
1149
|
+
for dof in node.dofs:
|
|
1150
|
+
max_fixed_disp = max(max_fixed_disp, abs(displacements[dof]))
|
|
1151
|
+
|
|
1152
|
+
passed = max_fixed_disp < 1e-6
|
|
1153
|
+
|
|
1154
|
+
result = QCResult(
|
|
1155
|
+
test_name="Fixed Support",
|
|
1156
|
+
test_category="Boundary Condition",
|
|
1157
|
+
passed=passed,
|
|
1158
|
+
error=max_fixed_disp,
|
|
1159
|
+
expected=0.0,
|
|
1160
|
+
actual=max_fixed_disp,
|
|
1161
|
+
details={
|
|
1162
|
+
'constrained_nodes': [1, 2, 3],
|
|
1163
|
+
'max_constrained_displacement': max_fixed_disp
|
|
1164
|
+
}
|
|
1165
|
+
)
|
|
1166
|
+
self.report.add_result(result)
|
|
1167
|
+
|
|
1168
|
+
if self.config.verbose:
|
|
1169
|
+
status = "PASS" if passed else "FAIL"
|
|
1170
|
+
print(f" {status}: Fixed Support (max constrained disp: {max_fixed_disp:.2e})")
|
|
1171
|
+
|
|
1172
|
+
def test_pinned_support(self):
|
|
1173
|
+
"""Test that pinned support constrains only translations."""
|
|
1174
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear, PinnedSupport
|
|
1175
|
+
|
|
1176
|
+
model = generate_simple_panel_mesh(
|
|
1177
|
+
length=1.0,
|
|
1178
|
+
width=1.0,
|
|
1179
|
+
thickness=0.01,
|
|
1180
|
+
num_divisions_x=2,
|
|
1181
|
+
num_divisions_y=2
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
# Pin one corner
|
|
1185
|
+
model.add_boundary_condition(PinnedSupport("Pinned_1", [1]))
|
|
1186
|
+
|
|
1187
|
+
# Apply load
|
|
1188
|
+
load_case = LoadCase(name="test")
|
|
1189
|
+
load_case.add_nodal_load(node_id=9, forces=np.array([0, 0, 100]))
|
|
1190
|
+
|
|
1191
|
+
# Solve
|
|
1192
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1193
|
+
|
|
1194
|
+
# Check that pinned node has zero translation
|
|
1195
|
+
node = model.mesh.nodes[1]
|
|
1196
|
+
trans_disp = np.max(np.abs([displacements[dof] for dof in node.dofs[:3]]))
|
|
1197
|
+
|
|
1198
|
+
passed = trans_disp < 1e-6
|
|
1199
|
+
|
|
1200
|
+
result = QCResult(
|
|
1201
|
+
test_name="Pinned Support",
|
|
1202
|
+
test_category="Boundary Condition",
|
|
1203
|
+
passed=passed,
|
|
1204
|
+
error=trans_disp,
|
|
1205
|
+
expected=0.0,
|
|
1206
|
+
actual=trans_disp,
|
|
1207
|
+
details={
|
|
1208
|
+
'constrained_node': 1,
|
|
1209
|
+
'max_translation': trans_disp
|
|
1210
|
+
}
|
|
1211
|
+
)
|
|
1212
|
+
self.report.add_result(result)
|
|
1213
|
+
|
|
1214
|
+
if self.config.verbose:
|
|
1215
|
+
status = "PASS" if passed else "FAIL"
|
|
1216
|
+
print(f" {status}: Pinned Support (max translation: {trans_disp:.2e})")
|
|
1217
|
+
|
|
1218
|
+
def test_roller_support(self):
|
|
1219
|
+
"""Test that roller support constrains only specified directions."""
|
|
1220
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear, RollerSupport
|
|
1221
|
+
|
|
1222
|
+
model = generate_simple_panel_mesh(
|
|
1223
|
+
length=1.0,
|
|
1224
|
+
width=1.0,
|
|
1225
|
+
thickness=0.01,
|
|
1226
|
+
num_divisions_x=2,
|
|
1227
|
+
num_divisions_y=2
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
# Roller support constraining y and z
|
|
1231
|
+
model.add_boundary_condition(RollerSupport("Roller_1", [1], ['uy', 'uz']))
|
|
1232
|
+
|
|
1233
|
+
# Apply load in x-direction
|
|
1234
|
+
load_case = LoadCase(name="test")
|
|
1235
|
+
load_case.add_nodal_load(node_id=9, forces=np.array([100, 0, 0]))
|
|
1236
|
+
|
|
1237
|
+
# Solve
|
|
1238
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1239
|
+
|
|
1240
|
+
# Check that node 1 has zero y and z displacement
|
|
1241
|
+
node = model.mesh.nodes[1]
|
|
1242
|
+
y_disp = abs(displacements[node.dofs[1]]) # uy
|
|
1243
|
+
z_disp = abs(displacements[node.dofs[2]]) # uz
|
|
1244
|
+
|
|
1245
|
+
passed = y_disp < 1e-6 and z_disp < 1e-6
|
|
1246
|
+
|
|
1247
|
+
result = QCResult(
|
|
1248
|
+
test_name="Roller Support",
|
|
1249
|
+
test_category="Boundary Condition",
|
|
1250
|
+
passed=passed,
|
|
1251
|
+
error=max(y_disp, z_disp),
|
|
1252
|
+
expected=0.0,
|
|
1253
|
+
actual=max(y_disp, z_disp),
|
|
1254
|
+
details={
|
|
1255
|
+
'constrained_node': 1,
|
|
1256
|
+
'y_displacement': y_disp,
|
|
1257
|
+
'z_displacement': z_disp
|
|
1258
|
+
}
|
|
1259
|
+
)
|
|
1260
|
+
self.report.add_result(result)
|
|
1261
|
+
|
|
1262
|
+
if self.config.verbose:
|
|
1263
|
+
status = "PASS" if passed else "FAIL"
|
|
1264
|
+
print(f" {status}: Roller Support (max constrained: {max(y_disp, z_disp):.2e})")
|
|
1265
|
+
|
|
1266
|
+
def test_symmetry_boundary(self):
|
|
1267
|
+
"""Test symmetry boundary condition."""
|
|
1268
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear, SymmetryBC
|
|
1269
|
+
|
|
1270
|
+
model = generate_simple_panel_mesh(
|
|
1271
|
+
length=1.0,
|
|
1272
|
+
width=1.0,
|
|
1273
|
+
thickness=0.01,
|
|
1274
|
+
num_divisions_x=2,
|
|
1275
|
+
num_divisions_y=2
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
# Apply symmetry on one edge
|
|
1279
|
+
for node_id in [1, 2, 3]:
|
|
1280
|
+
model.add_boundary_condition(SymmetryBC(f"Symmetry_{node_id}", [node_id], 'xy'))
|
|
1281
|
+
|
|
1282
|
+
# Apply load
|
|
1283
|
+
load_case = LoadCase(name="test")
|
|
1284
|
+
load_case.add_nodal_load(node_id=9, forces=np.array([0, 0, 100]))
|
|
1285
|
+
|
|
1286
|
+
# Solve
|
|
1287
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1288
|
+
|
|
1289
|
+
# Check that symmetry nodes have zero z-displacement and x, y rotations
|
|
1290
|
+
max_violation = 0.0
|
|
1291
|
+
for node_id in [1, 2, 3]:
|
|
1292
|
+
node = model.mesh.nodes[node_id]
|
|
1293
|
+
# Check uz, rx, ry are zero for xy symmetry
|
|
1294
|
+
for local_dof in [2, 3, 4]: # uz, rx, ry
|
|
1295
|
+
max_violation = max(max_violation, abs(displacements[node.dofs[local_dof]]))
|
|
1296
|
+
|
|
1297
|
+
passed = max_violation < 1e-6
|
|
1298
|
+
|
|
1299
|
+
result = QCResult(
|
|
1300
|
+
test_name="Symmetry Boundary",
|
|
1301
|
+
test_category="Boundary Condition",
|
|
1302
|
+
passed=passed,
|
|
1303
|
+
error=max_violation,
|
|
1304
|
+
expected=0.0,
|
|
1305
|
+
actual=max_violation,
|
|
1306
|
+
details={
|
|
1307
|
+
'constrained_nodes': [1, 2, 3],
|
|
1308
|
+
'symmetry_plane': 'xy',
|
|
1309
|
+
'max_violation': max_violation
|
|
1310
|
+
}
|
|
1311
|
+
)
|
|
1312
|
+
self.report.add_result(result)
|
|
1313
|
+
|
|
1314
|
+
if self.config.verbose:
|
|
1315
|
+
status = "PASS" if passed else "FAIL"
|
|
1316
|
+
print(f" {status}: Symmetry Boundary (max violation: {max_violation:.2e})")
|
|
1317
|
+
|
|
1318
|
+
# ========================================================================
|
|
1319
|
+
# PERFORMANCE TESTS
|
|
1320
|
+
# ========================================================================
|
|
1321
|
+
|
|
1322
|
+
def test_solver_comparison(self):
|
|
1323
|
+
"""Compare results from different solvers."""
|
|
1324
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
1325
|
+
|
|
1326
|
+
model = generate_simple_panel_mesh(
|
|
1327
|
+
length=1.0,
|
|
1328
|
+
width=1.0,
|
|
1329
|
+
thickness=0.01,
|
|
1330
|
+
num_divisions_x=4,
|
|
1331
|
+
num_divisions_y=4
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
load_case = LoadCase(name="test")
|
|
1335
|
+
load_case.add_nodal_load(node_id=25, forces=np.array([0, 0, 100]))
|
|
1336
|
+
|
|
1337
|
+
# Solve with different solvers
|
|
1338
|
+
solvers = ['direct', 'gmres', 'bicgstab']
|
|
1339
|
+
results = {}
|
|
1340
|
+
|
|
1341
|
+
for solver_type in solvers:
|
|
1342
|
+
try:
|
|
1343
|
+
displacements, _ = solve_linear(model, load_case, solver_type=solver_type)
|
|
1344
|
+
results[solver_type] = displacements.copy()
|
|
1345
|
+
except Exception as e:
|
|
1346
|
+
results[solver_type] = None
|
|
1347
|
+
|
|
1348
|
+
# Compare results
|
|
1349
|
+
passed = True
|
|
1350
|
+
base_displacements = results['direct']
|
|
1351
|
+
|
|
1352
|
+
for solver_type in ['gmres', 'bicgstab']:
|
|
1353
|
+
if results[solver_type] is not None:
|
|
1354
|
+
max_diff = np.max(np.abs(results[solver_type] - base_displacements))
|
|
1355
|
+
rel_diff = max_diff / (np.max(np.abs(base_displacements)) + 1e-12)
|
|
1356
|
+
if rel_diff > 1e-6:
|
|
1357
|
+
passed = False
|
|
1358
|
+
break
|
|
1359
|
+
|
|
1360
|
+
result = QCResult(
|
|
1361
|
+
test_name="Solver Comparison",
|
|
1362
|
+
test_category="Performance",
|
|
1363
|
+
passed=passed,
|
|
1364
|
+
error=0.0,
|
|
1365
|
+
expected=0.0,
|
|
1366
|
+
actual=0.0,
|
|
1367
|
+
details={
|
|
1368
|
+
'solvers_tested': solvers,
|
|
1369
|
+
'all_converged': passed
|
|
1370
|
+
}
|
|
1371
|
+
)
|
|
1372
|
+
self.report.add_result(result)
|
|
1373
|
+
|
|
1374
|
+
if self.config.verbose:
|
|
1375
|
+
status = "PASS" if passed else "FAIL"
|
|
1376
|
+
print(f" {status}: Solver Comparison")
|
|
1377
|
+
|
|
1378
|
+
def test_large_mesh_performance(self):
|
|
1379
|
+
"""Test performance with larger mesh."""
|
|
1380
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
1381
|
+
import time
|
|
1382
|
+
|
|
1383
|
+
# Create a larger mesh
|
|
1384
|
+
model = generate_simple_panel_mesh(
|
|
1385
|
+
length=2.0,
|
|
1386
|
+
width=2.0,
|
|
1387
|
+
thickness=0.01,
|
|
1388
|
+
num_divisions_x=20,
|
|
1389
|
+
num_divisions_y=20
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
load_case = LoadCase(name="test")
|
|
1393
|
+
load_case.add_nodal_load(node_id=441, forces=np.array([0, 0, 100]))
|
|
1394
|
+
|
|
1395
|
+
# Time the solution
|
|
1396
|
+
start_time = time.time()
|
|
1397
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1398
|
+
solve_time = time.time() - start_time
|
|
1399
|
+
|
|
1400
|
+
passed = solve_time < 10.0 # Should solve in under 10 seconds
|
|
1401
|
+
|
|
1402
|
+
result = QCResult(
|
|
1403
|
+
test_name="Large Mesh Performance",
|
|
1404
|
+
test_category="Performance",
|
|
1405
|
+
passed=passed,
|
|
1406
|
+
error=solve_time,
|
|
1407
|
+
expected=10.0,
|
|
1408
|
+
actual=solve_time,
|
|
1409
|
+
details={
|
|
1410
|
+
'mesh_size': f"20x20 ({model.mesh.num_nodes} nodes, {model.mesh.num_elements} elements)",
|
|
1411
|
+
'solve_time': solve_time,
|
|
1412
|
+
'solver': 'direct'
|
|
1413
|
+
}
|
|
1414
|
+
)
|
|
1415
|
+
self.report.add_result(result)
|
|
1416
|
+
|
|
1417
|
+
if self.config.verbose:
|
|
1418
|
+
status = "PASS" if passed else "FAIL"
|
|
1419
|
+
print(f" {status}: Large Mesh Performance ({solve_time:.2f}s)")
|
|
1420
|
+
|
|
1421
|
+
def test_ill_conditioned_system(self):
|
|
1422
|
+
"""Test handling of ill-conditioned systems."""
|
|
1423
|
+
from . import generate_simple_panel_mesh, LoadCase, solve_linear
|
|
1424
|
+
|
|
1425
|
+
# Create a very thin plate (ill-conditioned)
|
|
1426
|
+
model = generate_simple_panel_mesh(
|
|
1427
|
+
length=1.0,
|
|
1428
|
+
width=1.0,
|
|
1429
|
+
thickness=1e-6, # Very thin
|
|
1430
|
+
num_divisions_x=4,
|
|
1431
|
+
num_divisions_y=4
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
load_case = LoadCase(name="test")
|
|
1435
|
+
load_case.add_nodal_load(node_id=25, forces=np.array([0, 0, 1]))
|
|
1436
|
+
|
|
1437
|
+
try:
|
|
1438
|
+
displacements, solver_info = solve_linear(model, load_case, solver_type='direct')
|
|
1439
|
+
passed = True
|
|
1440
|
+
max_disp = np.max(np.abs(displacements))
|
|
1441
|
+
except Exception as e:
|
|
1442
|
+
passed = False
|
|
1443
|
+
max_disp = 0.0
|
|
1444
|
+
|
|
1445
|
+
result = QCResult(
|
|
1446
|
+
test_name="Ill-Conditioned System",
|
|
1447
|
+
test_category="Performance",
|
|
1448
|
+
passed=passed,
|
|
1449
|
+
error=0.0,
|
|
1450
|
+
expected=0.0,
|
|
1451
|
+
actual=max_disp,
|
|
1452
|
+
details={
|
|
1453
|
+
'thickness': 1e-6,
|
|
1454
|
+
'solved': passed,
|
|
1455
|
+
'max_displacement': max_disp
|
|
1456
|
+
}
|
|
1457
|
+
)
|
|
1458
|
+
self.report.add_result(result)
|
|
1459
|
+
|
|
1460
|
+
if self.config.verbose:
|
|
1461
|
+
status = "PASS" if passed else "FAIL"
|
|
1462
|
+
print(f" {status}: Ill-Conditioned System")
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def run_quality_control(config: QCConfig = None) -> QCReport:
|
|
1466
|
+
"""
|
|
1467
|
+
Run comprehensive quality control tests.
|
|
1468
|
+
|
|
1469
|
+
Args:
|
|
1470
|
+
config: Quality control configuration
|
|
1471
|
+
|
|
1472
|
+
Returns:
|
|
1473
|
+
QCReport with all test results
|
|
1474
|
+
"""
|
|
1475
|
+
qc = QualityControl(config)
|
|
1476
|
+
return qc.run_all_tests()
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def run_quick_qc() -> QCReport:
|
|
1480
|
+
"""Run a quick quality control test (fewer tests)."""
|
|
1481
|
+
config = QCConfig(
|
|
1482
|
+
run_convergence=False,
|
|
1483
|
+
run_patch=False,
|
|
1484
|
+
run_performance=False,
|
|
1485
|
+
verbose=True
|
|
1486
|
+
)
|
|
1487
|
+
return run_quality_control(config)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def run_full_qc() -> QCReport:
|
|
1491
|
+
"""Run full quality control test suite."""
|
|
1492
|
+
config = QCConfig(
|
|
1493
|
+
save_results=True,
|
|
1494
|
+
output_dir="qc_results",
|
|
1495
|
+
verbose=True
|
|
1496
|
+
)
|
|
1497
|
+
return run_quality_control(config)
|