ANYsolver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|