ANYsolver 0.1.0__py3-none-any.whl

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