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
anysolver/buckling.py ADDED
@@ -0,0 +1,442 @@
1
+ """Linear eigenvalue buckling helpers.
2
+
3
+ The first production buckling path is intentionally modest: element routines
4
+ assemble a reference geometric stiffness matrix ``KG`` and this module solves
5
+ the constrained generalized problem
6
+
7
+ K phi = lambda KG phi
8
+
9
+ Positive eigenvalues are critical load multipliers relative to the supplied
10
+ reference compression state.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple
17
+
18
+ import numpy as np
19
+ from scipy import linalg
20
+ from scipy import sparse
21
+ from scipy.sparse import linalg as sparse_linalg
22
+
23
+ from .assembly import build_constraint_transformation, build_reduced_rigid_body_modes
24
+ from .cases import make_result_case
25
+ from .linalg import FactorizationCache, MatrixClass, cached_inverse_operator
26
+ from .matrix_assembly import assemble_geometric_stiffness_matrix, assemble_stiffness_matrix
27
+
28
+ if TYPE_CHECKING:
29
+ from .fe_core import FEModel
30
+
31
+
32
+ @dataclass
33
+ class BucklingMode:
34
+ """One positive eigenvalue buckling mode."""
35
+
36
+ mode_number: int
37
+ load_factor: float
38
+ eigenvalue: float
39
+ mode_shape: np.ndarray
40
+ reduced_mode_shape: np.ndarray
41
+ modal_stiffness: float
42
+ modal_geometric_stiffness: float
43
+ residual_norm: float = 0.0
44
+ rigid_body_correlation: float = 0.0
45
+ validity_status: str = "ok"
46
+ repeated_group: Optional[int] = None
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ return {
50
+ "mode_number": self.mode_number,
51
+ "load_factor": self.load_factor,
52
+ "eigenvalue": self.eigenvalue,
53
+ "modal_stiffness": self.modal_stiffness,
54
+ "modal_geometric_stiffness": self.modal_geometric_stiffness,
55
+ "residual_norm": self.residual_norm,
56
+ "rigid_body_correlation": self.rigid_body_correlation,
57
+ "validity_status": self.validity_status,
58
+ "repeated_group": self.repeated_group,
59
+ "mode_shape": self.mode_shape.tolist(),
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class BucklingResult:
65
+ """Result bundle from the linear eigenvalue buckling solve."""
66
+
67
+ modes: List[BucklingMode]
68
+ num_modes_requested: int
69
+ solver_status: str
70
+ constraint_info: Dict[str, Any]
71
+ assembly_info: Dict[str, Any]
72
+ result_case: Optional[Dict[str, Any]] = None
73
+ diagnostics: Optional[Dict[str, Any]] = None
74
+
75
+ @property
76
+ def num_modes_returned(self) -> int:
77
+ return len(self.modes)
78
+
79
+ @property
80
+ def critical_load_factor(self) -> Optional[float]:
81
+ if not self.modes:
82
+ return None
83
+ return self.modes[0].load_factor
84
+
85
+ def to_dict(self) -> Dict[str, Any]:
86
+ return {
87
+ "solver_status": self.solver_status,
88
+ "num_modes_requested": self.num_modes_requested,
89
+ "num_modes_returned": self.num_modes_returned,
90
+ "critical_load_factor": self.critical_load_factor,
91
+ "constraint_info": self.constraint_info,
92
+ "assembly_info": self.assembly_info,
93
+ "result_case": self.result_case,
94
+ "diagnostics": self.diagnostics or {},
95
+ "modes": [mode.to_dict() for mode in self.modes],
96
+ }
97
+
98
+
99
+ def _as_symmetric_dense(matrix: sparse.spmatrix) -> np.ndarray:
100
+ dense = np.asarray(matrix.toarray(), dtype=float)
101
+ return 0.5 * (dense + dense.T)
102
+
103
+
104
+ def _normalize_mode(full_mode: np.ndarray, reduced_mode: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
105
+ scale = float(np.max(np.abs(full_mode))) if full_mode.size else 0.0
106
+ if scale <= 0.0:
107
+ return full_mode, reduced_mode
108
+ return full_mode / scale, reduced_mode / scale
109
+
110
+
111
+ def _mode_residual(
112
+ K: sparse.spmatrix,
113
+ KG: sparse.spmatrix,
114
+ reduced_mode: np.ndarray,
115
+ load_factor: float,
116
+ ) -> float:
117
+ residual = np.asarray(K @ reduced_mode - float(load_factor) * (KG @ reduced_mode), dtype=float).reshape(-1)
118
+ denominator = max(
119
+ float(np.linalg.norm(K @ reduced_mode))
120
+ + abs(float(load_factor)) * float(np.linalg.norm(KG @ reduced_mode)),
121
+ 1.0,
122
+ )
123
+ return float(np.linalg.norm(residual) / denominator)
124
+
125
+
126
+ def _factor_in_range(value: float, load_factor_range: Optional[Tuple[Optional[float], Optional[float]]]) -> bool:
127
+ if load_factor_range is None:
128
+ return True
129
+ lower, upper = load_factor_range
130
+ if lower is not None and value < float(lower):
131
+ return False
132
+ if upper is not None and value > float(upper):
133
+ return False
134
+ return True
135
+
136
+
137
+ def _sort_key(value: float, shift_load_factor: Optional[float]) -> Tuple[float, float]:
138
+ if shift_load_factor is None:
139
+ return (float(value), 0.0)
140
+ return (abs(float(value) - float(shift_load_factor)), float(value))
141
+
142
+
143
+ def _assign_repeated_groups(modes: List[BucklingMode], tolerance: float) -> List[Dict[str, Any]]:
144
+ groups: List[Dict[str, Any]] = []
145
+ if not modes:
146
+ return groups
147
+ current = [modes[0]]
148
+ group_index = 1
149
+ for mode in modes[1:]:
150
+ reference = current[0].load_factor
151
+ relative = abs(mode.load_factor - reference) / max(abs(reference), 1.0)
152
+ if relative <= tolerance:
153
+ current.append(mode)
154
+ else:
155
+ if len(current) > 1:
156
+ for item in current:
157
+ item.repeated_group = group_index
158
+ groups.append(
159
+ {
160
+ "group": group_index,
161
+ "mode_numbers": [int(item.mode_number) for item in current],
162
+ "load_factors": [float(item.load_factor) for item in current],
163
+ "relative_spread": float(
164
+ (max(item.load_factor for item in current) - min(item.load_factor for item in current))
165
+ / max(abs(reference), 1.0)
166
+ ),
167
+ }
168
+ )
169
+ group_index += 1
170
+ current = [mode]
171
+ if len(current) > 1:
172
+ reference = current[0].load_factor
173
+ for item in current:
174
+ item.repeated_group = group_index
175
+ groups.append(
176
+ {
177
+ "group": group_index,
178
+ "mode_numbers": [int(item.mode_number) for item in current],
179
+ "load_factors": [float(item.load_factor) for item in current],
180
+ "relative_spread": float(
181
+ (max(item.load_factor for item in current) - min(item.load_factor for item in current))
182
+ / max(abs(reference), 1.0)
183
+ ),
184
+ }
185
+ )
186
+ return groups
187
+
188
+
189
+ def solve_eigenvalue_buckling(
190
+ model: "FEModel",
191
+ element_states: Optional[Any] = None,
192
+ num_modes: int = 3,
193
+ eigen_tolerance: float = 1.0e-8,
194
+ dense_size_limit: int = 200,
195
+ shift_load_factor: Optional[float] = None,
196
+ load_factor_range: Optional[Tuple[Optional[float], Optional[float]]] = None,
197
+ search_factor: int = 4,
198
+ repeated_tolerance: float = 1.0e-3,
199
+ allow_dense_fallback: bool = False,
200
+ allow_free_mechanisms: bool = False,
201
+ factorization_cache: Optional[FactorizationCache] = None,
202
+ ) -> BucklingResult:
203
+ """Solve ``K phi = lambda KG phi`` for positive buckling factors.
204
+
205
+ ``element_states`` is passed to
206
+ :func:`anysolver.matrix_assembly.assemble_geometric_stiffness_matrix`.
207
+ Beam elements accept axial reference compression and shell elements accept
208
+ membrane resultant prestress (compression positive, or tension-positive
209
+ ``membrane_forces``).
210
+ """
211
+ if num_modes <= 0:
212
+ raise ValueError("num_modes must be positive")
213
+
214
+ # Make the solve independent of whether a caller happened to run another
215
+ # constrained analysis first. Rigid-mode filtering reads the active DOF
216
+ # constraints, so boundary conditions must be applied here explicitly.
217
+ model.apply_boundary_conditions()
218
+ K, stiffness_info = assemble_stiffness_matrix(model)
219
+ KG, geometric_info = assemble_geometric_stiffness_matrix(model, element_states)
220
+ zero_load = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
221
+
222
+ K_red, _, T, _, independent_dofs, constraint_info = build_constraint_transformation(K, zero_load, model)
223
+ KG_red = (T.T @ KG @ T).tocsr()
224
+
225
+ assembly_info = {
226
+ "stiffness": stiffness_info,
227
+ "geometric_stiffness": geometric_info,
228
+ "total_dofs": model.mesh.dof_manager.total_dofs,
229
+ "reduced_dofs": int(K_red.shape[0]),
230
+ }
231
+
232
+ settings = {
233
+ "num_modes": num_modes,
234
+ "dense_size_limit": dense_size_limit,
235
+ "eigen_tolerance": eigen_tolerance,
236
+ "shift_load_factor": shift_load_factor,
237
+ "load_factor_range": None if load_factor_range is None else list(load_factor_range),
238
+ "search_factor": search_factor,
239
+ "repeated_tolerance": repeated_tolerance,
240
+ "allow_dense_fallback": allow_dense_fallback,
241
+ "allow_free_mechanisms": allow_free_mechanisms,
242
+ "factorization_cache": None if factorization_cache is None else factorization_cache.name,
243
+ }
244
+
245
+ if K_red.shape[0] == 0:
246
+ diagnostics = {"status": "empty_reduced_system"}
247
+ result_case = make_result_case(
248
+ name="linear_buckling",
249
+ analysis_type="linear_buckling",
250
+ assembly_info=assembly_info,
251
+ solver_info={"convergence_info": diagnostics},
252
+ recovery={"modes": num_modes},
253
+ settings=settings,
254
+ metadata={"prestress_state_source": "none" if element_states is None else type(element_states).__name__},
255
+ ).to_dict()
256
+ return BucklingResult([], num_modes, "empty_reduced_system", constraint_info, assembly_info, result_case, diagnostics)
257
+ if KG_red.nnz == 0:
258
+ diagnostics = {"status": "zero_geometric_stiffness"}
259
+ result_case = make_result_case(
260
+ name="linear_buckling",
261
+ analysis_type="linear_buckling",
262
+ assembly_info=assembly_info,
263
+ solver_info={"convergence_info": diagnostics},
264
+ recovery={"modes": num_modes},
265
+ settings=settings,
266
+ metadata={"prestress_state_source": "none" if element_states is None else type(element_states).__name__},
267
+ ).to_dict()
268
+ return BucklingResult([], num_modes, "zero_geometric_stiffness", constraint_info, assembly_info, result_case, diagnostics)
269
+
270
+ Q_rigid, nullspace_info = build_reduced_rigid_body_modes(model, independent_dofs, int(K.shape[0]))
271
+ assembly_info["nullspace"] = nullspace_info
272
+
273
+ # Work with the inverted symmetric pencil KG phi = mu K phi where K is
274
+ # positive definite after constraint elimination. The largest mu
275
+ # correspond to the smallest positive load factors lambda = 1/mu, and the
276
+ # symmetric formulation lets both the Lanczos and the dense solver work on
277
+ # well-posed problems (KG itself may be indefinite or singular).
278
+ n_red = int(K_red.shape[0])
279
+ K_sym = (0.5 * (K_red + K_red.T)).tocsr()
280
+ KG_sym = (0.5 * (KG_red + KG_red.T)).tocsr()
281
+ k = min(max(num_modes * max(int(search_factor), 1), num_modes + 2), n_red - 1)
282
+
283
+ eigenvectors = None
284
+ solver_kind = "not_started"
285
+ sparse_error = None
286
+ shift_invert_diagnostics: Dict[str, Any] = {"shift_invert": False}
287
+ if n_red > dense_size_limit and 1 <= k < n_red:
288
+ try:
289
+ if shift_load_factor is None:
290
+ _, eigenvectors = sparse_linalg.eigsh(KG_sym.tocsc(), k=k, M=K_sym.tocsc(), which="LA")
291
+ else:
292
+ sigma = 1.0 / float(shift_load_factor)
293
+ shift_matrix = (KG_sym - sigma * K_sym).tocsc()
294
+ cache = factorization_cache or FactorizationCache(name="buckling_shift_invert", max_entries=2)
295
+ operator, handle = cached_inverse_operator(
296
+ shift_matrix,
297
+ MatrixClass.SYMMETRIC_INDEFINITE,
298
+ cache=cache,
299
+ )
300
+ _, eigenvectors = sparse_linalg.eigsh(
301
+ KG_sym.tocsc(),
302
+ k=k,
303
+ M=K_sym.tocsc(),
304
+ sigma=sigma,
305
+ which="LM",
306
+ OPinv=operator,
307
+ )
308
+ shift_invert_diagnostics = {
309
+ "shift_invert": True,
310
+ "shift_inverse_sigma": float(sigma),
311
+ "shift_factorization": handle.diagnostics(),
312
+ "factorization_cache": cache.diagnostics(),
313
+ }
314
+ solver_kind = "sparse_scipy_eigsh_inverted_pencil"
315
+ except Exception as exc:
316
+ sparse_error = str(exc)
317
+ eigenvectors = None
318
+ if eigenvectors is None and (n_red <= dense_size_limit or allow_dense_fallback):
319
+ K_dense = _as_symmetric_dense(K_red)
320
+ KG_dense = _as_symmetric_dense(KG_red)
321
+ try:
322
+ _, eigenvectors = linalg.eigh(KG_dense, K_dense)
323
+ solver_kind = "dense_scipy_eigh_inverted_pencil"
324
+ except linalg.LinAlgError:
325
+ # Singular K (e.g. unconstrained model): fall back to the general
326
+ # nonsymmetric pencil and let the Rayleigh filtering sort it out.
327
+ _, eigenvectors = linalg.eig(K_dense, KG_dense)
328
+ solver_kind = "dense_scipy_eig_general_pencil"
329
+ if eigenvectors is None:
330
+ diagnostics = {
331
+ "status": "failed",
332
+ "solver": solver_kind,
333
+ "sparse_error": sparse_error,
334
+ "reason": "sparse eigensolve failed and dense fallback is disabled for this reduced size",
335
+ }
336
+ result_case = make_result_case(
337
+ name="linear_buckling",
338
+ analysis_type="linear_buckling",
339
+ assembly_info=assembly_info,
340
+ solver_info={"convergence_info": diagnostics},
341
+ recovery={"modes": num_modes},
342
+ settings=settings,
343
+ metadata={"prestress_state_source": "none" if element_states is None else type(element_states).__name__},
344
+ ).to_dict()
345
+ return BucklingResult([], num_modes, "failed", constraint_info, assembly_info, result_case, diagnostics)
346
+
347
+ candidates: List[tuple[float, np.ndarray, float, float, float, float]] = []
348
+ rejected: List[Dict[str, Any]] = []
349
+ for i in range(eigenvectors.shape[1]):
350
+ reduced_mode = np.asarray(np.real(eigenvectors[:, i]), dtype=float)
351
+ mode_norm = float(np.linalg.norm(reduced_mode))
352
+ if not np.isfinite(mode_norm) or mode_norm <= 0.0:
353
+ rejected.append({"root_index": int(i), "reason": "invalid_or_zero_norm"})
354
+ continue
355
+ reduced_mode = reduced_mode / mode_norm
356
+ rigid_body_correlation = float(np.max(np.abs(Q_rigid.T @ reduced_mode))) if Q_rigid.shape[1] else 0.0
357
+ if rigid_body_correlation > 0.90 and not allow_free_mechanisms:
358
+ rejected.append(
359
+ {
360
+ "root_index": int(i),
361
+ "reason": "rigid_body_mode",
362
+ "rigid_body_correlation": rigid_body_correlation,
363
+ }
364
+ )
365
+ continue
366
+ modal_geometric = float(reduced_mode @ (KG_sym @ reduced_mode))
367
+ modal_stiffness = float(reduced_mode @ (K_sym @ reduced_mode))
368
+ if modal_geometric <= eigen_tolerance or modal_stiffness <= 0.0:
369
+ rejected.append(
370
+ {
371
+ "root_index": int(i),
372
+ "reason": "nonpositive_modal_terms",
373
+ "modal_stiffness": float(modal_stiffness),
374
+ "modal_geometric_stiffness": float(modal_geometric),
375
+ }
376
+ )
377
+ continue
378
+ rayleigh_value = modal_stiffness / modal_geometric
379
+ if rayleigh_value <= eigen_tolerance or not np.isfinite(rayleigh_value):
380
+ rejected.append({"root_index": int(i), "reason": "invalid_load_factor", "load_factor": float(rayleigh_value)})
381
+ continue
382
+ if not _factor_in_range(rayleigh_value, load_factor_range):
383
+ rejected.append({"root_index": int(i), "reason": "outside_load_factor_range", "load_factor": float(rayleigh_value)})
384
+ continue
385
+ residual_norm = _mode_residual(K_sym, KG_sym, reduced_mode, rayleigh_value)
386
+ candidates.append((rayleigh_value, reduced_mode, modal_stiffness, modal_geometric, residual_norm, rigid_body_correlation))
387
+
388
+ candidates.sort(key=lambda item: _sort_key(item[0], shift_load_factor))
389
+ modes: List[BucklingMode] = []
390
+ for mode_number, (value, reduced_mode, _modal_stiffness, _modal_geometric, _residual_norm, rigid_body_correlation) in enumerate(
391
+ candidates[:num_modes],
392
+ start=1,
393
+ ):
394
+ full_mode = np.asarray(T @ reduced_mode, dtype=float).reshape(-1)
395
+ full_mode, reduced_mode = _normalize_mode(full_mode, reduced_mode)
396
+ modal_stiffness = float(reduced_mode @ (K_sym @ reduced_mode))
397
+ modal_geometric = float(reduced_mode @ (KG_sym @ reduced_mode))
398
+ residual_norm = _mode_residual(K_sym, KG_sym, reduced_mode, value)
399
+ validity = "ok" if residual_norm <= max(1.0e-6, 100.0 * eigen_tolerance) else "high_residual"
400
+ modes.append(
401
+ BucklingMode(
402
+ mode_number=mode_number,
403
+ load_factor=float(value),
404
+ eigenvalue=float(value),
405
+ mode_shape=full_mode,
406
+ reduced_mode_shape=reduced_mode,
407
+ modal_stiffness=modal_stiffness,
408
+ modal_geometric_stiffness=modal_geometric,
409
+ residual_norm=residual_norm,
410
+ rigid_body_correlation=rigid_body_correlation,
411
+ validity_status=validity,
412
+ )
413
+ )
414
+
415
+ repeated_groups = _assign_repeated_groups(modes, repeated_tolerance)
416
+ status = "ok" if modes else "no_positive_modes"
417
+ diagnostics = {
418
+ "status": status,
419
+ "solver": solver_kind,
420
+ **shift_invert_diagnostics,
421
+ "sparse_error": sparse_error,
422
+ "nullspace_rank": int(Q_rigid.shape[1]),
423
+ "nullspace_info": nullspace_info,
424
+ "free_mechanism_handling": "retained" if allow_free_mechanisms else "rigid_body_roots_filtered",
425
+ "num_roots_considered": int(eigenvectors.shape[1]),
426
+ "num_rejected_roots": int(len(rejected)),
427
+ "rejected_roots": rejected[:50],
428
+ "max_residual_norm": max((mode.residual_norm for mode in modes), default=0.0),
429
+ "repeated_mode_groups": repeated_groups,
430
+ "num_repeated_mode_groups": int(len(repeated_groups)),
431
+ "sorting": "nearest_shift" if shift_load_factor is not None else "ascending_load_factor",
432
+ }
433
+ result_case = make_result_case(
434
+ name="linear_buckling",
435
+ analysis_type="linear_buckling",
436
+ assembly_info=assembly_info,
437
+ solver_info={"convergence_info": diagnostics},
438
+ recovery={"modes": num_modes, "num_modes_returned": len(modes)},
439
+ settings=settings,
440
+ metadata={"prestress_state_source": "none" if element_states is None else type(element_states).__name__},
441
+ ).to_dict()
442
+ return BucklingResult(modes, num_modes, status, constraint_info, assembly_info, result_case, diagnostics)
@@ -0,0 +1,91 @@
1
+ """Sparse buckling validity smoke metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Tuple
8
+
9
+ import numpy as np
10
+
11
+ from .boundary import BoundaryCondition
12
+ from .buckling import solve_eigenvalue_buckling
13
+ from .elements import BeamElement
14
+ from .fe_core import FEModel
15
+
16
+ DEFAULT_BUCKLING_VALIDITY_PATH = Path("reports/buckling_validity/buckling_validity_report.json")
17
+
18
+
19
+ def _column_model(num_elements: int = 12, symmetric: bool = False) -> Tuple[FEModel, float, Dict[str, float]]:
20
+ length = 4.0
21
+ model = FEModel("buckling_validity_column")
22
+ model.add_material("steel", 210.0e9, 0.3)
23
+ for i in range(num_elements + 1):
24
+ model.add_node(i + 1, length * i / num_elements, 0.0, 0.0)
25
+ inertia_y = 4.0e-6 if symmetric else 3.0e-6
26
+ inertia_z = 4.0e-6 if symmetric else 5.0e-6
27
+ section = {"area": 0.02, "Iy": inertia_y, "Iz": inertia_z, "J": 2.0e-6}
28
+ for element_id in range(1, num_elements + 1):
29
+ model.add_element(element_id, BeamElement(element_id, [element_id, element_id + 1], "steel", section))
30
+ all_nodes = list(range(1, num_elements + 2))
31
+ end_nodes = [1, num_elements + 1]
32
+ if symmetric:
33
+ model.add_boundary_condition(BoundaryCondition("suppress_axial_torsion", all_nodes, {"ux": 0.0, "rx": 0.0}))
34
+ model.add_boundary_condition(BoundaryCondition("pinned_lateral_ends", end_nodes, {"uy": 0.0, "uz": 0.0}))
35
+ else:
36
+ model.add_boundary_condition(
37
+ BoundaryCondition("suppress_unrelated_dofs", all_nodes, {"ux": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0})
38
+ )
39
+ model.add_boundary_condition(BoundaryCondition("pinned_lateral_ends", end_nodes, {"uy": 0.0}))
40
+ return model, length, section
41
+
42
+
43
+ def generate_buckling_validity_report() -> Dict[str, Any]:
44
+ model, length, section = _column_model()
45
+ states = {element_id: {"axial_compression": 1.0} for element_id in model.mesh.elements}
46
+ result = solve_eigenvalue_buckling(model, states, num_modes=3, dense_size_limit=2)
47
+ expected = np.pi**2 * model.get_material("steel").elastic_modulus * section["Iz"] / length**2
48
+
49
+ higher = solve_eigenvalue_buckling(
50
+ model,
51
+ states,
52
+ num_modes=1,
53
+ load_factor_range=(3.0 * float(result.critical_load_factor), 5.0 * float(result.critical_load_factor)),
54
+ dense_size_limit=2,
55
+ )
56
+
57
+ symmetric, _, _ = _column_model(symmetric=True)
58
+ symmetric_states = {element_id: {"axial_compression": 1.0} for element_id in symmetric.mesh.elements}
59
+ repeated = solve_eigenvalue_buckling(symmetric, symmetric_states, num_modes=4, dense_size_limit=2, repeated_tolerance=1.0e-2)
60
+
61
+ return {
62
+ "status": "passed" if result.solver_status == "ok" and repeated.diagnostics["num_repeated_mode_groups"] >= 1 else "failed",
63
+ "euler_column": {
64
+ "critical_load_factor": result.critical_load_factor,
65
+ "expected_euler_load": float(expected),
66
+ "relative_error": abs(float(result.critical_load_factor) - float(expected)) / float(expected),
67
+ "diagnostics": result.diagnostics,
68
+ },
69
+ "higher_mode_range": {
70
+ "critical_load_factor": higher.critical_load_factor,
71
+ "ratio_to_first": float(higher.critical_load_factor) / float(result.critical_load_factor),
72
+ "diagnostics": higher.diagnostics,
73
+ },
74
+ "repeated_modes": {
75
+ "load_factors": [mode.load_factor for mode in repeated.modes],
76
+ "groups": repeated.diagnostics["repeated_mode_groups"],
77
+ "diagnostics": repeated.diagnostics,
78
+ },
79
+ "known_limitations": [
80
+ "Shifted sparse buckling now exposes an explicit cached shift-invert factorization; unshifted eigsh still uses SciPy's internal operator policy.",
81
+ "Repeated modes are grouped by load-factor proximity; deterministic basis projection is later modal/buckling work.",
82
+ "External plate, shell and cylinder buckling references remain Phase 14 validation work.",
83
+ ],
84
+ }
85
+
86
+
87
+ def write_buckling_validity_report(path: Path = DEFAULT_BUCKLING_VALIDITY_PATH) -> Dict[str, Any]:
88
+ report = generate_buckling_validity_report()
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
91
+ return report