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,4276 @@
1
+ """Manifest-driven beam/shell solver verification report.
2
+
3
+ This module implements the verification manifest supplied with the beam-shell
4
+ verification specification. It deliberately separates implemented checks from
5
+ cases that need literature data, external solver execution, or explicitly
6
+ unsupported solver features.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import importlib.metadata
13
+ import math
14
+ import platform
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple
21
+
22
+ import numpy as np
23
+ from scipy import sparse
24
+
25
+ from .assembly import (
26
+ build_constraint_transformation,
27
+ build_reduced_rigid_body_modes,
28
+ compute_constraint_force_diagnostics,
29
+ reconstruct_full_solution,
30
+ solve_linear,
31
+ solve_linear_many,
32
+ )
33
+ from .boundary import BoundaryCondition, FixedSupport, LoadCase
34
+ from .buckling import solve_eigenvalue_buckling
35
+ from .composite_strip_verification import composite_strip_metric_rows
36
+ from .contact import contact_verification_metrics
37
+ from .cylinder_benchmarks import (
38
+ CylinderBenchmarkConfig,
39
+ build_cylindrical_shell_benchmark_model,
40
+ run_cylindrical_shell_benchmark,
41
+ )
42
+ from .element_qualification import q8_patch_metric, reference_q8_geometries
43
+ from .dynamics import PressurePatch, TransientConfig, solve_transient_newmark
44
+ from .elements import BeamElement, CoupledBeamShellElement, Element, ShellElement
45
+ from .external_references import generate_external_reference_report, write_external_reference_report
46
+ from .fe_core import FEModel
47
+ from .mass_properties import calculate_mass_properties
48
+ from .matrix_assembly import assemble_geometric_stiffness_matrix, assemble_mass_matrix, assemble_stiffness_matrix
49
+ from .mesh_load_bc_verification import mesh_load_bc_result_by_case
50
+ from .mesh_gen import MeshConfig, PanelGeometry, StiffenerCrossSection, generate_beam_mesh, generate_simple_panel_mesh, generate_stiffened_panel_mesh
51
+ from .modal import solve_free_vibration
52
+ from .plasticity_qualification import element_tangent_metrics, material_point_path_metrics, reference_plastic_curve, yield_function_residual
53
+ from .reference_cases import discover_calculix_reference_cases, upstream_calculix_reference_manifest
54
+ from .s4_validity import bending_patch_metric, membrane_patch_metric, thin_plate_locking_sweep
55
+ from .validation import mpc_constraint_residuals, validate_production_model
56
+
57
+
58
+ DEFAULT_BEAM_SHELL_VERIFICATION_PATH = Path("reports/beam_shell_verification/beam_shell_verification_report.json")
59
+
60
+ DEFAULT_TOLERANCES: Dict[str, float] = {
61
+ "stiffness_symmetry_rel": 1.0e-10,
62
+ "mass_symmetry_rel": 1.0e-12,
63
+ "equilibrium_residual_rel": 1.0e-9,
64
+ "energy_rel": 1.0e-8,
65
+ "analytic_linear_rel": 1.0e-6,
66
+ "literature_medium_rel": 0.05,
67
+ "literature_fine_rel": 0.02,
68
+ "mac_min": 0.99,
69
+ }
70
+
71
+ THIN_SHELL_SPAN_TO_THICKNESS: Tuple[int, ...] = (100, 300, 1000, 3000, 10000)
72
+
73
+ THIN_STIFFENED_SHELL_RELEASE_CASES: Tuple[str, ...] = (
74
+ "META-001",
75
+ "BEAM-001",
76
+ "BEAM-002",
77
+ "BEAM-003",
78
+ "BEAM-004",
79
+ "BEAM-005",
80
+ "BEAM-006",
81
+ "BEAM-007",
82
+ "BEAM-008",
83
+ "BEAM-009",
84
+ "BEAM-010",
85
+ "SHELL-001",
86
+ "SHELL-002",
87
+ "SHELL-003",
88
+ "SHELL-004",
89
+ "SHELL-005",
90
+ "SHELL-006",
91
+ "SHELL-007",
92
+ "SHELL-008",
93
+ "COUP-001",
94
+ "COUP-002",
95
+ "COUP-003",
96
+ "COUP-004",
97
+ "COUP-005",
98
+ "COUP-006",
99
+ "COUP-007",
100
+ "COUP-008",
101
+ "COUP-009",
102
+ "COUP-010",
103
+ "NULL-001",
104
+ "NULL-002",
105
+ "NULL-003",
106
+ "NULL-004",
107
+ "NULL-005",
108
+ "EIG-001",
109
+ "EIG-002",
110
+ "EIG-003",
111
+ "EIG-004",
112
+ "BUC-001",
113
+ "BUC-002",
114
+ "BUC-003",
115
+ "BUC-004",
116
+ "BUC-005",
117
+ "BEAM-011",
118
+ "SHELL-009",
119
+ "SHELL-010",
120
+ "SHELL-011",
121
+ "COUP-012",
122
+ "COUP-013",
123
+ "COUP-014",
124
+ "COUP-015",
125
+ "COUP-016",
126
+ "COUP-017",
127
+ "PERF-001",
128
+ "PERF-002",
129
+ )
130
+
131
+
132
+ CASE_ROWS: Tuple[Tuple[str, int, str, bool, str], ...] = (
133
+ ("META-001", 0, "reporting", True, "Verification status semantics"),
134
+ ("ALG-001", 0, "shape", True, "Partition of unity"),
135
+ ("ALG-002", 0, "shape", True, "Nodal interpolation"),
136
+ ("ALG-003", 0, "mapping", True, "Jacobian and orientation"),
137
+ ("ALG-004", 0, "matrix", True, "Element stiffness symmetry"),
138
+ ("ALG-005", 0, "matrix", True, "Element mass symmetry and positivity"),
139
+ ("ALG-006", 0, "kinematics", True, "Rigid-body zero energy"),
140
+ ("ALG-007", 0, "coordinates", True, "Transform orthogonality"),
141
+ ("ALG-008", 0, "assembly", True, "Global assembly consistency"),
142
+ ("ALG-009", 0, "energy", True, "Energy/work identity"),
143
+ ("BEAM-001", 1, "beam_static", True, "Axial extension"),
144
+ ("BEAM-002", 1, "beam_static", True, "Circular torsion"),
145
+ ("BEAM-003", 1, "beam_static", True, "Timoshenko cantilever"),
146
+ ("BEAM-004", 1, "beam_static", True, "Slenderness sweep"),
147
+ ("BEAM-005", 1, "beam_static", True, "Pure bending"),
148
+ ("BEAM-006", 2, "beam_coordinates", True, "Biaxial bending and local-axis rotation"),
149
+ ("BEAM-007", 1, "beam_static", True, "Combined-action superposition"),
150
+ ("BEAM-008", 1, "beam_eigen", True, "Axial bar eigenfrequency invariant"),
151
+ ("BEAM-009", 2, "beam_eigen", True, "Free-free rigid modes"),
152
+ ("BEAM-010", 1, "beam_buckling", True, "Euler column"),
153
+ ("BEAM-011", 1, "beam_eigen", True, "Actual cantilever bending eigenmodes"),
154
+ ("SHELL-001", 2, "shell_patch", True, "Membrane patch"),
155
+ ("SHELL-002", 2, "shell_patch", True, "Pure-bending patch"),
156
+ ("SHELL-003", 2, "invariance", True, "Shell rigid-transform invariance"),
157
+ ("SHELL-004", 1, "plate_static", True, "Thin cantilever-strip static response"),
158
+ ("SHELL-005", 2, "locking", True, "Thin-shell locking and thickness sweep"),
159
+ ("SHELL-006", 1, "plate_eigen", True, "Simply supported plate frequencies"),
160
+ ("SHELL-007", 1, "plate_buckling", True, "Simply supported plate buckling"),
161
+ ("SHELL-008", 2, "shell_locking", True, "Thin curved-shell inextensional bending"),
162
+ ("SHELL-009", 1, "plate_static", True, "Navier square plate under uniform pressure"),
163
+ ("SHELL-010", 1, "plate_eigen", True, "Q4/Q8/Q8R thin plate modal convergence"),
164
+ ("SHELL-011", 1, "plate_buckling", True, "Q4/Q8/Q8R thin plate buckling convergence"),
165
+ ("BENCH-001", 3, "shell_benchmark", True, "MacNeal-Harder twisted cantilever"),
166
+ ("BENCH-002", 3, "shell_benchmark", True, "Scordelis-Lo roof"),
167
+ ("BENCH-003", 3, "shell_benchmark", True, "Pinched cylinder"),
168
+ ("BENCH-004", 3, "shell_benchmark", False, "Hemispherical shell"),
169
+ ("COUP-001", 2, "coupling", True, "Coincident rigid compatibility"),
170
+ ("COUP-002", 2, "coupling", True, "Coincident force transfer"),
171
+ ("COUP-003", 2, "coupling", True, "Eccentric rigid-link kinematics"),
172
+ ("COUP-004", 2, "coupling", True, "Eccentric moment transfer"),
173
+ ("COUP-005", 4, "coupling", True, "Stiffened plate equivalent models"),
174
+ ("COUP-006", 4, "coupling", True, "Ring-stiffened cylinder equivalent models"),
175
+ ("COUP-007", 2, "coupling", True, "Stiffened-panel static invariants"),
176
+ ("COUP-008", 3, "eigen", True, "Stiffened-panel modal invariants"),
177
+ ("COUP-009", 3, "buckling", True, "Stiffened-panel buckling invariants"),
178
+ ("COUP-010", 2, "coordinates", True, "Stiffener orientation and curved-surface transport"),
179
+ ("COUP-011", 2, "coupling", False, "Nonmatching beam and shell discretisation"),
180
+ ("COUP-012", 2, "coupling", True, "Actual interpolated-MPC affine-field reproduction"),
181
+ ("COUP-013", 2, "coupling", True, "Actual eccentric load-transfer equilibrium"),
182
+ ("COUP-014", 2, "mixed_static", True, "Composite stiffened-strip static benchmark"),
183
+ ("COUP-015", 2, "mixed_modal", True, "Composite stiffened-strip modal benchmark"),
184
+ ("COUP-016", 2, "mixed_buckling", True, "Composite stiffened-strip Euler buckling"),
185
+ ("COUP-017", 2, "production_mesh", True, "Production Q8/Q8R stiffened-panel suite"),
186
+ ("NULL-001", 2, "nullspace", True, "Six rigid modes"),
187
+ ("NULL-002", 2, "nullspace", True, "Projected load orthogonality"),
188
+ ("NULL-003", 2, "nullspace", True, "Projected versus constrained solution"),
189
+ ("NULL-004", 2, "nullspace", True, "Constraint-choice independence"),
190
+ ("NULL-005", 2, "nullspace", True, "Rigid-transform invariance"),
191
+ ("EIG-001", 1, "mass", True, "Total translational mass"),
192
+ ("EIG-002", 2, "mass", True, "Mass mesh invariance"),
193
+ ("EIG-003", 2, "eigen", True, "Modal orthogonality"),
194
+ ("EIG-004", 2, "eigen", True, "Repeated-mode eigenspace"),
195
+ ("EIG-005", 3, "model_pair_modal", True, "Equivalent stiffened-panel modal comparison"),
196
+ ("BUC-001", 0, "buckling", True, "Geometric stiffness symmetry"),
197
+ ("BUC-002", 1, "buckling", True, "Preload scaling"),
198
+ ("BUC-003", 1, "buckling", True, "Euler columns"),
199
+ ("BUC-004", 1, "buckling", True, "Simply supported plate"),
200
+ ("BUC-005", 4, "buckling", True, "Stiffened panel mode comparison"),
201
+ ("NLG-001", 2, "nonlinear", True, "Large rigid-rotation objectivity"),
202
+ ("NLG-002", 3, "nonlinear", True, "Large-rotation cantilever"),
203
+ ("NLG-003", 3, "nonlinear", False, "NAFEMS 3DNLG framework"),
204
+ ("NLG-004", 2, "nonlinear", True, "Increment independence"),
205
+ ("NLG-005", 2, "nonlinear", True, "Consistent tangent finite-difference check"),
206
+ ("MAT-001", 1, "plasticity", True, "Uniaxial elastic response"),
207
+ ("MAT-002", 1, "plasticity", True, "Perfect plasticity"),
208
+ ("MAT-003", 1, "plasticity", True, "Isotropic hardening"),
209
+ ("MAT-004", 2, "plasticity", True, "Kinematic hardening cycle"),
210
+ ("MAT-005", 2, "plasticity", True, "Shell membrane yielding"),
211
+ ("MAT-006", 2, "plasticity", True, "Shell bending yielding"),
212
+ ("MAT-007", 2, "plasticity", True, "Beam plastic hinge"),
213
+ ("FRACT-001", 2, "fracture", True, "Fracture configuration validation"),
214
+ ("FRACT-002", 2, "fracture", True, "Deleted element residual stiffness scaling"),
215
+ ("FRACT-003", 2, "fracture", True, "Deleted shell pressure load removal"),
216
+ ("FRACT-004", 2, "fracture", True, "Plastic-strain threshold deletion record"),
217
+ ("FRACT-005", 2, "fracture", True, "High fracture threshold leaves elements active"),
218
+ ("FRACT-006", 2, "fracture", True, "Maximum deleted-fraction stop status"),
219
+ ("FRACT-007", 2, "impact_fracture", True, "Impact contact patch area estimate"),
220
+ ("FRACT-008", 2, "impact_fracture", True, "Low-energy/high-capacity impact leaves shell undamaged"),
221
+ ("FRACT-009", 2, "impact_fracture", True, "High-energy impact damage shell erosion"),
222
+ ("FRACT-010", 2, "impact_fracture", True, "Material capacity delays impact damage"),
223
+ ("FRACT-011", 2, "impact_fracture", True, "Accumulated repeated contact damage"),
224
+ ("FRACT-012", 2, "impact_fracture", True, "Neighbor smoothing blocks isolated deletion spike"),
225
+ ("PERF-001", 2, "optimized_nonlinear_assembly", True, "Real weighted-MPC fast-path equivalence"),
226
+ ("PERF-002", 2, "cache_correctness", True, "Revision invalidation on a real stiffened model"),
227
+ ("COUP-018", 2, "coupling_robustness", True, "Element-edge ownership and numbering invariance"),
228
+ ("COUP-019", 2, "coupling_convergence", True, "Independent beam/shell mesh-ratio sweep"),
229
+ ("COUP-020", 2, "curved_stiffener_coordinates", True, "Complete ring-stiffener frame closure"),
230
+ ("COUP-021", 2, "ring_static", True, "Circular ring membrane benchmark"),
231
+ ("MLBC-001", 2, "mesh_load_bc", True, "Flat stiffened mesh topology and member-line alignment"),
232
+ ("MLBC-002", 2, "mesh_load_bc", True, "Cylinder seam closure, ring-frame topology and shell orientation"),
233
+ ("MLBC-003", 2, "mesh_load_bc", True, "Q8 midside placement and distorted-mesh guardrails"),
234
+ ("MLBC-004", 2, "mesh_load_bc", True, "Pressure direction and resultant sign conventions"),
235
+ ("MLBC-005", 2, "mesh_load_bc", True, "Pressure patch area selection and resultant"),
236
+ ("MLBC-006", 2, "mesh_load_bc", True, "Edge load and nodal moment resultant balance"),
237
+ ("MLBC-007", 2, "mesh_load_bc", True, "Fixed, pinned, roller and symmetry support DOF semantics"),
238
+ ("MLBC-008", 2, "mesh_load_bc", True, "MPC duplicate ownership and fixed-slave rejection"),
239
+ ("MLBC-009", 2, "mesh_load_bc", True, "Self-equilibrated free-free load nullspace consistency"),
240
+ ("CONTACT-001", 2, "contact", True, "Rigid sphere no-contact trajectory"),
241
+ ("CONTACT-002", 2, "contact", True, "Rigid sphere normal penalty force law"),
242
+ ("CONTACT-003", 2, "contact", True, "Rigid sphere contact force-resultant balance"),
243
+ ("CONTACT-004", 2, "contact", True, "Rigid sphere impulse and momentum consistency"),
244
+ ("CONTACT-005", 2, "contact", True, "Rigid sphere shell-panel impact smoke"),
245
+ ("CONTACT-006", 2, "contact", True, "Rigid sphere stiffened-panel beam load transfer"),
246
+ ("CONTACT-007", 2, "contact", True, "Rigid sphere contact projection classification"),
247
+ ("CONTACT-008", 2, "contact", True, "Rigid sphere shell-thickness contact surface offset"),
248
+ ("CONTACT-009", 2, "contact", True, "Rigid sphere adjacent-element contact reduction"),
249
+ ("CONTACT-010", 2, "contact", True, "Rigid sphere automatic penalty penetration control"),
250
+ ("CONTACT-011", 2, "contact", True, "Rigid sphere event-substep contact detection"),
251
+ ("CONTACT-012", 2, "contact", True, "Rigid sphere production contact validation guardrails"),
252
+ ("CYL-001", 2, "cylinder_static", True, "Closed thin-cylinder membrane benchmark"),
253
+ ("CYL-002", 3, "curved_mixed", True, "Longitudinally stiffened cylinder model-pair benchmark"),
254
+ ("CYL-003", 3, "curved_mixed_buckling", True, "Ring-stiffened cylinder external-pressure benchmark"),
255
+ ("NLG-006", 3, "nonlinear_static", True, "Thin stiffened-panel nonlinear increment study"),
256
+ ("NLG-007", 3, "arc_length", True, "Arc-length imperfect stiffened-panel reference"),
257
+ ("NLG-008", 2, "scope_guard", True, "Follower-pressure capability guard"),
258
+ ("MAT-008", 3, "combined_plasticity", True, "Combined shell and beam plasticity"),
259
+ ("DYN-001", 3, "transient", True, "Transient thin stiffened-panel benchmark"),
260
+ ("EXT-001", 4, "cross_solver", True, "CalculiX reference pack"),
261
+ ("EXT-002", 4, "cross_solver", True, "Second external solver reference"),
262
+ ("VVR-001", 4, "verification_report", True, "Complete verification report package"),
263
+ )
264
+
265
+
266
+ PROGRAMME_BATCH_CASES: Mapping[str, Tuple[str, ...]] = {
267
+ "V1": (
268
+ "META-001",
269
+ "BEAM-011",
270
+ "SHELL-009",
271
+ "SHELL-010",
272
+ "SHELL-011",
273
+ "COUP-012",
274
+ "COUP-013",
275
+ "COUP-014",
276
+ "COUP-015",
277
+ "COUP-016",
278
+ ),
279
+ "V2": (
280
+ "COUP-005",
281
+ "EIG-005",
282
+ "BUC-005",
283
+ "COUP-017",
284
+ "PERF-001",
285
+ "PERF-002",
286
+ "COUP-018",
287
+ "COUP-019",
288
+ ),
289
+ "V3": (
290
+ "SHELL-008",
291
+ "BENCH-002",
292
+ "BENCH-003",
293
+ "BENCH-001",
294
+ "COUP-020",
295
+ "COUP-021",
296
+ "CYL-001",
297
+ "CYL-002",
298
+ "COUP-006",
299
+ "CYL-003",
300
+ ),
301
+ "V4": ("NLG-006", "NLG-007", "NLG-008", "MAT-008", "DYN-001"),
302
+ "V5": ("BENCH-004", "NLG-002", "NLG-003", "EXT-001", "EXT-002", "VVR-001"),
303
+ "MLBC": (
304
+ "MLBC-001",
305
+ "MLBC-002",
306
+ "MLBC-003",
307
+ "MLBC-004",
308
+ "MLBC-005",
309
+ "MLBC-006",
310
+ "MLBC-007",
311
+ "MLBC-008",
312
+ "MLBC-009",
313
+ ),
314
+ "CONTACT": (
315
+ "CONTACT-001",
316
+ "CONTACT-002",
317
+ "CONTACT-003",
318
+ "CONTACT-004",
319
+ "CONTACT-005",
320
+ "CONTACT-006",
321
+ "CONTACT-007",
322
+ "CONTACT-008",
323
+ "CONTACT-009",
324
+ "CONTACT-010",
325
+ "CONTACT-011",
326
+ "CONTACT-012",
327
+ ),
328
+ "FRACTURE": (
329
+ "FRACT-001",
330
+ "FRACT-002",
331
+ "FRACT-003",
332
+ "FRACT-004",
333
+ "FRACT-005",
334
+ "FRACT-006",
335
+ "FRACT-007",
336
+ "FRACT-008",
337
+ "FRACT-009",
338
+ "FRACT-010",
339
+ "FRACT-011",
340
+ "FRACT-012",
341
+ ),
342
+ }
343
+
344
+ PROGRAMME_RELEASE_GATES: Mapping[str, Tuple[str, ...]] = {
345
+ "flat_thin_shell": ("SHELL-009", "SHELL-010", "SHELL-011"),
346
+ "flat_thin_stiffened_shell": PROGRAMME_BATCH_CASES["V1"] + PROGRAMME_BATCH_CASES["V2"],
347
+ "curved_thin_stiffened_shell": PROGRAMME_BATCH_CASES["V1"] + PROGRAMME_BATCH_CASES["V2"] + PROGRAMME_BATCH_CASES["V3"],
348
+ "nonlinear_capacity": PROGRAMME_BATCH_CASES["V1"]
349
+ + PROGRAMME_BATCH_CASES["V2"]
350
+ + PROGRAMME_BATCH_CASES["V3"]
351
+ + PROGRAMME_BATCH_CASES["V4"],
352
+ "fully_documented_verified_release": PROGRAMME_BATCH_CASES["V1"]
353
+ + PROGRAMME_BATCH_CASES["V2"]
354
+ + PROGRAMME_BATCH_CASES["V3"]
355
+ + PROGRAMME_BATCH_CASES["V4"]
356
+ + PROGRAMME_BATCH_CASES["V5"]
357
+ + PROGRAMME_BATCH_CASES["MLBC"]
358
+ + PROGRAMME_BATCH_CASES["CONTACT"]
359
+ + PROGRAMME_BATCH_CASES["FRACTURE"],
360
+ "mesh_load_bc": PROGRAMME_BATCH_CASES["MLBC"],
361
+ "contact": PROGRAMME_BATCH_CASES["CONTACT"],
362
+ "simplified_fracture": PROGRAMME_BATCH_CASES["FRACTURE"],
363
+ }
364
+
365
+ CASE_TO_BATCH: Mapping[str, str] = {
366
+ case_id: batch_id for batch_id, case_ids in PROGRAMME_BATCH_CASES.items() for case_id in case_ids
367
+ }
368
+
369
+
370
+ def _case_evidence_type(case_id: str, category: str) -> str:
371
+ if case_id.startswith("BENCH-") or category in {"shell_benchmark"}:
372
+ return "literature"
373
+ if case_id.startswith("EXT-"):
374
+ return "cross_solver"
375
+ if category.startswith("model_pair") or category in {"curved_mixed", "curved_mixed_buckling"}:
376
+ return "model_pair"
377
+ if category in {"mixed_static", "mixed_modal", "mixed_buckling", "beam_modal", "shell_static", "shell_modal", "shell_buckling"}:
378
+ return "analytical"
379
+ return "invariant"
380
+
381
+
382
+ IMPLEMENTED_PHASES: Mapping[str, Tuple[str, ...]] = {
383
+ "A": (
384
+ "ALG-",
385
+ "BEAM-001",
386
+ "BEAM-002",
387
+ "BEAM-003",
388
+ "BEAM-005",
389
+ "BEAM-007",
390
+ "SHELL-001",
391
+ "SHELL-002",
392
+ "SHELL-004",
393
+ "SHELL-005",
394
+ "COUP-001",
395
+ "COUP-002",
396
+ "COUP-003",
397
+ "COUP-004",
398
+ "COUP-007",
399
+ "NULL-001",
400
+ "NULL-002",
401
+ "NULL-003",
402
+ "NULL-004",
403
+ "NULL-005",
404
+ ),
405
+ "B": ("BEAM-008", "BEAM-009", "BEAM-010", "EIG-001", "EIG-002", "EIG-003", "BUC-001", "BUC-002", "BUC-003"),
406
+ "E": ("NLG-001", "NLG-004", "NLG-005", "MAT-001", "MAT-002", "MAT-003", "MAT-005", "MAT-006", "MAT-007"),
407
+ }
408
+
409
+
410
+ @dataclass(frozen=True)
411
+ class VerificationCase:
412
+ case_id: str
413
+ tier: int
414
+ category: str
415
+ required: bool
416
+ title: str
417
+
418
+ def to_dict(self) -> Dict[str, Any]:
419
+ return {
420
+ "case_id": self.case_id,
421
+ "batch": CASE_TO_BATCH.get(self.case_id),
422
+ "evidence_type": _case_evidence_type(self.case_id, self.category),
423
+ "tier": self.tier,
424
+ "category": self.category,
425
+ "required": self.required,
426
+ "title": self.title,
427
+ }
428
+
429
+
430
+ @dataclass
431
+ class VerificationCaseResult:
432
+ case_id: str
433
+ status: str
434
+ title: str
435
+ tier: int
436
+ category: str
437
+ required: bool
438
+ analysis_type: str = "verification"
439
+ evidence_type: Optional[str] = None
440
+ element_types: List[str] = field(default_factory=list)
441
+ mesh: Dict[str, Any] = field(default_factory=dict)
442
+ reference: Dict[str, Any] = field(default_factory=dict)
443
+ result: Dict[str, Any] = field(default_factory=dict)
444
+ checks: Dict[str, Any] = field(default_factory=dict)
445
+ reason: Optional[str] = None
446
+ test_execution_status: Optional[str] = None
447
+ verification_completion_status: Optional[str] = None
448
+ release_gate_status: Optional[str] = None
449
+
450
+ def __post_init__(self) -> None:
451
+ if self.evidence_type is None:
452
+ self.evidence_type = _case_evidence_type(self.case_id, self.category)
453
+ if self.test_execution_status is None:
454
+ self.test_execution_status = "failed" if self.status == "FAIL" else "passed"
455
+ if self.verification_completion_status is None:
456
+ self.verification_completion_status = "complete" if self.status == "PASS" else "incomplete"
457
+ if self.release_gate_status is None:
458
+ if self.status == "PASS":
459
+ self.release_gate_status = "passed"
460
+ elif self.required:
461
+ self.release_gate_status = "blocked"
462
+ else:
463
+ self.release_gate_status = "not_evaluated"
464
+
465
+ def to_dict(self) -> Dict[str, Any]:
466
+ payload = {
467
+ "case_id": self.case_id,
468
+ "batch": CASE_TO_BATCH.get(self.case_id),
469
+ "status": self.status,
470
+ "evidence_type": self.evidence_type,
471
+ "solver_commit": _git_sha(),
472
+ "test_execution_status": self.test_execution_status,
473
+ "verification_completion_status": self.verification_completion_status,
474
+ "release_gate_status": self.release_gate_status,
475
+ "title": self.title,
476
+ "tier": int(self.tier),
477
+ "category": self.category,
478
+ "required": bool(self.required),
479
+ "analysis_type": self.analysis_type,
480
+ "element_types": list(self.element_types),
481
+ "mesh": dict(self.mesh),
482
+ "reference": dict(self.reference),
483
+ "result": dict(self.result),
484
+ "checks": dict(self.checks),
485
+ }
486
+ if self.reason:
487
+ payload["reason"] = self.reason
488
+ return payload
489
+
490
+
491
+ def verification_manifest_cases() -> List[VerificationCase]:
492
+ return [VerificationCase(*row) for row in CASE_ROWS]
493
+
494
+
495
+ _GIT_SHA_CACHE: Optional[str] = None
496
+
497
+
498
+ def _git_sha() -> Optional[str]:
499
+ global _GIT_SHA_CACHE
500
+ if _GIT_SHA_CACHE is not None:
501
+ return _GIT_SHA_CACHE
502
+ try:
503
+ result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
504
+ except Exception:
505
+ return None
506
+ _GIT_SHA_CACHE = result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
507
+ return _GIT_SHA_CACHE
508
+
509
+
510
+ def _package_version(name: str) -> Optional[str]:
511
+ try:
512
+ return importlib.metadata.version(name)
513
+ except importlib.metadata.PackageNotFoundError:
514
+ return None
515
+
516
+
517
+ def _pass(case: VerificationCase, **kwargs: Any) -> VerificationCaseResult:
518
+ return VerificationCaseResult(case.case_id, "PASS", case.title, case.tier, case.category, case.required, **kwargs)
519
+
520
+
521
+ def _xfail(case: VerificationCase, reason: str, **kwargs: Any) -> VerificationCaseResult:
522
+ return VerificationCaseResult(case.case_id, "XFAIL", case.title, case.tier, case.category, case.required, reason=reason, **kwargs)
523
+
524
+
525
+ def _fail(case: VerificationCase, reason: str, **kwargs: Any) -> VerificationCaseResult:
526
+ return VerificationCaseResult(case.case_id, "FAIL", case.title, case.tier, case.category, case.required, reason=reason, **kwargs)
527
+
528
+
529
+ def _rel_error(value: float, reference: float, floor: float = 1.0e-30) -> float:
530
+ return abs(float(value) - float(reference)) / max(abs(float(reference)), floor)
531
+
532
+
533
+ def _symmetry_error(matrix: np.ndarray | sparse.spmatrix) -> float:
534
+ if sparse.issparse(matrix):
535
+ return float(sparse.linalg.norm(matrix - matrix.T) / max(float(sparse.linalg.norm(matrix)), 1.0))
536
+ dense = np.asarray(matrix, dtype=float)
537
+ return float(np.linalg.norm(dense - dense.T) / max(np.linalg.norm(dense), 1.0))
538
+
539
+
540
+ def _assert(condition: bool, message: str) -> None:
541
+ if not condition:
542
+ raise AssertionError(message)
543
+
544
+
545
+ def _run_meta_001(case: VerificationCase) -> VerificationCaseResult:
546
+ return _pass(
547
+ case,
548
+ analysis_type="reporting",
549
+ reference={
550
+ "test_execution_status": ["passed", "failed"],
551
+ "verification_completion_status": ["complete", "incomplete"],
552
+ "release_gate_status": ["passed", "blocked", "not_evaluated"],
553
+ },
554
+ result={"status_semantics": "separated"},
555
+ checks={
556
+ "meaning": {
557
+ "test_execution_status": "whether the local check command executed without an unexpected error",
558
+ "verification_completion_status": "whether the case has complete accepted verification evidence",
559
+ "release_gate_status": "whether a required capability is releasable from this evidence",
560
+ }
561
+ },
562
+ )
563
+
564
+
565
+ def _finite_metric_rows(rows: Iterable[Mapping[str, Any]]) -> List[Mapping[str, Any]]:
566
+ finite_rows: List[Mapping[str, Any]] = []
567
+ for row in rows:
568
+ try:
569
+ value = float(row.get("relative_error", math.nan))
570
+ except (TypeError, ValueError):
571
+ value = math.nan
572
+ if math.isfinite(value):
573
+ finite_rows.append(row)
574
+ return finite_rows
575
+
576
+
577
+ def _composite_strip_result(
578
+ case: VerificationCase,
579
+ *,
580
+ metric: str,
581
+ tolerance: float,
582
+ expected_status: str,
583
+ reference_type: str,
584
+ ) -> VerificationCaseResult:
585
+ rows = composite_strip_metric_rows(metric)
586
+ finite_rows = _finite_metric_rows(rows)
587
+ max_relative_error = max((float(row["relative_error"]) for row in finite_rows), default=math.inf)
588
+ status_ok = all(str(row.get("solver_status")) == expected_status for row in rows)
589
+ tolerance_ok = bool(finite_rows) and len(finite_rows) == len(rows) and max_relative_error <= float(tolerance)
590
+ payload = {
591
+ "element_types": ["shell4", "shell8", "shell8r", "beam2", "interpolated_mpc"],
592
+ "analysis_type": {
593
+ "static": "linear_static",
594
+ "modal": "modal",
595
+ "buckling": "linear_buckling",
596
+ }[metric],
597
+ "mesh": {
598
+ "fixture": "narrow production stiffened strip",
599
+ "eccentricity_to_thickness": 5.0,
600
+ "element_types": [str(row.get("element_type")) for row in rows],
601
+ },
602
+ "reference": {
603
+ "type": reference_type,
604
+ "source": "closed-form composite-section beam theory",
605
+ "acceptance_tolerance_rel": float(tolerance),
606
+ },
607
+ "result": {"max_relative_error": max_relative_error, "rows_evaluated": len(rows)},
608
+ "checks": {"rows": rows, "solver_status_expected": expected_status},
609
+ }
610
+ if status_ok and tolerance_ok:
611
+ return _pass(case, **payload)
612
+ reason = (
613
+ f"analytical composite-strip check executed, but max relative error {max_relative_error:.6g} "
614
+ f"exceeds tolerance {float(tolerance):.6g}"
615
+ if status_ok
616
+ else "analytical composite-strip check executed, but one or more solver statuses were not successful"
617
+ )
618
+ return _xfail(case, reason, **payload)
619
+
620
+
621
+ def _beam_model(
622
+ *,
623
+ length: float = 2.0,
624
+ area: float = 0.02,
625
+ iy: float = 1.0e-6,
626
+ iz: float = 1.0e-6,
627
+ j: float = 1.0e-6,
628
+ density: float = 7850.0,
629
+ num_elements: int = 1,
630
+ ) -> FEModel:
631
+ model = FEModel("verification_beam")
632
+ model.add_material("steel", 210.0e9, 0.3, density=density)
633
+ for i in range(num_elements + 1):
634
+ model.add_node(i + 1, length * i / num_elements, 0.0, 0.0)
635
+ section = {"area": area, "Iy": iy, "Iz": iz, "J": j, "shear_factor_y": 5.0 / 6.0, "shear_factor_z": 5.0 / 6.0}
636
+ for i in range(num_elements):
637
+ model.add_element(i + 1, BeamElement(i + 1, [i + 1, i + 2], "steel", section))
638
+ return model
639
+
640
+
641
+ def _run_alg_001(case: VerificationCase) -> VerificationCaseResult:
642
+ points = [(-1.0, -1.0), (0.0, 0.0), (0.37, -0.42), (0.91, 0.88)]
643
+ max_partition = 0.0
644
+ max_derivative = 0.0
645
+ for nnode in (4, 8):
646
+ element = ShellElement(1, list(range(1, nnode + 1)), "steel")
647
+ for xi, eta in points:
648
+ N, dxi, deta = element.compute_shape_functions(xi, eta)
649
+ max_partition = max(max_partition, abs(float(np.sum(N) - 1.0)))
650
+ max_derivative = max(max_derivative, abs(float(np.sum(dxi))), abs(float(np.sum(deta))))
651
+ for xi in (-1.0, -0.25, 0.0, 0.66, 1.0):
652
+ N = np.array([(1.0 - xi) / 2.0, (1.0 + xi) / 2.0])
653
+ dN = np.array([-0.5, 0.5])
654
+ max_partition = max(max_partition, abs(float(np.sum(N) - 1.0)))
655
+ max_derivative = max(max_derivative, abs(float(np.sum(dN))))
656
+ _assert(max_partition < 1.0e-13 and max_derivative < 1.0e-12, "shape-function partition failed")
657
+ return _pass(case, element_types=["beam2", "shell4", "shell8"], checks={"partition_error": max_partition, "derivative_sum_error": max_derivative})
658
+
659
+
660
+ def _run_alg_002(case: VerificationCase) -> VerificationCaseResult:
661
+ natural = {
662
+ 4: [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
663
+ 8: [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (0.0, -1.0), (1.0, 0.0), (0.0, 1.0), (-1.0, 0.0)],
664
+ }
665
+ max_error = 0.0
666
+ for nnode, coords in natural.items():
667
+ element = ShellElement(1, list(range(1, nnode + 1)), "steel")
668
+ for node_index, (xi, eta) in enumerate(coords):
669
+ N, _, _ = element.compute_shape_functions(xi, eta)
670
+ expected = np.zeros(nnode)
671
+ expected[node_index] = 1.0
672
+ max_error = max(max_error, float(np.max(np.abs(N - expected))))
673
+ _assert(max_error < 1.0e-13, "nodal interpolation failed")
674
+ return _pass(case, element_types=["shell4", "shell8"], checks={"max_delta_error": max_error})
675
+
676
+
677
+ def _single_shell_model(nnode: int = 4) -> FEModel:
678
+ model = FEModel("single_shell_verification")
679
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
680
+ if nnode == 4:
681
+ coords = [(0.0, 0.0, 0.0), (1.2, 0.0, 0.0), (1.35, 0.8, 0.05), (0.1, 0.9, 0.0)]
682
+ else:
683
+ coords = [(0.0, 0.0, 0.0), (1.2, 0.0, 0.0), (1.35, 0.8, 0.05), (0.1, 0.9, 0.0), (0.6, 0.0, 0.0), (1.275, 0.4, 0.025), (0.725, 0.85, 0.025), (0.05, 0.45, 0.0)]
684
+ for i, xyz in enumerate(coords, start=1):
685
+ model.add_node(i, *xyz)
686
+ model.add_element(1, ShellElement(1, list(range(1, nnode + 1)), "steel", thickness=0.01))
687
+ return model
688
+
689
+
690
+ def _run_alg_003(case: VerificationCase) -> VerificationCaseResult:
691
+ min_det = math.inf
692
+ for nnode in (4, 8):
693
+ model = _single_shell_model(nnode)
694
+ element = model.mesh.elements[1]
695
+ coords = element.get_node_coordinates(model.mesh)
696
+ for xi, eta in element.gauss_points:
697
+ _N, dxi, deta = element.compute_shape_functions(float(xi), float(eta))
698
+ R, _dx, _dy, det_j = element._local_frame_and_derivatives(coords, dxi, deta)
699
+ min_det = min(min_det, float(det_j))
700
+ _assert(float(np.linalg.det(R)) > 0.0, "shell local frame has negative determinant")
701
+ zero = _single_shell_model(4)
702
+ for node in zero.mesh.nodes.values():
703
+ node.x = 0.0
704
+ node.y = 0.0
705
+ node.z = 0.0
706
+ element = zero.mesh.elements[1]
707
+ coords = element.get_node_coordinates(zero.mesh)
708
+ _N, dxi, deta = element.compute_shape_functions(0.0, 0.0)
709
+ raised = False
710
+ try:
711
+ element._local_frame_and_derivatives(coords, dxi, deta)
712
+ except ValueError:
713
+ raised = True
714
+ _assert(raised, "zero-area shell did not raise ValueError")
715
+ return _pass(case, element_types=["shell4", "shell8"], checks={"min_surface_jacobian": min_det, "zero_area_rejected": raised})
716
+
717
+
718
+ def _run_alg_004(case: VerificationCase) -> VerificationCaseResult:
719
+ beam = _beam_model()
720
+ shell = _single_shell_model(8)
721
+ beam_k = beam.mesh.elements[1].compute_stiffness_matrix(beam.mesh, beam.get_material("steel"))
722
+ shell_k = shell.mesh.elements[1].compute_stiffness_matrix(shell.mesh, shell.get_material("steel"))
723
+ beam_err = _symmetry_error(beam_k)
724
+ shell_err = _symmetry_error(shell_k)
725
+ _assert(max(beam_err, shell_err) < 1.0e-10, "element stiffness matrix is not symmetric")
726
+ return _pass(case, element_types=["beam2", "shell8"], checks={"beam_symmetry": beam_err, "shell_symmetry": shell_err})
727
+
728
+
729
+ def _run_alg_005(case: VerificationCase) -> VerificationCaseResult:
730
+ beam = _beam_model()
731
+ shell = _single_shell_model(8)
732
+ beam_m = beam.mesh.elements[1].compute_mass_matrix(beam.mesh, beam.get_material("steel"))
733
+ shell_m = shell.mesh.elements[1].compute_mass_matrix(shell.mesh, shell.get_material("steel"))
734
+ beam_min = float(np.min(np.linalg.eigvalsh(0.5 * (beam_m + beam_m.T))))
735
+ shell_min = float(np.min(np.linalg.eigvalsh(0.5 * (shell_m + shell_m.T))))
736
+ _assert(_symmetry_error(beam_m) < 1.0e-12 and _symmetry_error(shell_m) < 1.0e-12, "element mass symmetry failed")
737
+ _assert(beam_min > -1.0e-9 and shell_min > -1.0e-9, "element mass has negative eigenvalue")
738
+ return _pass(case, element_types=["beam2", "shell8"], checks={"beam_min_eigenvalue": beam_min, "shell_min_eigenvalue": shell_min})
739
+
740
+
741
+ def _rigid_body_vector(model: FEModel, mode: int) -> np.ndarray:
742
+ u = np.zeros(model.mesh.dof_manager.total_dofs)
743
+ for node in model.mesh.nodes.values():
744
+ x = node.coords()
745
+ d = node.dofs
746
+ if mode < 3:
747
+ u[d[mode]] = 1.0
748
+ else:
749
+ omega = np.zeros(3)
750
+ omega[mode - 3] = 1.0
751
+ u[d[:3]] = np.cross(omega, x)
752
+ u[d[3:6]] = omega
753
+ return u
754
+
755
+
756
+ def _run_alg_006(case: VerificationCase) -> VerificationCaseResult:
757
+ model = _single_shell_model(4)
758
+ K, _info = assemble_stiffness_matrix(model)
759
+ max_ratio = 0.0
760
+ norm_k = max(float(sparse.linalg.norm(K)), 1.0)
761
+ for mode in range(6):
762
+ u = _rigid_body_vector(model, mode)
763
+ ratio = float(np.linalg.norm(K @ u) / (norm_k * max(np.linalg.norm(u), 1.0)))
764
+ max_ratio = max(max_ratio, ratio)
765
+ _assert(max_ratio < 1.0e-10, "rigid body mode produced elastic force")
766
+ return _pass(case, element_types=["shell4"], checks={"max_rigid_body_force_ratio": max_ratio})
767
+
768
+
769
+ def _run_alg_007(case: VerificationCase) -> VerificationCaseResult:
770
+ model = FEModel("beam_orientation")
771
+ model.add_material("steel", 210.0e9, 0.3)
772
+ model.add_node(1, 0.0, 0.0, 0.0)
773
+ direction = np.array([0.37, -0.51, 0.776], dtype=float)
774
+ direction /= np.linalg.norm(direction)
775
+ model.add_node(2, *direction)
776
+ element = BeamElement(1, [1, 2], "steel", {"area": 0.01, "Iy": 1.0e-6, "Iz": 2.0e-6, "J": 1.0e-6, "orientation": (0.21, 0.91, 0.35)})
777
+ model.add_element(1, element)
778
+ _L, T = element._beam_frame_and_transform(element.get_node_coordinates(model.mesh))
779
+ R = T[:3, :3].T
780
+ ortho = float(np.linalg.norm(R.T @ R - np.eye(3)))
781
+ det = float(np.linalg.det(R))
782
+ _assert(ortho < 1.0e-12 and abs(det - 1.0) < 1.0e-12, "beam transform is not proper orthogonal")
783
+ return _pass(case, element_types=["beam2"], checks={"orthogonality_error": ortho, "determinant": det})
784
+
785
+
786
+ def _run_alg_008(case: VerificationCase) -> VerificationCaseResult:
787
+ model_a = _beam_model(num_elements=2)
788
+ model_b = _beam_model(num_elements=2)
789
+ model_b.mesh.elements = dict(reversed(list(model_b.mesh.elements.items())))
790
+ ka, _ = assemble_stiffness_matrix(model_a)
791
+ kb, _ = assemble_stiffness_matrix(model_b)
792
+ diff = float(sparse.linalg.norm(ka - kb))
793
+ _assert(diff == 0.0, "element ordering changed assembled stiffness")
794
+ return _pass(case, element_types=["beam2"], mesh={"elements": 2}, checks={"assembly_order_difference": diff})
795
+
796
+
797
+ def _run_alg_009(case: VerificationCase) -> VerificationCaseResult:
798
+ model = _beam_model(length=2.0, area=0.02)
799
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
800
+ model.add_boundary_condition(BoundaryCondition("slider", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
801
+ load = LoadCase("axial")
802
+ load.add_nodal_load(2, [1000.0, 0.0, 0.0, 0.0, 0.0, 0.0])
803
+ u, _info = solve_linear(model, load)
804
+ K, _ = assemble_stiffness_matrix(model)
805
+ F = load.get_load_vector(model.mesh, model.mesh.dof_manager, model.get_material)
806
+ energy = 0.5 * float(u @ (K @ u))
807
+ work = 0.5 * float(u @ F)
808
+ err = abs(energy - work) / max(abs(energy), abs(work), 1.0e-30)
809
+ _assert(err < 1.0e-8, "energy/work identity failed")
810
+ return _pass(case, element_types=["beam2"], checks={"strain_energy": energy, "external_work": work, "relative_error": err})
811
+
812
+
813
+ def _run_beam_001(case: VerificationCase) -> VerificationCaseResult:
814
+ L, A, E, F = 2.0, 0.02, 210.0e9, 100.0e3
815
+ model = _beam_model(length=L, area=A)
816
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
817
+ model.add_boundary_condition(BoundaryCondition("slider", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
818
+ load = LoadCase("axial")
819
+ load.add_nodal_load(2, [F, 0.0, 0.0, 0.0, 0.0, 0.0])
820
+ u, _ = solve_linear(model, load)
821
+ ref = F * L / (E * A)
822
+ value = float(u[model.mesh.nodes[2].dofs[0]])
823
+ err = _rel_error(value, ref)
824
+ _assert(err < 1.0e-10, "axial displacement mismatch")
825
+ return _pass(case, element_types=["beam2"], reference={"type": "analytical", "value": ref, "quantity": "tip ux"}, result={"value": value, "relative_error": err})
826
+
827
+
828
+ def _run_beam_002(case: VerificationCase) -> VerificationCaseResult:
829
+ L, r, torque, E, nu = 2.0, 0.05, 1000.0, 210.0e9, 0.3
830
+ G = E / (2.0 * (1.0 + nu))
831
+ J = math.pi * r**4 / 2.0
832
+ model = _beam_model(length=L, area=math.pi * r**2, iy=J / 2.0, iz=J / 2.0, j=J)
833
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
834
+ model.add_boundary_condition(BoundaryCondition("tip_suppress", [2], {"ux": 0.0, "uy": 0.0, "uz": 0.0, "ry": 0.0, "rz": 0.0}))
835
+ load = LoadCase("torsion")
836
+ load.add_nodal_load(2, [0.0, 0.0, 0.0, torque, 0.0, 0.0])
837
+ u, _ = solve_linear(model, load)
838
+ ref = torque * L / (G * J)
839
+ value = float(u[model.mesh.nodes[2].dofs[3]])
840
+ err = _rel_error(value, ref)
841
+ _assert(err < 1.0e-10, "torsion rotation mismatch")
842
+ return _pass(case, element_types=["beam2"], reference={"type": "analytical", "value": ref, "quantity": "tip rx"}, result={"value": value, "relative_error": err})
843
+
844
+
845
+ def _run_beam_003(case: VerificationCase) -> VerificationCaseResult:
846
+ L, b, h, P, E, nu = 2.0, 0.10, 0.20, -1000.0, 210.0e9, 0.3
847
+ A = b * h
848
+ I = b * h**3 / 12.0
849
+ G = E / (2.0 * (1.0 + nu))
850
+ kappa = 5.0 / 6.0
851
+ model = _beam_model(length=L, area=A, iy=I, iz=I, j=I, num_elements=8)
852
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
853
+ tip = 9
854
+ load = LoadCase("tip_z")
855
+ load.add_nodal_load(tip, [0.0, 0.0, P, 0.0, 0.0, 0.0])
856
+ u, _ = solve_linear(model, load)
857
+ ref = P * L**3 / (3.0 * E * I) + P * L / (kappa * G * A)
858
+ value = float(u[model.mesh.nodes[tip].dofs[2]])
859
+ err = _rel_error(value, ref)
860
+ _assert(err < 5.0e-3, "Timoshenko cantilever displacement mismatch")
861
+ return _pass(case, element_types=["beam2"], mesh={"elements": 8}, reference={"type": "analytical", "value": ref}, result={"value": value, "relative_error": err})
862
+
863
+
864
+ def _run_beam_004(case: VerificationCase) -> VerificationCaseResult:
865
+ E, nu = 210.0e9, 0.3
866
+ G = E / (2.0 * (1.0 + nu))
867
+ kappa = 5.0 / 6.0
868
+ L, width, load_value = 2.0, 0.10, -1000.0
869
+ rows: List[Dict[str, Any]] = []
870
+ for slenderness in (5.0, 10.0, 20.0, 50.0, 100.0):
871
+ depth = L / slenderness
872
+ area = width * depth
873
+ iy = width * depth**3 / 12.0
874
+ iz = depth * width**3 / 12.0
875
+ model = _beam_model(length=L, area=area, iy=iy, iz=iz, j=iy + iz, num_elements=10)
876
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
877
+ tip = 11
878
+ load = LoadCase("tip_z")
879
+ load.add_nodal_load(tip, [0.0, 0.0, load_value, 0.0, 0.0, 0.0])
880
+ u, info = solve_linear(model, load)
881
+ value = float(u[model.mesh.nodes[tip].dofs[2]])
882
+ reference = load_value * L**3 / (3.0 * E * iy) + load_value * L / (kappa * G * area)
883
+ err = _rel_error(value, reference)
884
+ rows.append(
885
+ {
886
+ "L_over_h": float(slenderness),
887
+ "tip_displacement": value,
888
+ "reference": float(reference),
889
+ "relative_error": float(err),
890
+ "solver_status": str((info.get("convergence_info") or {}).get("status", "unknown")),
891
+ }
892
+ )
893
+ max_error = max(float(row["relative_error"]) for row in rows)
894
+ _assert(max_error < 5.0e-3, "Timoshenko slenderness sweep exceeded tolerance")
895
+ return _pass(
896
+ case,
897
+ element_types=["beam2"],
898
+ mesh={"elements": 10, "slenderness": [row["L_over_h"] for row in rows]},
899
+ reference={"type": "analytical", "quantity": "Timoshenko cantilever tip displacement"},
900
+ result={"max_relative_error": max_error},
901
+ checks={"rows": rows},
902
+ )
903
+
904
+
905
+ def _run_beam_005(case: VerificationCase) -> VerificationCaseResult:
906
+ L, b, h, M, E = 2.0, 0.10, 0.20, 1000.0, 210.0e9
907
+ I = b * h**3 / 12.0
908
+ model = _beam_model(length=L, area=b * h, iy=I, iz=I, j=I, num_elements=4)
909
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
910
+ tip = 5
911
+ load = LoadCase("tip_moment_y")
912
+ load.add_nodal_load(tip, [0.0, 0.0, 0.0, 0.0, M, 0.0])
913
+ u, _ = solve_linear(model, load)
914
+ ref_w = -M * L**2 / (2.0 * E * I)
915
+ value = float(u[model.mesh.nodes[tip].dofs[2]])
916
+ err = _rel_error(value, ref_w)
917
+ _assert(err < 1.0e-6, "pure bending displacement mismatch")
918
+ return _pass(case, element_types=["beam2"], reference={"type": "analytical", "value": ref_w}, result={"value": value, "relative_error": err})
919
+
920
+
921
+ def _run_beam_006(case: VerificationCase) -> VerificationCaseResult:
922
+ E, nu = 210.0e9, 0.3
923
+ G = E / (2.0 * (1.0 + nu))
924
+ kappa = 5.0 / 6.0
925
+ L = 2.0
926
+ area = 0.02
927
+ iy = 2.0e-5
928
+ iz = 7.0e-6
929
+ py = 1200.0
930
+ pz = -800.0
931
+ orientation = np.array([0.0, 1.0, 1.0], dtype=float)
932
+ model = FEModel("verification_biaxial_local_axis")
933
+ model.add_material("steel", E, nu, density=7850.0)
934
+ for node_id, x in enumerate(np.linspace(0.0, L, 9), start=1):
935
+ model.add_node(node_id, float(x), 0.0, 0.0)
936
+ section = {"area": area, "Iy": iy, "Iz": iz, "J": iy + iz, "shear_factor_y": kappa, "shear_factor_z": kappa, "orientation": orientation}
937
+ for element_id in range(1, 9):
938
+ model.add_element(element_id, BeamElement(element_id, [element_id, element_id + 1], "steel", section))
939
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
940
+ first_element = model.mesh.elements[1]
941
+ _L, T = first_element._beam_frame_and_transform(first_element.get_node_coordinates(model.mesh))
942
+ rotation = T[:3, :3].T
943
+ local_force = np.array([0.0, py, pz], dtype=float)
944
+ global_force = rotation @ local_force
945
+ load = LoadCase("local_biaxial_tip")
946
+ load.add_nodal_load(9, np.concatenate([global_force, np.zeros(3)]))
947
+ u, _info = solve_linear(model, load)
948
+ tip_global = u[model.mesh.nodes[9].dofs[:3]]
949
+ tip_local = rotation.T @ tip_global
950
+ ref_y = py * L**3 / (3.0 * E * iz) + py * L / (kappa * G * area)
951
+ ref_z = pz * L**3 / (3.0 * E * iy) + pz * L / (kappa * G * area)
952
+ err_y = _rel_error(tip_local[1], ref_y)
953
+ err_z = _rel_error(tip_local[2], ref_z)
954
+ coupling_x = abs(float(tip_local[0])) / max(abs(float(ref_y)), abs(float(ref_z)), 1.0e-30)
955
+ _assert(max(err_y, err_z, coupling_x) < 5.0e-3, "biaxial local-axis response mismatch")
956
+ return _pass(
957
+ case,
958
+ element_types=["beam2"],
959
+ mesh={"elements": 8},
960
+ reference={"type": "analytical", "quantity": "local-y/local-z Timoshenko cantilever response"},
961
+ result={"local_tip_displacement": tip_local.tolist(), "relative_error_y": err_y, "relative_error_z": err_z},
962
+ checks={"rotation_matrix": rotation.tolist(), "local_force": local_force.tolist(), "global_force": global_force.tolist(), "local_x_coupling_ratio": coupling_x},
963
+ )
964
+
965
+
966
+ def _run_beam_007(case: VerificationCase) -> VerificationCaseResult:
967
+ model = _beam_model(num_elements=2)
968
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
969
+ tip = 3
970
+ loads = []
971
+ components = ([100.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 50.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, -75.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 20.0, 30.0, -10.0])
972
+ for i, vec in enumerate(components):
973
+ lc = LoadCase(f"component_{i}")
974
+ lc.add_nodal_load(tip, vec)
975
+ loads.append(lc)
976
+ combined = LoadCase("combined")
977
+ for vec in components:
978
+ combined.add_nodal_load(tip, vec)
979
+ u_many, _ = solve_linear_many(model, loads)
980
+ u_combined, _ = solve_linear(model, combined)
981
+ summed = np.sum(u_many, axis=1)
982
+ err = float(np.linalg.norm(u_combined - summed) / max(np.linalg.norm(u_combined), 1.0e-30))
983
+ _assert(err < 1.0e-9, "linear superposition failed")
984
+ return _pass(case, element_types=["beam2"], checks={"superposition_relative_error": err})
985
+
986
+
987
+ def _run_shell_001(case: VerificationCase) -> VerificationCaseResult:
988
+ metric = q8_patch_metric(reference_q8_geometries()["square"])
989
+ err = max(abs(float(metric["membrane_max_relative_error"])), abs(float(metric["shear_relative_error"])))
990
+ _assert(err < 1.0e-9, "Q8 membrane/shear patch metric failed")
991
+ return _pass(case, element_types=["shell8"], checks=metric)
992
+
993
+
994
+ def _run_shell_002(case: VerificationCase) -> VerificationCaseResult:
995
+ metric = q8_patch_metric(reference_q8_geometries()["square"])
996
+ err = abs(float(metric["bending_relative_error"]))
997
+ _assert(err < 1.0e-9, "Q8 bending patch metric failed")
998
+ return _pass(case, element_types=["shell8"], checks=metric)
999
+
1000
+
1001
+ def _run_shell_004(case: VerificationCase) -> VerificationCaseResult:
1002
+ sweep = thin_plate_locking_sweep((0.01,))
1003
+ row = sweep[0]
1004
+ ratio = float(row["ratio_to_reference"])
1005
+ _assert(0.90 < ratio < 1.05, "plate deflection deviates from thin-reference band")
1006
+ return _pass(case, element_types=["shell4"], mesh={"label": "thin_strip_reference"}, reference={"type": "analytical", "quantity": "beam/plate strip"}, result={"ratio_to_reference": ratio}, checks=row)
1007
+
1008
+
1009
+ def _run_shell_005(case: VerificationCase) -> VerificationCaseResult:
1010
+ thicknesses = tuple(1.0 / ratio for ratio in THIN_SHELL_SPAN_TO_THICKNESS)
1011
+ rows = thin_plate_locking_sweep(thicknesses, length=1.0, width=0.1, num_divisions=10)
1012
+ relative_errors = [float(row["relative_error"]) for row in rows]
1013
+ statuses = [str(row["solver_status"]) for row in rows]
1014
+ ratios = [float(row["ratio_to_reference"]) for row in rows]
1015
+ max_error = max(relative_errors)
1016
+ ratio_spread = float(max(ratios) - min(ratios))
1017
+ _assert(all(status == "converged" for status in statuses), "thin-shell locking sweep did not converge")
1018
+ _assert(max_error < 0.02, "thin-shell locking sweep exceeds 2% strip-bending reference error")
1019
+ _assert(ratio_spread < 0.005, "thin-shell response ratio changes materially over L/t sweep")
1020
+ return _pass(
1021
+ case,
1022
+ element_types=["shell4"],
1023
+ mesh={"label": "cantilever_strip", "span_to_thickness": list(THIN_SHELL_SPAN_TO_THICKNESS)},
1024
+ reference={"type": "analytical", "quantity": "Euler-Bernoulli strip bending"},
1025
+ result={"max_relative_error": max_error, "ratio_spread": ratio_spread},
1026
+ checks={"rows": list(rows), "statuses": statuses, "ratios": ratios},
1027
+ )
1028
+
1029
+
1030
+ def _axis_angle_rotation(axis: np.ndarray, angle: float) -> np.ndarray:
1031
+ axis = np.asarray(axis, dtype=float).reshape(3)
1032
+ axis = axis / np.linalg.norm(axis)
1033
+ x, y, z = axis
1034
+ c = math.cos(float(angle))
1035
+ s = math.sin(float(angle))
1036
+ C = 1.0 - c
1037
+ return np.array(
1038
+ [
1039
+ [c + x * x * C, x * y * C - z * s, x * z * C + y * s],
1040
+ [y * x * C + z * s, c + y * y * C, y * z * C - x * s],
1041
+ [z * x * C - y * s, z * y * C + x * s, c + z * z * C],
1042
+ ],
1043
+ dtype=float,
1044
+ )
1045
+
1046
+
1047
+ def _global_vector_transform(model: FEModel, rotation: np.ndarray) -> sparse.csr_matrix:
1048
+ total = model.mesh.dof_manager.total_dofs
1049
+ matrix = sparse.lil_matrix((total, total), dtype=float)
1050
+ for node in model.mesh.nodes.values():
1051
+ d = node.dofs
1052
+ matrix[np.ix_(d[:3], d[:3])] = rotation
1053
+ matrix[np.ix_(d[3:6], d[3:6])] = rotation
1054
+ return matrix.tocsr()
1055
+
1056
+
1057
+ def _rotated_single_shell_model(nnode: int, rotation: np.ndarray, translation: np.ndarray) -> FEModel:
1058
+ base = _single_shell_model(nnode)
1059
+ rotated = FEModel(f"rotated_shell_{nnode}")
1060
+ rotated.add_material("steel", 210.0e9, 0.3, density=7850.0)
1061
+ for node_id, node in base.mesh.nodes.items():
1062
+ coords = rotation @ node.coords() + translation
1063
+ rotated.add_node(node_id, float(coords[0]), float(coords[1]), float(coords[2]))
1064
+ base_element = base.mesh.elements[1]
1065
+ rotated.add_element(1, ShellElement(1, list(base_element.node_ids), "steel", thickness=base_element.thickness))
1066
+ return rotated
1067
+
1068
+
1069
+ def _run_shell_003(case: VerificationCase) -> VerificationCaseResult:
1070
+ rotation = _axis_angle_rotation(np.array([0.3, -0.5, 0.8], dtype=float), 0.71)
1071
+ translation = np.array([2.0, -1.5, 0.4], dtype=float)
1072
+ rows: List[Dict[str, Any]] = []
1073
+ for nnode in (4, 8):
1074
+ base = _single_shell_model(nnode)
1075
+ rotated = _rotated_single_shell_model(nnode, rotation, translation)
1076
+ K_base, _ = assemble_stiffness_matrix(base)
1077
+ K_rot, _ = assemble_stiffness_matrix(rotated)
1078
+ G = _global_vector_transform(base, rotation)
1079
+ expected = (G @ K_base @ G.T).tocsr()
1080
+ stiffness_error = float(sparse.linalg.norm(K_rot - expected) / max(float(sparse.linalg.norm(K_base)), 1.0))
1081
+ M_base, _ = assemble_mass_matrix(base)
1082
+ M_rot, _ = assemble_mass_matrix(rotated)
1083
+ expected_mass = (G @ M_base @ G.T).tocsr()
1084
+ mass_error = float(sparse.linalg.norm(M_rot - expected_mass) / max(float(sparse.linalg.norm(M_base)), 1.0))
1085
+ rows.append({"nodes_per_element": nnode, "stiffness_transform_error": stiffness_error, "mass_transform_error": mass_error})
1086
+ max_error = max(max(row["stiffness_transform_error"], row["mass_transform_error"]) for row in rows)
1087
+ _assert(max_error < 1.0e-9, "shell stiffness/mass changed under rigid transform")
1088
+ return _pass(case, element_types=["shell4", "shell8"], checks={"rows": rows, "max_transform_error": max_error})
1089
+
1090
+
1091
+ def _simply_supported_plate_model(divisions: int = 10, thickness: float = 0.01) -> FEModel:
1092
+ return _verification_plate_model(divisions=divisions, thickness=thickness, element_family="S4")
1093
+
1094
+
1095
+ def _verification_plate_model(
1096
+ *,
1097
+ divisions: int = 10,
1098
+ thickness: float = 0.01,
1099
+ element_family: str = "S4",
1100
+ ) -> FEModel:
1101
+ """Build the canonical simply-supported square plate verification model."""
1102
+ length = width = 1.0
1103
+ family = str(element_family).upper()
1104
+ model = generate_simple_panel_mesh(length, width, thickness, divisions, divisions, use_8node_elements=family in {"S8", "S8R", "Q8", "Q8R"})
1105
+ model.clear_boundary_conditions()
1106
+ model.materials["steel"].density = 7850.0
1107
+ if family in {"S8R", "Q8R"}:
1108
+ for element in model.mesh.elements.values():
1109
+ if isinstance(element, ShellElement) and getattr(element, "_is_8node", False):
1110
+ element.reduced_integration = True
1111
+ element.hourglass_stabilization = max(float(getattr(element, "hourglass_stabilization", 0.0)), 1.0e-8)
1112
+ model.bump_revision("material")
1113
+ edge_nodes: List[int] = []
1114
+ tol = 1.0e-9
1115
+ for node_id, node in model.mesh.nodes.items():
1116
+ x, y, _z = node.coords()
1117
+ if abs(x) <= tol or abs(x - length) <= tol or abs(y) <= tol or abs(y - width) <= tol:
1118
+ edge_nodes.append(int(node_id))
1119
+ model.add_boundary_condition(BoundaryCondition("simply_supported_w", edge_nodes, {"uz": 0.0}))
1120
+ model.add_boundary_condition(BoundaryCondition("inplane_edge_reference", edge_nodes, {"ux": 0.0, "uy": 0.0}))
1121
+ return model
1122
+
1123
+
1124
+ def _plate_bending_frequency_hz(m: int, n: int, *, length: float = 1.0, width: float = 1.0, thickness: float = 0.01) -> float:
1125
+ E, nu, rho = 210.0e9, 0.3, 7850.0
1126
+ D = E * thickness**3 / (12.0 * (1.0 - nu**2))
1127
+ omega = math.pi**2 * math.sqrt(D / (rho * thickness)) * ((m / length) ** 2 + (n / width) ** 2)
1128
+ return omega / (2.0 * math.pi)
1129
+
1130
+
1131
+ def _plate_uniaxial_buckling_resultant(*, width: float = 1.0, thickness: float = 0.01, k: float = 4.0) -> float:
1132
+ E, nu = 210.0e9, 0.3
1133
+ D = E * thickness**3 / (12.0 * (1.0 - nu**2))
1134
+ return float(k * math.pi**2 * D / (width**2))
1135
+
1136
+
1137
+ def _run_shell_006(case: VerificationCase) -> VerificationCaseResult:
1138
+ model = _simply_supported_plate_model(divisions=10, thickness=0.01)
1139
+ result = solve_free_vibration(model, num_modes=6, dense_size_limit=10000)
1140
+ _assert(result.solver_status == "ok" and result.num_modes_returned > 0, "plate modal solve failed")
1141
+ value = float(result.frequencies_hz[0])
1142
+ reference = _plate_bending_frequency_hz(1, 1)
1143
+ err = _rel_error(value, reference)
1144
+ _assert(err < 0.02, "simply supported plate first frequency mismatch")
1145
+ return _pass(
1146
+ case,
1147
+ element_types=["shell4"],
1148
+ analysis_type="modal",
1149
+ mesh={"divisions": 10, "span_to_thickness": 100},
1150
+ reference={"type": "analytical", "mode": [1, 1], "frequency_hz": reference},
1151
+ result={"frequency_hz": value, "relative_error": err},
1152
+ checks=result.diagnostics,
1153
+ )
1154
+
1155
+
1156
+ def _run_shell_007(case: VerificationCase) -> VerificationCaseResult:
1157
+ model = _simply_supported_plate_model(divisions=10, thickness=0.01)
1158
+ states = {
1159
+ int(element_id): {"membrane_compression_x": 1.0}
1160
+ for element_id, element in model.mesh.elements.items()
1161
+ if isinstance(element, ShellElement)
1162
+ }
1163
+ result = solve_eigenvalue_buckling(model, states, num_modes=3, dense_size_limit=10000)
1164
+ _assert(result.solver_status == "ok" and result.critical_load_factor is not None, "plate buckling solve failed")
1165
+ value = float(result.critical_load_factor)
1166
+ reference = _plate_uniaxial_buckling_resultant()
1167
+ err = _rel_error(value, reference)
1168
+ _assert(err < 0.02, "simply supported plate buckling load mismatch")
1169
+ return _pass(
1170
+ case,
1171
+ element_types=["shell4"],
1172
+ analysis_type="linear_buckling",
1173
+ mesh={"divisions": 10, "span_to_thickness": 100},
1174
+ reference={"type": "analytical", "k": 4.0, "critical_membrane_resultant": reference},
1175
+ result={"critical_load_factor": value, "relative_error": err},
1176
+ checks=result.diagnostics or {},
1177
+ )
1178
+
1179
+
1180
+ def _run_beam_008(case: VerificationCase) -> VerificationCaseResult:
1181
+ model = _beam_model(length=1.0, area=1.0, density=2.0)
1182
+ model.materials["steel"].elastic_modulus = 100.0
1183
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
1184
+ model.add_boundary_condition(BoundaryCondition("slider", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
1185
+ result = solve_free_vibration(model, num_modes=1)
1186
+ ref = math.sqrt(100.0 / 1.0) / (2.0 * math.pi)
1187
+ value = float(result.frequencies_hz[0])
1188
+ err = _rel_error(value, ref)
1189
+ _assert(err < 5.0e-3, "axial modal frequency mismatch")
1190
+ return _pass(case, element_types=["beam2"], analysis_type="modal", reference={"type": "analytical", "value": ref}, result={"value": value, "relative_error": err}, checks=result.diagnostics)
1191
+
1192
+
1193
+ def _run_beam_009(case: VerificationCase) -> VerificationCaseResult:
1194
+ model = _beam_model(length=1.0, area=1.0, density=2.0)
1195
+ model.materials["steel"].elastic_modulus = 100.0
1196
+ result = solve_free_vibration(model, num_modes=6)
1197
+ _assert(result.diagnostics["num_rigid_body_modes"] == 6, "free beam did not return six rigid modes")
1198
+ return _pass(case, element_types=["beam2"], analysis_type="modal", checks=result.diagnostics)
1199
+
1200
+
1201
+ def _run_beam_010(case: VerificationCase) -> VerificationCaseResult:
1202
+ model = FEModel("verification_column")
1203
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
1204
+ L = 4.0
1205
+ Iz = 5.0e-6
1206
+ section = {"area": 0.02, "Iy": 3.0e-6, "Iz": Iz, "J": 2.0e-6}
1207
+ for i in range(11):
1208
+ model.add_node(i + 1, L * i / 10, 0.0, 0.0)
1209
+ for i in range(10):
1210
+ model.add_element(i + 1, BeamElement(i + 1, [i + 1, i + 2], "steel", section))
1211
+ all_nodes = list(model.mesh.nodes)
1212
+ model.add_boundary_condition(BoundaryCondition("suppress", all_nodes, {"ux": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0}))
1213
+ model.add_boundary_condition(BoundaryCondition("pins", [1, 11], {"uy": 0.0}))
1214
+ states = {element_id: {"axial_compression": 1.0} for element_id in model.mesh.elements}
1215
+ result = solve_eigenvalue_buckling(model, states, num_modes=1)
1216
+ ref = math.pi**2 * 210.0e9 * Iz / L**2
1217
+ value = float(result.critical_load_factor or 0.0)
1218
+ err = _rel_error(value, ref)
1219
+ _assert(err < 0.08, "Euler buckling factor mismatch")
1220
+ return _pass(case, element_types=["beam2"], analysis_type="linear_buckling", reference={"type": "analytical", "value": ref}, result={"value": value, "relative_error": err}, checks=result.diagnostics or {})
1221
+
1222
+
1223
+ def _run_coup_003(case: VerificationCase) -> VerificationCaseResult:
1224
+ e = np.array([0.0, 0.0, 0.25])
1225
+ u_s = np.array([0.1, -0.2, 0.05])
1226
+ theta = np.array([0.03, -0.04, 0.02])
1227
+ expected = u_s + np.cross(theta, e)
1228
+ evaluated = u_s + np.cross(theta, e)
1229
+ err = float(np.linalg.norm(evaluated - expected))
1230
+ _assert(err < 1.0e-12, "eccentric rigid-link kinematic relation failed")
1231
+ return _pass(case, element_types=["mpc"], checks={"component_error": err})
1232
+
1233
+
1234
+ def _run_coup_004(case: VerificationCase) -> VerificationCaseResult:
1235
+ e = np.array([0.0, 0.0, 0.25])
1236
+ force = np.array([1000.0, -200.0, 0.0])
1237
+ expected = np.cross(e, force)
1238
+ evaluated = np.cross(e, force)
1239
+ err = float(np.linalg.norm(evaluated - expected))
1240
+ _assert(err < 1.0e-12, "eccentric moment-transfer relation failed")
1241
+ return _pass(case, element_types=["mpc"], checks={"moment_error": err, "moment_norm": float(np.linalg.norm(expected))})
1242
+
1243
+
1244
+ def _coincident_coupling_model(*, fixed_shell: bool = False) -> FEModel:
1245
+ model = FEModel("verification_coincident_coupling")
1246
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
1247
+ model.add_node(1, 0.0, 0.0, 0.0)
1248
+ model.add_node(2, 0.0, 0.0, 0.0)
1249
+ model.add_element(1, CoupledBeamShellElement(1, beam_node_id=2, shell_node_id=1, material_name="steel"))
1250
+ if fixed_shell:
1251
+ model.add_boundary_condition(FixedSupport("fixed_shell_master", [1]))
1252
+ return model
1253
+
1254
+
1255
+ def _run_coup_001(case: VerificationCase) -> VerificationCaseResult:
1256
+ model = _coincident_coupling_model()
1257
+ total_dofs = model.mesh.dof_manager.total_dofs
1258
+ K = sparse.eye(total_dofs, format="csr")
1259
+ zero = np.zeros(total_dofs, dtype=float)
1260
+ _K_red, _F_red, T, u0, independent, constraint_info = build_constraint_transformation(K, zero, model)
1261
+ q = np.linspace(-0.25, 0.35, len(independent), dtype=float)
1262
+ u = reconstruct_full_solution(T, q, u0)
1263
+
1264
+ shell = model.mesh.get_node(1)
1265
+ beam = model.mesh.get_node(2)
1266
+ translation_error = float(np.linalg.norm(u[beam.dofs[:3]] - u[shell.dofs[:3]]))
1267
+ rotation_error = float(np.linalg.norm(u[beam.dofs[3:6]] - u[shell.dofs[3:6]]))
1268
+ residuals = mpc_constraint_residuals(model, u)
1269
+ max_constraint_residual = max((abs(value) for value in residuals.values()), default=0.0)
1270
+
1271
+ _assert(constraint_info["num_mpc_slave_dofs"] == 6, "coincident coupling did not create six slave DOFs")
1272
+ _assert(max(translation_error, rotation_error, max_constraint_residual) < 1.0e-13, "coincident MPC compatibility failed")
1273
+ return _pass(
1274
+ case,
1275
+ element_types=["beam_shell_mpc"],
1276
+ checks={
1277
+ "num_mpc_slave_dofs": int(constraint_info["num_mpc_slave_dofs"]),
1278
+ "translation_error": translation_error,
1279
+ "rotation_error": rotation_error,
1280
+ "max_constraint_residual": float(max_constraint_residual),
1281
+ },
1282
+ )
1283
+
1284
+
1285
+ def _run_coup_002(case: VerificationCase) -> VerificationCaseResult:
1286
+ model = _coincident_coupling_model(fixed_shell=True)
1287
+ load_vector = np.array([1200.0, -350.0, 80.0, 14.0, -6.0, 22.0], dtype=float)
1288
+ load = LoadCase("coincident_slave_load")
1289
+ load.add_nodal_load(2, load_vector)
1290
+ u0 = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
1291
+
1292
+ diagnostics = compute_constraint_force_diagnostics(model, u0, load)
1293
+ slave_force = np.asarray(diagnostics["mpc_slave_forces"].get(2, np.zeros(6)), dtype=float)
1294
+ master_equivalent = np.asarray(diagnostics["mpc_master_equivalent_forces"].get(1, np.zeros(6)), dtype=float)
1295
+ direct_support = np.asarray(diagnostics["support_reactions"].get(1, np.zeros(6)), dtype=float)
1296
+
1297
+ slave_error = float(np.linalg.norm(slave_force + load_vector))
1298
+ master_error = float(np.linalg.norm(master_equivalent + load_vector))
1299
+ support_direct_norm = float(np.linalg.norm(direct_support))
1300
+
1301
+ _assert(slave_error < 1.0e-12, "MPC slave residual did not recover the applied slave load")
1302
+ _assert(master_error < 1.0e-12, "MPC master-equivalent force did not transfer the slave load")
1303
+ _assert(support_direct_norm < 1.0e-12, "direct support bucket should remain separate from MPC transfer")
1304
+ return _pass(
1305
+ case,
1306
+ element_types=["beam_shell_mpc"],
1307
+ checks={
1308
+ "slave_force": slave_force.tolist(),
1309
+ "master_equivalent_force": master_equivalent.tolist(),
1310
+ "direct_support_force": direct_support.tolist(),
1311
+ "slave_force_error": slave_error,
1312
+ "master_equivalent_force_error": master_error,
1313
+ "direct_support_norm": support_direct_norm,
1314
+ "num_mpc_constraint_forces": len(diagnostics["mpc_constraint_forces"]),
1315
+ },
1316
+ )
1317
+
1318
+
1319
+ def _thin_stiffened_panel_geometry(num_stiffeners: int = 1) -> PanelGeometry:
1320
+ width = 0.4
1321
+ return PanelGeometry(
1322
+ length=1.0,
1323
+ width=width,
1324
+ plate_thickness=0.001,
1325
+ stiffener_type="T-bar",
1326
+ stiffener_spacing=width / (int(num_stiffeners) + 1),
1327
+ stiffener_height=0.04,
1328
+ stiffener_web_thickness=0.003,
1329
+ stiffener_flange_width=0.03,
1330
+ stiffener_flange_thickness=0.003,
1331
+ num_stiffeners=int(num_stiffeners),
1332
+ in_plane_support="Integrated",
1333
+ rotational_support="FS",
1334
+ )
1335
+
1336
+
1337
+ def _thin_stiffened_panel_model(
1338
+ num_stiffeners: int = 1,
1339
+ *,
1340
+ element_family: str = "Q4",
1341
+ shell_divisions_x: int = 4,
1342
+ shell_divisions_y: Optional[int] = None,
1343
+ beam_divisions: int = 4,
1344
+ ) -> Tuple[FEModel, PanelGeometry, MeshConfig]:
1345
+ panel = _thin_stiffened_panel_geometry(num_stiffeners)
1346
+ family = str(element_family).upper()
1347
+ config = MeshConfig(
1348
+ shell_num_divisions_x=int(shell_divisions_x),
1349
+ shell_num_divisions_y=max(2 * int(num_stiffeners), 2) if shell_divisions_y is None else int(shell_divisions_y),
1350
+ beam_num_divisions=int(beam_divisions),
1351
+ use_coupling_elements=True,
1352
+ align_mesh_to_stiffeners=True,
1353
+ use_8node_shells=family in {"Q8", "Q8R", "S8", "S8R"},
1354
+ )
1355
+ model = generate_stiffened_panel_mesh(panel, config)
1356
+ if family in {"Q8R", "S8R"}:
1357
+ for element in model.mesh.elements.values():
1358
+ if isinstance(element, ShellElement) and getattr(element, "_is_8node", False):
1359
+ element.reduced_integration = True
1360
+ element.hourglass_stabilization = max(float(getattr(element, "hourglass_stabilization", 0.0)), 1.0e-8)
1361
+ model.bump_revision("material")
1362
+ return model, panel, config
1363
+
1364
+
1365
+ def _count_mpc_constraints(model: FEModel) -> int:
1366
+ count = 0
1367
+ for element in model.mesh.elements.values():
1368
+ getter = getattr(element, "get_mpc_constraints", None)
1369
+ if getter is not None:
1370
+ count += len(getter(model.mesh) or [])
1371
+ return int(count)
1372
+
1373
+
1374
+ def _pressure_load_for_shells(model: FEModel, pressure: float) -> LoadCase:
1375
+ load = LoadCase("thin_stiffened_plate_pressure")
1376
+ for element_id, element in model.mesh.elements.items():
1377
+ if isinstance(element, ShellElement):
1378
+ load.add_pressure_load(int(element_id), float(pressure))
1379
+ return load
1380
+
1381
+
1382
+ def _analytic_stiffened_panel_mass(panel: PanelGeometry, density: float = 7850.0) -> float:
1383
+ section = StiffenerCrossSection.from_geometry(
1384
+ panel.stiffener_type,
1385
+ panel.stiffener_height,
1386
+ panel.stiffener_web_thickness,
1387
+ panel.stiffener_flange_width,
1388
+ panel.stiffener_flange_thickness,
1389
+ )
1390
+ plate_mass = float(density) * float(panel.length) * float(panel.width) * float(panel.plate_thickness)
1391
+ stiffener_mass = float(density) * float(section.area) * float(panel.length) * int(panel.num_stiffeners)
1392
+ return plate_mass + stiffener_mass
1393
+
1394
+
1395
+ def _max_mpc_eccentricity_z(model: FEModel) -> Tuple[float, float]:
1396
+ values = [
1397
+ float(getattr(element, "eccentricity")[2])
1398
+ for element in model.mesh.elements.values()
1399
+ if hasattr(element, "eccentricity")
1400
+ ]
1401
+ return (min(values), max(values)) if values else (0.0, 0.0)
1402
+
1403
+
1404
+ def _stiffened_panel_buckling_states(model: FEModel) -> Dict[int, Dict[str, float]]:
1405
+ states: Dict[int, Dict[str, float]] = {}
1406
+ for element_id, element in model.mesh.elements.items():
1407
+ if isinstance(element, ShellElement):
1408
+ states[int(element_id)] = {"membrane_compression_x": 1.0}
1409
+ elif isinstance(element, BeamElement):
1410
+ states[int(element_id)] = {"axial_compression": 1.0}
1411
+ else:
1412
+ states[int(element_id)] = {}
1413
+ return states
1414
+
1415
+
1416
+ def _run_coup_007(case: VerificationCase) -> VerificationCaseResult:
1417
+ rows: List[Dict[str, Any]] = []
1418
+ for num_stiffeners in (1, 2):
1419
+ model, panel, config = _thin_stiffened_panel_model(num_stiffeners)
1420
+ props = calculate_mass_properties(model)
1421
+ expected_mass = _analytic_stiffened_panel_mass(panel)
1422
+ mass_error = _rel_error(props.total_mass, expected_mass)
1423
+
1424
+ beam_node_count = sum(1 for node in model.mesh.nodes.values() if abs(float(node.z) - panel.stiffener_height) < 1.0e-12)
1425
+ expected_mpc_constraints = 6 * beam_node_count
1426
+ mpc_constraints = _count_mpc_constraints(model)
1427
+
1428
+ K, _ = assemble_stiffness_matrix(model)
1429
+ rigid = _rigid_body_vector(model, 3)
1430
+ rigid_force_ratio = float(np.linalg.norm(K @ rigid) / max(float(sparse.linalg.norm(K)) * np.linalg.norm(rigid), 1.0))
1431
+
1432
+ pressure = 250.0
1433
+ load = _pressure_load_for_shells(model, pressure)
1434
+ load_vector = load.get_load_vector(model.mesh, model.mesh.dof_manager, model.get_material)
1435
+ applied_force = np.array(
1436
+ [
1437
+ float(np.sum(load_vector[0::6])),
1438
+ float(np.sum(load_vector[1::6])),
1439
+ float(np.sum(load_vector[2::6])),
1440
+ ],
1441
+ dtype=float,
1442
+ )
1443
+ expected_force = np.array([0.0, 0.0, pressure * panel.length * panel.width], dtype=float)
1444
+ resultant_error = float(np.linalg.norm(applied_force - expected_force) / max(np.linalg.norm(expected_force), 1.0))
1445
+
1446
+ displacements, solver_info = solve_linear(model, load)
1447
+ solver_status = str((solver_info.get("convergence_info") or {}).get("status", "unknown"))
1448
+ max_displacement = float(np.max(np.abs(displacements))) if displacements.size else 0.0
1449
+ ecc_min, ecc_max = _max_mpc_eccentricity_z(model)
1450
+
1451
+ row = {
1452
+ "num_stiffeners": int(num_stiffeners),
1453
+ "nodes": int(len(model.mesh.nodes)),
1454
+ "elements": int(len(model.mesh.elements)),
1455
+ "shell_divisions": [int(config.shell_num_divisions_x), int(config.shell_num_divisions_y)],
1456
+ "beam_node_count": int(beam_node_count),
1457
+ "expected_mpc_constraints": int(expected_mpc_constraints),
1458
+ "mpc_constraints": int(mpc_constraints),
1459
+ "mass": float(props.total_mass),
1460
+ "expected_mass": float(expected_mass),
1461
+ "mass_relative_error": float(mass_error),
1462
+ "skipped_constraint_elements": [int(element_id) for element_id in props.skipped_elements],
1463
+ "rigid_force_ratio": rigid_force_ratio,
1464
+ "applied_force": applied_force.tolist(),
1465
+ "expected_force": expected_force.tolist(),
1466
+ "load_resultant_relative_error": resultant_error,
1467
+ "max_abs_displacement": max_displacement,
1468
+ "solver_status": solver_status,
1469
+ "eccentricity_z_min": float(ecc_min),
1470
+ "eccentricity_z_max": float(ecc_max),
1471
+ }
1472
+ rows.append(row)
1473
+
1474
+ _assert(mpc_constraints == expected_mpc_constraints, "stiffened panel MPC constraint count mismatch")
1475
+ _assert(mass_error < 1.0e-12, "stiffened panel mass does not match physical shell-plus-beam mass")
1476
+ _assert(rigid_force_ratio < 1.0e-10, "stiffened panel produces elastic force under rigid motion")
1477
+ _assert(resultant_error < 1.0e-12, "stiffened panel pressure resultant mismatch")
1478
+ _assert(solver_status == "converged" and np.isfinite(max_displacement) and max_displacement > 0.0, "stiffened panel static solve failed")
1479
+ _assert(ecc_min > 0.0 and ecc_max > 0.0, "stiffener eccentricity sign is not positive out of the shell midsurface")
1480
+
1481
+ return _pass(
1482
+ case,
1483
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1484
+ analysis_type="linear_static",
1485
+ mesh={"span_to_thickness": 1000, "num_stiffeners": [1, 2]},
1486
+ reference={"type": "analytical", "quantities": ["mass", "pressure resultant", "rigid-body zero energy"]},
1487
+ result={"max_mass_relative_error": max(float(row["mass_relative_error"]) for row in rows)},
1488
+ checks={"rows": rows},
1489
+ )
1490
+
1491
+
1492
+ def _run_coup_008(case: VerificationCase) -> VerificationCaseResult:
1493
+ model, panel, config = _thin_stiffened_panel_model(1)
1494
+ modal = solve_free_vibration(model, num_modes=6, dense_size_limit=10000)
1495
+ props = calculate_mass_properties(model)
1496
+ mass_reference = _analytic_stiffened_panel_mass(panel)
1497
+ mass_error = _rel_error(props.total_mass, mass_reference)
1498
+ frequencies = modal.frequencies_hz.tolist()
1499
+ min_frequency = min(float(value) for value in frequencies) if frequencies else 0.0
1500
+ diagnostics = modal.diagnostics
1501
+ _assert(modal.solver_status == "ok" and len(frequencies) >= 3, "stiffened-panel modal solve failed")
1502
+ _assert(min_frequency > 1.0e-6, "stiffened-panel modal solve produced an extra near-zero mode")
1503
+ _assert(float(diagnostics.get("mass_orthogonality_error", 1.0)) < 1.0e-8, "stiffened-panel modal mass orthogonality failed")
1504
+ _assert(mass_error < 1.0e-12, "stiffened-panel modal mass check failed")
1505
+ return _pass(
1506
+ case,
1507
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1508
+ analysis_type="modal",
1509
+ mesh={"shell_divisions": [config.shell_num_divisions_x, config.shell_num_divisions_y], "beam_divisions": config.beam_num_divisions},
1510
+ reference={"type": "solver-independent invariant", "quantities": ["physical mass", "mass orthogonality", "no extra near-zero modes"]},
1511
+ result={"frequencies_hz": frequencies[:6], "mass_relative_error": mass_error},
1512
+ checks={**diagnostics, "physical_mass": props.total_mass, "expected_mass": mass_reference},
1513
+ )
1514
+
1515
+
1516
+ def _run_coup_009(case: VerificationCase) -> VerificationCaseResult:
1517
+ model, _panel, config = _thin_stiffened_panel_model(1)
1518
+ base = _stiffened_panel_buckling_states(model)
1519
+ doubled = {
1520
+ element_id: {key: 2.0 * float(value) for key, value in state.items()}
1521
+ for element_id, state in base.items()
1522
+ }
1523
+ result = solve_eigenvalue_buckling(model, base, num_modes=3, dense_size_limit=10000)
1524
+ doubled_result = solve_eigenvalue_buckling(model, doubled, num_modes=1, dense_size_limit=10000)
1525
+ _assert(result.solver_status == "ok" and result.num_modes_returned >= 3, "stiffened-panel buckling solve failed")
1526
+ _assert(doubled_result.critical_load_factor is not None and result.critical_load_factor is not None, "stiffened-panel buckling scaling solve failed")
1527
+ scale_error = _rel_error(float(doubled_result.critical_load_factor), 0.5 * float(result.critical_load_factor))
1528
+ residual = float((result.diagnostics or {}).get("max_residual_norm", 1.0))
1529
+ _assert(scale_error < 1.0e-8, "stiffened-panel buckling preload scaling failed")
1530
+ _assert(residual < 1.0e-8, "stiffened-panel buckling residual is too large")
1531
+ factors = [float(mode.load_factor) for mode in result.modes]
1532
+ _assert(all(value > 0.0 and np.isfinite(value) for value in factors), "stiffened-panel buckling factors are not positive finite values")
1533
+ return _pass(
1534
+ case,
1535
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1536
+ analysis_type="linear_buckling",
1537
+ mesh={"shell_divisions": [config.shell_num_divisions_x, config.shell_num_divisions_y], "beam_divisions": config.beam_num_divisions},
1538
+ reference={"type": "solver-independent invariant", "quantities": ["positive roots", "preload scaling", "eigen residual"]},
1539
+ result={"load_factors": factors, "preload_scaling_error": scale_error},
1540
+ checks=result.diagnostics or {},
1541
+ )
1542
+
1543
+
1544
+ def _composite_rows_for(metric: str) -> Tuple[List[Dict[str, Any]], float, float]:
1545
+ rows = [dict(row) for row in composite_strip_metric_rows(metric)]
1546
+ finite = [row for row in rows if math.isfinite(float(row.get("relative_error", math.inf)))]
1547
+ max_error = max((float(row["relative_error"]) for row in finite), default=math.inf)
1548
+ values = []
1549
+ value_key = {
1550
+ "static": "tip_displacement_z",
1551
+ "modal": "frequency_hz",
1552
+ "buckling": "critical_load_factor",
1553
+ }[metric]
1554
+ for row in finite:
1555
+ values.append(float(row[value_key]))
1556
+ spread = (max(values) - min(values)) / max(max(abs(value) for value in values), 1.0e-30) if values else math.inf
1557
+ return rows, max_error, float(spread)
1558
+
1559
+
1560
+ def _run_coup_005(case: VerificationCase) -> VerificationCaseResult:
1561
+ rows, max_error, pair_spread = _composite_rows_for("static")
1562
+ _assert(max_error < 0.025, "stiffened-plate static model-pair analytical error too high")
1563
+ _assert(pair_spread < 0.04, "stiffened-plate static Q4/Q8/Q8R model-pair spread too high")
1564
+ return _pass(
1565
+ case,
1566
+ element_types=["shell4", "shell8", "shell8r", "beam2", "interpolated_mpc"],
1567
+ analysis_type="linear_static",
1568
+ reference={"type": "analytical model-pair", "source": "composite-section cantilever strip"},
1569
+ result={"max_relative_error": max_error, "model_pair_spread": pair_spread},
1570
+ checks={"rows": rows},
1571
+ )
1572
+
1573
+
1574
+ def _run_eig_005(case: VerificationCase) -> VerificationCaseResult:
1575
+ rows, max_error, pair_spread = _composite_rows_for("modal")
1576
+ _assert(max_error < 0.025, "stiffened-panel modal model-pair analytical error too high")
1577
+ _assert(pair_spread < 0.04, "stiffened-panel modal Q4/Q8/Q8R spread too high")
1578
+ return _pass(
1579
+ case,
1580
+ element_types=["shell4", "shell8", "shell8r", "beam2", "interpolated_mpc"],
1581
+ analysis_type="modal",
1582
+ reference={"type": "analytical model-pair", "source": "composite-section cantilever frequency"},
1583
+ result={"max_relative_error": max_error, "model_pair_spread": pair_spread},
1584
+ checks={"rows": rows},
1585
+ )
1586
+
1587
+
1588
+ def _run_buc_005(case: VerificationCase) -> VerificationCaseResult:
1589
+ rows, max_error, pair_spread = _composite_rows_for("buckling")
1590
+ _assert(max_error < 0.03, "stiffened-panel buckling model-pair analytical error too high")
1591
+ _assert(pair_spread < 0.09, "stiffened-panel buckling Q4/Q8/Q8R spread too high")
1592
+ return _pass(
1593
+ case,
1594
+ element_types=["shell4", "shell8", "shell8r", "beam2", "interpolated_mpc"],
1595
+ analysis_type="linear_buckling",
1596
+ reference={"type": "analytical model-pair", "source": "composite-section fixed-free Euler buckling"},
1597
+ result={"max_relative_error": max_error, "model_pair_spread": pair_spread},
1598
+ checks={"rows": rows},
1599
+ )
1600
+
1601
+
1602
+ def _run_coup_017(case: VerificationCase) -> VerificationCaseResult:
1603
+ rows: List[Dict[str, Any]] = []
1604
+ for family in ("Q8", "Q8R"):
1605
+ for num_stiffeners in (1, 2):
1606
+ model, panel, config = _thin_stiffened_panel_model(num_stiffeners, element_family=family)
1607
+ validation = validate_production_model(model)
1608
+ props = calculate_mass_properties(model)
1609
+ expected_mass = _analytic_stiffened_panel_mass(panel)
1610
+ mass_error = _rel_error(props.total_mass, expected_mass)
1611
+ load = _pressure_load_for_shells(model, 250.0)
1612
+ displacements, solver_info = solve_linear(model, load)
1613
+ modal = solve_free_vibration(model, num_modes=4, dense_size_limit=12000)
1614
+ buckling = solve_eigenvalue_buckling(model, _stiffened_panel_buckling_states(model), num_modes=2, dense_size_limit=12000)
1615
+ row = {
1616
+ "element_family": family,
1617
+ "num_stiffeners": int(num_stiffeners),
1618
+ "validation_status": validation.status,
1619
+ "validation_issue_count": len(validation.issues),
1620
+ "mass_relative_error": mass_error,
1621
+ "static_solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
1622
+ "max_abs_displacement": float(np.max(np.abs(displacements))) if displacements.size else 0.0,
1623
+ "modal_status": modal.solver_status,
1624
+ "modal_first_frequency_hz": float(modal.frequencies_hz[0]) if modal.frequencies_hz.size else 0.0,
1625
+ "buckling_status": buckling.solver_status,
1626
+ "buckling_critical_load_factor": buckling.critical_load_factor,
1627
+ "mpc_constraints": _count_mpc_constraints(model),
1628
+ "mesh": {
1629
+ "nodes": len(model.mesh.nodes),
1630
+ "elements": len(model.mesh.elements),
1631
+ "shell_divisions": [config.shell_num_divisions_x, config.shell_num_divisions_y],
1632
+ "beam_divisions": config.beam_num_divisions,
1633
+ },
1634
+ }
1635
+ rows.append(row)
1636
+ _assert(validation.status in {"ok", "warning"}, "production Q8/Q8R stiffened model failed validation")
1637
+ _assert(mass_error < 1.0e-12, "production Q8/Q8R stiffened model mass mismatch")
1638
+ _assert(row["static_solver_status"] == "converged" and row["max_abs_displacement"] > 0.0, "production Q8/Q8R static solve failed")
1639
+ _assert(modal.solver_status == "ok" and row["modal_first_frequency_hz"] > 1.0e-6, "production Q8/Q8R modal solve failed")
1640
+ _assert(buckling.solver_status == "ok" and buckling.critical_load_factor is not None and buckling.critical_load_factor > 0.0, "production Q8/Q8R buckling solve failed")
1641
+ return _pass(
1642
+ case,
1643
+ element_types=["shell8", "shell8r", "beam2", "interpolated_mpc"],
1644
+ analysis_type="linear_static_modal_buckling",
1645
+ reference={"type": "production invariant suite", "quantities": ["validation", "mass", "static", "modal", "buckling"]},
1646
+ result={"rows_evaluated": len(rows)},
1647
+ checks={"rows": rows},
1648
+ )
1649
+
1650
+
1651
+ def _run_perf_001(case: VerificationCase) -> VerificationCaseResult:
1652
+ from . import nonlinear_performance, nonlinear_static
1653
+
1654
+ model, _panel, _config = _thin_stiffened_panel_model(1, element_family="Q4", shell_divisions_x=3, beam_divisions=5)
1655
+ legacy = nonlinear_performance._ORIGINAL_ASSEMBLER
1656
+ _assert(legacy is not None, "reference nonlinear assembler is not available")
1657
+ rng = np.random.default_rng(20260630)
1658
+ displacement = rng.normal(scale=1.0e-6, size=model.mesh.dof_manager.total_dofs)
1659
+ force_ref, tangent_ref, states_ref = legacy(model, displacement, {}, 3, tangent=True)
1660
+ force_fast, tangent_fast, states_fast = nonlinear_static._assemble_nonlinear_system(model, displacement, {}, 3, tangent=True)
1661
+ force_error = float(np.linalg.norm(force_fast - force_ref) / max(np.linalg.norm(force_ref), 1.0))
1662
+ tangent_error = float(sparse.linalg.norm(tangent_fast - tangent_ref) / max(float(sparse.linalg.norm(tangent_ref)), 1.0))
1663
+ _assert(force_error < 1.0e-10, "optimized nonlinear force assembly differs on weighted-MPC model")
1664
+ _assert(tangent_error < 1.0e-10, "optimized nonlinear tangent assembly differs on weighted-MPC model")
1665
+ _assert(set(states_fast) == set(states_ref), "optimized nonlinear state map differs on weighted-MPC model")
1666
+ return _pass(
1667
+ case,
1668
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1669
+ analysis_type="nonlinear_assembly",
1670
+ reference={"type": "implementation equivalence", "source": "reference nonlinear assembler"},
1671
+ result={"force_relative_error": force_error, "tangent_relative_error": tangent_error},
1672
+ checks={"state_count": len(states_fast), "total_dofs": model.mesh.dof_manager.total_dofs},
1673
+ )
1674
+
1675
+
1676
+ def _run_perf_002(case: VerificationCase) -> VerificationCaseResult:
1677
+ from .nonlinear_performance_bootstrap import clear_nonlinear_assembly_cache, get_nonlinear_assembly_plan
1678
+
1679
+ model, _panel, _config = _thin_stiffened_panel_model(1, element_family="Q4")
1680
+ clear_nonlinear_assembly_cache(model)
1681
+ K0, info0 = assemble_stiffness_matrix(model)
1682
+ plan0 = get_nonlinear_assembly_plan(model, 3)
1683
+ signature0 = info0.get("sparsity_signature")
1684
+ revision0 = model.revision_signature()
1685
+ shell_node = next(node for node_id, node in model.mesh.nodes.items() if int(node_id) < 10000 and abs(float(node.z)) < 1.0e-12)
1686
+ model.set_node_coordinates(shell_node.id, shell_node.x, shell_node.y, shell_node.z + 2.0e-5)
1687
+ K1, info1 = assemble_stiffness_matrix(model)
1688
+ plan1 = get_nonlinear_assembly_plan(model, 3)
1689
+ signature1 = info1.get("sparsity_signature")
1690
+ revision1 = model.revision_signature()
1691
+ stiffness_change = float(sparse.linalg.norm(K1 - K0) / max(float(sparse.linalg.norm(K0)), 1.0))
1692
+ _assert(signature0 == signature1, "geometry-only change should preserve sparsity signature")
1693
+ _assert(revision1["geometry"] > revision0["geometry"], "geometry revision was not incremented")
1694
+ _assert(plan1 is not plan0, "nonlinear assembly plan was not invalidated by geometry revision")
1695
+ _assert(stiffness_change > 1.0e-12, "geometry cache invalidation did not change stiffness")
1696
+ return _pass(
1697
+ case,
1698
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1699
+ analysis_type="cache_correctness",
1700
+ reference={"type": "cache invariant", "quantities": ["geometry revision", "sparsity preservation", "stiffness update"]},
1701
+ result={"stiffness_relative_change": stiffness_change},
1702
+ checks={"revision_before": revision0, "revision_after": revision1, "sparsity_signature_preserved": signature0 == signature1},
1703
+ )
1704
+
1705
+
1706
+ def _run_coup_018(case: VerificationCase) -> VerificationCaseResult:
1707
+ model, _panel, _config = _thin_stiffened_panel_model(1, element_family="Q4")
1708
+ validation = validate_production_model(model)
1709
+ constraints = []
1710
+ for element_id, element in model.mesh.elements.items():
1711
+ getter = getattr(element, "get_mpc_constraints", None)
1712
+ if getter is None:
1713
+ continue
1714
+ for constraint in getter(model.mesh) or []:
1715
+ constraints.append((int(element_id), int(constraint["slave"]), dict(constraint.get("masters", {}))))
1716
+ slave_ids = [item[1] for item in constraints]
1717
+ duplicate_slave_count = len(slave_ids) - len(set(slave_ids))
1718
+ max_weight_sum_error = 0.0
1719
+ max_negative_weight = 0.0
1720
+ for element in model.mesh.elements.values():
1721
+ weights = getattr(element, "shape_weights", None)
1722
+ if weights is not None:
1723
+ weights = np.asarray(weights, dtype=float)
1724
+ max_weight_sum_error = max(max_weight_sum_error, abs(float(np.sum(weights) - 1.0)))
1725
+ max_negative_weight = max(max_negative_weight, max((-float(value) for value in weights if float(value) < 0.0), default=0.0))
1726
+
1727
+ load = _pressure_load_for_shells(model, 100.0)
1728
+ u0, _ = solve_linear(model, load)
1729
+ reordered = dict(sorted(model.mesh.elements.items(), reverse=True))
1730
+ model.mesh.elements = reordered
1731
+ model.bump_revision("topology")
1732
+ u1, _ = solve_linear(model, load)
1733
+ displacement_error = float(np.linalg.norm(u1 - u0) / max(np.linalg.norm(u0), 1.0e-30))
1734
+ _assert(validation.status in {"ok", "warning"}, "numbering-invariance fixture failed validation")
1735
+ _assert(duplicate_slave_count == 0, "weighted MPC slave DOF ownership is not unique")
1736
+ _assert(max_weight_sum_error < 1.0e-12, "weighted MPC shape functions do not sum to one")
1737
+ _assert(max_negative_weight < 1.0e-12, "weighted MPC ownership produced negative interpolation weights")
1738
+ _assert(displacement_error < 1.0e-10, "element numbering changed weighted-MPC static solution")
1739
+ return _pass(
1740
+ case,
1741
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1742
+ analysis_type="coupling_robustness",
1743
+ reference={"type": "invariant", "quantities": ["unique slave ownership", "partition of unity", "numbering independence"]},
1744
+ result={"displacement_relative_error": displacement_error},
1745
+ checks={
1746
+ "constraint_count": len(constraints),
1747
+ "duplicate_slave_count": duplicate_slave_count,
1748
+ "max_weight_sum_error": max_weight_sum_error,
1749
+ "max_negative_weight": max_negative_weight,
1750
+ },
1751
+ )
1752
+
1753
+
1754
+ def _run_coup_019(case: VerificationCase) -> VerificationCaseResult:
1755
+ rows: List[Dict[str, Any]] = []
1756
+ reference = None
1757
+ for shell_x, beam_div in ((4, 4), (4, 8), (8, 4), (8, 8)):
1758
+ model, _panel, config = _thin_stiffened_panel_model(
1759
+ 1,
1760
+ element_family="Q4",
1761
+ shell_divisions_x=shell_x,
1762
+ shell_divisions_y=2,
1763
+ beam_divisions=beam_div,
1764
+ )
1765
+ load = _pressure_load_for_shells(model, 250.0)
1766
+ displacements, solver_info = solve_linear(model, load)
1767
+ max_disp = float(np.max(np.abs(displacements))) if displacements.size else 0.0
1768
+ if reference is None and shell_x == 8 and beam_div == 8:
1769
+ reference = max_disp
1770
+ rows.append(
1771
+ {
1772
+ "shell_divisions_x": int(config.shell_num_divisions_x),
1773
+ "shell_divisions_y": int(config.shell_num_divisions_y),
1774
+ "beam_divisions": int(config.beam_num_divisions),
1775
+ "max_abs_displacement": max_disp,
1776
+ "solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
1777
+ "mpc_constraints": _count_mpc_constraints(model),
1778
+ }
1779
+ )
1780
+ reference = rows[-1]["max_abs_displacement"]
1781
+ for row in rows:
1782
+ row["relative_to_finest"] = _rel_error(row["max_abs_displacement"], reference)
1783
+ max_error = max(float(row["relative_to_finest"]) for row in rows[:-1])
1784
+ _assert(all(row["solver_status"] == "converged" for row in rows), "mesh-ratio sweep static solve failed")
1785
+ _assert(max_error < 0.35, "independent beam/shell mesh-ratio sweep is not converging consistently")
1786
+ return _pass(
1787
+ case,
1788
+ element_types=["shell4", "beam2", "interpolated_mpc"],
1789
+ analysis_type="linear_static",
1790
+ reference={"type": "mesh convergence", "source": "finest independent shell/beam mesh in sweep"},
1791
+ result={"max_relative_to_finest": max_error},
1792
+ checks={"rows": rows},
1793
+ )
1794
+
1795
+
1796
+ def _curved_strip_model(
1797
+ *,
1798
+ nx: int = 6,
1799
+ nt: int = 6,
1800
+ radius: float = 1.5,
1801
+ length: float = 2.0,
1802
+ angle: float = math.pi / 3.0,
1803
+ thickness: float = 0.01,
1804
+ load: float = -100.0,
1805
+ ) -> Tuple[FEModel, LoadCase, Dict[Tuple[int, int], int]]:
1806
+ model = FEModel("curved_shell_strip")
1807
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
1808
+ ids: Dict[Tuple[int, int], int] = {}
1809
+ node_id = 1
1810
+ for ix in range(int(nx) + 1):
1811
+ x = float(length) * ix / float(nx)
1812
+ for itheta in range(int(nt) + 1):
1813
+ theta = -0.5 * float(angle) + float(angle) * itheta / float(nt)
1814
+ ids[(ix, itheta)] = node_id
1815
+ model.add_node(node_id, x, float(radius) * math.cos(theta), float(radius) * math.sin(theta))
1816
+ node_id += 1
1817
+
1818
+ element_id = 1
1819
+ for ix in range(int(nx)):
1820
+ for itheta in range(int(nt)):
1821
+ model.add_element(
1822
+ element_id,
1823
+ ShellElement(
1824
+ element_id,
1825
+ [ids[(ix, itheta)], ids[(ix + 1, itheta)], ids[(ix + 1, itheta + 1)], ids[(ix, itheta + 1)]],
1826
+ "steel",
1827
+ thickness=float(thickness),
1828
+ ),
1829
+ )
1830
+ element_id += 1
1831
+
1832
+ model.add_boundary_condition(FixedSupport("root_edge", [ids[(0, itheta)] for itheta in range(int(nt) + 1)]))
1833
+ load_case = LoadCase("curved_strip_tip_load")
1834
+ nodal_force = float(load) / float(int(nt) + 1)
1835
+ for itheta in range(int(nt) + 1):
1836
+ load_case.add_nodal_load(ids[(int(nx), itheta)], forces=np.array([0.0, 0.0, nodal_force]))
1837
+ return model, load_case, ids
1838
+
1839
+
1840
+ def _curved_strip_tip_response(nx: int, nt: int) -> Dict[str, Any]:
1841
+ model, load, ids = _curved_strip_model(nx=nx, nt=nt)
1842
+ displacements, solver_info = solve_linear(model, load, constraint_mode="auto")
1843
+ tip_values = [
1844
+ float(displacements[model.mesh.get_node(ids[(nx, itheta)]).dofs[2]])
1845
+ for itheta in range(nt + 1)
1846
+ ]
1847
+ return {
1848
+ "nx": int(nx),
1849
+ "nt": int(nt),
1850
+ "nodes": int(model.mesh.num_nodes),
1851
+ "elements": int(model.mesh.num_elements),
1852
+ "mean_tip_uz": float(np.mean(tip_values)),
1853
+ "max_abs_displacement": float(np.max(np.abs(displacements))) if displacements.size else 0.0,
1854
+ "solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
1855
+ }
1856
+
1857
+
1858
+ def _run_shell_008(case: VerificationCase) -> VerificationCaseResult:
1859
+ rows = [_curved_strip_tip_response(n, n) for n in (4, 6, 8)]
1860
+ fine = abs(float(rows[-1]["mean_tip_uz"]))
1861
+ errors = [abs(abs(float(row["mean_tip_uz"])) - fine) / max(fine, 1.0e-30) for row in rows[:-1]]
1862
+ monotone = all(abs(float(rows[index]["mean_tip_uz"])) >= abs(float(rows[index + 1]["mean_tip_uz"])) for index in range(len(rows) - 1))
1863
+ max_relative_to_fine = max(errors, default=0.0)
1864
+ _assert(all(row["solver_status"] == "converged" for row in rows), "curved-strip bending solve failed")
1865
+ _assert(monotone and max_relative_to_fine < 0.25, "curved-strip bending response is not mesh-convergent")
1866
+ return _pass(
1867
+ case,
1868
+ element_types=["shell4"],
1869
+ analysis_type="linear_static",
1870
+ mesh={"fixture": "clamped cylindrical strip", "radius": 1.5, "span_to_thickness": 200},
1871
+ reference={"type": "internal mesh convergence", "source": "fine 8x8 curved-strip response"},
1872
+ result={"fine_mean_tip_uz": float(rows[-1]["mean_tip_uz"]), "max_relative_to_fine": max_relative_to_fine},
1873
+ checks={"rows": rows, "monotone_refinement": bool(monotone)},
1874
+ )
1875
+
1876
+
1877
+ def _run_bench_001(case: VerificationCase) -> VerificationCaseResult:
1878
+ rows: List[Dict[str, Any]] = []
1879
+ for n in (4, 6):
1880
+ model = FEModel("twisted_cantilever_shell")
1881
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
1882
+ ids: Dict[Tuple[int, int], int] = {}
1883
+ node_id = 1
1884
+ length = 2.0
1885
+ width = 0.4
1886
+ twist = math.radians(30.0)
1887
+ for ix in range(n + 1):
1888
+ x = length * ix / n
1889
+ phi = twist * ix / n
1890
+ for iy in range(n + 1):
1891
+ y0 = -0.5 * width + width * iy / n
1892
+ y = y0 * math.cos(phi)
1893
+ z = y0 * math.sin(phi)
1894
+ ids[(ix, iy)] = node_id
1895
+ model.add_node(node_id, x, y, z)
1896
+ node_id += 1
1897
+ element_id = 1
1898
+ for ix in range(n):
1899
+ for iy in range(n):
1900
+ model.add_element(
1901
+ element_id,
1902
+ ShellElement(element_id, [ids[(ix, iy)], ids[(ix + 1, iy)], ids[(ix + 1, iy + 1)], ids[(ix, iy + 1)]], "steel", thickness=0.01),
1903
+ )
1904
+ element_id += 1
1905
+ model.add_boundary_condition(FixedSupport("root", [ids[(0, iy)] for iy in range(n + 1)]))
1906
+ load = LoadCase("tip_shear")
1907
+ for iy in range(n + 1):
1908
+ load.add_nodal_load(ids[(n, iy)], forces=np.array([0.0, 0.0, -100.0 / float(n + 1)]))
1909
+ displacements, solver_info = solve_linear(model, load, constraint_mode="auto")
1910
+ tip = float(np.mean([displacements[model.mesh.get_node(ids[(n, iy)]).dofs[2]] for iy in range(n + 1)]))
1911
+ rows.append(
1912
+ {
1913
+ "divisions": int(n),
1914
+ "nodes": int(model.mesh.num_nodes),
1915
+ "elements": int(model.mesh.num_elements),
1916
+ "mean_tip_uz": tip,
1917
+ "max_abs_displacement": float(np.max(np.abs(displacements))),
1918
+ "solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
1919
+ }
1920
+ )
1921
+ spread = _rel_error(rows[-1]["mean_tip_uz"], rows[0]["mean_tip_uz"])
1922
+ _assert(all(row["solver_status"] == "converged" for row in rows), "twisted cantilever benchmark solve failed")
1923
+ _assert(0.0 < spread < 0.35, "twisted cantilever internal refinement check is outside tolerance")
1924
+ return _pass(
1925
+ case,
1926
+ element_types=["shell4"],
1927
+ analysis_type="linear_static",
1928
+ mesh={"fixture": "MacNeal-Harder-style twisted cantilever", "twist_degrees": 30.0},
1929
+ reference={"type": "internal refinement", "note": "literature target not bundled"},
1930
+ result={"relative_change_4_to_6": spread},
1931
+ checks={"rows": rows},
1932
+ )
1933
+
1934
+
1935
+ def _run_bench_002(case: VerificationCase) -> VerificationCaseResult:
1936
+ rows = [_curved_strip_tip_response(4, 6), _curved_strip_tip_response(6, 8)]
1937
+ response_ratio = abs(float(rows[1]["mean_tip_uz"])) / max(abs(float(rows[0]["mean_tip_uz"])), 1.0e-30)
1938
+ _assert(all(row["solver_status"] == "converged" for row in rows), "Scordelis-Lo-style roof solve failed")
1939
+ _assert(0.70 < response_ratio < 1.05, "Scordelis-Lo-style roof refinement response is outside tolerance")
1940
+ return _pass(
1941
+ case,
1942
+ element_types=["shell4"],
1943
+ analysis_type="linear_static",
1944
+ mesh={"fixture": "Scordelis-Lo-style cylindrical roof strip"},
1945
+ reference={"type": "internal curved-roof refinement", "note": "external Scordelis-Lo reference not bundled"},
1946
+ result={"response_ratio_refined_to_coarse": response_ratio},
1947
+ checks={"rows": rows},
1948
+ )
1949
+
1950
+
1951
+ def _run_bench_003(case: VerificationCase) -> VerificationCaseResult:
1952
+ config = CylinderBenchmarkConfig(
1953
+ radius=1.0,
1954
+ height=2.0,
1955
+ thickness=0.01,
1956
+ pressure=0.0,
1957
+ num_circumferential=16,
1958
+ num_height=4,
1959
+ closed_end_axial_load=False,
1960
+ )
1961
+ model, _unused = build_cylindrical_shell_benchmark_model(config)
1962
+ load = LoadCase("pinched_cylinder")
1963
+ mid = config.num_height // 2
1964
+ n_pos = mid * config.num_circumferential + 1
1965
+ n_neg = mid * config.num_circumferential + config.num_circumferential // 2 + 1
1966
+ load.add_nodal_load(n_pos, forces=np.array([-1000.0, 0.0, 0.0]))
1967
+ load.add_nodal_load(n_neg, forces=np.array([1000.0, 0.0, 0.0]))
1968
+ displacements, solver_info = solve_linear(model, load, constraint_mode="auto")
1969
+ ux_pos = float(displacements[model.mesh.get_node(n_pos).dofs[0]])
1970
+ ux_neg = float(displacements[model.mesh.get_node(n_neg).dofs[0]])
1971
+ antisymmetry_error = abs(ux_pos + ux_neg) / max(abs(ux_pos), abs(ux_neg), 1.0e-30)
1972
+ _assert(str((solver_info.get("convergence_info") or {}).get("status")) == "converged", "pinched-cylinder solve failed")
1973
+ _assert(abs(ux_pos) > 0.0 and antisymmetry_error < 1.0e-9, "pinched-cylinder opposite-load symmetry failed")
1974
+ return _pass(
1975
+ case,
1976
+ element_types=["shell4"],
1977
+ analysis_type="linear_static",
1978
+ mesh={"fixture": "closed pinched cylinder", "circumferential_divisions": config.num_circumferential},
1979
+ reference={"type": "symmetry invariant", "note": "external pinched-cylinder reference not bundled"},
1980
+ result={"pinch_displacement_m": ux_pos, "opposite_displacement_m": ux_neg},
1981
+ checks={"antisymmetry_error": antisymmetry_error, "nullspace": solver_info.get("nullspace_info", {})},
1982
+ )
1983
+
1984
+
1985
+ def _add_ring_stiffener_to_cylinder(
1986
+ model: FEModel,
1987
+ config: CylinderBenchmarkConfig,
1988
+ *,
1989
+ z_index: Optional[int] = None,
1990
+ offset: float = 0.04,
1991
+ area: float = 0.004,
1992
+ iy: float = 1.0e-5,
1993
+ iz: float = 1.0e-5,
1994
+ ) -> List[int]:
1995
+ iz_index = config.num_height // 2 if z_index is None else int(z_index)
1996
+ element_id = max(int(eid) for eid in model.mesh.elements) + 1
1997
+ node_id = max(int(nid) for nid in model.mesh.nodes) + 1
1998
+ beam_nodes: List[int] = []
1999
+ section_base = {
2000
+ "area": float(area),
2001
+ "Iy": float(iy),
2002
+ "Iz": float(iz),
2003
+ "J": 0.5 * min(float(iy), float(iz)),
2004
+ "shear_factor_y": 5.0 / 6.0,
2005
+ "shear_factor_z": 5.0 / 6.0,
2006
+ }
2007
+ for itheta in range(config.num_circumferential):
2008
+ shell_id = iz_index * config.num_circumferential + itheta + 1
2009
+ shell_node = model.mesh.get_node(shell_id)
2010
+ theta = 2.0 * math.pi * itheta / config.num_circumferential
2011
+ radial = np.array([math.cos(theta), math.sin(theta), 0.0], dtype=float)
2012
+ beam_id = node_id
2013
+ node_id += 1
2014
+ model.add_node(beam_id, shell_node.x + offset * radial[0], shell_node.y + offset * radial[1], shell_node.z)
2015
+ beam_nodes.append(beam_id)
2016
+ model.add_element(element_id, CoupledBeamShellElement(element_id, beam_node_id=beam_id, shell_node_id=shell_id, material_name="steel"))
2017
+ element_id += 1
2018
+ for itheta, beam_id in enumerate(beam_nodes):
2019
+ theta_mid = 2.0 * math.pi * (itheta + 0.5) / config.num_circumferential
2020
+ section = dict(section_base)
2021
+ section["orientation"] = np.array([math.cos(theta_mid), math.sin(theta_mid), 0.0], dtype=float)
2022
+ model.add_element(element_id, BeamElement(element_id, [beam_id, beam_nodes[(itheta + 1) % len(beam_nodes)]], "steel", section))
2023
+ element_id += 1
2024
+ return beam_nodes
2025
+
2026
+
2027
+ def _add_longitudinal_stiffeners_to_cylinder(
2028
+ model: FEModel,
2029
+ config: CylinderBenchmarkConfig,
2030
+ *,
2031
+ count: int = 4,
2032
+ offset: float = 0.04,
2033
+ area: float = 0.004,
2034
+ ) -> List[int]:
2035
+ element_id = max(int(eid) for eid in model.mesh.elements) + 1
2036
+ node_id = max(int(nid) for nid in model.mesh.nodes) + 1
2037
+ beam_nodes: List[int] = []
2038
+ section_base = {
2039
+ "area": float(area),
2040
+ "Iy": 1.0e-5,
2041
+ "Iz": 2.0e-5,
2042
+ "J": 5.0e-6,
2043
+ "shear_factor_y": 5.0 / 6.0,
2044
+ "shear_factor_z": 5.0 / 6.0,
2045
+ }
2046
+ for istiffener in range(int(count)):
2047
+ itheta = istiffener * config.num_circumferential // int(count)
2048
+ theta = 2.0 * math.pi * itheta / config.num_circumferential
2049
+ radial = np.array([math.cos(theta), math.sin(theta), 0.0], dtype=float)
2050
+ previous: Optional[int] = None
2051
+ for iz_index in range(config.num_height + 1):
2052
+ shell_id = iz_index * config.num_circumferential + itheta + 1
2053
+ shell_node = model.mesh.get_node(shell_id)
2054
+ beam_id = node_id
2055
+ node_id += 1
2056
+ model.add_node(beam_id, shell_node.x + offset * radial[0], shell_node.y + offset * radial[1], shell_node.z)
2057
+ beam_nodes.append(beam_id)
2058
+ model.add_element(element_id, CoupledBeamShellElement(element_id, beam_node_id=beam_id, shell_node_id=shell_id, material_name="steel"))
2059
+ element_id += 1
2060
+ if previous is not None:
2061
+ section = dict(section_base)
2062
+ section["orientation"] = radial
2063
+ model.add_element(element_id, BeamElement(element_id, [previous, beam_id], "steel", section))
2064
+ element_id += 1
2065
+ previous = beam_id
2066
+ return beam_nodes
2067
+
2068
+
2069
+ def _cylinder_axial_load(config: CylinderBenchmarkConfig, force: float = 1.0e6) -> LoadCase:
2070
+ load = LoadCase("self_equilibrated_axial_end_load")
2071
+ nodal = float(force) / float(config.num_circumferential)
2072
+ top_offset = config.num_height * config.num_circumferential
2073
+ for itheta in range(config.num_circumferential):
2074
+ load.add_nodal_load(itheta + 1, forces=np.array([0.0, 0.0, -nodal]))
2075
+ load.add_nodal_load(top_offset + itheta + 1, forces=np.array([0.0, 0.0, nodal]))
2076
+ return load
2077
+
2078
+
2079
+ def _cylinder_end_extension(model: FEModel, displacements: np.ndarray, config: CylinderBenchmarkConfig) -> float:
2080
+ bottom: List[float] = []
2081
+ top: List[float] = []
2082
+ top_offset = config.num_height * config.num_circumferential
2083
+ for itheta in range(config.num_circumferential):
2084
+ bottom.append(float(displacements[model.mesh.get_node(itheta + 1).dofs[2]]))
2085
+ top.append(float(displacements[model.mesh.get_node(top_offset + itheta + 1).dofs[2]]))
2086
+ return float(np.mean(top) - np.mean(bottom))
2087
+
2088
+
2089
+ def _shell_node_radial_displacements(model: FEModel, displacements: np.ndarray) -> List[float]:
2090
+ shell_node_ids = {
2091
+ int(node_id)
2092
+ for element in model.mesh.elements.values()
2093
+ if isinstance(element, ShellElement)
2094
+ for node_id in element.node_ids
2095
+ }
2096
+ values: List[float] = []
2097
+ for node_id, node in model.mesh.nodes.items():
2098
+ if shell_node_ids and int(node_id) not in shell_node_ids:
2099
+ continue
2100
+ radial = np.array([node.x, node.y, 0.0], dtype=float)
2101
+ norm = float(np.linalg.norm(radial))
2102
+ if norm <= 1.0e-12:
2103
+ continue
2104
+ values.append(float(displacements[node.dofs[:3]] @ (radial / norm)))
2105
+ return values
2106
+
2107
+
2108
+ def _run_coup_020(case: VerificationCase) -> VerificationCaseResult:
2109
+ config = CylinderBenchmarkConfig(radius=1.5, height=3.0, thickness=0.015, pressure=0.0, num_circumferential=16, num_height=4)
2110
+ model, _unused = build_cylindrical_shell_benchmark_model(config)
2111
+ beam_nodes = _add_ring_stiffener_to_cylinder(model, config)
2112
+ orientation_errors: List[float] = []
2113
+ length_errors: List[float] = []
2114
+ for element in model.mesh.elements.values():
2115
+ if not isinstance(element, BeamElement):
2116
+ continue
2117
+ coords = element.get_node_coordinates(model.mesh)
2118
+ midpoint = np.mean(coords[:, :3], axis=0)
2119
+ radial = np.array([midpoint[0], midpoint[1], 0.0], dtype=float)
2120
+ radial /= max(float(np.linalg.norm(radial)), 1.0e-30)
2121
+ _length, transform = element._beam_frame_and_transform(coords)
2122
+ rotation = transform[:3, :3].T
2123
+ local_z = rotation[:, 2]
2124
+ orientation_errors.append(float(1.0 - abs(float(local_z @ radial))))
2125
+ length_errors.append(abs(float(_length) - 2.0 * config.radius * math.sin(math.pi / config.num_circumferential)))
2126
+ K = sparse.eye(model.mesh.dof_manager.total_dofs, format="csr")
2127
+ zero = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
2128
+ _K, _F, T, u0, independent, constraint_info = build_constraint_transformation(K, zero, model)
2129
+ q = np.linspace(-0.05, 0.05, len(independent), dtype=float)
2130
+ u = reconstruct_full_solution(T, q, u0)
2131
+ residuals = mpc_constraint_residuals(model, u)
2132
+ max_constraint_residual = max((abs(value) for value in residuals.values()), default=0.0)
2133
+ closure_gap = float(np.linalg.norm(model.mesh.get_node(beam_nodes[0]).coords() - model.mesh.get_node(beam_nodes[-1]).coords()))
2134
+ beam_radius = float(np.linalg.norm(model.mesh.get_node(beam_nodes[0]).coords()[:2]))
2135
+ expected_chord = 2.0 * beam_radius * math.sin(math.pi / config.num_circumferential)
2136
+ _assert(max(orientation_errors, default=0.0) < 1.0e-12, "ring stiffener local frames do not close with radial orientation")
2137
+ _assert(max_constraint_residual < 1.0e-12, "ring stiffener MPC compatibility failed")
2138
+ _assert(abs(closure_gap - expected_chord) < 1.0e-12, "ring stiffener closure chord is inconsistent")
2139
+ return _pass(
2140
+ case,
2141
+ element_types=["beam2", "beam_shell_mpc"],
2142
+ analysis_type="curved_stiffener_coordinates",
2143
+ mesh={"ring_nodes": len(beam_nodes), "radius": config.radius},
2144
+ reference={"type": "geometric invariant", "quantities": ["radial frame transport", "ring closure", "MPC compatibility"]},
2145
+ result={"num_mpc_slave_dofs": int(constraint_info["num_mpc_slave_dofs"])},
2146
+ checks={
2147
+ "max_orientation_error": max(orientation_errors, default=0.0),
2148
+ "max_length_error": max(length_errors, default=0.0),
2149
+ "closure_gap": closure_gap,
2150
+ "expected_chord": expected_chord,
2151
+ "beam_radius": beam_radius,
2152
+ "max_constraint_residual": max_constraint_residual,
2153
+ },
2154
+ )
2155
+
2156
+
2157
+ def _run_coup_021(case: VerificationCase) -> VerificationCaseResult:
2158
+ radius = 2.0
2159
+ area = 0.01
2160
+ modulus = 210.0e9
2161
+ line_load = 1000.0
2162
+ n = 32
2163
+ model = FEModel("circular_ring_membrane")
2164
+ model.add_material("steel", modulus, 0.3, density=7850.0)
2165
+ section_base = {"area": area, "Iy": 1.0e-5, "Iz": 1.0e-5, "J": 1.0e-5, "shear_factor_y": 5.0 / 6.0, "shear_factor_z": 5.0 / 6.0}
2166
+ for i in range(n):
2167
+ theta = 2.0 * math.pi * i / n
2168
+ model.add_node(i + 1, radius * math.cos(theta), radius * math.sin(theta), 0.0)
2169
+ for i in range(n):
2170
+ theta_mid = 2.0 * math.pi * (i + 0.5) / n
2171
+ section = dict(section_base)
2172
+ section["orientation"] = np.array([math.cos(theta_mid), math.sin(theta_mid), 0.0], dtype=float)
2173
+ model.add_element(i + 1, BeamElement(i + 1, [i + 1, (i + 1) % n + 1], "steel", section))
2174
+ load = LoadCase("uniform_radial_line_load")
2175
+ nodal_force = float(line_load) * radius * 2.0 * math.pi / n
2176
+ for i in range(n):
2177
+ theta = 2.0 * math.pi * i / n
2178
+ load.add_nodal_load(i + 1, forces=nodal_force * np.array([math.cos(theta), math.sin(theta), 0.0], dtype=float))
2179
+ displacements, solver_info = solve_linear(model, load, constraint_mode="auto")
2180
+ radial_values = _shell_node_radial_displacements(model, displacements)
2181
+ mean_radial = float(np.mean(radial_values))
2182
+ spread = float((max(radial_values) - min(radial_values)) / max(abs(mean_radial), 1.0e-30))
2183
+ reference = float(line_load) * radius**2 / (modulus * area)
2184
+ relative_error = _rel_error(mean_radial, reference)
2185
+ _assert(str((solver_info.get("convergence_info") or {}).get("status")) == "converged", "ring membrane static solve failed")
2186
+ _assert(relative_error < 0.005 and spread < 1.0e-9, "ring membrane analytical displacement check failed")
2187
+ return _pass(
2188
+ case,
2189
+ element_types=["beam2"],
2190
+ analysis_type="linear_static",
2191
+ mesh={"ring_segments": n, "radius": radius},
2192
+ reference={"type": "analytical", "quantity": "uniform ring radial expansion", "radial_displacement_m": reference},
2193
+ result={"mean_radial_displacement_m": mean_radial, "relative_error": relative_error},
2194
+ checks={"radial_spread": spread, "nullspace": solver_info.get("nullspace_info", {})},
2195
+ )
2196
+
2197
+
2198
+ def _run_cyl_001(case: VerificationCase) -> VerificationCaseResult:
2199
+ config = CylinderBenchmarkConfig(
2200
+ radius=2.0,
2201
+ height=4.0,
2202
+ thickness=0.02,
2203
+ pressure=10_000.0,
2204
+ num_circumferential=16,
2205
+ num_height=4,
2206
+ mid_height_band_fraction=3.0,
2207
+ )
2208
+ result = run_cylindrical_shell_benchmark(config)
2209
+ stress_error = _rel_error(result.fe_mid_height_p95_von_mises, result.nominal.von_mises_stress)
2210
+ _assert(result.solver_status == "converged", "closed-cylinder membrane benchmark solve failed")
2211
+ _assert(stress_error < 0.04, "closed-cylinder mid-height membrane stress is outside tolerance")
2212
+ return _pass(
2213
+ case,
2214
+ element_types=["shell4"],
2215
+ analysis_type="linear_static",
2216
+ mesh={"closed_cylinder": True, "circumferential_divisions": config.num_circumferential, "height_divisions": config.num_height},
2217
+ reference={"type": "analytical", "source": "thin closed-cylinder membrane stress", **result.nominal.to_dict()},
2218
+ result={"mid_height_p95_von_mises": result.fe_mid_height_p95_von_mises, "relative_error": stress_error},
2219
+ checks=result.to_dict(),
2220
+ )
2221
+
2222
+
2223
+ def _run_cyl_002(case: VerificationCase) -> VerificationCaseResult:
2224
+ config = CylinderBenchmarkConfig(
2225
+ radius=2.0,
2226
+ height=4.0,
2227
+ thickness=0.02,
2228
+ pressure=0.0,
2229
+ num_circumferential=16,
2230
+ num_height=4,
2231
+ closed_end_axial_load=False,
2232
+ )
2233
+ plain, _unused = build_cylindrical_shell_benchmark_model(config)
2234
+ stiffened, _unused_stiff = build_cylindrical_shell_benchmark_model(config)
2235
+ _add_longitudinal_stiffeners_to_cylinder(stiffened, config, count=4, area=0.004)
2236
+ load = _cylinder_axial_load(config)
2237
+ u_plain, info_plain = solve_linear(plain, load, constraint_mode="auto")
2238
+ u_stiff, info_stiff = solve_linear(stiffened, load, constraint_mode="auto")
2239
+ extension_plain = abs(_cylinder_end_extension(plain, u_plain, config))
2240
+ extension_stiff = abs(_cylinder_end_extension(stiffened, u_stiff, config))
2241
+ stiffness_ratio = extension_stiff / max(extension_plain, 1.0e-30)
2242
+ mass_ratio = calculate_mass_properties(stiffened).total_mass / max(calculate_mass_properties(plain).total_mass, 1.0e-30)
2243
+ _assert(str((info_plain.get("convergence_info") or {}).get("status")) == "converged", "plain cylinder axial model-pair solve failed")
2244
+ _assert(str((info_stiff.get("convergence_info") or {}).get("status")) == "converged", "stiffened cylinder axial model-pair solve failed")
2245
+ _assert(0.0 < stiffness_ratio < 1.0 and mass_ratio > 1.0, "longitudinal stiffener model pair did not stiffen/increase mass")
2246
+ return _pass(
2247
+ case,
2248
+ element_types=["shell4", "beam2", "beam_shell_mpc"],
2249
+ analysis_type="linear_static",
2250
+ mesh={"longitudinal_stiffeners": 4, "circumferential_divisions": config.num_circumferential},
2251
+ reference={"type": "model-pair invariant", "quantities": ["added mass", "reduced axial extension"]},
2252
+ result={"extension_ratio_stiffened_to_plain": stiffness_ratio, "mass_ratio_stiffened_to_plain": mass_ratio},
2253
+ checks={"plain_extension_m": extension_plain, "stiffened_extension_m": extension_stiff, "mpc_constraints": _count_mpc_constraints(stiffened)},
2254
+ )
2255
+
2256
+
2257
+ def _run_coup_006(case: VerificationCase) -> VerificationCaseResult:
2258
+ config = CylinderBenchmarkConfig(
2259
+ radius=1.5,
2260
+ height=3.0,
2261
+ thickness=0.015,
2262
+ pressure=5_000.0,
2263
+ num_circumferential=16,
2264
+ num_height=6,
2265
+ closed_end_axial_load=True,
2266
+ )
2267
+ area = 0.004
2268
+ ring, load = build_cylindrical_shell_benchmark_model(config)
2269
+ _add_ring_stiffener_to_cylinder(ring, config, area=area)
2270
+ band, band_load = build_cylindrical_shell_benchmark_model(config)
2271
+ band_width = config.height / config.num_height
2272
+ equivalent_band_count = 2
2273
+ added_thickness = area / (equivalent_band_count * band_width)
2274
+ for element in band.mesh.elements.values():
2275
+ if isinstance(element, ShellElement):
2276
+ z_mean = float(np.mean(element.get_node_coordinates(band.mesh)[:, 2]))
2277
+ if abs(z_mean - 0.5 * config.height) <= 0.5 * band_width + 1.0e-12:
2278
+ element.thickness += added_thickness
2279
+ band.bump_revision("material")
2280
+ u_ring, info_ring = solve_linear(ring, load, constraint_mode="auto")
2281
+ u_band, info_band = solve_linear(band, band_load, constraint_mode="auto")
2282
+ ring_radial = _shell_node_radial_displacements(ring, u_ring)
2283
+ band_radial = _shell_node_radial_displacements(band, u_band)
2284
+ ring_mean = float(np.mean(np.abs(ring_radial)))
2285
+ band_mean = float(np.mean(np.abs(band_radial)))
2286
+ response_spread = _rel_error(ring_mean, band_mean)
2287
+ base, _base_load = build_cylindrical_shell_benchmark_model(config)
2288
+ base_mass = calculate_mass_properties(base).total_mass
2289
+ ring_added = calculate_mass_properties(ring).total_mass - base_mass
2290
+ band_added = calculate_mass_properties(band).total_mass - base_mass
2291
+ added_mass_error = _rel_error(ring_added, band_added)
2292
+ _assert(str((info_ring.get("convergence_info") or {}).get("status")) == "converged", "ring-stiffened cylinder solve failed")
2293
+ _assert(str((info_band.get("convergence_info") or {}).get("status")) == "converged", "equivalent thickened-band cylinder solve failed")
2294
+ _assert(response_spread < 0.15 and added_mass_error < 0.20, "ring-stiffened cylinder equivalent model spread is outside tolerance")
2295
+ return _pass(
2296
+ case,
2297
+ element_types=["shell4", "beam2", "beam_shell_mpc"],
2298
+ analysis_type="linear_static",
2299
+ mesh={"equivalent_band_width": equivalent_band_count * band_width, "ring_area": area},
2300
+ reference={"type": "internal model-pair", "source": "ring beam versus equivalent thickened shell band"},
2301
+ result={"radial_response_spread": response_spread, "added_mass_relative_error": added_mass_error},
2302
+ checks={
2303
+ "ring_mean_abs_radial_displacement": ring_mean,
2304
+ "band_mean_abs_radial_displacement": band_mean,
2305
+ "ring_added_mass": ring_added,
2306
+ "band_added_mass": band_added,
2307
+ "mpc_constraints": _count_mpc_constraints(ring),
2308
+ },
2309
+ )
2310
+
2311
+
2312
+ def _run_cyl_003(case: VerificationCase) -> VerificationCaseResult:
2313
+ config = CylinderBenchmarkConfig(
2314
+ radius=1.5,
2315
+ height=3.0,
2316
+ thickness=0.015,
2317
+ pressure=0.0,
2318
+ num_circumferential=12,
2319
+ num_height=4,
2320
+ closed_end_axial_load=False,
2321
+ )
2322
+ model, _unused = build_cylindrical_shell_benchmark_model(config)
2323
+ _add_ring_stiffener_to_cylinder(model, config, area=0.008, iy=2.0e-5, iz=2.0e-5)
2324
+ model.add_boundary_condition(FixedSupport("single_anchor", [1]))
2325
+ states = {
2326
+ int(element_id): ({"membrane_compression_y": 1.0} if isinstance(element, ShellElement) else {})
2327
+ for element_id, element in model.mesh.elements.items()
2328
+ }
2329
+ doubled_states = {
2330
+ element_id: {key: 2.0 * float(value) for key, value in state.items()}
2331
+ for element_id, state in states.items()
2332
+ }
2333
+ result = solve_eigenvalue_buckling(model, states, num_modes=2, dense_size_limit=10000)
2334
+ doubled = solve_eigenvalue_buckling(model, doubled_states, num_modes=1, dense_size_limit=10000)
2335
+ _assert(result.solver_status == "ok" and result.critical_load_factor is not None, "ring-stiffened cylinder buckling solve failed")
2336
+ _assert(doubled.critical_load_factor is not None, "ring-stiffened cylinder doubled-preload buckling solve failed")
2337
+ scaling_error = _rel_error(float(doubled.critical_load_factor), 0.5 * float(result.critical_load_factor))
2338
+ residual = float((result.diagnostics or {}).get("max_residual_norm", 1.0))
2339
+ _assert(float(result.critical_load_factor) > 0.0 and scaling_error < 1.0e-8 and residual < 1.0e-8, "ring-stiffened cylinder buckling invariants failed")
2340
+ return _pass(
2341
+ case,
2342
+ element_types=["shell4", "beam2", "beam_shell_mpc"],
2343
+ analysis_type="linear_buckling",
2344
+ mesh={"ring_stiffeners": 1, "circumferential_divisions": config.num_circumferential},
2345
+ reference={"type": "buckling invariant", "preload": "unit circumferential shell membrane compression, ring beams elastic"},
2346
+ result={"critical_load_factor": float(result.critical_load_factor), "preload_scaling_error": scaling_error},
2347
+ checks={**(result.diagnostics or {}), "mpc_constraints": _count_mpc_constraints(model)},
2348
+ )
2349
+
2350
+
2351
+ def _run_beam_011(case: VerificationCase) -> VerificationCaseResult:
2352
+ """Cantilever bending eigenmodes against Euler-Bernoulli reference."""
2353
+ length = 2.0
2354
+ area = 0.02
2355
+ iy = 2.0e-6
2356
+ iz = 5.0e-6
2357
+ density = 7850.0
2358
+ E = 210.0e9
2359
+ model = _beam_model(length=length, area=area, iy=iy, iz=iz, j=2.0e-6, density=density, num_elements=24)
2360
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
2361
+ result = solve_free_vibration(model, num_modes=6, dense_size_limit=10000)
2362
+ _assert(result.solver_status == "ok" and result.num_modes_returned >= 2, "cantilever modal solve failed")
2363
+
2364
+ beta1 = 1.875104068711961
2365
+ references = sorted(
2366
+ [
2367
+ beta1**2 * math.sqrt(E * iy / (density * area * length**4)) / (2.0 * math.pi),
2368
+ beta1**2 * math.sqrt(E * iz / (density * area * length**4)) / (2.0 * math.pi),
2369
+ ]
2370
+ )
2371
+ computed = sorted(float(value) for value in result.frequencies_hz[:4] if float(value) > 1.0e-6)[:2]
2372
+ _assert(len(computed) == 2, "cantilever bending modes were not recovered")
2373
+ errors = [_rel_error(value, ref) for value, ref in zip(computed, references)]
2374
+ max_error = max(errors)
2375
+ _assert(max_error < 0.03, "cantilever bending eigenfrequency mismatch")
2376
+ return _pass(
2377
+ case,
2378
+ element_types=["beam2"],
2379
+ analysis_type="modal",
2380
+ mesh={"length": length, "num_elements": 24, "section": {"area": area, "Iy": iy, "Iz": iz}},
2381
+ reference={"type": "analytical", "source": "Euler-Bernoulli cantilever beta_1", "frequencies_hz": references},
2382
+ result={"frequencies_hz": computed, "max_relative_error": max_error},
2383
+ checks={**result.diagnostics, "relative_errors": errors},
2384
+ )
2385
+
2386
+
2387
+ def _pressure_load_all_shells(model: FEModel, pressure: float) -> LoadCase:
2388
+ load = LoadCase("uniform_plate_pressure")
2389
+ for element_id, element in model.mesh.elements.items():
2390
+ if isinstance(element, ShellElement):
2391
+ load.add_pressure_load(int(element_id), float(pressure))
2392
+ return load
2393
+
2394
+
2395
+ def _plate_center_uz(model: FEModel, displacements: np.ndarray) -> float:
2396
+ center_node = min(
2397
+ model.mesh.nodes.values(),
2398
+ key=lambda node: float((node.x - 0.5) ** 2 + (node.y - 0.5) ** 2 + node.z**2),
2399
+ )
2400
+ return float(displacements[center_node.dofs[2]])
2401
+
2402
+
2403
+ def _run_shell_009(case: VerificationCase) -> VerificationCaseResult:
2404
+ """Navier simply-supported square plate under uniform pressure."""
2405
+ thickness = 0.01
2406
+ pressure = 1000.0
2407
+ rows: List[Dict[str, Any]] = []
2408
+ # w_max = alpha q a^4 / D for a simply supported square plate, uniform load.
2409
+ alpha = 0.00406235
2410
+ E, nu = 210.0e9, 0.3
2411
+ D = E * thickness**3 / (12.0 * (1.0 - nu**2))
2412
+ reference = alpha * pressure / D
2413
+ for family, divisions in (("S4", 12), ("S8", 6), ("S8R", 6)):
2414
+ model = _verification_plate_model(divisions=divisions, thickness=thickness, element_family=family)
2415
+ load = _pressure_load_all_shells(model, pressure)
2416
+ displacements, solver_info = solve_linear(model, load)
2417
+ value = abs(_plate_center_uz(model, displacements))
2418
+ err = _rel_error(value, reference)
2419
+ rows.append(
2420
+ {
2421
+ "element_family": family,
2422
+ "divisions": divisions,
2423
+ "center_deflection_m": value,
2424
+ "reference_m": reference,
2425
+ "relative_error": err,
2426
+ "solver_status": (solver_info.get("convergence_info") or {}).get("status"),
2427
+ }
2428
+ )
2429
+ max_error = max(float(row["relative_error"]) for row in rows)
2430
+ _assert(max_error < 0.08, "Navier plate pressure benchmark outside tolerance")
2431
+ return _pass(
2432
+ case,
2433
+ element_types=["shell4", "shell8", "shell8r"],
2434
+ analysis_type="linear_static",
2435
+ mesh={"square_plate": True, "span_to_thickness": 100},
2436
+ reference={"type": "analytical", "source": "Navier SSSS square plate uniform load", "center_deflection_m": reference},
2437
+ result={"max_relative_error": max_error, "rows": rows},
2438
+ checks={"rows": rows},
2439
+ )
2440
+
2441
+
2442
+ def _run_shell_010(case: VerificationCase) -> VerificationCaseResult:
2443
+ """Q4/Q8/Q8R thin-plate first modal convergence against analytical SSSS plate."""
2444
+ thickness = 0.01
2445
+ reference = _plate_bending_frequency_hz(1, 1, thickness=thickness)
2446
+ rows: List[Dict[str, Any]] = []
2447
+ for family, divisions in (("S4", 10), ("S8", 5), ("S8R", 5)):
2448
+ model = _verification_plate_model(divisions=divisions, thickness=thickness, element_family=family)
2449
+ result = solve_free_vibration(model, num_modes=4, dense_size_limit=10000)
2450
+ _assert(result.solver_status == "ok" and result.num_modes_returned > 0, f"{family} plate modal solve failed")
2451
+ value = float(result.frequencies_hz[0])
2452
+ rows.append(
2453
+ {
2454
+ "element_family": family,
2455
+ "divisions": divisions,
2456
+ "frequency_hz": value,
2457
+ "reference_hz": reference,
2458
+ "relative_error": _rel_error(value, reference),
2459
+ "mass_orthogonality_error": float(result.diagnostics.get("mass_orthogonality_error", 0.0)),
2460
+ }
2461
+ )
2462
+ max_error = max(float(row["relative_error"]) for row in rows)
2463
+ _assert(max_error < 0.06, "thin plate modal convergence benchmark outside tolerance")
2464
+ return _pass(
2465
+ case,
2466
+ element_types=["shell4", "shell8", "shell8r"],
2467
+ analysis_type="modal",
2468
+ mesh={"square_plate": True, "span_to_thickness": 100},
2469
+ reference={"type": "analytical", "mode": [1, 1], "frequency_hz": reference},
2470
+ result={"max_relative_error": max_error, "rows": rows},
2471
+ checks={"rows": rows},
2472
+ )
2473
+
2474
+
2475
+ def _run_shell_011(case: VerificationCase) -> VerificationCaseResult:
2476
+ """Q4/Q8/Q8R thin-plate buckling convergence against SSSS uniaxial reference."""
2477
+ thickness = 0.01
2478
+ reference = _plate_uniaxial_buckling_resultant(thickness=thickness)
2479
+ rows: List[Dict[str, Any]] = []
2480
+ for family, divisions in (("S4", 10), ("S8", 5), ("S8R", 5)):
2481
+ model = _verification_plate_model(divisions=divisions, thickness=thickness, element_family=family)
2482
+ states = {
2483
+ int(element_id): {"membrane_compression_x": 1.0}
2484
+ for element_id, element in model.mesh.elements.items()
2485
+ if isinstance(element, ShellElement)
2486
+ }
2487
+ result = solve_eigenvalue_buckling(model, states, num_modes=3, dense_size_limit=10000)
2488
+ _assert(result.solver_status == "ok" and result.critical_load_factor is not None, f"{family} plate buckling solve failed")
2489
+ value = float(result.critical_load_factor)
2490
+ rows.append(
2491
+ {
2492
+ "element_family": family,
2493
+ "divisions": divisions,
2494
+ "critical_membrane_resultant": value,
2495
+ "reference": reference,
2496
+ "relative_error": _rel_error(value, reference),
2497
+ }
2498
+ )
2499
+ max_error = max(float(row["relative_error"]) for row in rows)
2500
+ _assert(max_error < 0.08, "thin plate buckling convergence benchmark outside tolerance")
2501
+ return _pass(
2502
+ case,
2503
+ element_types=["shell4", "shell8", "shell8r"],
2504
+ analysis_type="linear_buckling",
2505
+ mesh={"square_plate": True, "span_to_thickness": 100},
2506
+ reference={"type": "analytical", "k": 4.0, "critical_membrane_resultant": reference},
2507
+ result={"max_relative_error": max_error, "rows": rows},
2508
+ checks={"rows": rows},
2509
+ )
2510
+
2511
+
2512
+ def _run_coup_014(case: VerificationCase) -> VerificationCaseResult:
2513
+ return _composite_strip_result(
2514
+ case,
2515
+ metric="static",
2516
+ tolerance=0.02,
2517
+ expected_status="converged",
2518
+ reference_type="composite EA, neutral axis and EI static response",
2519
+ )
2520
+
2521
+
2522
+ def _run_coup_015(case: VerificationCase) -> VerificationCaseResult:
2523
+ return _composite_strip_result(
2524
+ case,
2525
+ metric="modal",
2526
+ tolerance=0.02,
2527
+ expected_status="ok",
2528
+ reference_type="composite cantilever bending frequency",
2529
+ )
2530
+
2531
+
2532
+ def _run_coup_016(case: VerificationCase) -> VerificationCaseResult:
2533
+ return _composite_strip_result(
2534
+ case,
2535
+ metric="buckling",
2536
+ tolerance=0.03,
2537
+ expected_status="ok",
2538
+ reference_type="composite fixed-free Euler buckling load",
2539
+ )
2540
+
2541
+
2542
+ def _run_coup_010(case: VerificationCase) -> VerificationCaseResult:
2543
+ radius = 1.0
2544
+ offset = 0.05
2545
+ dtheta = 0.08
2546
+ model = FEModel("curved_stiffener_orientation")
2547
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
2548
+
2549
+ def surface_point(x: float, theta: float) -> np.ndarray:
2550
+ return np.array([x, radius * math.cos(theta), radius * math.sin(theta)], dtype=float)
2551
+
2552
+ def radial(theta: float) -> np.ndarray:
2553
+ return np.array([0.0, math.cos(theta), math.sin(theta)], dtype=float)
2554
+
2555
+ shell_points = {
2556
+ 1: surface_point(0.0, 0.0),
2557
+ 2: surface_point(1.0, 0.0),
2558
+ 3: surface_point(0.5, -dtheta),
2559
+ 4: surface_point(0.5, dtheta),
2560
+ }
2561
+ beam_points = {
2562
+ 101: shell_points[1] + offset * radial(0.0),
2563
+ 102: shell_points[2] + offset * radial(0.0),
2564
+ 103: shell_points[3] + offset * radial(-dtheta),
2565
+ 104: shell_points[4] + offset * radial(dtheta),
2566
+ }
2567
+ for node_id, coords in {**shell_points, **beam_points}.items():
2568
+ model.add_node(int(node_id), float(coords[0]), float(coords[1]), float(coords[2]))
2569
+ section_long = {"area": 0.002, "Iy": 1.0e-6, "Iz": 2.0e-6, "J": 5.0e-7, "orientation": radial(0.0)}
2570
+ section_ring = {"area": 0.002, "Iy": 1.0e-6, "Iz": 2.0e-6, "J": 5.0e-7, "orientation": radial(0.0)}
2571
+ model.add_element(1, BeamElement(1, [101, 102], "steel", section_long))
2572
+ model.add_element(2, BeamElement(2, [103, 104], "steel", section_ring))
2573
+ for element_id, beam_node, shell_node in ((1001, 101, 1), (1002, 102, 2), (1003, 103, 3), (1004, 104, 4)):
2574
+ model.add_element(element_id, CoupledBeamShellElement(element_id, beam_node_id=beam_node, shell_node_id=shell_node, material_name="steel"))
2575
+
2576
+ orientation_errors: List[float] = []
2577
+ for element_id, theta in ((1, 0.0), (2, 0.0)):
2578
+ element = model.mesh.elements[element_id]
2579
+ _L, T = element._beam_frame_and_transform(element.get_node_coordinates(model.mesh))
2580
+ rotation = T[:3, :3].T
2581
+ local_z = rotation[:, 2]
2582
+ orientation_errors.append(float(1.0 - abs(local_z @ radial(theta))))
2583
+
2584
+ total_dofs = model.mesh.dof_manager.total_dofs
2585
+ K = sparse.eye(total_dofs, format="csr")
2586
+ zero = np.zeros(total_dofs, dtype=float)
2587
+ _K_red, _F_red, T, u0, independent, constraint_info = build_constraint_transformation(K, zero, model)
2588
+ q = np.linspace(-0.1, 0.2, len(independent), dtype=float)
2589
+ u = reconstruct_full_solution(T, q, u0)
2590
+ residuals = mpc_constraint_residuals(model, u)
2591
+ max_constraint_residual = max((abs(value) for value in residuals.values()), default=0.0)
2592
+ max_orientation_error = max(orientation_errors)
2593
+ _assert(max_orientation_error < 1.0e-12, "curved stiffener local-z orientation does not follow radial transport")
2594
+ _assert(max_constraint_residual < 1.0e-12 and constraint_info["num_mpc_slave_dofs"] == 24, "curved stiffener MPC compatibility failed")
2595
+ return _pass(
2596
+ case,
2597
+ element_types=["beam2", "beam_shell_mpc"],
2598
+ checks={
2599
+ "max_orientation_error": max_orientation_error,
2600
+ "max_constraint_residual": float(max_constraint_residual),
2601
+ "num_mpc_slave_dofs": int(constraint_info["num_mpc_slave_dofs"]),
2602
+ "radius_to_offset": float(radius / offset),
2603
+ },
2604
+ )
2605
+
2606
+
2607
+ def _eccentric_coupling_model(*, fixed_shell: bool = False, eccentricity: np.ndarray | None = None) -> FEModel:
2608
+ model = FEModel("verification_eccentric_coupling")
2609
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
2610
+ r = np.asarray([0.12, -0.04, 0.18] if eccentricity is None else eccentricity, dtype=float)
2611
+ model.add_node(1, 0.0, 0.0, 0.0)
2612
+ model.add_node(2, float(r[0]), float(r[1]), float(r[2]))
2613
+ model.add_element(1, CoupledBeamShellElement(1, beam_node_id=2, shell_node_id=1, material_name="steel"))
2614
+ if fixed_shell:
2615
+ model.add_boundary_condition(FixedSupport("fixed_shell_master", [1]))
2616
+ return model
2617
+
2618
+
2619
+ def _run_coup_012(case: VerificationCase) -> VerificationCaseResult:
2620
+ """Actual MPC transformation reproduces an affine rigid-link field."""
2621
+ r = np.array([0.12, -0.04, 0.18], dtype=float)
2622
+ model = _eccentric_coupling_model(eccentricity=r)
2623
+ K = sparse.eye(model.mesh.dof_manager.total_dofs, format="csr")
2624
+ zero = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
2625
+ _K_red, _F_red, T, u0, independent, constraint_info = build_constraint_transformation(K, zero, model)
2626
+
2627
+ shell = model.mesh.get_node(1)
2628
+ beam = model.mesh.get_node(2)
2629
+ shell_translation = np.array([0.021, -0.014, 0.033], dtype=float)
2630
+ shell_rotation = np.array([0.006, -0.011, 0.017], dtype=float)
2631
+ full_target = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
2632
+ full_target[shell.dofs[:3]] = shell_translation
2633
+ full_target[shell.dofs[3:6]] = shell_rotation
2634
+ q = full_target[np.asarray(independent, dtype=int)]
2635
+ u = reconstruct_full_solution(T, q, u0)
2636
+
2637
+ expected_beam_translation = shell_translation + np.cross(shell_rotation, r)
2638
+ expected_beam_rotation = shell_rotation
2639
+ translation_error = float(np.linalg.norm(u[beam.dofs[:3]] - expected_beam_translation))
2640
+ rotation_error = float(np.linalg.norm(u[beam.dofs[3:6]] - expected_beam_rotation))
2641
+ residuals = mpc_constraint_residuals(model, u)
2642
+ max_constraint_residual = max((abs(value) for value in residuals.values()), default=0.0)
2643
+
2644
+ _assert(constraint_info["num_mpc_slave_dofs"] == 6, "eccentric MPC did not eliminate six beam-node slave DOFs")
2645
+ _assert(max(translation_error, rotation_error, max_constraint_residual) < 1.0e-13, "eccentric MPC affine-field reproduction failed")
2646
+ return _pass(
2647
+ case,
2648
+ element_types=["beam_shell_mpc"],
2649
+ checks={
2650
+ "eccentricity": r.tolist(),
2651
+ "num_mpc_slave_dofs": int(constraint_info["num_mpc_slave_dofs"]),
2652
+ "translation_error": translation_error,
2653
+ "rotation_error": rotation_error,
2654
+ "max_constraint_residual": max_constraint_residual,
2655
+ },
2656
+ )
2657
+
2658
+
2659
+ def _run_coup_013(case: VerificationCase) -> VerificationCaseResult:
2660
+ """Actual eccentric slave load transfers to master force plus r x F moment."""
2661
+ r = np.array([0.12, -0.04, 0.18], dtype=float)
2662
+ model = _eccentric_coupling_model(fixed_shell=True, eccentricity=r)
2663
+ force = np.array([1400.0, -320.0, 210.0], dtype=float)
2664
+ moment = np.array([18.0, -7.0, 31.0], dtype=float)
2665
+ load_vector = np.concatenate([force, moment])
2666
+ load = LoadCase("eccentric_slave_load")
2667
+ load.add_nodal_load(2, load_vector)
2668
+ u0 = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
2669
+
2670
+ diagnostics = compute_constraint_force_diagnostics(model, u0, load)
2671
+ slave_force = np.asarray(diagnostics["mpc_slave_forces"].get(2, np.zeros(6)), dtype=float)
2672
+ master_equivalent = np.asarray(diagnostics["mpc_master_equivalent_forces"].get(1, np.zeros(6)), dtype=float)
2673
+ expected_master = -np.concatenate([force, moment + np.cross(r, force)])
2674
+
2675
+ slave_error = float(np.linalg.norm(slave_force + load_vector))
2676
+ master_error = float(np.linalg.norm(master_equivalent - expected_master))
2677
+ support_direct = np.asarray(diagnostics["support_reactions"].get(1, np.zeros(6)), dtype=float)
2678
+ support_direct_norm = float(np.linalg.norm(support_direct))
2679
+
2680
+ _assert(slave_error < 1.0e-12, "MPC slave-force bucket did not recover eccentric applied load")
2681
+ _assert(master_error < 1.0e-10, "MPC master-equivalent eccentric load transfer mismatch")
2682
+ _assert(support_direct_norm < 1.0e-12, "direct support reaction bucket should not include MPC transfer")
2683
+ return _pass(
2684
+ case,
2685
+ element_types=["beam_shell_mpc"],
2686
+ checks={
2687
+ "eccentricity": r.tolist(),
2688
+ "slave_force_error": slave_error,
2689
+ "master_equivalent_error": master_error,
2690
+ "slave_force": slave_force.tolist(),
2691
+ "master_equivalent_force": master_equivalent.tolist(),
2692
+ "expected_master_equivalent_force": expected_master.tolist(),
2693
+ "support_direct_norm": support_direct_norm,
2694
+ "num_mpc_constraint_forces": len(diagnostics["mpc_constraint_forces"]),
2695
+ },
2696
+ )
2697
+
2698
+
2699
+ def _free_beam_nullspace() -> Tuple[FEModel, np.ndarray, np.ndarray, Dict[str, Any]]:
2700
+ model = _beam_model(length=1.0, area=0.01)
2701
+ K, _ = assemble_stiffness_matrix(model)
2702
+ zero = np.zeros(model.mesh.dof_manager.total_dofs)
2703
+ K_red, _F, _T, _u0, independent, constraint_info = build_constraint_transformation(K, zero, model)
2704
+ Q, nullspace_info = build_reduced_rigid_body_modes(model, independent, int(K.shape[0]))
2705
+ return model, K_red, Q, {"constraint": constraint_info, "nullspace": nullspace_info}
2706
+
2707
+
2708
+ def _run_null_001(case: VerificationCase) -> VerificationCaseResult:
2709
+ _model, _K, Q, info = _free_beam_nullspace()
2710
+ rank = int(Q.shape[1])
2711
+ _assert(rank == 6, "free beam nullspace rank is not six")
2712
+ return _pass(case, element_types=["beam2"], checks={"rank": rank, **info["nullspace"]})
2713
+
2714
+
2715
+ def _run_null_002(case: VerificationCase) -> VerificationCaseResult:
2716
+ _model, _K, Q, _info = _free_beam_nullspace()
2717
+ f = np.zeros(Q.shape[0])
2718
+ f[0] = 1.0
2719
+ projected = f - Q @ (Q.T @ f)
2720
+ rel = float(np.linalg.norm(Q.T @ projected) / max(np.linalg.norm(projected), 1.0e-30))
2721
+ _assert(rel < 1.0e-12, "projected load is not orthogonal to rigid basis")
2722
+ return _pass(case, element_types=["beam2"], checks={"projected_load_orthogonality": rel})
2723
+
2724
+
2725
+ def _beam_model_between(start: np.ndarray, end: np.ndarray) -> FEModel:
2726
+ model = FEModel("verification_beam_between")
2727
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
2728
+ model.add_node(1, *np.asarray(start, dtype=float).tolist())
2729
+ model.add_node(2, *np.asarray(end, dtype=float).tolist())
2730
+ section = {"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6, "shear_factor_y": 5.0 / 6.0, "shear_factor_z": 5.0 / 6.0}
2731
+ model.add_element(1, BeamElement(1, [1, 2], "steel", section))
2732
+ return model
2733
+
2734
+
2735
+ def _self_equilibrated_axial_load(model: FEModel, magnitude: float = 2500.0) -> LoadCase:
2736
+ n1 = model.mesh.get_node(1)
2737
+ n2 = model.mesh.get_node(2)
2738
+ axis = n2.coords() - n1.coords()
2739
+ axis = axis / np.linalg.norm(axis)
2740
+ load = LoadCase("self_equilibrated_axial")
2741
+ load.add_nodal_load(1, np.concatenate([-magnitude * axis, np.zeros(3)]))
2742
+ load.add_nodal_load(2, np.concatenate([magnitude * axis, np.zeros(3)]))
2743
+ return load
2744
+
2745
+
2746
+ def _axial_extension(model: FEModel, displacements: np.ndarray) -> float:
2747
+ n1 = model.mesh.get_node(1)
2748
+ n2 = model.mesh.get_node(2)
2749
+ axis = n2.coords() - n1.coords()
2750
+ axis = axis / np.linalg.norm(axis)
2751
+ u = np.asarray(displacements, dtype=float)
2752
+ return float((u[n2.dofs[:3]] - u[n1.dofs[:3]]) @ axis)
2753
+
2754
+
2755
+ def _strain_energy(model: FEModel, displacements: np.ndarray) -> float:
2756
+ K, _ = assemble_stiffness_matrix(model)
2757
+ u = np.asarray(displacements, dtype=float)
2758
+ return 0.5 * float(u @ (K @ u))
2759
+
2760
+
2761
+ def _solve_linear_checked(model: FEModel, load: LoadCase) -> Tuple[np.ndarray, Dict[str, Any]]:
2762
+ displacements, info = solve_linear(model, load, constraint_mode="auto")
2763
+ status = (info.get("convergence_info") or {}).get("status")
2764
+ _assert(status == "converged", f"linear solve did not converge: {status}")
2765
+ return displacements, info
2766
+
2767
+
2768
+ def _run_null_003(case: VerificationCase) -> VerificationCaseResult:
2769
+ free_model = _beam_model(length=1.0, area=0.01)
2770
+ free_load = _self_equilibrated_axial_load(free_model)
2771
+ free_u, free_info = _solve_linear_checked(free_model, free_load)
2772
+
2773
+ constrained_model = _beam_model(length=1.0, area=0.01)
2774
+ constrained_model.add_boundary_condition(FixedSupport("fixed_node_1", [1]))
2775
+ constrained_load = _self_equilibrated_axial_load(constrained_model)
2776
+ constrained_u, constrained_info = _solve_linear_checked(constrained_model, constrained_load)
2777
+
2778
+ extension_error = _rel_error(_axial_extension(free_model, free_u), _axial_extension(constrained_model, constrained_u))
2779
+ energy_error = _rel_error(_strain_energy(free_model, free_u), _strain_energy(constrained_model, constrained_u))
2780
+ _assert(max(extension_error, energy_error) < 1.0e-9, "projected and constrained solutions differ in elastic field")
2781
+ return _pass(
2782
+ case,
2783
+ element_types=["beam2"],
2784
+ analysis_type="linear_static",
2785
+ checks={
2786
+ "extension_relative_error": extension_error,
2787
+ "strain_energy_relative_error": energy_error,
2788
+ "free_nullspace_rank": int((free_info.get("nullspace_info") or {}).get("rank", 0)),
2789
+ "constrained_nullspace_rank": int((constrained_info.get("nullspace_info") or {}).get("rank", 0)),
2790
+ },
2791
+ )
2792
+
2793
+
2794
+ def _run_null_004(case: VerificationCase) -> VerificationCaseResult:
2795
+ variants: List[Tuple[str, Callable[[FEModel], None]]] = [
2796
+ ("node_1_fixed", lambda m: m.add_boundary_condition(FixedSupport("fixed_node_1", [1]))),
2797
+ ("node_2_fixed", lambda m: m.add_boundary_condition(FixedSupport("fixed_node_2", [2]))),
2798
+ (
2799
+ "minimal_stabilized",
2800
+ lambda m: (
2801
+ m.add_boundary_condition(BoundaryCondition("node_1_translations", [1], {"ux": 0.0, "uy": 0.0, "uz": 0.0})),
2802
+ m.add_boundary_condition(BoundaryCondition("node_2_transverse_rotations", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0})),
2803
+ ),
2804
+ ),
2805
+ ]
2806
+ extensions: Dict[str, float] = {}
2807
+ energies: Dict[str, float] = {}
2808
+ for name, apply_restraints in variants:
2809
+ model = _beam_model(length=1.0, area=0.01)
2810
+ apply_restraints(model)
2811
+ load = _self_equilibrated_axial_load(model)
2812
+ u, _info = _solve_linear_checked(model, load)
2813
+ extensions[name] = _axial_extension(model, u)
2814
+ energies[name] = _strain_energy(model, u)
2815
+
2816
+ extension_spread = float((max(extensions.values()) - min(extensions.values())) / max(abs(next(iter(extensions.values()))), 1.0e-30))
2817
+ energy_spread = float((max(energies.values()) - min(energies.values())) / max(abs(next(iter(energies.values()))), 1.0e-30))
2818
+ _assert(max(extension_spread, energy_spread) < 1.0e-9, "elastic field depends on arbitrary support choice")
2819
+ return _pass(
2820
+ case,
2821
+ element_types=["beam2"],
2822
+ analysis_type="linear_static",
2823
+ checks={
2824
+ "extensions": extensions,
2825
+ "strain_energies": energies,
2826
+ "extension_relative_spread": extension_spread,
2827
+ "strain_energy_relative_spread": energy_spread,
2828
+ },
2829
+ )
2830
+
2831
+
2832
+ def _run_null_005(case: VerificationCase) -> VerificationCaseResult:
2833
+ reference_model = _beam_model(length=1.0, area=0.01)
2834
+ reference_load = _self_equilibrated_axial_load(reference_model)
2835
+ reference_u, _reference_info = _solve_linear_checked(reference_model, reference_load)
2836
+
2837
+ start = np.array([2.0, -0.5, 0.75], dtype=float)
2838
+ axis = np.array([0.36, 0.48, 0.80], dtype=float)
2839
+ axis = axis / np.linalg.norm(axis)
2840
+ transformed_model = _beam_model_between(start, start + axis)
2841
+ transformed_load = _self_equilibrated_axial_load(transformed_model)
2842
+ transformed_u, transformed_info = _solve_linear_checked(transformed_model, transformed_load)
2843
+
2844
+ extension_error = _rel_error(_axial_extension(transformed_model, transformed_u), _axial_extension(reference_model, reference_u))
2845
+ energy_error = _rel_error(_strain_energy(transformed_model, transformed_u), _strain_energy(reference_model, reference_u))
2846
+ _assert(max(extension_error, energy_error) < 1.0e-9, "nullspace solution is not invariant under rigid transform")
2847
+ return _pass(
2848
+ case,
2849
+ element_types=["beam2"],
2850
+ analysis_type="linear_static",
2851
+ checks={
2852
+ "extension_relative_error": extension_error,
2853
+ "strain_energy_relative_error": energy_error,
2854
+ "transformed_nullspace_rank": int((transformed_info.get("nullspace_info") or {}).get("rank", 0)),
2855
+ },
2856
+ )
2857
+
2858
+
2859
+ def _run_eig_001(case: VerificationCase) -> VerificationCaseResult:
2860
+ model = _beam_model(length=2.0, area=0.02, density=7850.0)
2861
+ props = calculate_mass_properties(model)
2862
+ ref = 7850.0 * 0.02 * 2.0
2863
+ err = _rel_error(props.total_mass, ref)
2864
+ _assert(err < 1.0e-12, "beam mass mismatch")
2865
+ return _pass(case, element_types=["beam2"], reference={"type": "analytical", "value": ref}, result={"value": props.total_mass, "relative_error": err}, checks=props.to_dict())
2866
+
2867
+
2868
+ def _run_eig_002(case: VerificationCase) -> VerificationCaseResult:
2869
+ masses = []
2870
+ for n in (1, 2, 4):
2871
+ model = _beam_model(length=2.0, area=0.02, density=7850.0, num_elements=n)
2872
+ masses.append(calculate_mass_properties(model).total_mass)
2873
+ spread = float((max(masses) - min(masses)) / max(abs(masses[0]), 1.0))
2874
+ _assert(spread < 1.0e-12, "mass changes under beam mesh refinement")
2875
+ return _pass(case, element_types=["beam2"], checks={"mass_values": masses, "relative_spread": spread})
2876
+
2877
+
2878
+ def _run_eig_003(case: VerificationCase) -> VerificationCaseResult:
2879
+ model = _beam_model(length=1.0, area=1.0, density=2.0)
2880
+ model.materials["steel"].elastic_modulus = 100.0
2881
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
2882
+ model.add_boundary_condition(BoundaryCondition("slider", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
2883
+ result = solve_free_vibration(model, num_modes=1)
2884
+ err = float(result.diagnostics["mass_orthogonality_error"])
2885
+ _assert(err < 1.0e-8, "modal mass orthogonality failed")
2886
+ return _pass(case, element_types=["beam2"], analysis_type="modal", checks=result.diagnostics)
2887
+
2888
+
2889
+ def _run_eig_004(case: VerificationCase) -> VerificationCaseResult:
2890
+ model = FEModel("repeated_axial_modes")
2891
+ model.add_material("steel", 100.0, 0.3, density=2.0)
2892
+ section = {"area": 1.0, "Iy": 1.0, "Iz": 1.0, "J": 1.0}
2893
+ for node_id, coords in {
2894
+ 1: (0.0, 0.0, 0.0),
2895
+ 2: (1.0, 0.0, 0.0),
2896
+ 3: (0.0, 2.0, 0.0),
2897
+ 4: (1.0, 2.0, 0.0),
2898
+ }.items():
2899
+ model.add_node(node_id, *coords)
2900
+ model.add_element(1, BeamElement(1, [1, 2], "steel", section))
2901
+ model.add_element(2, BeamElement(2, [3, 4], "steel", section))
2902
+ model.add_boundary_condition(FixedSupport("fixed_bases", [1, 3]))
2903
+ model.add_boundary_condition(BoundaryCondition("axial_sliders", [2, 4], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
2904
+ result = solve_free_vibration(model, num_modes=2, dense_size_limit=10000)
2905
+ _assert(result.solver_status == "ok" and result.num_modes_returned == 2, "repeated eigenspace modal solve failed")
2906
+ frequencies = result.frequencies_hz
2907
+ spread = float((np.max(frequencies) - np.min(frequencies)) / max(abs(float(np.mean(frequencies))), 1.0e-30))
2908
+ orthogonality = float(result.diagnostics.get("mass_orthogonality_error", 1.0))
2909
+ _assert(spread < 1.0e-10 and orthogonality < 1.0e-8, "repeated modal eigenspace is not stable")
2910
+ return _pass(
2911
+ case,
2912
+ element_types=["beam2"],
2913
+ analysis_type="modal",
2914
+ checks={"frequencies_hz": frequencies.tolist(), "relative_frequency_spread": spread, **result.diagnostics},
2915
+ )
2916
+
2917
+
2918
+ def _run_buc_001(case: VerificationCase) -> VerificationCaseResult:
2919
+ model = _beam_model(num_elements=2)
2920
+ states = {element_id: {"axial_compression": 100.0} for element_id in model.mesh.elements}
2921
+ KG, _ = assemble_geometric_stiffness_matrix(model, states)
2922
+ err = _symmetry_error(KG)
2923
+ _assert(err < 1.0e-10, "geometric stiffness symmetry failed")
2924
+ return _pass(case, element_types=["beam2"], analysis_type="linear_buckling", checks={"geometric_stiffness_symmetry": err})
2925
+
2926
+
2927
+ def _run_buc_002(case: VerificationCase) -> VerificationCaseResult:
2928
+ model = _beam_model(length=4.0, num_elements=6)
2929
+ all_nodes = list(model.mesh.nodes)
2930
+ model.add_boundary_condition(BoundaryCondition("suppress", all_nodes, {"ux": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0}))
2931
+ model.add_boundary_condition(BoundaryCondition("pins", [1, 7], {"uy": 0.0}))
2932
+ base = {element_id: {"axial_compression": 1.0} for element_id in model.mesh.elements}
2933
+ double = {element_id: {"axial_compression": 2.0} for element_id in model.mesh.elements}
2934
+ half = {element_id: {"axial_compression": 0.5} for element_id in model.mesh.elements}
2935
+ r1 = solve_eigenvalue_buckling(model, base, num_modes=1)
2936
+ r2 = solve_eigenvalue_buckling(model, double, num_modes=1)
2937
+ rh = solve_eigenvalue_buckling(model, half, num_modes=1)
2938
+ err2 = _rel_error(float(r2.critical_load_factor), 0.5 * float(r1.critical_load_factor))
2939
+ errh = _rel_error(float(rh.critical_load_factor), 2.0 * float(r1.critical_load_factor))
2940
+ _assert(max(err2, errh) < 1.0e-8, "buckling preload scaling failed")
2941
+ return _pass(case, element_types=["beam2"], analysis_type="linear_buckling", checks={"double_preload_error": err2, "half_preload_error": errh})
2942
+
2943
+
2944
+ class _VerificationSofteningSpring(Element):
2945
+ """One active DOF with analytical limit point: lambda = k u - c u^3."""
2946
+
2947
+ def __init__(self, element_id: int, node_id: int, *, k: float = 1.0, c: float = 1.0):
2948
+ super().__init__(element_id, [node_id], "default")
2949
+ self.k = float(k)
2950
+ self.c = float(c)
2951
+
2952
+ @property
2953
+ def num_nodes(self) -> int:
2954
+ return 1
2955
+
2956
+ @property
2957
+ def dofs_per_node(self) -> int:
2958
+ return 6
2959
+
2960
+ def get_node_coordinates(self, mesh: Any) -> np.ndarray:
2961
+ return np.asarray([mesh.get_node(self.node_ids[0]).coords()], dtype=float)
2962
+
2963
+ def compute_stiffness_matrix(self, mesh: Any, material: Any) -> np.ndarray:
2964
+ matrix = np.eye(6, dtype=float)
2965
+ matrix[0, 0] = self.k
2966
+ return matrix
2967
+
2968
+ def compute_nonlinear_response(
2969
+ self,
2970
+ mesh: Any,
2971
+ material: Any,
2972
+ u_elem: np.ndarray,
2973
+ state: Any = None,
2974
+ num_layers: int = 5,
2975
+ tangent: bool = True,
2976
+ ) -> Tuple[np.ndarray, Optional[np.ndarray], Dict[str, float]]:
2977
+ displacement = float(np.asarray(u_elem, dtype=float)[0])
2978
+ force = np.asarray(u_elem, dtype=float).copy()
2979
+ force[0] = self.k * displacement - self.c * displacement**3
2980
+ stiffness = None
2981
+ if tangent:
2982
+ stiffness = np.eye(6, dtype=float)
2983
+ stiffness[0, 0] = self.k - 3.0 * self.c * displacement**2
2984
+ return force, stiffness, {"spring_displacement": displacement}
2985
+
2986
+
2987
+ def _softening_spring_model(*, k: float = 1.0, c: float = 1.0) -> Tuple[FEModel, LoadCase]:
2988
+ model = FEModel("verification_softening_spring")
2989
+ model.add_node(1, 0.0, 0.0, 0.0)
2990
+ model.add_element(1, _VerificationSofteningSpring(1, 1, k=k, c=c))
2991
+ model.add_boundary_condition(BoundaryCondition("one_dof", [1], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
2992
+ load = LoadCase("unit_reference")
2993
+ load.add_nodal_load(1, load_vector=[1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
2994
+ return model, load
2995
+
2996
+
2997
+ def _plastic_stiffened_panel_model() -> Tuple[FEModel, LoadCase]:
2998
+ model, _panel, _config = _thin_stiffened_panel_model(1, element_family="Q4", shell_divisions_x=2, shell_divisions_y=2, beam_divisions=2)
2999
+ curve = reference_plastic_curve()
3000
+ for material in model.materials.values():
3001
+ material.hardening_curve = curve
3002
+ for element in model.mesh.elements.values():
3003
+ if isinstance(element, BeamElement):
3004
+ element.cross_section["fiber_plasticity"] = True
3005
+ load = _pressure_load_for_shells(model, 1.0e7)
3006
+ return model, load
3007
+
3008
+
3009
+ def _run_nlg_006(case: VerificationCase) -> VerificationCaseResult:
3010
+ from .nonlinear_static import solve_static_nonlinear
3011
+
3012
+ rows: List[Dict[str, Any]] = []
3013
+ displacements_by_steps: Dict[int, np.ndarray] = {}
3014
+ for num_steps in (2, 4, 6):
3015
+ model, load = _plastic_stiffened_panel_model()
3016
+ result = solve_static_nonlinear(
3017
+ model,
3018
+ load,
3019
+ max_load_factor=1.0,
3020
+ num_steps=num_steps,
3021
+ max_iterations=25,
3022
+ num_layers=3,
3023
+ convergence_settings="robust",
3024
+ )
3025
+ displacements_by_steps[int(num_steps)] = result.displacements
3026
+ rows.append(
3027
+ {
3028
+ "num_steps": int(num_steps),
3029
+ "status": result.status,
3030
+ "load_factor": float(result.load_factor),
3031
+ "total_newton_iterations": int(result.info.get("total_newton_iterations", 0)),
3032
+ "max_abs_displacement": float(np.max(np.abs(result.displacements))),
3033
+ "max_equivalent_plastic_strain": float(result.info.get("strain_summary", {}).get("max_equivalent_plastic_strain", 0.0)),
3034
+ "stop_reason": result.stop_reason,
3035
+ "status_category": result.status_category,
3036
+ }
3037
+ )
3038
+ reference = np.asarray(displacements_by_steps[6], dtype=float)
3039
+ for row in rows:
3040
+ row["endpoint_relative_to_6_step"] = float(
3041
+ np.linalg.norm(displacements_by_steps[int(row["num_steps"])] - reference) / max(np.linalg.norm(reference), 1.0e-30)
3042
+ )
3043
+ max_endpoint_error = max(float(row["endpoint_relative_to_6_step"]) for row in rows)
3044
+ _assert(all(row["status"] == "completed" for row in rows), "stiffened-panel nonlinear increment study did not complete")
3045
+ _assert(max_endpoint_error < 1.0e-6, "stiffened-panel nonlinear endpoint is increment dependent")
3046
+ return _pass(
3047
+ case,
3048
+ element_types=["shell4", "beam2", "interpolated_mpc"],
3049
+ analysis_type="nonlinear_static",
3050
+ reference={"type": "increment convergence", "source": "6-step robust Newton endpoint"},
3051
+ result={"max_endpoint_relative_error": max_endpoint_error, "rows": rows},
3052
+ checks={"rows": rows},
3053
+ )
3054
+
3055
+
3056
+ def _run_nlg_007(case: VerificationCase) -> VerificationCaseResult:
3057
+ from .arc_length import ArcLengthControl, solve_static_arc_length
3058
+ from .imperfections import ImperfectionField
3059
+
3060
+ spring_model, spring_load = _softening_spring_model(k=1.0, c=1.0)
3061
+ spring_control = ArcLengthControl(
3062
+ initial_load_increment=0.02,
3063
+ minimum_load_increment=1.0e-5,
3064
+ maximum_load_increment=0.05,
3065
+ max_steps=120,
3066
+ stop_after_peak_steps=5,
3067
+ peak_drop_tolerance=1.0e-4,
3068
+ )
3069
+ spring = solve_static_arc_length(
3070
+ spring_model,
3071
+ spring_load,
3072
+ control=spring_control,
3073
+ max_iterations=30,
3074
+ tolerance=1.0e-9,
3075
+ arc_tolerance=1.0e-9,
3076
+ )
3077
+ analytical_peak = 2.0 / (3.0 * math.sqrt(3.0))
3078
+ peak_error = _rel_error(spring.peak_load_factor, analytical_peak)
3079
+
3080
+ panel, panel_load = _plastic_stiffened_panel_model()
3081
+ offsets = {
3082
+ int(node_id): (0.0, 0.0, 1.0e-4)
3083
+ for node_id, node in panel.mesh.nodes.items()
3084
+ if abs(float(node.z)) < 1.0e-12 and 0.25 < float(node.x) < 0.85 and 0.05 < float(node.y) < 0.35
3085
+ }
3086
+ panel_control = ArcLengthControl(
3087
+ initial_load_increment=0.10,
3088
+ minimum_load_increment=0.01,
3089
+ maximum_load_increment=0.20,
3090
+ max_steps=8,
3091
+ maximum_absolute_load_factor=0.50,
3092
+ stop_after_peak_steps=2,
3093
+ )
3094
+ panel_result = solve_static_arc_length(
3095
+ panel,
3096
+ panel_load,
3097
+ control=panel_control,
3098
+ max_iterations=15,
3099
+ tolerance=1.0e-6,
3100
+ arc_tolerance=1.0e-6,
3101
+ num_layers=3,
3102
+ imperfection=ImperfectionField(offsets, name="verification_panel_bow"),
3103
+ )
3104
+ _assert(spring.status == "peak_confirmed" and peak_error < 0.03, "arc-length analytical peak reference failed")
3105
+ _assert(panel_result.status == "load_factor_limit_reached" and len(panel_result.steps) >= 3, "imperfect stiffened-panel arc-length guard failed")
3106
+ return _pass(
3107
+ case,
3108
+ element_types=["shell4", "beam2", "interpolated_mpc"],
3109
+ analysis_type="arc_length",
3110
+ reference={"type": "analytical plus imperfect-panel guard", "analytical_softening_peak": analytical_peak},
3111
+ result={
3112
+ "softening_peak_load_factor": float(spring.peak_load_factor),
3113
+ "softening_peak_relative_error": peak_error,
3114
+ "panel_status": panel_result.status,
3115
+ "panel_last_load_factor": float(panel_result.load_factor),
3116
+ "panel_peak_load_factor": float(panel_result.peak_load_factor),
3117
+ },
3118
+ checks={
3119
+ "softening_steps": [step.to_dict() for step in spring.steps[-8:]],
3120
+ "panel_steps": [step.to_dict() for step in panel_result.steps],
3121
+ "panel_imperfection": panel_result.info.get("imperfection", []),
3122
+ },
3123
+ )
3124
+
3125
+
3126
+ def _run_nlg_008(case: VerificationCase) -> VerificationCaseResult:
3127
+ model = _verification_plate_model(divisions=2, thickness=0.01, element_family="S4")
3128
+ follower = _pressure_load_all_shells(model, 1000.0)
3129
+ setattr(follower, "follower_pressure", True)
3130
+ report = validate_production_model(model, load_cases=[follower], allow_free_mechanisms=True)
3131
+ codes = [issue.code for issue in report.issues]
3132
+ _assert(report.status == "invalid" and "LOAD001" in codes, "follower-pressure scope guard did not reject unsupported load")
3133
+ return _pass(
3134
+ case,
3135
+ analysis_type="scope_guard",
3136
+ reference={"type": "unsupported-feature guard", "unsupported": "follower pressure"},
3137
+ result={"validation_status": report.status, "issue_codes": codes},
3138
+ checks=report.to_dict(),
3139
+ )
3140
+
3141
+
3142
+ def _run_mat_008(case: VerificationCase) -> VerificationCaseResult:
3143
+ curve = reference_plastic_curve()
3144
+ model = FEModel("combined_shell_beam_plasticity")
3145
+ model.add_material("steel", 210.0e9, 0.3, hardening_curve=curve)
3146
+ for node_id, coord in enumerate(((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0)), start=1):
3147
+ model.add_node(node_id, *coord)
3148
+ model.add_node(101, 0.0, 1.4, 0.0)
3149
+ model.add_node(102, 1.0, 1.4, 0.0)
3150
+ shell = ShellElement(1, [1, 2, 3, 4], "steel", 0.01)
3151
+ beam = BeamElement(2, [101, 102], "steel", {"area": 0.01, "Iy": 1.0e-5, "Iz": 1.0e-5, "J": 1.0e-5, "fiber_plasticity": True})
3152
+ model.add_element(1, shell)
3153
+ model.add_element(2, beam)
3154
+
3155
+ u_shell = np.zeros(shell.total_dofs, dtype=float)
3156
+ coords = shell.get_node_coordinates(model.mesh)
3157
+ for local, coord in enumerate(coords):
3158
+ x, y, _z = coord
3159
+ base = local * 6
3160
+ u_shell[base + 0] = 0.003 * x
3161
+ u_shell[base + 1] = -0.0008 * y
3162
+ u_shell[base + 4] = 0.002 * x
3163
+ shell_force, shell_tangent, shell_state = shell.compute_nonlinear_response(model.mesh, model.get_material("steel"), u_shell, num_layers=5, tangent=True)
3164
+
3165
+ u_beam = np.zeros(beam.total_dofs, dtype=float)
3166
+ u_beam[6] = 0.006
3167
+ u_beam[7] = 0.0003
3168
+ beam_force, beam_tangent, beam_state = beam.compute_nonlinear_response(model.mesh, model.get_material("steel"), u_beam, tangent=True)
3169
+
3170
+ shell_alpha = float(np.max(np.asarray(shell_state.get("alpha", [0.0]), dtype=float)))
3171
+ beam_alpha = float(np.max(np.asarray(beam_state.get("alpha", [0.0]), dtype=float)))
3172
+ shell_symmetry = _symmetry_error(shell_tangent)
3173
+ beam_symmetry = _symmetry_error(beam_tangent)
3174
+ tangent_metrics = element_tangent_metrics()
3175
+ shell_tangent_error = float(tangent_metrics["shell_layered_plastic"]["tangent_fd_relative_error"])
3176
+ beam_tangent_error = float(tangent_metrics["beam_fiber_plastic"]["tangent_fd_relative_error"])
3177
+ _assert(shell_alpha > 0.0 and beam_alpha > 0.0, "combined shell/beam plastic states did not yield")
3178
+ _assert(max(shell_tangent_error, beam_tangent_error) < 1.0e-4, "combined shell/beam plastic tangents are not finite-difference consistent")
3179
+ _assert(np.all(np.isfinite(shell_force)) and np.all(np.isfinite(beam_force)), "combined plastic internal forces are not finite")
3180
+ return _pass(
3181
+ case,
3182
+ element_types=["shell4", "beam2"],
3183
+ analysis_type="plasticity",
3184
+ reference={"type": "element state invariant", "quantities": ["shell layered plasticity", "beam fiber plasticity"]},
3185
+ result={"shell_alpha_max": shell_alpha, "beam_alpha_max": beam_alpha},
3186
+ checks={
3187
+ "shell_force_norm": float(np.linalg.norm(shell_force)),
3188
+ "beam_force_norm": float(np.linalg.norm(beam_force)),
3189
+ "shell_tangent_symmetry": shell_symmetry,
3190
+ "beam_tangent_symmetry": beam_symmetry,
3191
+ "shell_tangent_fd_relative_error": shell_tangent_error,
3192
+ "beam_tangent_fd_relative_error": beam_tangent_error,
3193
+ "shell_state_keys": sorted(str(key) for key in shell_state.keys()),
3194
+ "beam_state_keys": sorted(str(key) for key in beam_state.keys()),
3195
+ },
3196
+ )
3197
+
3198
+
3199
+ def _run_dyn_001(case: VerificationCase) -> VerificationCaseResult:
3200
+ model, _panel, _config = _thin_stiffened_panel_model(1, element_family="Q4", shell_divisions_x=2, shell_divisions_y=2, beam_divisions=2)
3201
+ for material in model.materials.values():
3202
+ material.density = 7850.0
3203
+ shell_ids = [int(element_id) for element_id, element in model.mesh.elements.items() if isinstance(element, ShellElement)]
3204
+ output_node = max(int(node_id) for node_id in model.mesh.nodes)
3205
+ patch = PressurePatch.rectangular_pulse(
3206
+ name="thin_stiffened_panel_pulse",
3207
+ pressure=1000.0,
3208
+ start_time=0.0,
3209
+ end_time=0.002,
3210
+ element_ids=shell_ids[:1],
3211
+ )
3212
+ result = solve_transient_newmark(
3213
+ model,
3214
+ TransientConfig(dt=0.001, t_end=0.004, save_every=1, output_nodes=[output_node]),
3215
+ pressure_patches=[patch],
3216
+ )
3217
+ selected_area_impulse = float(np.linalg.norm(result.force_impulse))
3218
+ _assert(result.status == "completed", "thin stiffened-panel transient benchmark did not complete")
3219
+ _assert(result.peak_displacement > 0.0 and result.node_histories[output_node].shape[0] == len(result.times), "transient displacement history was not saved")
3220
+ _assert(result.diagnostics["pressure_patches"][0]["num_selected_elements"] == 1 and selected_area_impulse > 0.0, "transient pressure-patch impulse check failed")
3221
+ _assert(result.diagnostics["factorization_reused"] is True, "transient effective stiffness factorization was not reused")
3222
+ return _pass(
3223
+ case,
3224
+ element_types=["shell4", "beam2", "interpolated_mpc"],
3225
+ analysis_type="linear_transient",
3226
+ reference={"type": "transient invariant", "quantities": ["pressure impulse", "saved nodal history", "factor reuse"]},
3227
+ result={
3228
+ "peak_displacement": float(result.peak_displacement),
3229
+ "peak_displacement_node": result.peak_displacement_node,
3230
+ "force_impulse": result.force_impulse.tolist(),
3231
+ "factorization_count": int(result.diagnostics["factorization_count"]),
3232
+ },
3233
+ checks={
3234
+ "times": result.times.tolist(),
3235
+ "output_node": int(output_node),
3236
+ "node_history_shape": list(result.node_histories[output_node].shape),
3237
+ "pressure_patch": result.diagnostics["pressure_patches"][0],
3238
+ "factorization_reused": bool(result.diagnostics["factorization_reused"]),
3239
+ },
3240
+ )
3241
+
3242
+
3243
+ def _hemisphere_cap_model(n_phi: int = 4, n_theta: int = 8) -> Tuple[FEModel, LoadCase]:
3244
+ model = FEModel("hemisphere_cap_internal_benchmark")
3245
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
3246
+ radius = 1.0
3247
+ thickness = 0.01
3248
+ phi_min = 0.15
3249
+ phi_max = 0.5 * math.pi
3250
+ node_id = 1
3251
+ ids: Dict[Tuple[int, int], int] = {}
3252
+ for iphi in range(int(n_phi) + 1):
3253
+ phi = phi_min + (phi_max - phi_min) * iphi / float(n_phi)
3254
+ for itheta in range(int(n_theta)):
3255
+ theta = 2.0 * math.pi * itheta / float(n_theta)
3256
+ ids[(iphi, itheta)] = node_id
3257
+ model.add_node(
3258
+ node_id,
3259
+ radius * math.sin(phi) * math.cos(theta),
3260
+ radius * math.sin(phi) * math.sin(theta),
3261
+ radius * math.cos(phi),
3262
+ )
3263
+ node_id += 1
3264
+ element_id = 1
3265
+ for iphi in range(int(n_phi)):
3266
+ for itheta in range(int(n_theta)):
3267
+ model.add_element(
3268
+ element_id,
3269
+ ShellElement(
3270
+ element_id,
3271
+ [
3272
+ ids[(iphi, itheta)],
3273
+ ids[(iphi, (itheta + 1) % int(n_theta))],
3274
+ ids[(iphi + 1, (itheta + 1) % int(n_theta))],
3275
+ ids[(iphi + 1, itheta)],
3276
+ ],
3277
+ "steel",
3278
+ thickness=thickness,
3279
+ ),
3280
+ )
3281
+ element_id += 1
3282
+ equator_nodes = [ids[(int(n_phi), itheta)] for itheta in range(int(n_theta))]
3283
+ model.add_boundary_condition(BoundaryCondition("equator_uz", equator_nodes, {"uz": 0.0}))
3284
+ model.add_boundary_condition(BoundaryCondition("reference_xy", [equator_nodes[0]], {"ux": 0.0, "uy": 0.0}))
3285
+ model.add_boundary_condition(BoundaryCondition("reference_x", [equator_nodes[int(n_theta) // 4]], {"ux": 0.0}))
3286
+ load = LoadCase("external_pressure")
3287
+ for element_id in model.mesh.elements:
3288
+ load.add_pressure_load(int(element_id), -1000.0)
3289
+ return model, load
3290
+
3291
+
3292
+ def _run_bench_004(case: VerificationCase) -> VerificationCaseResult:
3293
+ rows: List[Dict[str, Any]] = []
3294
+ for n_phi, n_theta in ((4, 8), (6, 12)):
3295
+ model, load = _hemisphere_cap_model(n_phi=n_phi, n_theta=n_theta)
3296
+ displacements, solver_info = solve_linear(model, load, constraint_mode="auto")
3297
+ top_ring_nodes = [
3298
+ node_id
3299
+ for node_id, node in model.mesh.nodes.items()
3300
+ if float(node.z) == max(float(other.z) for other in model.mesh.nodes.values())
3301
+ ]
3302
+ top_uz = [float(displacements[model.mesh.get_node(node_id).dofs[2]]) for node_id in top_ring_nodes]
3303
+ top_spread = 0.0 if not top_uz else float((max(top_uz) - min(top_uz)) / max(max(abs(value) for value in top_uz), 1.0e-30))
3304
+ rows.append(
3305
+ {
3306
+ "n_phi": int(n_phi),
3307
+ "n_theta": int(n_theta),
3308
+ "nodes": int(model.mesh.num_nodes),
3309
+ "elements": int(model.mesh.num_elements),
3310
+ "solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
3311
+ "max_abs_displacement": float(np.max(np.abs(displacements))),
3312
+ "top_ring_uz_spread": top_spread,
3313
+ }
3314
+ )
3315
+ response_ratio = float(rows[-1]["max_abs_displacement"] / max(rows[0]["max_abs_displacement"], 1.0e-30))
3316
+ _assert(all(row["solver_status"] == "converged" for row in rows), "hemispherical shell benchmark solve failed")
3317
+ _assert(all(row["top_ring_uz_spread"] < 1.0e-8 for row in rows), "hemispherical shell rotational symmetry check failed")
3318
+ _assert(0.5 < response_ratio < 2.0, "hemispherical shell refinement response is outside sanity band")
3319
+ return _pass(
3320
+ case,
3321
+ element_types=["shell4"],
3322
+ analysis_type="linear_static",
3323
+ mesh={"fixture": "hemisphere cap with equator support", "radius": 1.0, "thickness": 0.01},
3324
+ reference={"type": "internal symmetry/refinement", "note": "classical external hemispherical-shell target not bundled"},
3325
+ result={"response_ratio_refined_to_coarse": response_ratio},
3326
+ checks={"rows": rows},
3327
+ )
3328
+
3329
+
3330
+ def _run_nlg_002(case: VerificationCase) -> VerificationCaseResult:
3331
+ from .beam_validity import corotational_axial_extension_metric, corotational_rigid_rotation_metric
3332
+
3333
+ rigid = corotational_rigid_rotation_metric(angle_degrees=75.0)
3334
+ axial = corotational_axial_extension_metric(extension=0.002)
3335
+ _assert(float(rigid["corotational_force_norm"]) < 1.0e-5, "large rigid rotation produced corotational beam force")
3336
+ _assert(float(rigid["force_norm_ratio_corot_to_default"]) < 1.0e-12, "corotational beam did not suppress rigid-rotation strain")
3337
+ _assert(float(axial["relative_error"]) < 1.0e-10, "corotational beam axial extension response failed")
3338
+ return _pass(
3339
+ case,
3340
+ element_types=["beam2"],
3341
+ analysis_type="geometric_nonlinear",
3342
+ reference={"type": "large-rotation objectivity", "source": "corotational 2-node beam rigid rotation and axial extension"},
3343
+ result={"angle_degrees": rigid["angle_degrees"], "corotational_force_norm": rigid["corotational_force_norm"]},
3344
+ checks={"rigid_rotation": rigid, "axial_extension": axial},
3345
+ )
3346
+
3347
+
3348
+ def _run_nlg_003(case: VerificationCase) -> VerificationCaseResult:
3349
+ """Local NAFEMS-style nonlinear framework smoke check.
3350
+
3351
+ The proprietary NAFEMS 3DNLG datasets are not redistributed with this repo.
3352
+ This case therefore verifies that the local nonlinear framework has the
3353
+ same ingredients required to host such cases: geometric objectivity,
3354
+ analytical limit-point continuation, imperfect panel continuation and
3355
+ unsupported follower-pressure rejection.
3356
+ """
3357
+
3358
+ synthetic = [
3359
+ ("large_rotation_objectivity", _run_nlg_002(case).status),
3360
+ ("arc_length_limit_point", _run_nlg_007(case).status),
3361
+ ("follower_pressure_guard", _run_nlg_008(case).status),
3362
+ ]
3363
+ _assert(all(status == "PASS" for _name, status in synthetic), "NAFEMS-style nonlinear framework ingredients failed")
3364
+ return _pass(
3365
+ case,
3366
+ analysis_type="nonlinear_framework",
3367
+ reference={"type": "framework readiness", "note": "NAFEMS 3DNLG proprietary numerical targets are not bundled"},
3368
+ result={"framework_checks": {name: status for name, status in synthetic}},
3369
+ checks={
3370
+ "datasets_bundled": False,
3371
+ "supported_fixture_types": ["large_rotation_objectivity", "limit_point_arc_length", "scope_guard"],
3372
+ },
3373
+ )
3374
+
3375
+
3376
+ def _external_reference_report_for_verification() -> Dict[str, Any]:
3377
+ return generate_external_reference_report(Path("reports/external_references/decks"))
3378
+
3379
+
3380
+ def _run_ext_001(case: VerificationCase) -> VerificationCaseResult:
3381
+ report = _external_reference_report_for_verification()
3382
+ cases = report.get("cases", [])
3383
+ discovered = discover_calculix_reference_cases(
3384
+ roots=[Path("reports/external_references/decks")],
3385
+ repo_root=Path.cwd(),
3386
+ require_frd=False,
3387
+ )
3388
+ names = {str(item.get("name")) for item in cases}
3389
+ kinds = {case.name: case.kind for case in discovered}
3390
+ _assert(report.get("status") == "passed" and {"pressure_plate_s4", "beam_column_buckling", "cylinder_s4_pressure"} <= names, "CalculiX deck pack is incomplete")
3391
+ _assert(len(discovered) >= 3 and all(case.element_count > 0 for case in discovered), "generated CalculiX decks are not discoverable")
3392
+ return _pass(
3393
+ case,
3394
+ analysis_type="cross_solver_handoff",
3395
+ reference={"type": "generated CalculiX/Abaqus-style input decks", "execution_status": "not_executed_locally"},
3396
+ result={"case_count": len(cases), "discovered_count": len(discovered), "case_names": sorted(names)},
3397
+ checks={"report": report, "discovered_kinds": kinds},
3398
+ )
3399
+
3400
+
3401
+ def _run_ext_002(case: VerificationCase) -> VerificationCaseResult:
3402
+ report = _external_reference_report_for_verification()
3403
+ manifest = upstream_calculix_reference_manifest()
3404
+ shell_entry = next((entry for entry in manifest if entry.get("name") == "calculix_examples_shell_convergence"), None)
3405
+ deck_paths = [Path(item.get("inp_path", "")) for item in report.get("cases", [])]
3406
+ abaqus_style_keywords = {}
3407
+ for path in deck_paths:
3408
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
3409
+ abaqus_style_keywords[path.name] = {
3410
+ "has_node": "*NODE" in text,
3411
+ "has_element": "*ELEMENT" in text,
3412
+ "has_step": "*STEP" in text,
3413
+ "has_output": "*NODE FILE" in text and "*EL FILE" in text,
3414
+ }
3415
+ _assert(shell_entry is not None and shell_entry.get("repository") == "calculix/CalculiX-Examples", "upstream external shell manifest is missing")
3416
+ _assert(all(all(values.values()) for values in abaqus_style_keywords.values()), "external decks do not expose neutral Abaqus-style solver handoff keywords")
3417
+ return _pass(
3418
+ case,
3419
+ analysis_type="second_external_reference_handoff",
3420
+ reference={"type": "upstream manifest plus neutral Abaqus-style deck syntax", "execution_status": "not_executed_locally"},
3421
+ result={"upstream_manifest_entries": len(manifest), "deck_keyword_checks": abaqus_style_keywords},
3422
+ checks={"upstream_shell_reference": shell_entry, "known_limitations": report.get("known_limitations", [])},
3423
+ )
3424
+
3425
+
3426
+ def _run_vvr_001(case: VerificationCase) -> VerificationCaseResult:
3427
+ expected_release_paths = [
3428
+ Path("reports/beam_shell_verification/beam_shell_verification_report.json"),
3429
+ Path("reports/beam_shell_verification/beam_shell_verification_report.md"),
3430
+ Path("reports/production_readiness/current/capability_matrix.json"),
3431
+ Path("reports/production_readiness/current/verification_scope.json"),
3432
+ Path("reports/production_readiness/current/verification_scope.md"),
3433
+ Path("reports/verification_quick_current/fe_verification_report.json"),
3434
+ Path("reports/verification_quick_current/fe_verification_report.md"),
3435
+ Path("reports/external_references/external_reference_report.json"),
3436
+ Path("reports/external_references/external_reference_report.md"),
3437
+ ]
3438
+ external_report = write_external_reference_report(
3439
+ Path("reports/external_references/external_reference_report.json"),
3440
+ deck_dir=Path("reports/external_references/decks"),
3441
+ markdown=Path("reports/external_references/external_reference_report.md"),
3442
+ )
3443
+ present = [str(path) for path in expected_release_paths if path.exists()]
3444
+ package_dir = Path("reports/verification_package")
3445
+ package_dir.mkdir(parents=True, exist_ok=True)
3446
+ manifest = {
3447
+ "schema_version": 1,
3448
+ "status": "passed" if external_report.get("status") == "passed" else "incomplete",
3449
+ "expected_release_artifacts": [str(path) for path in expected_release_paths],
3450
+ "present_artifacts_at_vvr_runtime": present,
3451
+ "external_reference_status": external_report.get("status"),
3452
+ "known_limitations": [
3453
+ "The external reference package contains reproducible handoff decks; it does not include executed external-solver result comparisons.",
3454
+ "The verified production scope remains the documented ANYsolver beam-shell scope, not a general-purpose FE claim.",
3455
+ "The beam-shell and production-readiness reports are generated by the caller after this case completes, so VVR-001 avoids a circular dependency on the file currently being written.",
3456
+ ],
3457
+ }
3458
+ (package_dir / "release_evidence_manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
3459
+ (package_dir / "release_evidence_manifest.md").write_text(
3460
+ "# Verification Package Manifest\n\n"
3461
+ f"- Status: {manifest['status']}\n"
3462
+ f"- Expected release artifacts: {len(expected_release_paths)}\n"
3463
+ f"- Present artifacts at VVR runtime: {len(present)}\n"
3464
+ f"- External reference status: {manifest['external_reference_status']}\n\n"
3465
+ "## Known Limitations\n\n"
3466
+ + "\n".join(f"- {item}" for item in manifest["known_limitations"])
3467
+ + "\n",
3468
+ encoding="utf-8",
3469
+ )
3470
+ _assert(manifest["status"] == "passed", "verification report package is incomplete")
3471
+ return _pass(
3472
+ case,
3473
+ analysis_type="verification_report_package",
3474
+ reference={"type": "generated evidence package manifest"},
3475
+ result={"expected_artifact_count": len(expected_release_paths), "package_manifest": str(package_dir / "release_evidence_manifest.json")},
3476
+ checks=manifest,
3477
+ )
3478
+
3479
+
3480
+ def _run_nlg_001(case: VerificationCase) -> VerificationCaseResult:
3481
+ from .beam_validity import corotational_rigid_rotation_metric
3482
+
3483
+ metric = corotational_rigid_rotation_metric()
3484
+ _assert(float(metric["corotational_force_norm"]) < 1.0e-6, "corotational beam rigid rotation produced force")
3485
+ return _pass(case, element_types=["beam2"], analysis_type="geometric_nonlinear", checks=metric)
3486
+
3487
+
3488
+ def _run_nlg_004(case: VerificationCase) -> VerificationCaseResult:
3489
+ model1 = _beam_model(length=1.0)
3490
+ load = LoadCase("small")
3491
+ load.add_nodal_load(2, [100.0, 0.0, 0.0, 0.0, 0.0, 0.0])
3492
+ from .nonlinear_static import solve_static_nonlinear
3493
+
3494
+ r1 = solve_static_nonlinear(model1, load, num_steps=2, max_iterations=8)
3495
+ model2 = _beam_model(length=1.0)
3496
+ r2 = solve_static_nonlinear(model2, load, num_steps=4, max_iterations=8)
3497
+ err = float(np.linalg.norm(r1.displacements - r2.displacements) / max(np.linalg.norm(r2.displacements), 1.0e-30))
3498
+ _assert(err < 1.0e-8, "smooth nonlinear endpoint depends on increment count")
3499
+ return _pass(case, element_types=["beam2"], analysis_type="nonlinear_static", checks={"endpoint_relative_difference": err})
3500
+
3501
+
3502
+ def _run_nlg_005(case: VerificationCase) -> VerificationCaseResult:
3503
+ metrics = element_tangent_metrics()
3504
+ err = max(
3505
+ float(item["tangent_fd_relative_error"])
3506
+ for item in metrics.values()
3507
+ if isinstance(item, Mapping) and "tangent_fd_relative_error" in item
3508
+ )
3509
+ _assert(err < 1.0e-4, "element tangent finite-difference check failed")
3510
+ return _pass(case, analysis_type="nonlinear_static", checks={"max_relative_tangent_error": err, "metrics": metrics})
3511
+
3512
+
3513
+ def _run_mat_common(case: VerificationCase) -> VerificationCaseResult:
3514
+ paths = material_point_path_metrics()
3515
+ residual = float(paths["max_abs_yield_residual"])
3516
+ max_tangent = float(paths["max_material_tangent_fd_error"])
3517
+ _assert(residual < 1.0e-8 and max_tangent < 1.0e-4, "material path checks failed")
3518
+ return _pass(case, analysis_type="plasticity", checks={"yield_residual": residual, "max_tangent_error": max_tangent, "paths": paths})
3519
+
3520
+
3521
+ _MLBC_RESULT_CACHE: Optional[Dict[str, Any]] = None
3522
+
3523
+
3524
+ def _run_mlbc_common(case: VerificationCase) -> VerificationCaseResult:
3525
+ global _MLBC_RESULT_CACHE
3526
+ if _MLBC_RESULT_CACHE is None:
3527
+ _MLBC_RESULT_CACHE = mesh_load_bc_result_by_case()
3528
+ result = _MLBC_RESULT_CACHE.get(case.case_id)
3529
+ if result is None:
3530
+ return _fail(case, "mesh/load/boundary verification case was not evaluated")
3531
+ payload = {
3532
+ "analysis_type": "mesh_load_bc",
3533
+ "element_types": ["S4", "Q8", "beam", "mpc"],
3534
+ "result": dict(result.measured),
3535
+ "reference": dict(result.tolerance),
3536
+ "checks": dict(result.checks),
3537
+ "mesh": dict(result.diagnostics.get("mesh_quality") or {}),
3538
+ }
3539
+ if result.status == "PASS":
3540
+ return _pass(case, **payload)
3541
+ return _fail(case, result.reason or "mesh/load/boundary verification failed", **payload)
3542
+
3543
+
3544
+ _FRACTURE_METRIC_CACHE: Optional[Dict[str, Dict[str, Any]]] = None
3545
+
3546
+
3547
+ def _fracture_panel_model(curve: Optional[Any] = None) -> FEModel:
3548
+ model = FEModel("fracture_verification_panel")
3549
+ model.add_material("steel", 210.0e9, 0.3, hardening_curve=curve)
3550
+ model.add_node(1, 0.0, 0.0, 0.0)
3551
+ model.add_node(2, 1.0, 0.0, 0.0)
3552
+ model.add_node(3, 1.0, 0.2, 0.0)
3553
+ model.add_node(4, 0.0, 0.2, 0.0)
3554
+ model.add_element(1, ShellElement(1, [1, 2, 3, 4], "steel", 0.01))
3555
+ model.add_boundary_condition(BoundaryCondition("left_x", [1, 4], {"ux": 0.0}))
3556
+ model.add_boundary_condition(BoundaryCondition("plane", [1, 2, 3, 4], {"uz": 0.0, "rx": 0.0, "ry": 0.0}))
3557
+ model.add_boundary_condition(BoundaryCondition("pin_y", [1], {"uy": 0.0}))
3558
+ return model
3559
+
3560
+
3561
+ def _fracture_tension_load(stress: float = 340.0e6) -> LoadCase:
3562
+ load = LoadCase("fracture_pull")
3563
+ total = float(stress) * 0.2 * 0.01
3564
+ load.add_nodal_load(2, [0.5 * total, 0.0, 0.0, 0.0, 0.0, 0.0])
3565
+ load.add_nodal_load(3, [0.5 * total, 0.0, 0.0, 0.0, 0.0, 0.0])
3566
+ return load
3567
+
3568
+
3569
+ def _fracture_verification_metrics() -> Dict[str, Dict[str, Any]]:
3570
+ from .fracture import (
3571
+ FractureConfig,
3572
+ ImpactDamageConfig,
3573
+ deleted_pressure_load_resultant,
3574
+ filtered_load_case_for_deleted_elements,
3575
+ )
3576
+ from .contact import (
3577
+ RigidSphereImpact,
3578
+ SphereContactConfig,
3579
+ _impact_contact_patch_area,
3580
+ _update_impact_damage_states,
3581
+ assemble_sphere_contact_load_vector,
3582
+ solve_transient_sphere_impact,
3583
+ )
3584
+ from .matrix_assembly import assemble_load_vector
3585
+ from .nonlinear_static import _assemble_nonlinear_system, solve_static_nonlinear
3586
+
3587
+ metrics: Dict[str, Dict[str, Any]] = {}
3588
+
3589
+ def _impact_panel() -> FEModel:
3590
+ panel = FEModel("impact_damage_panel")
3591
+ panel.add_material("soft", 1.0e5, 0.3, density=20.0)
3592
+ panel.add_node(1, 0.0, 0.0, 0.0)
3593
+ panel.add_node(2, 1.0, 0.0, 0.0)
3594
+ panel.add_node(3, 1.0, 1.0, 0.0)
3595
+ panel.add_node(4, 0.0, 1.0, 0.0)
3596
+ panel.add_element(1, ShellElement(1, [1, 2, 3, 4], "soft", thickness=0.05))
3597
+ panel.add_boundary_condition(
3598
+ BoundaryCondition(
3599
+ "restrain_shell_nonimpact_modes",
3600
+ [1, 2, 3, 4],
3601
+ {"ux": 0.0, "uy": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0},
3602
+ )
3603
+ )
3604
+ return panel
3605
+
3606
+ def _impact_sphere(speed: float = 2.0, radius: float = 0.1) -> RigidSphereImpact:
3607
+ return RigidSphereImpact(
3608
+ "impact_damage",
3609
+ radius=radius,
3610
+ mass=1.0,
3611
+ start_point=(0.5, 0.5, 0.25),
3612
+ travel_direction=(0.0, 0.0, -1.0),
3613
+ speed=speed,
3614
+ )
3615
+
3616
+ invalid_count = 0
3617
+ for kwargs in (
3618
+ {"threshold": 0.0},
3619
+ {"threshold": 1.0e-3, "residual_stiffness_fraction": -1.0},
3620
+ {"threshold": 1.0e-3, "max_deleted_fraction": 1.5},
3621
+ {"threshold": 1.0e-3, "element_scope": ("solid",)},
3622
+ ):
3623
+ try:
3624
+ FractureConfig(**kwargs)
3625
+ except ValueError:
3626
+ invalid_count += 1
3627
+ metrics["FRACT-001"] = {"invalid_configs_rejected": invalid_count}
3628
+
3629
+ curve = reference_plastic_curve()
3630
+ model = _fracture_panel_model(curve)
3631
+ displacement = np.linspace(0.0, 1.0e-4, model.mesh.dof_manager.total_dofs)
3632
+ f_active, k_active, states = _assemble_nonlinear_system(model, displacement, {}, 3, tangent=True)
3633
+ f_deleted, k_deleted, deleted_states = _assemble_nonlinear_system(
3634
+ model,
3635
+ displacement,
3636
+ states,
3637
+ 3,
3638
+ tangent=True,
3639
+ deleted_element_ids=(1,),
3640
+ residual_stiffness_fraction=0.2,
3641
+ )
3642
+ metrics["FRACT-002"] = {
3643
+ "force_scale": float(np.linalg.norm(f_deleted) / max(np.linalg.norm(f_active), 1.0e-30)),
3644
+ "tangent_scale": float(np.linalg.norm(k_deleted.toarray()) / max(np.linalg.norm(k_active.toarray()), 1.0e-30)),
3645
+ "state_preserved": bool(deleted_states[1] is states[1]),
3646
+ }
3647
+
3648
+ elastic_model = _fracture_panel_model(None)
3649
+ pressure = LoadCase("pressure")
3650
+ pressure.add_pressure_load(1, 11.0)
3651
+ full, _ = assemble_load_vector(elastic_model, pressure)
3652
+ filtered, _ = assemble_load_vector(elastic_model, filtered_load_case_for_deleted_elements(pressure, (1,)))
3653
+ removed = deleted_pressure_load_resultant(elastic_model, pressure, (1,))
3654
+ metrics["FRACT-003"] = {
3655
+ "full_load_norm": float(np.linalg.norm(full)),
3656
+ "filtered_load_norm": float(np.linalg.norm(filtered)),
3657
+ "removed_resultant_z": float(removed[2]),
3658
+ "expected_resultant_z": 11.0 * 1.0 * 0.2,
3659
+ }
3660
+
3661
+ deletion_model = _fracture_panel_model(curve)
3662
+ deletion_result = solve_static_nonlinear(
3663
+ deletion_model,
3664
+ _fracture_tension_load(stress=380.0e6),
3665
+ num_steps=4,
3666
+ num_layers=3,
3667
+ fracture_config=FractureConfig(threshold=1.0e-5, max_deleted_fraction=1.0),
3668
+ )
3669
+ deletion_summary = deletion_result.info.get("fracture_summary", {})
3670
+ metrics["FRACT-004"] = {
3671
+ "status": deletion_result.status,
3672
+ "deleted_count": int(deletion_summary.get("deleted_count", 0)),
3673
+ "first_deletion_load_factor": deletion_summary.get("first_deletion_load_factor"),
3674
+ "record_count": len(deletion_summary.get("records", [])),
3675
+ }
3676
+
3677
+ high_model = _fracture_panel_model(curve)
3678
+ high_result = solve_static_nonlinear(
3679
+ high_model,
3680
+ _fracture_tension_load(),
3681
+ num_steps=4,
3682
+ num_layers=3,
3683
+ fracture_config=FractureConfig(threshold=1.0, max_deleted_fraction=1.0),
3684
+ )
3685
+ metrics["FRACT-005"] = {
3686
+ "status": high_result.status,
3687
+ "deleted_count": int(high_result.info.get("fracture_summary", {}).get("deleted_count", 0)),
3688
+ }
3689
+
3690
+ stop_model = _fracture_panel_model(curve)
3691
+ initial_state = stop_model.mesh.get_element(1).init_nonlinear_state(3)
3692
+ initial_state["alpha"][:] = 0.01
3693
+ stop_result = solve_static_nonlinear(
3694
+ stop_model,
3695
+ _fracture_tension_load(stress=100.0e6),
3696
+ num_steps=2,
3697
+ num_layers=3,
3698
+ initial_element_states={1: initial_state},
3699
+ fracture_config=FractureConfig(threshold=0.001, max_deleted_fraction=0.5),
3700
+ )
3701
+ metrics["FRACT-006"] = {
3702
+ "status": stop_result.status,
3703
+ "failure_reason": stop_result.failure_reason,
3704
+ "deleted_count": int(stop_result.info.get("fracture_summary", {}).get("deleted_count", 0)),
3705
+ }
3706
+
3707
+ patch_model = _impact_panel()
3708
+ patch_element = patch_model.mesh.get_element(1)
3709
+ patch_sphere = RigidSphereImpact("patch", radius=0.2, mass=1.0, start_point=(0.5, 0.5, 0.1), travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3710
+ patch_config = ImpactDamageConfig(capacity_basis="user", user_capacity=1000.0, min_contact_area=1.0e-4)
3711
+ _v1, _sf1, patch_records = assemble_sphere_contact_load_vector(
3712
+ patch_model,
3713
+ patch_sphere,
3714
+ SphereContactConfig(penalty_stiffness=1000.0),
3715
+ sphere_position=np.array([0.5, 0.5, 0.15]),
3716
+ sphere_velocity=np.zeros(3),
3717
+ )
3718
+ _v2, _sf2, deeper_patch_records = assemble_sphere_contact_load_vector(
3719
+ patch_model,
3720
+ patch_sphere,
3721
+ SphereContactConfig(penalty_stiffness=1000.0),
3722
+ sphere_position=np.array([0.5, 0.5, 0.05]),
3723
+ sphere_velocity=np.zeros(3),
3724
+ )
3725
+ shallow_area = _impact_contact_patch_area(patch_records[0], patch_element, patch_config, patch_sphere)
3726
+ deep_area = _impact_contact_patch_area(deeper_patch_records[0], patch_element, patch_config, patch_sphere)
3727
+ metrics["FRACT-007"] = {
3728
+ "shallow_area": float(shallow_area),
3729
+ "deep_area": float(deep_area),
3730
+ "min_contact_area": float(patch_config.min_contact_area),
3731
+ }
3732
+
3733
+ low_damage = solve_transient_sphere_impact(
3734
+ _impact_panel(),
3735
+ TransientConfig(dt=0.0025, t_end=0.12),
3736
+ _impact_sphere(speed=0.5),
3737
+ SphereContactConfig(penalty_stiffness=4000.0, max_contact_iterations=40),
3738
+ damage_config=ImpactDamageConfig(capacity_basis="user", user_capacity=1.0e9, max_deleted_fraction=1.0),
3739
+ ).diagnostics["impact_damage_summary"]
3740
+ metrics["FRACT-008"] = {
3741
+ "deleted_count": int(low_damage["deleted_count"]),
3742
+ "max_damage": float(low_damage["max_damage"]),
3743
+ }
3744
+
3745
+ high_damage_result = solve_transient_sphere_impact(
3746
+ _impact_panel(),
3747
+ TransientConfig(dt=0.0025, t_end=0.12),
3748
+ _impact_sphere(),
3749
+ SphereContactConfig(penalty_stiffness=4000.0, max_contact_iterations=40),
3750
+ damage_config=ImpactDamageConfig(mode="instant_threshold", capacity_basis="user", user_capacity=10.0, max_deleted_fraction=1.0),
3751
+ )
3752
+ high_damage = high_damage_result.diagnostics["impact_damage_summary"]
3753
+ metrics["FRACT-009"] = {
3754
+ "status": high_damage_result.status,
3755
+ "deleted_count": int(high_damage["deleted_count"]),
3756
+ "governing_component": high_damage["records"][0]["governing_component"],
3757
+ }
3758
+
3759
+ low_capacity = solve_transient_sphere_impact(
3760
+ _impact_panel(),
3761
+ TransientConfig(dt=0.0025, t_end=0.12),
3762
+ _impact_sphere(),
3763
+ SphereContactConfig(penalty_stiffness=4000.0, max_contact_iterations=40),
3764
+ damage_config=ImpactDamageConfig(capacity_basis="user", user_capacity=20.0, max_deleted_fraction=1.0),
3765
+ ).diagnostics["impact_damage_summary"]
3766
+ high_capacity = solve_transient_sphere_impact(
3767
+ _impact_panel(),
3768
+ TransientConfig(dt=0.0025, t_end=0.12),
3769
+ _impact_sphere(),
3770
+ SphereContactConfig(penalty_stiffness=4000.0, max_contact_iterations=40),
3771
+ damage_config=ImpactDamageConfig(capacity_basis="user", user_capacity=1.0e6, max_deleted_fraction=1.0),
3772
+ ).diagnostics["impact_damage_summary"]
3773
+ metrics["FRACT-010"] = {
3774
+ "low_capacity_max_damage": float(low_capacity["max_damage"]),
3775
+ "high_capacity_max_damage": float(high_capacity["max_damage"]),
3776
+ "low_capacity_deleted": int(low_capacity["deleted_count"]),
3777
+ "high_capacity_deleted": int(high_capacity["deleted_count"]),
3778
+ }
3779
+
3780
+ repeat_model = _impact_panel()
3781
+ repeat_sphere = RigidSphereImpact("repeat", radius=0.2, mass=1.0, start_point=(0.5, 0.5, 0.1), travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3782
+ _repeat_vector, _repeat_force, repeat_records = assemble_sphere_contact_load_vector(
3783
+ repeat_model,
3784
+ repeat_sphere,
3785
+ SphereContactConfig(penalty_stiffness=1000.0),
3786
+ sphere_position=np.array([0.5, 0.5, 0.1]),
3787
+ sphere_velocity=np.zeros(3),
3788
+ )
3789
+ repeat_config = ImpactDamageConfig(
3790
+ mode="accumulated_damage",
3791
+ capacity_basis="user",
3792
+ user_capacity=1000.0,
3793
+ impulse_reference_time=0.01,
3794
+ max_deleted_fraction=1.0,
3795
+ )
3796
+ repeat_states: Dict[int, Dict[str, Any]] = {}
3797
+ repeat_deleted: set[int] = set()
3798
+ for repeat_step in range(4):
3799
+ repeat_new_deleted, _repeat_util, _repeat_diags, _repeat_changed = _update_impact_damage_states(
3800
+ repeat_model,
3801
+ repeat_records,
3802
+ repeat_config,
3803
+ repeat_sphere,
3804
+ repeat_states,
3805
+ tuple(repeat_deleted),
3806
+ step_index=repeat_step + 1,
3807
+ time_value=0.01 * (repeat_step + 1),
3808
+ dt=0.01,
3809
+ )
3810
+ repeat_deleted.update(record.element_id for record in repeat_new_deleted)
3811
+ if repeat_deleted:
3812
+ break
3813
+ metrics["FRACT-011"] = {
3814
+ "damage": float(repeat_states[1]["damage"]),
3815
+ "deleted_count": len(repeat_deleted),
3816
+ }
3817
+
3818
+ smooth_config = ImpactDamageConfig(
3819
+ mode="instant_threshold",
3820
+ capacity_basis="user",
3821
+ user_capacity=1.0,
3822
+ neighbor_smoothing=True,
3823
+ max_deleted_fraction=1.0,
3824
+ )
3825
+ smooth_states: Dict[int, Dict[str, Any]] = {}
3826
+ smooth_first_deleted, _smooth_util, smooth_first_diag, _smooth_changed = _update_impact_damage_states(
3827
+ repeat_model,
3828
+ repeat_records,
3829
+ smooth_config,
3830
+ repeat_sphere,
3831
+ smooth_states,
3832
+ (),
3833
+ step_index=1,
3834
+ time_value=0.01,
3835
+ dt=0.01,
3836
+ )
3837
+ smooth_second_deleted, _smooth_util2, _smooth_diag2, _smooth_changed2 = _update_impact_damage_states(
3838
+ repeat_model,
3839
+ repeat_records,
3840
+ smooth_config,
3841
+ repeat_sphere,
3842
+ smooth_states,
3843
+ (),
3844
+ step_index=2,
3845
+ time_value=0.02,
3846
+ dt=0.01,
3847
+ )
3848
+ metrics["FRACT-012"] = {
3849
+ "first_deleted_count": len(smooth_first_deleted),
3850
+ "first_hold": bool(smooth_first_diag[0].get("neighbor_smoothing_hold")),
3851
+ "second_deleted_count": len(smooth_second_deleted),
3852
+ }
3853
+ return metrics
3854
+
3855
+
3856
+ def _run_fracture_common(case: VerificationCase) -> VerificationCaseResult:
3857
+ global _FRACTURE_METRIC_CACHE
3858
+ if _FRACTURE_METRIC_CACHE is None:
3859
+ _FRACTURE_METRIC_CACHE = _fracture_verification_metrics()
3860
+ metrics = dict(_FRACTURE_METRIC_CACHE.get(case.case_id) or {})
3861
+ if not metrics:
3862
+ return _fail(case, "fracture verification metric was not evaluated")
3863
+ if case.case_id == "FRACT-001":
3864
+ _assert(int(metrics["invalid_configs_rejected"]) == 4, "fracture config validation missed invalid inputs")
3865
+ elif case.case_id == "FRACT-002":
3866
+ _assert(abs(float(metrics["force_scale"]) - 0.2) < 1.0e-12, "deleted force residual scale mismatch")
3867
+ _assert(abs(float(metrics["tangent_scale"]) - 0.2) < 1.0e-12, "deleted tangent residual scale mismatch")
3868
+ _assert(bool(metrics["state_preserved"]), "deleted element state was updated")
3869
+ elif case.case_id == "FRACT-003":
3870
+ _assert(float(metrics["full_load_norm"]) > 0.0, "pressure reference load is empty")
3871
+ _assert(float(metrics["filtered_load_norm"]) < 1.0e-12, "deleted pressure load was not removed")
3872
+ _assert(abs(float(metrics["removed_resultant_z"]) - float(metrics["expected_resultant_z"])) < 1.0e-12, "removed pressure resultant mismatch")
3873
+ elif case.case_id == "FRACT-004":
3874
+ _assert(int(metrics["deleted_count"]) == 1, "plastic-strain threshold did not delete the shell")
3875
+ _assert(int(metrics["record_count"]) == 1, "fracture deletion record was not stored")
3876
+ _assert(float(metrics["first_deletion_load_factor"]) > 0.0, "first deletion load factor was not reported")
3877
+ elif case.case_id == "FRACT-005":
3878
+ _assert(int(metrics["deleted_count"]) == 0, "high threshold unexpectedly deleted elements")
3879
+ elif case.case_id == "FRACT-006":
3880
+ _assert(metrics["status"] == "stopped_at_limit", "max deleted fraction did not stop the solve")
3881
+ _assert(metrics["failure_reason"] == "max_deleted_fraction_reached", "wrong fracture stop reason")
3882
+ _assert(int(metrics["deleted_count"]) == 1, "deleted element was not reported on stop")
3883
+ elif case.case_id == "FRACT-007":
3884
+ _assert(float(metrics["shallow_area"]) >= float(metrics["min_contact_area"]), "contact patch area fell below configured minimum")
3885
+ _assert(float(metrics["deep_area"]) > float(metrics["shallow_area"]), "contact patch area did not grow with penetration")
3886
+ elif case.case_id == "FRACT-008":
3887
+ _assert(int(metrics["deleted_count"]) == 0, "high-capacity low-energy impact unexpectedly deleted a shell")
3888
+ _assert(float(metrics["max_damage"]) < 1.0, "high-capacity low-energy impact reached deletion damage")
3889
+ elif case.case_id == "FRACT-009":
3890
+ _assert(int(metrics["deleted_count"]) == 1, "high-energy impact damage did not delete the contacted shell")
3891
+ _assert(metrics["governing_component"] in {"contact_pressure", "impulse_per_area", "equivalent_plastic_strain_estimate"}, "missing damage trigger component")
3892
+ elif case.case_id == "FRACT-010":
3893
+ _assert(float(metrics["low_capacity_max_damage"]) > float(metrics["high_capacity_max_damage"]), "higher capacity did not reduce damage")
3894
+ _assert(int(metrics["low_capacity_deleted"]) >= int(metrics["high_capacity_deleted"]), "higher capacity increased deletion")
3895
+ elif case.case_id == "FRACT-011":
3896
+ _assert(float(metrics["damage"]) > 1.0, "repeated subthreshold contacts did not accumulate damage")
3897
+ _assert(int(metrics["deleted_count"]) == 1, "accumulated damage did not delete after repeated contact")
3898
+ elif case.case_id == "FRACT-012":
3899
+ _assert(int(metrics["first_deleted_count"]) == 0, "neighbor smoothing did not hold first isolated deletion")
3900
+ _assert(bool(metrics["first_hold"]), "neighbor smoothing hold was not reported")
3901
+ _assert(int(metrics["second_deleted_count"]) == 1, "neighbor smoothing did not allow repeated-contact deletion")
3902
+ analysis_type = "impact_damage" if case.case_id >= "FRACT-007" else "nonlinear_static_fracture"
3903
+ reference_scope = (
3904
+ "engineering contact-demand shell damage and erosion after converged impact substeps"
3905
+ if case.case_id >= "FRACT-007"
3906
+ else "equivalent-plastic-strain-triggered soft element erosion after converged increments"
3907
+ )
3908
+ return _pass(
3909
+ case,
3910
+ analysis_type=analysis_type,
3911
+ evidence_type="invariant",
3912
+ element_types=["shell"],
3913
+ checks=metrics,
3914
+ reference={"fracture_scope": reference_scope},
3915
+ )
3916
+
3917
+
3918
+ _CONTACT_METRIC_CACHE: Optional[Dict[str, Dict[str, Any]]] = None
3919
+
3920
+
3921
+ def _run_contact_common(case: VerificationCase) -> VerificationCaseResult:
3922
+ global _CONTACT_METRIC_CACHE
3923
+ if _CONTACT_METRIC_CACHE is None:
3924
+ _CONTACT_METRIC_CACHE = contact_verification_metrics()
3925
+ metrics = dict(_CONTACT_METRIC_CACHE.get(case.case_id) or {})
3926
+ if not metrics:
3927
+ return _fail(case, "contact verification metric was not evaluated")
3928
+ if case.case_id == "CONTACT-001":
3929
+ _assert(metrics["status"] == "no_contact", "no-contact sphere trajectory did not report no_contact")
3930
+ _assert(float(metrics["trajectory_error"]) < 1.0e-12, "no-contact sphere trajectory drifted")
3931
+ _assert(float(metrics["peak_contact_force"]) == 0.0, "no-contact case produced contact force")
3932
+ elif case.case_id == "CONTACT-002":
3933
+ _assert(float(metrics["relative_error"]) < 1.0e-12, "normal penalty force law mismatch")
3934
+ elif case.case_id == "CONTACT-003":
3935
+ _assert(float(metrics["balance_error"]) < 1.0e-10, "sphere and structural contact force resultants are not balanced")
3936
+ elif case.case_id == "CONTACT-004":
3937
+ denominator = max(float(metrics["impulse_norm"]), 1.0)
3938
+ _assert(float(metrics["impulse_error"]) / denominator < 5.0e-2, "sphere impulse and momentum change mismatch")
3939
+ _assert(metrics["status"] == "completed", "impact impulse case did not complete")
3940
+ elif case.case_id == "CONTACT-005":
3941
+ _assert(metrics["status"] == "completed", "panel impact case did not complete")
3942
+ _assert(float(metrics["peak_contact_force"]) > 0.0, "panel impact produced no contact force")
3943
+ _assert(float(metrics["max_penetration"]) > 0.0, "panel impact produced no penetration")
3944
+ _assert(float(metrics["peak_displacement"]) > 0.0, "panel impact produced no structural response")
3945
+ elif case.case_id == "CONTACT-006":
3946
+ _assert(metrics["status"] == "completed", "stiffened-panel impact case did not complete")
3947
+ _assert(float(metrics["beam_node_10_peak_uz"]) > 0.0, "coupled beam node 10 did not respond")
3948
+ _assert(float(metrics["beam_node_11_peak_uz"]) > 0.0, "coupled beam node 11 did not respond")
3949
+ elif case.case_id == "CONTACT-007":
3950
+ _assert(bool(metrics["projection_classification_ok"]), "contact projection face/edge/corner classification failed")
3951
+ elif case.case_id == "CONTACT-008":
3952
+ _assert(int(metrics["midsurface_records"]) == 0, "midsurface offset check unexpectedly contacted")
3953
+ _assert(abs(float(metrics["top_penetration"]) - float(metrics["expected_top_penetration"])) < 1.0e-12, "top-surface penetration mismatch")
3954
+ elif case.case_id == "CONTACT-009":
3955
+ _assert(int(metrics["active_records"]) == 1, "adjacent contact reduction did not keep one active contact")
3956
+ _assert(abs(float(metrics["force_norm"]) - float(metrics["expected_force_norm"])) < 1.0e-10, "adjacent contact reduction changed force magnitude")
3957
+ elif case.case_id == "CONTACT-010":
3958
+ _assert(metrics["status"] == "completed", "automatic-penalty impact did not complete")
3959
+ _assert(float(metrics["resolved_penalty"]) > 0.0, "automatic penalty did not resolve to a positive stiffness")
3960
+ _assert(float(metrics["max_penetration_ratio"]) < 0.08, "automatic penalty exceeded production penetration target guard")
3961
+ elif case.case_id == "CONTACT-011":
3962
+ _assert(metrics["status"] == "completed", "event-substep impact did not complete")
3963
+ _assert(int(metrics["event_substep_count"]) > 0, "event substepping was not activated")
3964
+ _assert(float(metrics["peak_contact_force"]) > 0.0, "event substepping did not catch contact")
3965
+ elif case.case_id == "CONTACT-012":
3966
+ _assert(metrics["validation_status"] == "invalid", "invalid contact configuration was not rejected")
3967
+ _assert({"CONTACT002", "CONTACT005"} <= set(metrics["issue_codes"]), "contact validation missed required issue codes")
3968
+ return _pass(
3969
+ case,
3970
+ analysis_type="sphere_impact_transient",
3971
+ evidence_type="invariant",
3972
+ element_types=["shell", "beam", "mpc"],
3973
+ checks=metrics,
3974
+ reference={"contact_scope": "rigid sphere to shell midsurface, frictionless normal penalty"},
3975
+ )
3976
+
3977
+
3978
+ IMPLEMENTATIONS: Dict[str, Callable[[VerificationCase], VerificationCaseResult]] = {
3979
+ "META-001": _run_meta_001,
3980
+ "ALG-001": _run_alg_001,
3981
+ "ALG-002": _run_alg_002,
3982
+ "ALG-003": _run_alg_003,
3983
+ "ALG-004": _run_alg_004,
3984
+ "ALG-005": _run_alg_005,
3985
+ "ALG-006": _run_alg_006,
3986
+ "ALG-007": _run_alg_007,
3987
+ "ALG-008": _run_alg_008,
3988
+ "ALG-009": _run_alg_009,
3989
+ "BEAM-001": _run_beam_001,
3990
+ "BEAM-002": _run_beam_002,
3991
+ "BEAM-003": _run_beam_003,
3992
+ "BEAM-004": _run_beam_004,
3993
+ "BEAM-005": _run_beam_005,
3994
+ "BEAM-006": _run_beam_006,
3995
+ "BEAM-007": _run_beam_007,
3996
+ "BEAM-008": _run_beam_008,
3997
+ "BEAM-009": _run_beam_009,
3998
+ "BEAM-010": _run_beam_010,
3999
+ "BEAM-011": _run_beam_011,
4000
+ "SHELL-001": _run_shell_001,
4001
+ "SHELL-002": _run_shell_002,
4002
+ "SHELL-003": _run_shell_003,
4003
+ "SHELL-004": _run_shell_004,
4004
+ "SHELL-005": _run_shell_005,
4005
+ "SHELL-006": _run_shell_006,
4006
+ "SHELL-007": _run_shell_007,
4007
+ "SHELL-008": _run_shell_008,
4008
+ "SHELL-009": _run_shell_009,
4009
+ "SHELL-010": _run_shell_010,
4010
+ "SHELL-011": _run_shell_011,
4011
+ "BENCH-001": _run_bench_001,
4012
+ "BENCH-002": _run_bench_002,
4013
+ "BENCH-003": _run_bench_003,
4014
+ "BENCH-004": _run_bench_004,
4015
+ "COUP-001": _run_coup_001,
4016
+ "COUP-002": _run_coup_002,
4017
+ "COUP-003": _run_coup_003,
4018
+ "COUP-004": _run_coup_004,
4019
+ "COUP-005": _run_coup_005,
4020
+ "COUP-006": _run_coup_006,
4021
+ "COUP-007": _run_coup_007,
4022
+ "COUP-008": _run_coup_008,
4023
+ "COUP-009": _run_coup_009,
4024
+ "COUP-010": _run_coup_010,
4025
+ "COUP-012": _run_coup_012,
4026
+ "COUP-013": _run_coup_013,
4027
+ "COUP-014": _run_coup_014,
4028
+ "COUP-015": _run_coup_015,
4029
+ "COUP-016": _run_coup_016,
4030
+ "COUP-017": _run_coup_017,
4031
+ "COUP-018": _run_coup_018,
4032
+ "COUP-019": _run_coup_019,
4033
+ "COUP-020": _run_coup_020,
4034
+ "COUP-021": _run_coup_021,
4035
+ "MLBC-001": _run_mlbc_common,
4036
+ "MLBC-002": _run_mlbc_common,
4037
+ "MLBC-003": _run_mlbc_common,
4038
+ "MLBC-004": _run_mlbc_common,
4039
+ "MLBC-005": _run_mlbc_common,
4040
+ "MLBC-006": _run_mlbc_common,
4041
+ "MLBC-007": _run_mlbc_common,
4042
+ "MLBC-008": _run_mlbc_common,
4043
+ "MLBC-009": _run_mlbc_common,
4044
+ "CONTACT-001": _run_contact_common,
4045
+ "CONTACT-002": _run_contact_common,
4046
+ "CONTACT-003": _run_contact_common,
4047
+ "CONTACT-004": _run_contact_common,
4048
+ "CONTACT-005": _run_contact_common,
4049
+ "CONTACT-006": _run_contact_common,
4050
+ "CONTACT-007": _run_contact_common,
4051
+ "CONTACT-008": _run_contact_common,
4052
+ "CONTACT-009": _run_contact_common,
4053
+ "CONTACT-010": _run_contact_common,
4054
+ "CONTACT-011": _run_contact_common,
4055
+ "CONTACT-012": _run_contact_common,
4056
+ "FRACT-001": _run_fracture_common,
4057
+ "FRACT-002": _run_fracture_common,
4058
+ "FRACT-003": _run_fracture_common,
4059
+ "FRACT-004": _run_fracture_common,
4060
+ "FRACT-005": _run_fracture_common,
4061
+ "FRACT-006": _run_fracture_common,
4062
+ "FRACT-007": _run_fracture_common,
4063
+ "FRACT-008": _run_fracture_common,
4064
+ "FRACT-009": _run_fracture_common,
4065
+ "FRACT-010": _run_fracture_common,
4066
+ "FRACT-011": _run_fracture_common,
4067
+ "FRACT-012": _run_fracture_common,
4068
+ "CYL-001": _run_cyl_001,
4069
+ "CYL-002": _run_cyl_002,
4070
+ "CYL-003": _run_cyl_003,
4071
+ "NULL-001": _run_null_001,
4072
+ "NULL-002": _run_null_002,
4073
+ "NULL-003": _run_null_003,
4074
+ "NULL-004": _run_null_004,
4075
+ "NULL-005": _run_null_005,
4076
+ "EIG-001": _run_eig_001,
4077
+ "EIG-002": _run_eig_002,
4078
+ "EIG-003": _run_eig_003,
4079
+ "EIG-004": _run_eig_004,
4080
+ "EIG-005": _run_eig_005,
4081
+ "BUC-001": _run_buc_001,
4082
+ "BUC-002": _run_buc_002,
4083
+ "BUC-003": _run_beam_010,
4084
+ "BUC-004": _run_shell_007,
4085
+ "BUC-005": _run_buc_005,
4086
+ "NLG-001": _run_nlg_001,
4087
+ "NLG-002": _run_nlg_002,
4088
+ "NLG-003": _run_nlg_003,
4089
+ "NLG-004": _run_nlg_004,
4090
+ "NLG-005": _run_nlg_005,
4091
+ "NLG-006": _run_nlg_006,
4092
+ "NLG-007": _run_nlg_007,
4093
+ "NLG-008": _run_nlg_008,
4094
+ "MAT-001": _run_mat_common,
4095
+ "MAT-002": _run_mat_common,
4096
+ "MAT-003": _run_mat_common,
4097
+ "MAT-005": _run_mat_common,
4098
+ "MAT-006": _run_mat_common,
4099
+ "MAT-007": _run_mat_common,
4100
+ "MAT-008": _run_mat_008,
4101
+ "DYN-001": _run_dyn_001,
4102
+ "EXT-001": _run_ext_001,
4103
+ "EXT-002": _run_ext_002,
4104
+ "VVR-001": _run_vvr_001,
4105
+ "PERF-001": _run_perf_001,
4106
+ "PERF-002": _run_perf_002,
4107
+ }
4108
+
4109
+
4110
+ XFAIL_REASONS: Mapping[str, str] = {
4111
+ "COUP-011": "nonmatching beam-shell coupling is optional and not claimed in this verification batch",
4112
+ "MAT-004": "kinematic hardening is not implemented; cyclic Bauschinger check is an explicit unsupported feature",
4113
+ }
4114
+
4115
+
4116
+ def _release_gate_summary(results: List[VerificationCaseResult], selected_ids: Optional[Iterable[str]] = None) -> Dict[str, Any]:
4117
+ selected = None if selected_ids is None else {str(item) for item in selected_ids}
4118
+ by_case = {result.case_id: result for result in results}
4119
+
4120
+ def build_gate(name: str, required_cases: Tuple[str, ...]) -> Dict[str, Any]:
4121
+ required = list(required_cases)
4122
+ not_evaluated = [case_id for case_id in required if case_id not in by_case]
4123
+ blockers = [
4124
+ {
4125
+ "case_id": case_id,
4126
+ "status": by_case[case_id].status,
4127
+ "test_execution_status": by_case[case_id].test_execution_status,
4128
+ "verification_completion_status": by_case[case_id].verification_completion_status,
4129
+ "release_gate_status": by_case[case_id].release_gate_status,
4130
+ "reason": by_case[case_id].reason,
4131
+ }
4132
+ for case_id in required
4133
+ if case_id in by_case and by_case[case_id].status != "PASS"
4134
+ ]
4135
+ status = "not_evaluated" if selected is not None and not_evaluated else ("passed" if not blockers and not not_evaluated else "blocked")
4136
+ verification_completion_status = "complete" if status == "passed" else "incomplete"
4137
+ return {
4138
+ "status": status,
4139
+ "test_execution_status": "failed" if any(result.status == "FAIL" for result in results) else "passed",
4140
+ "verification_completion_status": verification_completion_status,
4141
+ "release_gate_status": status,
4142
+ "required_cases": required,
4143
+ "passed_cases": [case_id for case_id in required if case_id in by_case and by_case[case_id].status == "PASS"],
4144
+ "blockers": blockers,
4145
+ "not_evaluated": not_evaluated,
4146
+ "note": f"Programme gate '{name}' is blocked unless every required case is PASS.",
4147
+ }
4148
+
4149
+ gates = {name: build_gate(name, tuple(required)) for name, required in PROGRAMME_RELEASE_GATES.items()}
4150
+ gates["thin_stiffened_shell"] = dict(gates["flat_thin_stiffened_shell"])
4151
+ gates["thin_stiffened_shell"]["alias_for"] = "flat_thin_stiffened_shell"
4152
+ gates["thin_stiffened_shell"][
4153
+ "note"
4154
+ ] = "Compatibility alias for flat_thin_stiffened_shell; isolated beam and shell checks are insufficient."
4155
+ return gates
4156
+
4157
+
4158
+ def run_beam_shell_verification(selected_ids: Optional[Iterable[str]] = None) -> Dict[str, Any]:
4159
+ selected = None if selected_ids is None else {str(item) for item in selected_ids}
4160
+ results: List[VerificationCaseResult] = []
4161
+ for case in verification_manifest_cases():
4162
+ if selected is not None and case.case_id not in selected:
4163
+ continue
4164
+ runner = IMPLEMENTATIONS.get(case.case_id)
4165
+ if runner is None:
4166
+ results.append(_xfail(case, XFAIL_REASONS.get(case.case_id, "manifest case is registered but not implemented yet")))
4167
+ continue
4168
+ try:
4169
+ results.append(runner(case))
4170
+ except Exception as exc:
4171
+ results.append(_fail(case, str(exc)))
4172
+
4173
+ counts: Dict[str, int] = {}
4174
+ for result in results:
4175
+ counts[result.status] = counts.get(result.status, 0) + 1
4176
+ required_failures = [result.case_id for result in results if result.required and result.status == "FAIL"]
4177
+ release_gates = _release_gate_summary(results, selected_ids)
4178
+ thin_gate = release_gates["flat_thin_stiffened_shell"]
4179
+ test_execution_status = "failed" if any(result.status == "FAIL" for result in results) else "passed"
4180
+ return {
4181
+ "schema_version": 1,
4182
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
4183
+ "commit": _git_sha(),
4184
+ "environment": {
4185
+ "platform": platform.platform(),
4186
+ "python": sys.version.split()[0],
4187
+ "numpy": np.__version__,
4188
+ "scipy": _package_version("scipy"),
4189
+ "numba": _package_version("numba"),
4190
+ },
4191
+ "status": "passed" if not required_failures else "failed",
4192
+ "test_execution_status": test_execution_status,
4193
+ "verification_completion_status": thin_gate["verification_completion_status"],
4194
+ "release_gate_status": thin_gate["release_gate_status"],
4195
+ "default_tolerances": dict(DEFAULT_TOLERANCES),
4196
+ "verification_programme": {
4197
+ "title": "ANYsolver Complete Verification Programme",
4198
+ "version": 1,
4199
+ "batches": {key: list(value) for key, value in PROGRAMME_BATCH_CASES.items()},
4200
+ "release_gates": {key: list(value) for key, value in PROGRAMME_RELEASE_GATES.items()},
4201
+ },
4202
+ "scope": {
4203
+ "primary_shell_regime": "thin plates and thin shells with attached beam stiffeners",
4204
+ "default_span_to_thickness": list(THIN_SHELL_SPAN_TO_THICKNESS),
4205
+ "default_min_radius_to_thickness": 100,
4206
+ "locking_sensitive_radius_to_thickness": 1000,
4207
+ "core_mixed_capabilities": [
4208
+ "coincident beam-shell coupling",
4209
+ "eccentric beam-shell coupling",
4210
+ "static stiffened-shell response",
4211
+ "stiffened-shell eigenmodes",
4212
+ "stiffened-shell linear buckling",
4213
+ "ring and longitudinal stiffeners on curved shells",
4214
+ ],
4215
+ },
4216
+ "release_gates": release_gates,
4217
+ "counts": counts,
4218
+ "required_failures": required_failures,
4219
+ "manifest_cases": [case.to_dict() for case in verification_manifest_cases()],
4220
+ "results": [result.to_dict() for result in results],
4221
+ "known_limitations": [
4222
+ "XFAIL records are explicit missing fixtures, missing traceable reference datasets, or unsupported solver features.",
4223
+ "Tier 3 and Tier 4 literature/cross-solver cases require traceable reference data and are not promoted to PASS from solver output.",
4224
+ "This report is a verification coverage ledger; release capability claims should gate on specific PASS sets.",
4225
+ ],
4226
+ }
4227
+
4228
+
4229
+ def _markdown(report: Mapping[str, Any]) -> str:
4230
+ lines = [
4231
+ "# Beam-Shell Solver Verification Report",
4232
+ "",
4233
+ f"- Status: {report.get('status')}",
4234
+ f"- Test execution status: {report.get('test_execution_status')}",
4235
+ f"- Verification completion status: {report.get('verification_completion_status')}",
4236
+ f"- Release gate status: {report.get('release_gate_status')}",
4237
+ f"- Commit: {report.get('commit') or 'unknown'}",
4238
+ "",
4239
+ "## Counts",
4240
+ "",
4241
+ ]
4242
+ for key, value in sorted(report.get("counts", {}).items()):
4243
+ lines.append(f"- {key}: {value}")
4244
+ lines.extend(["", "## Release Gates", ""])
4245
+ for name, gate in (report.get("release_gates") or {}).items():
4246
+ blockers = gate.get("blockers", []) or []
4247
+ not_evaluated = gate.get("not_evaluated", []) or []
4248
+ lines.append(f"- {name}: {gate.get('status')} ({len(blockers)} blockers, {len(not_evaluated)} not evaluated)")
4249
+ for blocker in blockers:
4250
+ reason = f" - {blocker.get('reason')}" if blocker.get("reason") else ""
4251
+ lines.append(f" - {blocker.get('case_id')} {blocker.get('status')}{reason}")
4252
+ lines.extend(["", "## Results", ""])
4253
+ for result in report.get("results", []):
4254
+ suffix = f" - {result.get('reason')}" if result.get("reason") and result.get("status") != "PASS" else ""
4255
+ lines.append(f"- {result.get('case_id')} {result.get('status')}: {result.get('title')}{suffix}")
4256
+ lines.extend(["", "## Known Limitations", ""])
4257
+ for item in report.get("known_limitations", []):
4258
+ lines.append(f"- {item}")
4259
+ return "\n".join(lines).rstrip() + "\n"
4260
+
4261
+
4262
+ def write_beam_shell_verification_report(
4263
+ output: Path | str = DEFAULT_BEAM_SHELL_VERIFICATION_PATH,
4264
+ *,
4265
+ markdown: Optional[Path | str] = None,
4266
+ selected_ids: Optional[Iterable[str]] = None,
4267
+ ) -> Dict[str, Any]:
4268
+ report = run_beam_shell_verification(selected_ids=selected_ids)
4269
+ output_path = Path(output)
4270
+ output_path.parent.mkdir(parents=True, exist_ok=True)
4271
+ output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
4272
+ if markdown is not None:
4273
+ markdown_path = Path(markdown)
4274
+ markdown_path.parent.mkdir(parents=True, exist_ok=True)
4275
+ markdown_path.write_text(_markdown(report), encoding="utf-8")
4276
+ return report