ANYsolver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. anysolver/__init__.py +631 -0
  2. anysolver/anystructure_fem_mode.py +942 -0
  3. anysolver/arc_length.py +758 -0
  4. anysolver/assembly.py +950 -0
  5. anysolver/baselines.py +303 -0
  6. anysolver/beam_shell_verification.py +4276 -0
  7. anysolver/beam_validity.py +110 -0
  8. anysolver/benchmarks.py +495 -0
  9. anysolver/boundary.py +476 -0
  10. anysolver/buckling.py +442 -0
  11. anysolver/buckling_validity.py +91 -0
  12. anysolver/capacity_workflow.py +350 -0
  13. anysolver/cases.py +173 -0
  14. anysolver/composite_strip_verification.py +297 -0
  15. anysolver/contact.py +3461 -0
  16. anysolver/corotational.py +371 -0
  17. anysolver/cylinder_benchmarks.py +364 -0
  18. anysolver/dynamics.py +723 -0
  19. anysolver/element_qualification.py +432 -0
  20. anysolver/elements.py +2724 -0
  21. anysolver/external_references.py +369 -0
  22. anysolver/fe_core.py +327 -0
  23. anysolver/fracture.py +551 -0
  24. anysolver/imperfections.py +390 -0
  25. anysolver/jit_compiler.py +108 -0
  26. anysolver/kernel_warmup.py +180 -0
  27. anysolver/linalg.py +760 -0
  28. anysolver/mass_properties.py +179 -0
  29. anysolver/material_curves.py +231 -0
  30. anysolver/matrix_assembly.py +558 -0
  31. anysolver/mesh_gen.py +1065 -0
  32. anysolver/mesh_load_bc_verification.py +609 -0
  33. anysolver/modal.py +282 -0
  34. anysolver/nonlinear.py +300 -0
  35. anysolver/nonlinear_performance.py +920 -0
  36. anysolver/nonlinear_performance_batch_b.py +592 -0
  37. anysolver/nonlinear_performance_batch_c.py +506 -0
  38. anysolver/nonlinear_performance_bootstrap.py +120 -0
  39. anysolver/nonlinear_reduced_assembly.py +760 -0
  40. anysolver/nonlinear_static.py +1518 -0
  41. anysolver/plasticity.py +419 -0
  42. anysolver/plasticity_qualification.py +314 -0
  43. anysolver/production_readiness.py +304 -0
  44. anysolver/quality_control.py +1497 -0
  45. anysolver/recovery.py +505 -0
  46. anysolver/recovery_policy.py +205 -0
  47. anysolver/reference_cases.py +425 -0
  48. anysolver/results.py +503 -0
  49. anysolver/runtime.py +9030 -0
  50. anysolver/s4_validity.py +326 -0
  51. anysolver/sesam_fem/__init__.py +55 -0
  52. anysolver/sesam_fem/__main__.py +80 -0
  53. anysolver/sesam_fem/diagnostics.py +65 -0
  54. anysolver/sesam_fem/document.py +814 -0
  55. anysolver/sesam_fem/exporter.py +84 -0
  56. anysolver/sesam_fem/importer.py +365 -0
  57. anysolver/sesam_fem/records.py +257 -0
  58. anysolver/sesam_fem/schema.py +107 -0
  59. anysolver/sesam_fem/sif_importer.py +397 -0
  60. anysolver/sesam_fem/validation.py +62 -0
  61. anysolver/shell_benchmarks.py +367 -0
  62. anysolver/test_cases.py +610 -0
  63. anysolver/validation.py +416 -0
  64. anysolver/vectorized_nonlinear.py +334 -0
  65. anysolver/vectorized_stiffness.py +571 -0
  66. anysolver-0.1.0.dist-info/METADATA +165 -0
  67. anysolver-0.1.0.dist-info/RECORD +70 -0
  68. anysolver-0.1.0.dist-info/WHEEL +5 -0
  69. anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. anysolver-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)