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,432 @@
|
|
|
1
|
+
"""Q8 shell, beam, and mass qualification metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Sequence, Tuple, Type
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .assembly import solve_linear
|
|
14
|
+
from .boundary import BoundaryCondition, FixedSupport, LoadCase
|
|
15
|
+
from .elements import BeamElement, QuadraticBeamElement, ShellElement
|
|
16
|
+
from .fe_core import FEModel
|
|
17
|
+
from .mass_properties import calculate_mass_properties
|
|
18
|
+
from .shell_benchmarks import run_simple_supported_shell_benchmark
|
|
19
|
+
|
|
20
|
+
DEFAULT_ELEMENT_QUALIFICATION_PATH = Path("reports/element_qualification/element_qualification_report.json")
|
|
21
|
+
|
|
22
|
+
E_STEEL = 210.0e9
|
|
23
|
+
NU_STEEL = 0.3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def reference_q8_geometries() -> Dict[str, Tuple[Tuple[float, float, float], ...]]:
|
|
27
|
+
"""Representative Q8 geometries with corner and midside node ordering."""
|
|
28
|
+
square = (
|
|
29
|
+
(0.0, 0.0, 0.0),
|
|
30
|
+
(1.0, 0.0, 0.0),
|
|
31
|
+
(1.0, 1.0, 0.0),
|
|
32
|
+
(0.0, 1.0, 0.0),
|
|
33
|
+
(0.5, 0.0, 0.0),
|
|
34
|
+
(1.0, 0.5, 0.0),
|
|
35
|
+
(0.5, 1.0, 0.0),
|
|
36
|
+
(0.0, 0.5, 0.0),
|
|
37
|
+
)
|
|
38
|
+
skew = (
|
|
39
|
+
(0.0, 0.0, 0.0),
|
|
40
|
+
(1.35, 0.08, 0.0),
|
|
41
|
+
(1.08, 0.95, 0.0),
|
|
42
|
+
(-0.18, 1.12, 0.0),
|
|
43
|
+
(0.675, 0.04, 0.0),
|
|
44
|
+
(1.215, 0.515, 0.0),
|
|
45
|
+
(0.45, 1.035, 0.0),
|
|
46
|
+
(-0.09, 0.56, 0.0),
|
|
47
|
+
)
|
|
48
|
+
distorted_midside = (
|
|
49
|
+
(0.0, 0.0, 0.0),
|
|
50
|
+
(1.0, 0.0, 0.0),
|
|
51
|
+
(1.0, 1.0, 0.0),
|
|
52
|
+
(0.0, 1.0, 0.0),
|
|
53
|
+
(0.52, -0.025, 0.0),
|
|
54
|
+
(1.035, 0.47, 0.0),
|
|
55
|
+
(0.46, 1.02, 0.0),
|
|
56
|
+
(-0.025, 0.55, 0.0),
|
|
57
|
+
)
|
|
58
|
+
return {"square": square, "skew": skew, "distorted_midside": distorted_midside}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _single_q8_model(coords: Sequence[Sequence[float]], thickness: float = 0.01) -> FEModel:
|
|
62
|
+
model = FEModel("single_q8")
|
|
63
|
+
model.add_material("steel", E_STEEL, NU_STEEL, density=7850.0)
|
|
64
|
+
for node_id, coord in enumerate(coords, start=1):
|
|
65
|
+
model.add_node(node_id, float(coord[0]), float(coord[1]), float(coord[2]))
|
|
66
|
+
model.add_element(1, ShellElement(1, list(range(1, 9)), "steel", thickness))
|
|
67
|
+
return model
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _single_q8r_model(coords: Sequence[Sequence[float]], thickness: float = 0.01) -> FEModel:
|
|
71
|
+
model = FEModel("single_q8r")
|
|
72
|
+
model.add_material("steel", E_STEEL, NU_STEEL, density=7850.0)
|
|
73
|
+
for node_id, coord in enumerate(coords, start=1):
|
|
74
|
+
model.add_node(node_id, float(coord[0]), float(coord[1]), float(coord[2]))
|
|
75
|
+
model.add_element(1, ShellElement(1, list(range(1, 9)), "steel", thickness, reduced_integration=True))
|
|
76
|
+
return model
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def q8_free_mode_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
|
|
80
|
+
model = _single_q8_model(coords)
|
|
81
|
+
element = model.mesh.get_element(1)
|
|
82
|
+
K = element.compute_stiffness_matrix(model.mesh, model.get_material("steel"))
|
|
83
|
+
eig = np.linalg.eigvalsh(0.5 * (K + K.T))
|
|
84
|
+
max_eig = max(float(np.max(np.abs(eig))), 1.0)
|
|
85
|
+
threshold = 1.0e-9 * max_eig
|
|
86
|
+
return {
|
|
87
|
+
"zero_mode_count": int(np.sum(np.abs(eig) < threshold)),
|
|
88
|
+
"threshold": float(threshold),
|
|
89
|
+
"min_eigenvalue": float(np.min(eig)),
|
|
90
|
+
"max_eigenvalue": float(np.max(eig)),
|
|
91
|
+
"relative_negative_eigenvalue": float(min(np.min(eig), 0.0) / max_eig),
|
|
92
|
+
"relative_symmetry_error": float(np.linalg.norm(K - K.T) / max(np.linalg.norm(K), 1.0)),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def q8r_free_mode_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
|
|
97
|
+
model = _single_q8r_model(coords)
|
|
98
|
+
element = model.mesh.get_element(1)
|
|
99
|
+
K = element.compute_stiffness_matrix(model.mesh, model.get_material("steel"))
|
|
100
|
+
eig = np.linalg.eigvalsh(0.5 * (K + K.T))
|
|
101
|
+
max_eig = max(float(np.max(np.abs(eig))), 1.0)
|
|
102
|
+
threshold = 1.0e-9 * max_eig
|
|
103
|
+
return {
|
|
104
|
+
"zero_mode_count": int(np.sum(np.abs(eig) < threshold)),
|
|
105
|
+
"threshold": float(threshold),
|
|
106
|
+
"min_eigenvalue": float(np.min(eig)),
|
|
107
|
+
"max_eigenvalue": float(np.max(eig)),
|
|
108
|
+
"relative_negative_eigenvalue": float(min(np.min(eig), 0.0) / max_eig),
|
|
109
|
+
"relative_symmetry_error": float(np.linalg.norm(K - K.T) / max(np.linalg.norm(K), 1.0)),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def q8r_hourglass_assessment(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
|
|
114
|
+
"""Classify reduced-integration Q8 extra zero-energy modes."""
|
|
115
|
+
free_modes = q8r_free_mode_metric(coords)
|
|
116
|
+
rigid_modes = 6
|
|
117
|
+
extra_zero_modes = max(int(free_modes["zero_mode_count"]) - rigid_modes, 0)
|
|
118
|
+
passed = extra_zero_modes == 0
|
|
119
|
+
return {
|
|
120
|
+
"expected_rigid_body_modes": rigid_modes,
|
|
121
|
+
"observed_zero_modes": int(free_modes["zero_mode_count"]),
|
|
122
|
+
"extra_zero_energy_modes": extra_zero_modes,
|
|
123
|
+
"hourglass_control": "active" if passed else "insufficient",
|
|
124
|
+
"qc_status": "passed_stabilized_free_mode_check" if passed else "hourglass_modes_present",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _q8_patch_metric(coords: Sequence[Sequence[float]], *, reduced_integration: bool) -> Dict[str, Any]:
|
|
129
|
+
model = _single_q8r_model(coords) if reduced_integration else _single_q8_model(coords)
|
|
130
|
+
element = model.mesh.get_element(1)
|
|
131
|
+
material = model.get_material("steel")
|
|
132
|
+
xyz = element.get_node_coordinates(model.mesh)
|
|
133
|
+
|
|
134
|
+
eps_x, eps_y, gamma_xy = 1.2e-4, -0.4e-4, 0.7e-4
|
|
135
|
+
u = np.zeros(element.total_dofs, dtype=float)
|
|
136
|
+
for local, coord in enumerate(xyz):
|
|
137
|
+
x, y, _ = coord
|
|
138
|
+
base = local * 6
|
|
139
|
+
u[base + 0] = eps_x * x
|
|
140
|
+
u[base + 1] = eps_y * y + gamma_xy * x
|
|
141
|
+
stresses = element.compute_stresses(model.mesh, u, material)
|
|
142
|
+
E = material.elastic_modulus
|
|
143
|
+
nu = material.poisson_ratio
|
|
144
|
+
expected = {
|
|
145
|
+
"membrane_xx": E / (1.0 - nu**2) * (eps_x + nu * eps_y),
|
|
146
|
+
"membrane_yy": E / (1.0 - nu**2) * (eps_y + nu * eps_x),
|
|
147
|
+
"membrane_xy": E / (2.0 * (1.0 + nu)) * gamma_xy,
|
|
148
|
+
}
|
|
149
|
+
membrane_errors = {
|
|
150
|
+
key: float(np.max(np.abs(np.asarray(stresses[key], dtype=float) - target)) / max(abs(target), 1.0))
|
|
151
|
+
for key, target in expected.items()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
kappa_x = 1.1e-3
|
|
155
|
+
u = np.zeros(element.total_dofs, dtype=float)
|
|
156
|
+
for local, coord in enumerate(xyz):
|
|
157
|
+
u[local * 6 + 4] = kappa_x * coord[0]
|
|
158
|
+
stresses = element.compute_stresses(model.mesh, u, material)
|
|
159
|
+
bending_expected = E * element.thickness / (2.0 * (1.0 - nu**2)) * kappa_x
|
|
160
|
+
bending_values = np.asarray(stresses["bending_xx"], dtype=float)
|
|
161
|
+
|
|
162
|
+
gamma_xz = 2.0e-4
|
|
163
|
+
u = np.zeros(element.total_dofs, dtype=float)
|
|
164
|
+
for local, coord in enumerate(xyz):
|
|
165
|
+
u[local * 6 + 2] = gamma_xz * coord[0]
|
|
166
|
+
stresses = element.compute_stresses(model.mesh, u, material)
|
|
167
|
+
shear_expected = material.shear_modulus * (5.0 / 6.0) * gamma_xz
|
|
168
|
+
shear_values = np.asarray(stresses["shear_xz"], dtype=float)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"membrane_relative_errors": membrane_errors,
|
|
172
|
+
"membrane_max_relative_error": max(membrane_errors.values()),
|
|
173
|
+
"bending_relative_error": float(np.max(np.abs(bending_values - bending_expected)) / max(abs(bending_expected), 1.0)),
|
|
174
|
+
"bending_relative_spread": float((np.max(bending_values) - np.min(bending_values)) / max(abs(bending_expected), 1.0)),
|
|
175
|
+
"shear_relative_error": float(np.max(np.abs(shear_values - shear_expected)) / max(abs(shear_expected), 1.0)),
|
|
176
|
+
"shear_relative_spread": float((np.max(shear_values) - np.min(shear_values)) / max(abs(shear_expected), 1.0)),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def q8_patch_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
|
|
181
|
+
"""Affine membrane, bending, and shear patch metrics for one full-integration Q8."""
|
|
182
|
+
return _q8_patch_metric(coords, reduced_integration=False)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def q8r_patch_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
|
|
186
|
+
"""Affine membrane, bending, and shear patch metrics for one reduced-integration Q8."""
|
|
187
|
+
return _q8_patch_metric(coords, reduced_integration=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _isoparametric_reference_area(element: Any, coords: Sequence[Sequence[float]]) -> float:
|
|
191
|
+
"""True midsurface area of the (possibly curved-edge) isoparametric element.
|
|
192
|
+
|
|
193
|
+
A Q8 with displaced midside nodes has curved edges, so its true area
|
|
194
|
+
genuinely differs from the straight-edged corner-quad area; a consistent
|
|
195
|
+
mass matrix must integrate the curved geometry.
|
|
196
|
+
"""
|
|
197
|
+
from numpy.polynomial.legendre import leggauss
|
|
198
|
+
|
|
199
|
+
points, weights = leggauss(8)
|
|
200
|
+
c = np.asarray(coords, dtype=float)
|
|
201
|
+
area = 0.0
|
|
202
|
+
for xi, wx in zip(points, weights):
|
|
203
|
+
for eta, wy in zip(points, weights):
|
|
204
|
+
_N, dN_dxi, dN_deta = element.compute_shape_functions(float(xi), float(eta))
|
|
205
|
+
j_xi = dN_dxi @ c
|
|
206
|
+
j_eta = dN_deta @ c
|
|
207
|
+
area += float(np.linalg.norm(np.cross(j_xi, j_eta))) * float(wx) * float(wy)
|
|
208
|
+
return area
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def q8_mass_metric(coords: Sequence[Sequence[float]], thickness: float = 0.01) -> Dict[str, Any]:
|
|
213
|
+
model = _single_q8_model(coords, thickness=thickness)
|
|
214
|
+
element = model.mesh.get_element(1)
|
|
215
|
+
props = calculate_mass_properties(model)
|
|
216
|
+
expected = 7850.0 * thickness * _isoparametric_reference_area(element, coords)
|
|
217
|
+
corner_expected = 7850.0 * thickness
|
|
218
|
+
if len(coords) == 8 and np.allclose(np.asarray(coords, dtype=float)[:, 2], 0.0):
|
|
219
|
+
corners = np.asarray(coords[:4], dtype=float)
|
|
220
|
+
area = 0.5 * np.linalg.norm(np.cross(corners[1] - corners[0], corners[2] - corners[0]))
|
|
221
|
+
area += 0.5 * np.linalg.norm(np.cross(corners[2] - corners[0], corners[3] - corners[0]))
|
|
222
|
+
corner_expected *= area
|
|
223
|
+
return {
|
|
224
|
+
"total_mass": float(props.total_mass),
|
|
225
|
+
"expected_mass_from_isoparametric_area": float(expected),
|
|
226
|
+
"expected_mass_from_corner_area": float(corner_expected),
|
|
227
|
+
"relative_mass_error": float(abs(props.total_mass - expected) / max(abs(expected), 1.0)),
|
|
228
|
+
"corner_area_mass_deviation": float(abs(props.total_mass - corner_expected) / max(abs(corner_expected), 1.0)),
|
|
229
|
+
"assembled_translation_masses": props.assembled_translation_masses,
|
|
230
|
+
"center_of_mass": props.center_of_mass.tolist(),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def q8r_mass_metric(coords: Sequence[Sequence[float]], thickness: float = 0.01) -> Dict[str, Any]:
|
|
235
|
+
model = _single_q8r_model(coords, thickness=thickness)
|
|
236
|
+
element = model.mesh.get_element(1)
|
|
237
|
+
props = calculate_mass_properties(model)
|
|
238
|
+
expected = 7850.0 * thickness * _isoparametric_reference_area(element, coords)
|
|
239
|
+
corner_expected = 7850.0 * thickness
|
|
240
|
+
if len(coords) == 8 and np.allclose(np.asarray(coords, dtype=float)[:, 2], 0.0):
|
|
241
|
+
corners = np.asarray(coords[:4], dtype=float)
|
|
242
|
+
area = 0.5 * np.linalg.norm(np.cross(corners[1] - corners[0], corners[2] - corners[0]))
|
|
243
|
+
area += 0.5 * np.linalg.norm(np.cross(corners[2] - corners[0], corners[3] - corners[0]))
|
|
244
|
+
corner_expected *= area
|
|
245
|
+
return {
|
|
246
|
+
"total_mass": float(props.total_mass),
|
|
247
|
+
"expected_mass_from_isoparametric_area": float(expected),
|
|
248
|
+
"expected_mass_from_corner_area": float(corner_expected),
|
|
249
|
+
"relative_mass_error": float(abs(props.total_mass - expected) / max(abs(expected), 1.0)),
|
|
250
|
+
"corner_area_mass_deviation": float(abs(props.total_mass - corner_expected) / max(abs(corner_expected), 1.0)),
|
|
251
|
+
"assembled_translation_masses": props.assembled_translation_masses,
|
|
252
|
+
"center_of_mass": props.center_of_mass.tolist(),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def q4_q8_convergence_cost_sweep(divisions: Sequence[int] = (2, 4)) -> Tuple[Dict[str, Any], ...]:
|
|
257
|
+
rows: List[Dict[str, Any]] = []
|
|
258
|
+
for div in divisions:
|
|
259
|
+
q4 = run_simple_supported_shell_benchmark(divisions_x=int(div), divisions_y=int(div), use_8node_elements=False)
|
|
260
|
+
q8 = run_simple_supported_shell_benchmark(divisions_x=int(div), divisions_y=int(div), use_8node_elements=True)
|
|
261
|
+
rows.append(
|
|
262
|
+
{
|
|
263
|
+
"division": int(div),
|
|
264
|
+
"q4_nodes": int(q4.node_count),
|
|
265
|
+
"q8_nodes": int(q8.node_count),
|
|
266
|
+
"q4_elements": int(q4.element_count),
|
|
267
|
+
"q8_elements": int(q8.element_count),
|
|
268
|
+
"q4_dofs": int(q4.node_count * 6),
|
|
269
|
+
"q8_dofs": int(q8.node_count * 6),
|
|
270
|
+
"q4_displacement": float(q4.max_out_of_plane_displacement),
|
|
271
|
+
"q8_displacement": float(q8.max_out_of_plane_displacement),
|
|
272
|
+
"displacement_ratio_q4_to_q8": float(q4.max_out_of_plane_displacement / max(q8.max_out_of_plane_displacement, 1.0e-30)),
|
|
273
|
+
"q4_von_mises": float(q4.max_von_mises_stress),
|
|
274
|
+
"q8_von_mises": float(q8.max_von_mises_stress),
|
|
275
|
+
"stress_ratio_q4_to_q8": float(q4.max_von_mises_stress / max(q8.max_von_mises_stress, 1.0e-30)),
|
|
276
|
+
"q4_status": q4.solver_status,
|
|
277
|
+
"q8_status": q8.solver_status,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
return tuple(rows)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cantilever_model(element_cls: Type[BeamElement], axis: str, n_elem: int = 12) -> Tuple[FEModel, int, Dict[str, float]]:
|
|
284
|
+
length = 2.0
|
|
285
|
+
section = {
|
|
286
|
+
"area": 0.01,
|
|
287
|
+
"Iy": 8.0e-6,
|
|
288
|
+
"Iz": 1.0e-6,
|
|
289
|
+
"J": 1.0e-6,
|
|
290
|
+
"shear_factor_y": 5.0 / 6.0,
|
|
291
|
+
"shear_factor_z": 5.0 / 6.0,
|
|
292
|
+
"orientation": (0.0, 0.0, 1.0),
|
|
293
|
+
}
|
|
294
|
+
model = FEModel(f"{element_cls.__name__}_{axis}_cantilever")
|
|
295
|
+
model.add_material("steel", E_STEEL, NU_STEEL, density=7850.0)
|
|
296
|
+
num_nodes = n_elem + 1 if element_cls is BeamElement else 2 * n_elem + 1
|
|
297
|
+
for i in range(num_nodes):
|
|
298
|
+
s = length * i / (num_nodes - 1)
|
|
299
|
+
model.add_node(i + 1, s if axis == "X" else 0.0, s if axis == "Y" else 0.0, 0.0)
|
|
300
|
+
if element_cls is BeamElement:
|
|
301
|
+
for e in range(n_elem):
|
|
302
|
+
model.add_element(e + 1, BeamElement(e + 1, [e + 1, e + 2], "steel", section))
|
|
303
|
+
else:
|
|
304
|
+
for e in range(n_elem):
|
|
305
|
+
base = 2 * e + 1
|
|
306
|
+
model.add_element(e + 1, QuadraticBeamElement(e + 1, [base, base + 1, base + 2], "steel", section))
|
|
307
|
+
model.add_boundary_condition(FixedSupport("fixed", [1]))
|
|
308
|
+
return model, num_nodes, section
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def beam_qualification_metrics() -> Dict[str, Any]:
|
|
312
|
+
length = 2.0
|
|
313
|
+
force = 1.0e4
|
|
314
|
+
shear = 5.0 / 6.0
|
|
315
|
+
G = E_STEEL / (2.0 * (1.0 + NU_STEEL))
|
|
316
|
+
rows = []
|
|
317
|
+
for element_cls in (BeamElement, QuadraticBeamElement):
|
|
318
|
+
for axis in ("X", "Y"):
|
|
319
|
+
model, tip, section = _cantilever_model(element_cls, axis)
|
|
320
|
+
load = LoadCase("strong_axis_tip")
|
|
321
|
+
load.add_nodal_load(tip, [0.0, 0.0, force, 0.0, 0.0, 0.0])
|
|
322
|
+
u, info = solve_linear(model, load)
|
|
323
|
+
observed = float(u[model.mesh.get_node(tip).dofs[2]])
|
|
324
|
+
expected = force * length**3 / (3.0 * E_STEEL * section["Iy"]) + force * length / (shear * G * section["area"])
|
|
325
|
+
rows.append(
|
|
326
|
+
{
|
|
327
|
+
"element": element_cls.__name__,
|
|
328
|
+
"axis": axis,
|
|
329
|
+
"case": "strong_axis_tip",
|
|
330
|
+
"observed": observed,
|
|
331
|
+
"expected": expected,
|
|
332
|
+
"relative_error": abs(observed - expected) / expected,
|
|
333
|
+
"solver_status": str((info.get("convergence_info") or {}).get("status", "unknown")),
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
model, tip, section = _cantilever_model(BeamElement, "X")
|
|
337
|
+
torque = 100.0
|
|
338
|
+
load = LoadCase("torsion")
|
|
339
|
+
load.add_nodal_load(tip, [0.0, 0.0, 0.0, torque, 0.0, 0.0])
|
|
340
|
+
u, info = solve_linear(model, load)
|
|
341
|
+
theta = float(u[model.mesh.get_node(tip).dofs[3]])
|
|
342
|
+
expected_theta = torque * length / (G * section["J"])
|
|
343
|
+
rows.append(
|
|
344
|
+
{
|
|
345
|
+
"element": "BeamElement",
|
|
346
|
+
"axis": "X",
|
|
347
|
+
"case": "torsion",
|
|
348
|
+
"observed": theta,
|
|
349
|
+
"expected": expected_theta,
|
|
350
|
+
"relative_error": abs(theta - expected_theta) / expected_theta,
|
|
351
|
+
"solver_status": str((info.get("convergence_info") or {}).get("status", "unknown")),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
mass_model, _, _ = _cantilever_model(BeamElement, "X", n_elem=1)
|
|
355
|
+
mass_props = calculate_mass_properties(mass_model)
|
|
356
|
+
return {
|
|
357
|
+
"response": rows,
|
|
358
|
+
"max_relative_error": max(row["relative_error"] for row in rows),
|
|
359
|
+
"beam_mass": {
|
|
360
|
+
"total_mass": mass_props.total_mass,
|
|
361
|
+
"assembled_translation_masses": mass_props.assembled_translation_masses,
|
|
362
|
+
"center_of_mass": mass_props.center_of_mass.tolist(),
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def generate_element_qualification_report() -> Dict[str, Any]:
|
|
368
|
+
geometries = reference_q8_geometries()
|
|
369
|
+
q8_metrics: Dict[str, Any] = {}
|
|
370
|
+
q8r_metrics: Dict[str, Any] = {}
|
|
371
|
+
for name, coords in geometries.items():
|
|
372
|
+
entry = {
|
|
373
|
+
"free_modes": q8_free_mode_metric(coords),
|
|
374
|
+
"mass": q8_mass_metric(coords),
|
|
375
|
+
}
|
|
376
|
+
entry_r = {
|
|
377
|
+
"free_modes": q8r_free_mode_metric(coords),
|
|
378
|
+
"hourglass_assessment": q8r_hourglass_assessment(coords),
|
|
379
|
+
"mass": q8r_mass_metric(coords),
|
|
380
|
+
}
|
|
381
|
+
if name == "square":
|
|
382
|
+
entry["patch"] = q8_patch_metric(coords)
|
|
383
|
+
entry_r["patch"] = q8r_patch_metric(coords)
|
|
384
|
+
else:
|
|
385
|
+
model = _single_q8_model(coords)
|
|
386
|
+
element = model.mesh.get_element(1)
|
|
387
|
+
K = element.compute_stiffness_matrix(model.mesh, model.get_material("steel"))
|
|
388
|
+
entry["distortion"] = {
|
|
389
|
+
"stiffness_finite": bool(np.all(np.isfinite(K))),
|
|
390
|
+
"relative_symmetry_error": float(np.linalg.norm(K - K.T) / max(np.linalg.norm(K), 1.0)),
|
|
391
|
+
}
|
|
392
|
+
q8_metrics[name] = entry
|
|
393
|
+
q8r_metrics[name] = entry_r
|
|
394
|
+
return {
|
|
395
|
+
"schema_version": 1,
|
|
396
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
397
|
+
"environment": {
|
|
398
|
+
"platform": platform.platform(),
|
|
399
|
+
"python": platform.python_version(),
|
|
400
|
+
"numpy": np.__version__,
|
|
401
|
+
},
|
|
402
|
+
"q8": {
|
|
403
|
+
"geometry_metrics": q8_metrics,
|
|
404
|
+
"q4_q8_convergence_cost_sweep": list(q4_q8_convergence_cost_sweep()),
|
|
405
|
+
},
|
|
406
|
+
"q8r": {
|
|
407
|
+
"qc_status": (
|
|
408
|
+
"hourglass_modes_present"
|
|
409
|
+
if any(
|
|
410
|
+
metric["hourglass_assessment"]["extra_zero_energy_modes"] > 0
|
|
411
|
+
for metric in q8r_metrics.values()
|
|
412
|
+
)
|
|
413
|
+
else "passed_stabilized_free_mode_check"
|
|
414
|
+
),
|
|
415
|
+
"geometry_metrics": q8r_metrics,
|
|
416
|
+
},
|
|
417
|
+
"beam": beam_qualification_metrics(),
|
|
418
|
+
"known_limitations": [
|
|
419
|
+
"Q8 metrics are local algebraic and internal benchmark evidence, not external CalculiX validation.",
|
|
420
|
+
"Q8R/S8R reduced integration uses nullspace-projection hourglass stabilization; broad production use still needs external benchmark coverage for representative distorted shell panels.",
|
|
421
|
+
"2-node beam mass defaults to lumped but has an opt-in consistent-mass path; 3-node beam mass is consistently integrated.",
|
|
422
|
+
"Profile-aware beam fibers cover supplied web/flange dimensions; arbitrary profile geometry and fiber shear/torsion plasticity remain outside scope.",
|
|
423
|
+
],
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def write_element_qualification_report(path: Path | str = DEFAULT_ELEMENT_QUALIFICATION_PATH) -> Dict[str, Any]:
|
|
428
|
+
report = generate_element_qualification_report()
|
|
429
|
+
output = Path(path)
|
|
430
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
432
|
+
return report
|