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,110 @@
1
+ """Beam/member geometric validity evidence helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ import numpy as np
14
+
15
+ from .elements import BeamElement
16
+ from .fe_core import FEModel
17
+
18
+
19
+ DEFAULT_BEAM_VALIDITY_PATH = Path("reports/beam_validity/beam_validity_report.json")
20
+
21
+
22
+ def _git_sha() -> Optional[str]:
23
+ try:
24
+ result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
25
+ except Exception:
26
+ return None
27
+ return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
28
+
29
+
30
+ def _single_beam(corotational: bool) -> tuple[FEModel, BeamElement]:
31
+ model = FEModel("beam_validity")
32
+ model.add_material("steel", 210.0e9, 0.3)
33
+ model.add_node(1, 0.0, 0.0, 0.0)
34
+ model.add_node(2, 2.0, 0.0, 0.0)
35
+ section = {"area": 0.01, "Iy": 2.0e-6, "Iz": 1.0e-6, "J": 1.0e-6, "orientation": (0.0, 0.0, 1.0)}
36
+ if corotational:
37
+ section["geometric_nonlinearity"] = "corotational"
38
+ element = BeamElement(1, [1, 2], "steel", section)
39
+ model.add_element(1, element)
40
+ return model, element
41
+
42
+
43
+ def corotational_rigid_rotation_metric(angle_degrees: float = 35.0) -> Dict[str, Any]:
44
+ """Compare default and corotational internal force for a finite rigid rotation."""
45
+ theta = float(np.deg2rad(angle_degrees))
46
+ length = 2.0
47
+ u = np.zeros(12)
48
+ u[6] = length * np.cos(theta) - length
49
+ u[7] = length * np.sin(theta)
50
+ u[5] = theta
51
+ u[11] = theta
52
+
53
+ default_model, default_element = _single_beam(corotational=False)
54
+ corot_model, corot_element = _single_beam(corotational=True)
55
+ f_default, _k_default, _state_default = default_element.compute_nonlinear_response(default_model.mesh, default_model.get_material("steel"), u)
56
+ f_corot, _k_corot, state_corot = corot_element.compute_nonlinear_response(corot_model.mesh, corot_model.get_material("steel"), u)
57
+ return {
58
+ "angle_degrees": float(angle_degrees),
59
+ "default_force_norm": float(np.linalg.norm(f_default)),
60
+ "corotational_force_norm": float(np.linalg.norm(f_corot)),
61
+ "force_norm_ratio_corot_to_default": float(np.linalg.norm(f_corot) / max(np.linalg.norm(f_default), 1.0e-30)),
62
+ "corotational_basic_deformation_norm": float(state_corot.get("basic_deformation_norm", 0.0)),
63
+ "corotational_axial_extension": float(state_corot.get("axial_extension", 0.0)),
64
+ }
65
+
66
+
67
+ def corotational_axial_extension_metric(extension: float = 0.002) -> Dict[str, Any]:
68
+ """Check axial extension response for the corotational beam."""
69
+ model, element = _single_beam(corotational=True)
70
+ u = np.zeros(12)
71
+ u[6] = float(extension)
72
+ force, _k, state = element.compute_nonlinear_response(model.mesh, model.get_material("steel"), u)
73
+ expected = 210.0e9 * 0.01 / 2.0 * float(extension)
74
+ return {
75
+ "extension": float(extension),
76
+ "expected_end_force": float(expected),
77
+ "computed_end_force": float(force[6]),
78
+ "relative_error": float(abs(force[6] - expected) / max(abs(expected), 1.0)),
79
+ "current_length": float(state.get("current_length", 0.0)),
80
+ }
81
+
82
+
83
+ def generate_beam_validity_report() -> Dict[str, Any]:
84
+ """Generate local beam/member geometric validity report."""
85
+ return {
86
+ "schema_version": 1,
87
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
88
+ "commit": _git_sha(),
89
+ "environment": {
90
+ "platform": platform.platform(),
91
+ "python": sys.version.split()[0],
92
+ "numpy": np.__version__,
93
+ },
94
+ "corotational_v1": {
95
+ "rigid_rotation": corotational_rigid_rotation_metric(),
96
+ "axial_extension": corotational_axial_extension_metric(),
97
+ },
98
+ "known_limitations": [
99
+ "Corotational v1 is opt-in for 2-node elastic beam geometric response via cross_section['geometric_nonlinearity']='corotational'.",
100
+ "Fiber-section plasticity keeps the existing beam-column path in this batch.",
101
+ ],
102
+ }
103
+
104
+
105
+ def write_beam_validity_report(path: Path | str = DEFAULT_BEAM_VALIDITY_PATH) -> Dict[str, Any]:
106
+ report = generate_beam_validity_report()
107
+ output = Path(path)
108
+ output.parent.mkdir(parents=True, exist_ok=True)
109
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
110
+ return report
@@ -0,0 +1,495 @@
1
+ """Local FE solver infrastructure benchmark helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import tracemalloc
11
+ import importlib.metadata
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional, Tuple
14
+
15
+ import numpy as np
16
+ import scipy
17
+ from scipy import sparse
18
+
19
+ from .assembly import solve_linear, solve_linear_many
20
+ from .boundary import BoundaryCondition, FixedSupport, LoadCase
21
+ from .buckling import solve_eigenvalue_buckling
22
+ from .dynamics import TransientConfig, solve_transient_newmark
23
+ from .contact import RigidSphereImpact, SphereContactConfig, solve_transient_sphere_impact
24
+ from .elements import BeamElement
25
+ from .fe_core import FEModel
26
+ from .fracture import FractureConfig, ImpactDamageConfig, detect_new_deletions
27
+ from .matrix_assembly import assemble_load_vector, assemble_mass_matrix, assemble_stiffness_matrix
28
+ from .mesh_gen import generate_beam_mesh, generate_simple_panel_mesh
29
+ from .recovery import RecoveryConfig, ResourceConfig, recover_element_stresses_with_report
30
+ from .linalg import FactorizationCache, MatrixClass, factorize_cached
31
+
32
+
33
+ DEFAULT_BENCHMARK_PATH = Path("reports/benchmarks/fe_infrastructure_benchmarks.json")
34
+
35
+
36
+ def _git_sha() -> Optional[str]:
37
+ try:
38
+ result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
39
+ except Exception:
40
+ return None
41
+ return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
42
+
43
+
44
+ def _optional_version(package_name: str) -> Optional[str]:
45
+ try:
46
+ return importlib.metadata.version(package_name)
47
+ except importlib.metadata.PackageNotFoundError:
48
+ return None
49
+
50
+
51
+ def _measure(func: Callable[[], Dict[str, Any]]) -> Dict[str, Any]:
52
+ tracemalloc.start()
53
+ start = time.perf_counter()
54
+ try:
55
+ payload = func()
56
+ finally:
57
+ elapsed = time.perf_counter() - start
58
+ current, peak = tracemalloc.get_traced_memory()
59
+ tracemalloc.stop()
60
+ payload.setdefault("timing", {})
61
+ payload["timing"]["wall_seconds"] = float(elapsed)
62
+ payload["memory"] = {"current_bytes": int(current), "peak_bytes": int(peak)}
63
+ return payload
64
+
65
+
66
+ def _topology(model: FEModel) -> Dict[str, int]:
67
+ return {
68
+ "nodes": int(model.mesh.num_nodes),
69
+ "elements": int(model.mesh.num_elements),
70
+ "dofs": int(model.mesh.dof_manager.total_dofs),
71
+ }
72
+
73
+
74
+ def _static_beam_case() -> Dict[str, Any]:
75
+ model = generate_beam_mesh(2.0, num_divisions=8, cross_section={"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6})
76
+ load_case = LoadCase("tip_load")
77
+ load_case.add_nodal_load(9, [0.0, 0.0, -1000.0, 0.0, 0.0, 0.0])
78
+ K, stiffness_info = assemble_stiffness_matrix(model)
79
+ M, mass_info = assemble_mass_matrix(model)
80
+ F, load_info = assemble_load_vector(model, load_case)
81
+ u, solver_info = solve_linear(model, load_case)
82
+ backend = (solver_info.get("convergence_info") or {}).get("backend", {})
83
+ return {
84
+ "topology": _topology(model),
85
+ "matrix_nnz": {"K": int(K.nnz), "M": int(M.nnz)},
86
+ "load_norm": float(np.linalg.norm(F)),
87
+ "timing": {
88
+ "stiffness_assembly_seconds": float(stiffness_info.get("assembly_time", 0.0)),
89
+ "mass_assembly_seconds": float(mass_info.get("assembly_time", 0.0)),
90
+ "load_assembly_seconds": float(load_info.get("assembly_time", 0.0)),
91
+ "solve_seconds": float(solver_info.get("solve_time", 0.0)),
92
+ "factorization_seconds": float(backend.get("factorization_time", 0.0)) if isinstance(backend, dict) else 0.0,
93
+ "backend_solve_seconds": float(backend.get("solve_time", 0.0)) if isinstance(backend, dict) else 0.0,
94
+ },
95
+ "results": {"max_abs_displacement": float(np.max(np.abs(u)))},
96
+ "backend": backend,
97
+ "status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
98
+ }
99
+
100
+
101
+ def _multi_rhs_case() -> Dict[str, Any]:
102
+ model = generate_beam_mesh(2.0, num_divisions=8, cross_section={"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6})
103
+ tip = 9
104
+ load_x = LoadCase("tip_x")
105
+ load_x.add_nodal_load(tip, [100.0, 0.0, 0.0, 0.0, 0.0, 0.0])
106
+ load_z = LoadCase("tip_z")
107
+ load_z.add_nodal_load(tip, [0.0, 0.0, -100.0, 0.0, 0.0, 0.0])
108
+ load_m = LoadCase("tip_m")
109
+ load_m.add_nodal_load(tip, [0.0, 0.0, 0.0, 0.0, 50.0, 0.0])
110
+ U, info = solve_linear_many(model, [load_x, load_z, load_m])
111
+ backend = info.get("backend", {})
112
+ return {
113
+ "topology": _topology(model),
114
+ "num_rhs": 3,
115
+ "timing": {
116
+ "solve_many_seconds": float(info.get("solve_time", 0.0)),
117
+ "factorization_seconds": float(backend.get("factorization_time", 0.0)) if isinstance(backend, dict) else 0.0,
118
+ "backend_solve_seconds": float(backend.get("solve_time", 0.0)) if isinstance(backend, dict) else 0.0,
119
+ },
120
+ "results": {"solution_matrix_norm": float(np.linalg.norm(U))},
121
+ "backend": backend,
122
+ "status": str(info.get("status", "unknown")),
123
+ }
124
+
125
+
126
+ def _set_reduced_integration_for_q8r(model: FEModel) -> None:
127
+ for element in model.mesh.elements.values():
128
+ if getattr(element, "_is_8node", False):
129
+ element.reduced_integration = True
130
+
131
+
132
+ def _shell_stiffness_cold_warm_case(shell_order: str) -> Dict[str, Any]:
133
+ from .jit_compiler import JIT_DISABLED_REASON, JIT_ENABLED, jit_diagnostics
134
+
135
+ order = str(shell_order).upper()
136
+ use_8node = order in {"Q8", "Q8R", "S8", "S8R"}
137
+ model = generate_simple_panel_mesh(1.5, 1.0, 0.01, num_divisions_x=2, num_divisions_y=2, use_8node_elements=use_8node)
138
+ normalized_order = "Q8R" if order in {"Q8R", "S8R"} else ("Q8" if use_8node else "S4")
139
+ if normalized_order == "Q8R":
140
+ _set_reduced_integration_for_q8r(model)
141
+
142
+ cold_start = time.perf_counter()
143
+ K_cold, cold_info = assemble_stiffness_matrix(model)
144
+ cold_seconds = time.perf_counter() - cold_start
145
+ warm_start = time.perf_counter()
146
+ K_warm, warm_info = assemble_stiffness_matrix(model)
147
+ warm_seconds = time.perf_counter() - warm_start
148
+
149
+ denominator = max(float(sparse.linalg.norm(K_cold)), 1.0)
150
+ matrix_difference_norm = float(sparse.linalg.norm(K_cold - K_warm) / denominator)
151
+ jit = jit_diagnostics()
152
+ return {
153
+ "topology": _topology(model),
154
+ "shell_order": normalized_order,
155
+ "element_count": int(model.mesh.num_elements),
156
+ "jit_enabled": bool(JIT_ENABLED),
157
+ "jit_disabled_reason": JIT_DISABLED_REASON,
158
+ "parallel_threads": jit.get("num_threads"),
159
+ "timing": {
160
+ "cold_assembly_seconds": float(cold_seconds),
161
+ "warm_assembly_seconds": float(warm_seconds),
162
+ "warm_speedup": float(cold_seconds / warm_seconds) if warm_seconds > 0.0 else 0.0,
163
+ },
164
+ "results": {
165
+ "matrix_difference_norm": matrix_difference_norm,
166
+ "cold_diagnostics": cold_info.get("diagnostics", {}),
167
+ "warm_diagnostics": warm_info.get("diagnostics", {}),
168
+ },
169
+ "status": "completed" if matrix_difference_norm < 1.0e-12 else "failed",
170
+ }
171
+
172
+
173
+ def _shell_assembly_case() -> Dict[str, Any]:
174
+ model = generate_simple_panel_mesh(2.0, 1.0, 0.01, num_divisions_x=8, num_divisions_y=4)
175
+ load_case = LoadCase("pressure")
176
+ for element_id in model.mesh.elements:
177
+ load_case.add_pressure_load(element_id, 1000.0)
178
+ K, stiffness_info = assemble_stiffness_matrix(model)
179
+ M, mass_info = assemble_mass_matrix(model)
180
+ _F, load_info = assemble_load_vector(model, load_case)
181
+ return {
182
+ "topology": _topology(model),
183
+ "matrix_nnz": {"K": int(K.nnz), "M": int(M.nnz)},
184
+ "timing": {
185
+ "stiffness_assembly_seconds": float(stiffness_info.get("assembly_time", 0.0)),
186
+ "mass_assembly_seconds": float(mass_info.get("assembly_time", 0.0)),
187
+ "load_assembly_seconds": float(load_info.get("assembly_time", 0.0)),
188
+ },
189
+ "diagnostics": {"stiffness_symmetry": stiffness_info.get("diagnostics", {}).get("assembled_symmetry_error")},
190
+ "status": "completed",
191
+ }
192
+
193
+
194
+ def _buckling_case() -> Dict[str, Any]:
195
+ model = FEModel("benchmark_column")
196
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
197
+ section = {"area": 0.02, "Iy": 3.0e-6, "Iz": 5.0e-6, "J": 2.0e-6}
198
+ for i in range(9):
199
+ model.add_node(i + 1, 4.0 * i / 8, 0.0, 0.0)
200
+ for i in range(8):
201
+ model.add_element(i + 1, BeamElement(i + 1, [i + 1, i + 2], "steel", section))
202
+ all_nodes = list(range(1, 10))
203
+ model.add_boundary_condition(BoundaryCondition("suppress", all_nodes, {"ux": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0}))
204
+ model.add_boundary_condition(BoundaryCondition("pins", [1, 9], {"uy": 0.0}))
205
+ states = {element_id: {"axial_compression": 1.0} for element_id in model.mesh.elements}
206
+ result = solve_eigenvalue_buckling(model, states, num_modes=2)
207
+ return {
208
+ "topology": _topology(model),
209
+ "timing": {
210
+ "stiffness_assembly_seconds": float(result.assembly_info.get("stiffness", {}).get("assembly_time", 0.0)),
211
+ "geometric_assembly_seconds": float(result.assembly_info.get("geometric_stiffness", {}).get("assembly_time", 0.0)),
212
+ },
213
+ "results": {"critical_load_factor": float(result.critical_load_factor or 0.0), "num_modes": int(result.num_modes_returned)},
214
+ "status": result.solver_status,
215
+ }
216
+
217
+
218
+ def _transient_case() -> Dict[str, Any]:
219
+ model = FEModel("benchmark_sdof")
220
+ model.add_material("steel", elastic_modulus=100.0, poisson_ratio=0.3, density=2.0)
221
+ model.add_node(1, 0.0, 0.0, 0.0)
222
+ model.add_node(2, 1.0, 0.0, 0.0)
223
+ model.add_element(1, BeamElement(1, [1, 2], "steel", {"area": 1.0, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}))
224
+ model.add_boundary_condition(FixedSupport("fixed", [1]))
225
+ model.add_boundary_condition(BoundaryCondition("slider", [2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
226
+ load_case = LoadCase("step")
227
+ load_case.add_nodal_load(2, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
228
+ result = solve_transient_newmark(model, TransientConfig(dt=0.001, t_end=0.05), base_load_case=load_case)
229
+ return {
230
+ "topology": _topology(model),
231
+ "timing": {
232
+ "factorization_count": int(result.diagnostics.get("factorization_count", 0)),
233
+ "solve_count": int(result.diagnostics.get("solve_count", 0)),
234
+ },
235
+ "results": {"peak_displacement": float(result.peak_displacement), "energy_drift": float(result.diagnostics.get("max_relative_energy_drift", 0.0))},
236
+ "status": result.status,
237
+ }
238
+
239
+
240
+ def _selective_recovery_case() -> Dict[str, Any]:
241
+ model = generate_simple_panel_mesh(3.0, 2.0, 0.01, num_divisions_x=6, num_divisions_y=4)
242
+ displacement = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
243
+ recovery = RecoveryConfig(components=["von_mises"])
244
+ serial, serial_report = recover_element_stresses_with_report(
245
+ model,
246
+ displacement,
247
+ recovery,
248
+ resource_config=ResourceConfig(recovery_threads=1),
249
+ )
250
+ threaded, threaded_report = recover_element_stresses_with_report(
251
+ model,
252
+ displacement,
253
+ recovery,
254
+ resource_config=ResourceConfig(recovery_threads=2),
255
+ )
256
+ results_match = sorted(serial) == sorted(threaded) and all(
257
+ np.allclose(serial[element_id]["von_mises"], threaded[element_id]["von_mises"])
258
+ for element_id in serial
259
+ )
260
+ return {
261
+ "topology": _topology(model),
262
+ "timing": {
263
+ "serial_recovery_seconds": float(serial_report.elapsed_seconds),
264
+ "threaded_recovery_seconds": float(threaded_report.elapsed_seconds),
265
+ "observed_speedup": (
266
+ float(serial_report.elapsed_seconds / threaded_report.elapsed_seconds)
267
+ if threaded_report.elapsed_seconds > 0.0
268
+ else 0.0
269
+ ),
270
+ },
271
+ "resources": {
272
+ "serial": serial_report.to_dict(),
273
+ "threaded": threaded_report.to_dict(),
274
+ },
275
+ "results": {"num_stress_results": len(serial), "results_match": bool(results_match)},
276
+ "status": "completed" if results_match else "failed",
277
+ }
278
+
279
+
280
+ def _factorization_reuse_case() -> Dict[str, Any]:
281
+ matrix = sparse.diags([1.0, 4.0, 1.0], offsets=[-1, 0, 1], shape=(200, 200), format="csr")
282
+ rhs = np.ones(200, dtype=float)
283
+ cache = FactorizationCache(name="benchmark_factorization_reuse", max_entries=2)
284
+ first = factorize_cached(matrix, MatrixClass.SPD, cache=cache)
285
+ first.solve(rhs)
286
+ second = factorize_cached(matrix.copy(), MatrixClass.SPD, cache=cache)
287
+ second.solve(rhs)
288
+ changed = factorize_cached(matrix + sparse.eye(200, format="csr") * 0.01, MatrixClass.SPD, cache=cache)
289
+ changed.solve(rhs)
290
+ return {
291
+ "topology": {"dofs": 200, "nnz": int(matrix.nnz)},
292
+ "timing": {
293
+ "first_factorization_seconds": float(first.factorization_time),
294
+ "reused_solve_seconds_total": float(second.solve_time),
295
+ "changed_factorization_seconds": float(changed.factorization_time),
296
+ },
297
+ "cache": cache.diagnostics(),
298
+ "results": {"same_handle_reused": bool(first is second), "changed_matrix_new_handle": bool(changed is not first)},
299
+ "status": "completed" if first is second and changed is not first else "failed",
300
+ }
301
+
302
+
303
+ def _nonlinear_assembly_case() -> Dict[str, Any]:
304
+ from . import nonlinear_performance, nonlinear_static
305
+ from .nonlinear_performance_bootstrap import get_nonlinear_assembly_plan, nonlinear_performance_status
306
+
307
+ model = generate_simple_panel_mesh(1.2, 0.8, 0.01, num_divisions_x=2, num_divisions_y=2)
308
+ rng = np.random.default_rng(20260630)
309
+ displacement = rng.normal(scale=2.0e-5, size=model.mesh.dof_manager.total_dofs)
310
+ committed: Dict[int, Any] = {}
311
+ original = nonlinear_performance._ORIGINAL_ASSEMBLER
312
+ if original is None:
313
+ return {
314
+ "topology": _topology(model),
315
+ "timing": {},
316
+ "results": {},
317
+ "performance_layer": nonlinear_performance_status(),
318
+ "status": "skipped",
319
+ "reason": "legacy nonlinear assembler is unavailable",
320
+ }
321
+
322
+ start = time.perf_counter()
323
+ force_reference, tangent_reference, _states_reference = original(
324
+ model,
325
+ displacement,
326
+ committed,
327
+ 5,
328
+ tangent=True,
329
+ )
330
+ legacy_seconds = time.perf_counter() - start
331
+
332
+ plan = get_nonlinear_assembly_plan(model, 5)
333
+ start = time.perf_counter()
334
+ force_fast, tangent_fast, _states_fast = nonlinear_static._assemble_nonlinear_system(
335
+ model,
336
+ displacement,
337
+ committed,
338
+ 5,
339
+ tangent=True,
340
+ )
341
+ fast_seconds = time.perf_counter() - start
342
+
343
+ force_error = float(
344
+ np.linalg.norm(force_fast - force_reference) / max(float(np.linalg.norm(force_reference)), 1.0)
345
+ )
346
+ tangent_error = float(
347
+ sparse.linalg.norm(tangent_fast - tangent_reference) / max(float(sparse.linalg.norm(tangent_reference)), 1.0)
348
+ )
349
+ return {
350
+ "topology": _topology(model),
351
+ "timing": {
352
+ "legacy_assembly_seconds": float(legacy_seconds),
353
+ "active_fast_path_seconds": float(fast_seconds),
354
+ "observed_speedup": float(legacy_seconds / fast_seconds) if fast_seconds > 0.0 else 0.0,
355
+ },
356
+ "results": {
357
+ "relative_force_error": force_error,
358
+ "relative_tangent_error": tangent_error,
359
+ "plan_diagnostics": plan.diagnostics(),
360
+ },
361
+ "performance_layer": nonlinear_performance_status(),
362
+ "status": "completed" if force_error < 1.0e-10 and tangent_error < 1.0e-9 else "failed",
363
+ }
364
+
365
+
366
+ def _fracture_damage_case() -> Dict[str, Any]:
367
+ static_model = generate_simple_panel_mesh(2.0, 1.0, 0.01, num_divisions_x=8, num_divisions_y=4)
368
+ states: Dict[int, Any] = {}
369
+ element_ids = list(static_model.mesh.elements)
370
+ for element_id in element_ids:
371
+ states[int(element_id)] = {"alpha": np.zeros((4, 3), dtype=float)}
372
+ for element_id in element_ids[: max(1, len(element_ids) // 16)]:
373
+ states[int(element_id)]["alpha"][0, 0] = 0.02
374
+ fracture_config = FractureConfig(threshold=0.01, max_deleted_fraction=1.0)
375
+ scan_start = time.perf_counter()
376
+ deletion_records, max_utilization = detect_new_deletions(
377
+ static_model,
378
+ states,
379
+ fracture_config,
380
+ (),
381
+ step_index=1,
382
+ load_factor=1.0,
383
+ )
384
+ scan_seconds = time.perf_counter() - scan_start
385
+
386
+ impact_model = FEModel("benchmark_impact_damage_panel")
387
+ impact_model.add_material("soft", 1.0e5, 0.3, density=20.0)
388
+ impact_model.add_node(1, 0.0, 0.0, 0.0)
389
+ impact_model.add_node(2, 1.0, 0.0, 0.0)
390
+ impact_model.add_node(3, 1.0, 1.0, 0.0)
391
+ impact_model.add_node(4, 0.0, 1.0, 0.0)
392
+ from .elements import ShellElement
393
+
394
+ impact_model.add_element(1, ShellElement(1, [1, 2, 3, 4], "soft", thickness=0.05))
395
+ impact_model.add_boundary_condition(
396
+ BoundaryCondition(
397
+ "restrain_shell_nonimpact_modes",
398
+ [1, 2, 3, 4],
399
+ {"ux": 0.0, "uy": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0},
400
+ )
401
+ )
402
+ sphere = RigidSphereImpact(
403
+ "benchmark_damage",
404
+ radius=0.1,
405
+ mass=1.0,
406
+ start_point=(0.5, 0.5, 0.25),
407
+ travel_direction=(0.0, 0.0, -1.0),
408
+ speed=2.0,
409
+ )
410
+ impact_result = solve_transient_sphere_impact(
411
+ impact_model,
412
+ TransientConfig(dt=0.0025, t_end=0.12),
413
+ sphere,
414
+ SphereContactConfig(penalty_stiffness=4000.0, max_contact_iterations=40),
415
+ damage_config=ImpactDamageConfig(
416
+ capacity_basis="user",
417
+ user_capacity=1.0e6,
418
+ softening_start=0.9,
419
+ delete_at=1.0,
420
+ max_deleted_fraction=1.0,
421
+ ),
422
+ )
423
+ damage_summary = impact_result.diagnostics.get("impact_damage_summary", {})
424
+ return {
425
+ "topology": {
426
+ "static_fracture_elements": int(static_model.mesh.num_elements),
427
+ "impact_damage_elements": int(impact_model.mesh.num_elements),
428
+ },
429
+ "timing": {
430
+ "static_deletion_scan_seconds": float(scan_seconds),
431
+ "impact_wall_solver_steps": int(impact_result.diagnostics.get("num_steps", 0)),
432
+ "impact_solve_count": int(impact_result.diagnostics.get("solve_count", 0)),
433
+ "impact_factorization_count": int(impact_result.diagnostics.get("factorization_count", 0)),
434
+ "impact_eroded_matrix_rebuild_count": int(impact_result.diagnostics.get("eroded_matrix_rebuild_count", 0)),
435
+ "impact_damage_state_update_count": int(impact_result.diagnostics.get("damage_state_update_count", 0)),
436
+ },
437
+ "results": {
438
+ "static_deleted_records": len(deletion_records),
439
+ "static_max_fracture_utilization": float(max_utilization),
440
+ "impact_status": impact_result.status,
441
+ "impact_max_damage": float(damage_summary.get("max_damage", 0.0)),
442
+ "impact_deleted_count": int(damage_summary.get("deleted_count", 0)),
443
+ "sub_softening_rebuilds_skipped": int(impact_result.diagnostics.get("eroded_matrix_rebuild_count", 0)) == 0,
444
+ },
445
+ "status": "completed"
446
+ if len(deletion_records) > 0 and int(impact_result.diagnostics.get("eroded_matrix_rebuild_count", 0)) == 0
447
+ else "failed",
448
+ }
449
+
450
+
451
+ BENCHMARK_CASES: Dict[str, Callable[[], Dict[str, Any]]] = {
452
+ "static_beam": _static_beam_case,
453
+ "multi_rhs_static": _multi_rhs_case,
454
+ "shell_stiffness_S4_cold_warm": lambda: _shell_stiffness_cold_warm_case("S4"),
455
+ "shell_stiffness_Q8_cold_warm": lambda: _shell_stiffness_cold_warm_case("Q8"),
456
+ "shell_stiffness_Q8R_cold_warm": lambda: _shell_stiffness_cold_warm_case("Q8R"),
457
+ "shell_assembly": _shell_assembly_case,
458
+ "beam_column_buckling": _buckling_case,
459
+ "transient_newmark": _transient_case,
460
+ "selective_recovery": _selective_recovery_case,
461
+ "factorization_reuse": _factorization_reuse_case,
462
+ "nonlinear_assembly": _nonlinear_assembly_case,
463
+ "fracture_damage": _fracture_damage_case,
464
+ }
465
+
466
+
467
+ def run_infrastructure_benchmarks() -> Dict[str, Any]:
468
+ """Run local benchmark smoke cases and return a serializable report."""
469
+ cases = {name: _measure(builder) for name, builder in BENCHMARK_CASES.items()}
470
+ return {
471
+ "schema_version": 1,
472
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
473
+ "commit": _git_sha(),
474
+ "environment": {
475
+ "platform": platform.platform(),
476
+ "python": sys.version.split()[0],
477
+ "numpy": np.__version__,
478
+ "scipy": scipy.__version__,
479
+ "pypardiso": _optional_version("pypardiso"),
480
+ },
481
+ "cases": cases,
482
+ "known_limitations": [
483
+ "Benchmarks are local smoke measurements; compare trends on the same machine and Python environment.",
484
+ "tracemalloc reports Python allocation peaks, not full process resident memory.",
485
+ "Threaded recovery speedups are informational; deterministic correctness is the gate.",
486
+ ],
487
+ }
488
+
489
+
490
+ def write_benchmark_report(path: Path | str = DEFAULT_BENCHMARK_PATH) -> Dict[str, Any]:
491
+ report = run_infrastructure_benchmarks()
492
+ output = Path(path)
493
+ output.parent.mkdir(parents=True, exist_ok=True)
494
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
495
+ return report