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,758 @@
1
+ """Bounded Crisfield-style arc-length continuation for nonlinear static analysis.
2
+
3
+ This module follows one proportional reference load through the first limit
4
+ point. It reuses the production nonlinear element response, constraint
5
+ transformation and committed material-state machinery from
6
+ :mod:`anysolver.nonlinear_static`.
7
+
8
+ Scope is deliberately limited to the ANYsolver capacity workflow:
9
+
10
+ * one proportional load pattern plus an optional constant preload,
11
+ * constrained models (no nonlinear free-free nullspace solve),
12
+ * geometric and material nonlinearity already supported by the elements,
13
+ * continuation only far enough beyond the peak to confirm the descending
14
+ branch.
15
+
16
+ The equilibrium equations are
17
+
18
+ R(q, lambda) = F_constant + lambda F_reference - F_internal(q) = 0
19
+
20
+ with the spherical constraint
21
+
22
+ dq.T W dq + alpha**2 dlambda**2 = ds**2.
23
+
24
+ Newton corrections use block elimination. The tangent is factorized once per
25
+ iteration and solved for two right-hand sides instead of assembling a generally
26
+ nonsymmetric bordered matrix.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import copy
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional
35
+
36
+ import numpy as np
37
+ from scipy import sparse
38
+ from scipy.sparse import linalg as sparse_linalg
39
+
40
+ from .assembly import build_constraint_transformation
41
+ from .cases import make_result_case
42
+ from .linalg import MatrixClass, factorize
43
+ from .matrix_assembly import assemble_load_vector, assemble_stiffness_matrix
44
+ from .nonlinear_static import (
45
+ _assemble_nonlinear_system,
46
+ _copy_initial_states,
47
+ _max_plastic_strain,
48
+ _nonlinear_state_summary,
49
+ solve_static_nonlinear,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from .boundary import LoadCase
54
+ from .fe_core import FEModel
55
+
56
+
57
+ _SMALL = 1.0e-14
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ArcLengthControl:
62
+ """Controls for bounded spherical arc-length continuation.
63
+
64
+ ``initial_load_increment`` is used only to construct the first arc radius
65
+ from the initial tangent direction. Thereafter the radius is adapted in
66
+ path space, not forced to produce a particular load increment.
67
+ """
68
+
69
+ initial_load_increment: float = 0.05
70
+ minimum_load_increment: float = 5.0e-4
71
+ maximum_load_increment: float = 0.20
72
+ load_scaling: Optional[float] = None
73
+ rotation_length_scale: Optional[float] = None
74
+ target_iterations: int = 5
75
+ growth_factor: float = 1.25
76
+ cutback_factor: float = 0.5
77
+ max_steps: int = 100
78
+ max_retries_per_step: int = 8
79
+ stop_after_peak_steps: int = 4
80
+ peak_drop_tolerance: float = 1.0e-3
81
+ maximum_absolute_load_factor: Optional[float] = None
82
+ preload_steps: int = 10
83
+ # Post-buckling continuation controls. When ``post_peak_load_fraction``
84
+ # is set the trace continues past the limit point and stops automatically
85
+ # once the load factor has fallen to that fraction of the recorded peak
86
+ # (set ``stop_after_peak_steps`` high to allow the descending branch).
87
+ # ``max_translation`` is an absolute displacement guard in metres on the
88
+ # largest nodal translation, protecting against runaway post-peak paths.
89
+ post_peak_load_fraction: Optional[float] = None
90
+ max_translation: Optional[float] = None
91
+
92
+ def __post_init__(self) -> None:
93
+ if self.initial_load_increment <= 0.0:
94
+ raise ValueError("initial_load_increment must be positive")
95
+ if self.minimum_load_increment <= 0.0:
96
+ raise ValueError("minimum_load_increment must be positive")
97
+ if self.maximum_load_increment < self.initial_load_increment:
98
+ raise ValueError("maximum_load_increment must be at least initial_load_increment")
99
+ if self.minimum_load_increment > self.initial_load_increment:
100
+ raise ValueError("minimum_load_increment must not exceed initial_load_increment")
101
+ if self.load_scaling is not None and self.load_scaling <= 0.0:
102
+ raise ValueError("load_scaling must be positive when supplied")
103
+ if self.rotation_length_scale is not None and self.rotation_length_scale <= 0.0:
104
+ raise ValueError("rotation_length_scale must be positive when supplied")
105
+ if self.target_iterations <= 0:
106
+ raise ValueError("target_iterations must be positive")
107
+ if self.growth_factor < 1.0:
108
+ raise ValueError("growth_factor must be at least 1.0")
109
+ if not (0.0 < self.cutback_factor < 1.0):
110
+ raise ValueError("cutback_factor must be between 0 and 1")
111
+ if self.max_steps <= 0 or self.max_retries_per_step <= 0:
112
+ raise ValueError("max_steps and max_retries_per_step must be positive")
113
+ if self.stop_after_peak_steps <= 0:
114
+ raise ValueError("stop_after_peak_steps must be positive")
115
+ if self.peak_drop_tolerance < 0.0:
116
+ raise ValueError("peak_drop_tolerance must be non-negative")
117
+ if self.maximum_absolute_load_factor is not None and self.maximum_absolute_load_factor <= 0.0:
118
+ raise ValueError("maximum_absolute_load_factor must be positive when supplied")
119
+ if self.preload_steps <= 0:
120
+ raise ValueError("preload_steps must be positive")
121
+ if self.post_peak_load_fraction is not None and not (0.0 < self.post_peak_load_fraction < 1.0):
122
+ raise ValueError("post_peak_load_fraction must be in (0, 1) when supplied")
123
+ if self.max_translation is not None and self.max_translation <= 0.0:
124
+ raise ValueError("max_translation must be positive when supplied")
125
+
126
+ def to_dict(self) -> Dict[str, Any]:
127
+ return {
128
+ "initial_load_increment": float(self.initial_load_increment),
129
+ "minimum_load_increment": float(self.minimum_load_increment),
130
+ "maximum_load_increment": float(self.maximum_load_increment),
131
+ "load_scaling": self.load_scaling,
132
+ "rotation_length_scale": self.rotation_length_scale,
133
+ "target_iterations": int(self.target_iterations),
134
+ "growth_factor": float(self.growth_factor),
135
+ "cutback_factor": float(self.cutback_factor),
136
+ "max_steps": int(self.max_steps),
137
+ "max_retries_per_step": int(self.max_retries_per_step),
138
+ "stop_after_peak_steps": int(self.stop_after_peak_steps),
139
+ "peak_drop_tolerance": float(self.peak_drop_tolerance),
140
+ "maximum_absolute_load_factor": self.maximum_absolute_load_factor,
141
+ "preload_steps": int(self.preload_steps),
142
+ "post_peak_load_fraction": self.post_peak_load_fraction,
143
+ "max_translation": self.max_translation,
144
+ }
145
+
146
+
147
+ @dataclass
148
+ class ArcLengthStep:
149
+ """One converged point on the equilibrium path."""
150
+
151
+ step_index: int
152
+ load_factor: float
153
+ iterations: int
154
+ retries: int
155
+ arc_radius: float
156
+ residual_norm: float
157
+ arc_residual: float
158
+ displacement_norm: float
159
+ load_increment: float
160
+ path_increment_norm: float
161
+ max_equivalent_plastic_strain: float
162
+ is_peak: bool = False
163
+
164
+ def to_dict(self) -> Dict[str, Any]:
165
+ return {
166
+ "step_index": int(self.step_index),
167
+ "load_factor": float(self.load_factor),
168
+ "iterations": int(self.iterations),
169
+ "retries": int(self.retries),
170
+ "arc_radius": float(self.arc_radius),
171
+ "residual_norm": float(self.residual_norm),
172
+ "arc_residual": float(self.arc_residual),
173
+ "displacement_norm": float(self.displacement_norm),
174
+ "load_increment": float(self.load_increment),
175
+ "path_increment_norm": float(self.path_increment_norm),
176
+ "max_equivalent_plastic_strain": float(self.max_equivalent_plastic_strain),
177
+ "is_peak": bool(self.is_peak),
178
+ }
179
+
180
+
181
+ @dataclass
182
+ class ArcLengthResult:
183
+ """Result from bounded arc-length continuation."""
184
+
185
+ steps: List[ArcLengthStep]
186
+ status: str
187
+ displacements: np.ndarray
188
+ load_factor: float
189
+ peak_load_factor: float
190
+ peak_step_index: Optional[int]
191
+ element_states: Dict[int, Any] = field(default_factory=dict)
192
+ info: Dict[str, Any] = field(default_factory=dict)
193
+
194
+ @property
195
+ def converged(self) -> bool:
196
+ return self.status in {
197
+ "peak_confirmed",
198
+ "maximum_steps_reached",
199
+ "load_factor_limit_reached",
200
+ "post_buckling_traced",
201
+ "displacement_limit_reached",
202
+ }
203
+
204
+ @property
205
+ def capacity_estimate(self) -> float:
206
+ return float(self.peak_load_factor)
207
+
208
+ def to_dict(self) -> Dict[str, Any]:
209
+ return {
210
+ "status": self.status,
211
+ "converged": self.converged,
212
+ "load_factor": float(self.load_factor),
213
+ "peak_load_factor": float(self.peak_load_factor),
214
+ "peak_step_index": self.peak_step_index,
215
+ "capacity_estimate": self.capacity_estimate,
216
+ "info": self.info,
217
+ "steps": [step.to_dict() for step in self.steps],
218
+ }
219
+
220
+
221
+ def _characteristic_length(model: "FEModel") -> float:
222
+ coords = np.asarray(model.mesh.get_node_coordinates(), dtype=float)
223
+ if coords.size == 0:
224
+ return 1.0
225
+ spans = np.ptp(coords, axis=0)
226
+ value = float(np.max(spans))
227
+ return value if value > _SMALL else 1.0
228
+
229
+
230
+ def _reduced_metric(model: "FEModel", T: sparse.spmatrix, rotation_length_scale: float) -> sparse.csr_matrix:
231
+ """Project a translation-equivalent full-DOF metric to reduced coordinates."""
232
+ total_dofs = model.mesh.dof_manager.total_dofs
233
+ weights = np.ones(total_dofs, dtype=float)
234
+ rotation_weight = float(rotation_length_scale) ** 2
235
+ for dof in range(total_dofs):
236
+ _node_id, local_index, _name = model.mesh.dof_manager.get_dof_info(dof)
237
+ if local_index >= 3:
238
+ weights[dof] = rotation_weight
239
+ W_full = sparse.diags(weights, format="csr")
240
+ return (T.T @ W_full @ T).tocsr()
241
+
242
+
243
+ def _metric_dot(W: sparse.spmatrix, left: np.ndarray, right: np.ndarray) -> float:
244
+ return float(np.asarray(left, dtype=float) @ np.asarray(W @ right, dtype=float))
245
+
246
+
247
+ def _metric_norm(W: sparse.spmatrix, vector: np.ndarray) -> float:
248
+ return float(np.sqrt(max(_metric_dot(W, vector, vector), 0.0)))
249
+
250
+
251
+ def _factorized_solve(matrix: sparse.spmatrix, rhs: np.ndarray, signature: str) -> np.ndarray:
252
+ handle = factorize(matrix, MatrixClass.SYMMETRIC_INDEFINITE, signature=signature)
253
+ solution = np.asarray(handle.solve(np.asarray(rhs, dtype=float)), dtype=float).reshape(-1)
254
+ if np.any(~np.isfinite(solution)):
255
+ raise np.linalg.LinAlgError("non-finite tangent solution")
256
+ return solution
257
+
258
+
259
+ def _recover_reduced_coordinates(T: sparse.spmatrix, u0: np.ndarray, displacements: np.ndarray) -> np.ndarray:
260
+ rhs = np.asarray(displacements, dtype=float).reshape(-1) - np.asarray(u0, dtype=float).reshape(-1)
261
+ result = sparse_linalg.lsqr(T, rhs, atol=1.0e-12, btol=1.0e-12)
262
+ q = np.asarray(result[0], dtype=float).reshape(-1)
263
+ mismatch = np.asarray(T @ q + u0 - displacements, dtype=float).reshape(-1)
264
+ scale = max(float(np.linalg.norm(displacements)), 1.0)
265
+ if float(np.linalg.norm(mismatch)) > 1.0e-8 * scale:
266
+ raise RuntimeError("could not recover reduced coordinates from preloaded displacement state")
267
+ return q
268
+
269
+
270
+ def _copy_model_with_imperfection(model: "FEModel", imperfection: Optional[Any]) -> "FEModel":
271
+ if imperfection is None:
272
+ return model
273
+ from .imperfections import apply_imperfection
274
+
275
+ return apply_imperfection(model, imperfection, copy_model=True)
276
+
277
+
278
+ def _max_nodal_translation(model: "FEModel", displacements: np.ndarray) -> float:
279
+ """Largest nodal translation magnitude in the displacement vector."""
280
+ peak = 0.0
281
+ size = int(displacements.size)
282
+ for node in model.mesh.nodes.values():
283
+ dofs = np.asarray(node.dofs[:3], dtype=np.intp)
284
+ if dofs.size == 0 or int(dofs.max()) >= size:
285
+ continue
286
+ value = float(np.linalg.norm(displacements[dofs]))
287
+ if value > peak:
288
+ peak = value
289
+ return peak
290
+
291
+
292
+ def solve_static_arc_length(
293
+ model: "FEModel",
294
+ load_case: "LoadCase",
295
+ *,
296
+ constant_load_case: Optional["LoadCase"] = None,
297
+ control: Optional[ArcLengthControl] = None,
298
+ max_iterations: int = 25,
299
+ tolerance: float = 1.0e-6,
300
+ arc_tolerance: float = 1.0e-6,
301
+ num_layers: int = 5,
302
+ imperfection: Optional[Any] = None,
303
+ initial_element_states: Optional[Mapping[int, Any]] = None,
304
+ progress_callback: Optional[Any] = None,
305
+ ) -> ArcLengthResult:
306
+ """Trace the first nonlinear limit point with spherical arc-length control.
307
+
308
+ The optional ``constant_load_case`` is first brought to equilibrium using
309
+ the existing adaptive force-control solver. Arc-length continuation then
310
+ scales only ``load_case``. Material states are committed only after a full
311
+ equilibrium-plus-constraint convergence.
312
+ """
313
+ if load_case is None:
314
+ raise ValueError("load_case is required for arc-length continuation")
315
+ if max_iterations <= 0:
316
+ raise ValueError("max_iterations must be positive")
317
+ if tolerance <= 0.0 or arc_tolerance <= 0.0:
318
+ raise ValueError("tolerances must be positive")
319
+ if num_layers <= 0:
320
+ raise ValueError("num_layers must be positive")
321
+
322
+ settings = control or ArcLengthControl()
323
+ start_time = time.time()
324
+ working_model = _copy_model_with_imperfection(model, imperfection)
325
+ working_model.apply_boundary_conditions()
326
+
327
+ K0, stiffness_info = assemble_stiffness_matrix(working_model)
328
+ F_prop, load_info = assemble_load_vector(working_model, load_case)
329
+ if constant_load_case is None:
330
+ F_const = np.zeros_like(F_prop)
331
+ constant_load_info = None
332
+ else:
333
+ F_const, constant_load_info = assemble_load_vector(working_model, constant_load_case)
334
+
335
+ _, _, T, u0, _, constraint_info = build_constraint_transformation(K0, F_prop, working_model)
336
+ n_red = int(T.shape[1])
337
+ assembly_info = {
338
+ "stiffness": stiffness_info,
339
+ "load": load_info,
340
+ "constant_load": constant_load_info,
341
+ "constraint_info": constraint_info,
342
+ "total_dofs": int(working_model.mesh.dof_manager.total_dofs),
343
+ "reduced_dofs": n_red,
344
+ }
345
+
346
+ info: Dict[str, Any] = {
347
+ **assembly_info,
348
+ "control": settings.to_dict(),
349
+ "num_layers": int(num_layers),
350
+ "formulation": "crisfield_spherical_block_elimination",
351
+ }
352
+ if imperfection is not None:
353
+ info["imperfection"] = getattr(working_model, "imperfection_metadata", [])
354
+
355
+ if n_red == 0:
356
+ info["failure_reason"] = "empty_reduced_system"
357
+ return ArcLengthResult([], "empty_reduced_system", u0.copy(), 0.0, 0.0, None, {}, info)
358
+
359
+ F_prop_red = np.asarray(T.T @ F_prop, dtype=float).reshape(-1)
360
+ F_const_red = np.asarray(T.T @ F_const, dtype=float).reshape(-1)
361
+ if float(np.linalg.norm(F_prop_red)) <= _SMALL:
362
+ info["failure_reason"] = "zero_reduced_reference_load"
363
+ return ArcLengthResult([], "zero_reference_load", u0.copy(), 0.0, 0.0, None, {}, info)
364
+
365
+ committed_states: Dict[int, Any] = _copy_initial_states(initial_element_states)
366
+ q = np.zeros(n_red, dtype=float)
367
+ lam = 0.0
368
+ preload_info = None
369
+
370
+ if constant_load_case is not None and float(np.linalg.norm(F_const_red)) > _SMALL:
371
+ preload = solve_static_nonlinear(
372
+ working_model,
373
+ load_case=constant_load_case,
374
+ max_load_factor=1.0,
375
+ num_steps=settings.preload_steps,
376
+ max_iterations=max_iterations,
377
+ tolerance=tolerance,
378
+ num_layers=num_layers,
379
+ initial_element_states=committed_states,
380
+ )
381
+ preload_info = preload.to_dict()
382
+ if preload.status != "completed":
383
+ info["preload"] = preload_info
384
+ info["failure_reason"] = "constant_preload_not_converged"
385
+ return ArcLengthResult(
386
+ [],
387
+ "preload_failed",
388
+ preload.displacements,
389
+ 0.0,
390
+ 0.0,
391
+ None,
392
+ preload.element_states,
393
+ info,
394
+ )
395
+ q = _recover_reduced_coordinates(T, u0, preload.displacements)
396
+ committed_states = copy.deepcopy(preload.element_states)
397
+
398
+ rotation_scale = settings.rotation_length_scale or _characteristic_length(working_model)
399
+ W = _reduced_metric(working_model, T, rotation_scale)
400
+
401
+ # Establish the first tangent direction and derive fixed path-space radius
402
+ # limits from the user-facing load-increment settings.
403
+ u = np.asarray(T @ q + u0, dtype=float).reshape(-1)
404
+ F_int, K_T, _trial_states = _assemble_nonlinear_system(
405
+ working_model, u, committed_states, num_layers, tangent=True
406
+ )
407
+ residual0 = F_const_red + lam * F_prop_red - np.asarray(T.T @ F_int, dtype=float).reshape(-1)
408
+ reference0 = max(float(np.linalg.norm(F_const_red + F_prop_red)), 1.0)
409
+ if float(np.linalg.norm(residual0)) > 10.0 * tolerance * reference0:
410
+ info["failure_reason"] = "initial_state_not_in_equilibrium"
411
+ info["initial_residual_norm"] = float(np.linalg.norm(residual0))
412
+ return ArcLengthResult([], "initial_equilibrium_failed", u, lam, lam, None, committed_states, info)
413
+
414
+ K_red = (T.T @ K_T @ T).tocsr()
415
+ try:
416
+ tangent_direction = _factorized_solve(K_red, F_prop_red, "arc_length.initial_tangent")
417
+ except Exception as exc:
418
+ info["failure_reason"] = "initial_tangent_factorization_failed"
419
+ info["factorization_error"] = str(exc)
420
+ return ArcLengthResult([], "initial_tangent_failed", u, lam, lam, None, committed_states, info)
421
+
422
+ tangent_norm = _metric_norm(W, tangent_direction)
423
+ load_scaling = float(settings.load_scaling) if settings.load_scaling is not None else max(tangent_norm, 1.0e-12)
424
+ predictor_norm = float(np.sqrt(tangent_norm * tangent_norm + load_scaling * load_scaling))
425
+ radius = settings.initial_load_increment * predictor_norm
426
+ min_radius = radius * settings.minimum_load_increment / settings.initial_load_increment
427
+ max_radius = radius * settings.maximum_load_increment / settings.initial_load_increment
428
+
429
+ steps: List[ArcLengthStep] = []
430
+ previous_dq: Optional[np.ndarray] = None
431
+ previous_dlambda: Optional[float] = None
432
+ peak_load_factor = float(lam)
433
+ peak_step_index: Optional[int] = None
434
+ max_translation = 0.0
435
+ descending_steps = 0
436
+ status = "maximum_steps_reached"
437
+ failure_reason: Optional[str] = None
438
+ total_iterations = 0
439
+ total_retries = 0
440
+ adaptation_history: List[Dict[str, Any]] = []
441
+
442
+ for step_index in range(1, settings.max_steps + 1):
443
+ q_base = q.copy()
444
+ lambda_base = float(lam)
445
+ states_base = copy.deepcopy(committed_states)
446
+ accepted = False
447
+ step_failure = "unknown"
448
+
449
+ for retry in range(settings.max_retries_per_step + 1):
450
+ total_retries += int(retry > 0)
451
+ u_base = np.asarray(T @ q_base + u0, dtype=float).reshape(-1)
452
+ F_base, K_base, _ = _assemble_nonlinear_system(
453
+ working_model, u_base, states_base, num_layers, tangent=True
454
+ )
455
+ K_base_red = (T.T @ K_base @ T).tocsr()
456
+ try:
457
+ load_direction = _factorized_solve(
458
+ K_base_red,
459
+ F_prop_red,
460
+ f"arc_length.predictor:{step_index}:{retry}",
461
+ )
462
+ except Exception:
463
+ step_failure = "singular_predictor_tangent"
464
+ radius *= settings.cutback_factor
465
+ if radius < min_radius:
466
+ break
467
+ continue
468
+
469
+ sign = 1.0
470
+ if previous_dq is not None and previous_dlambda is not None:
471
+ orientation = _metric_dot(W, previous_dq, load_direction) + (
472
+ load_scaling * load_scaling * previous_dlambda
473
+ )
474
+ sign = 1.0 if orientation >= 0.0 else -1.0
475
+
476
+ direction_norm = float(
477
+ np.sqrt(
478
+ max(_metric_dot(W, load_direction, load_direction), 0.0)
479
+ + load_scaling * load_scaling
480
+ )
481
+ )
482
+ if direction_norm <= _SMALL:
483
+ step_failure = "zero_predictor_direction"
484
+ break
485
+
486
+ dlambda_total = sign * radius / direction_norm
487
+ dq_total = dlambda_total * load_direction
488
+ q_trial = q_base + dq_total
489
+ lambda_trial = lambda_base + dlambda_total
490
+ residual_norm = float("inf")
491
+ arc_residual = float("inf")
492
+ trial_states = states_base
493
+
494
+ for iteration in range(1, max_iterations + 1):
495
+ total_iterations += 1
496
+ u_trial = np.asarray(T @ q_trial + u0, dtype=float).reshape(-1)
497
+ F_internal, K_trial, states_candidate = _assemble_nonlinear_system(
498
+ working_model, u_trial, states_base, num_layers, tangent=True
499
+ )
500
+ residual = (
501
+ F_const_red
502
+ + lambda_trial * F_prop_red
503
+ - np.asarray(T.T @ F_internal, dtype=float).reshape(-1)
504
+ )
505
+ residual_norm = float(np.linalg.norm(residual))
506
+ arc_residual = float(
507
+ _metric_dot(W, dq_total, dq_total)
508
+ + (load_scaling * dlambda_total) ** 2
509
+ - radius * radius
510
+ )
511
+ force_reference = max(
512
+ float(np.linalg.norm(F_const_red + lambda_trial * F_prop_red)),
513
+ float(np.linalg.norm(F_prop_red)),
514
+ 1.0,
515
+ )
516
+ arc_reference = max(radius * radius, 1.0e-24)
517
+
518
+ if (
519
+ residual_norm <= tolerance * force_reference
520
+ and abs(arc_residual) <= arc_tolerance * arc_reference
521
+ ):
522
+ trial_states = states_candidate
523
+ accepted = True
524
+ break
525
+
526
+ K_trial_red = (T.T @ K_trial @ T).tocsr()
527
+ try:
528
+ handle = factorize(
529
+ K_trial_red,
530
+ MatrixClass.SYMMETRIC_INDEFINITE,
531
+ signature=f"arc_length.corrector:{step_index}:{retry}:{iteration}",
532
+ )
533
+ correction_at_fixed_load = np.asarray(handle.solve(residual), dtype=float).reshape(-1)
534
+ correction_per_load = np.asarray(handle.solve(F_prop_red), dtype=float).reshape(-1)
535
+ except Exception:
536
+ step_failure = "singular_corrector_tangent"
537
+ break
538
+ if (
539
+ np.any(~np.isfinite(correction_at_fixed_load))
540
+ or np.any(~np.isfinite(correction_per_load))
541
+ ):
542
+ step_failure = "nonfinite_corrector_solution"
543
+ break
544
+
545
+ denominator = 2.0 * (
546
+ _metric_dot(W, dq_total, correction_per_load)
547
+ + load_scaling * load_scaling * dlambda_total
548
+ )
549
+ denominator_scale = max(
550
+ 2.0 * radius * max(_metric_norm(W, correction_per_load), load_scaling),
551
+ 1.0,
552
+ )
553
+ if abs(denominator) <= 1.0e-14 * denominator_scale:
554
+ step_failure = "singular_arc_constraint_linearization"
555
+ break
556
+
557
+ numerator = -arc_residual - 2.0 * _metric_dot(
558
+ W, dq_total, correction_at_fixed_load
559
+ )
560
+ dlambda_correction = numerator / denominator
561
+ dq_correction = correction_at_fixed_load + correction_per_load * dlambda_correction
562
+ if (
563
+ np.any(~np.isfinite(dq_correction))
564
+ or not np.isfinite(dlambda_correction)
565
+ ):
566
+ step_failure = "nonfinite_arc_correction"
567
+ break
568
+
569
+ q_trial += dq_correction
570
+ lambda_trial += float(dlambda_correction)
571
+ dq_total = q_trial - q_base
572
+ dlambda_total = lambda_trial - lambda_base
573
+ else:
574
+ step_failure = "maximum_iterations_reached"
575
+
576
+ if accepted:
577
+ q = q_trial
578
+ lam = float(lambda_trial)
579
+ committed_states = trial_states
580
+ u = np.asarray(T @ q + u0, dtype=float).reshape(-1)
581
+ path_increment_norm = float(
582
+ np.sqrt(
583
+ max(_metric_dot(W, dq_total, dq_total), 0.0)
584
+ + (load_scaling * dlambda_total) ** 2
585
+ )
586
+ )
587
+ is_new_peak = lam > peak_load_factor
588
+ if is_new_peak:
589
+ peak_load_factor = float(lam)
590
+ peak_step_index = step_index
591
+ descending_steps = 0
592
+ for old_step in steps:
593
+ old_step.is_peak = False
594
+ else:
595
+ required_drop = settings.peak_drop_tolerance * max(abs(peak_load_factor), 1.0)
596
+ if peak_step_index is not None and lam < peak_load_factor - required_drop:
597
+ descending_steps += 1
598
+ else:
599
+ descending_steps = 0
600
+
601
+ step = ArcLengthStep(
602
+ step_index=step_index,
603
+ load_factor=float(lam),
604
+ iterations=iteration,
605
+ retries=retry,
606
+ arc_radius=float(radius),
607
+ residual_norm=float(residual_norm),
608
+ arc_residual=float(arc_residual),
609
+ displacement_norm=float(np.linalg.norm(u)),
610
+ load_increment=float(dlambda_total),
611
+ path_increment_norm=path_increment_norm,
612
+ max_equivalent_plastic_strain=_max_plastic_strain(committed_states),
613
+ is_peak=is_new_peak,
614
+ )
615
+ steps.append(step)
616
+ previous_dq = dq_total.copy()
617
+ previous_dlambda = float(dlambda_total)
618
+ max_translation = _max_nodal_translation(working_model, u)
619
+ if progress_callback is not None:
620
+ try:
621
+ progress_callback(
622
+ {
623
+ "type": "nonlinear_static_step",
624
+ "control": "arc length",
625
+ "step_index": int(step_index),
626
+ "load_factor": float(lam),
627
+ "peak_load_factor": float(peak_load_factor),
628
+ "displacement_norm": float(np.linalg.norm(u)),
629
+ "max_translation": float(max_translation),
630
+ "iterations": int(iteration),
631
+ "max_equivalent_plastic_strain": float(step.max_equivalent_plastic_strain),
632
+ }
633
+ )
634
+ except Exception:
635
+ pass
636
+
637
+ old_radius = radius
638
+ if iteration <= max(settings.target_iterations // 2, 1):
639
+ radius = min(radius * settings.growth_factor, max_radius)
640
+ action = "grow"
641
+ elif iteration > settings.target_iterations:
642
+ radius = max(
643
+ radius * max(settings.cutback_factor, np.sqrt(settings.target_iterations / iteration)),
644
+ min_radius,
645
+ )
646
+ action = "shrink_after_slow_convergence"
647
+ else:
648
+ action = "keep"
649
+ adaptation_history.append(
650
+ {
651
+ "step_index": step_index,
652
+ "iterations": int(iteration),
653
+ "retries": int(retry),
654
+ "accepted_radius": float(old_radius),
655
+ "next_radius": float(radius),
656
+ "action": action,
657
+ }
658
+ )
659
+ break
660
+
661
+ radius *= settings.cutback_factor
662
+ adaptation_history.append(
663
+ {
664
+ "step_index": step_index,
665
+ "retry": int(retry),
666
+ "accepted": False,
667
+ "next_radius": float(radius),
668
+ "action": "cutback_after_nonconvergence",
669
+ "failure_reason": step_failure,
670
+ }
671
+ )
672
+ if radius < min_radius:
673
+ break
674
+
675
+ if not accepted:
676
+ q = q_base
677
+ lam = lambda_base
678
+ committed_states = states_base
679
+ status = "stopped_at_limit" if steps else "diverged"
680
+ failure_reason = step_failure if radius >= min_radius else "minimum_arc_radius_reached"
681
+ break
682
+
683
+ if (
684
+ settings.post_peak_load_fraction is not None
685
+ and peak_step_index is not None
686
+ and step_index > peak_step_index
687
+ and lam <= settings.post_peak_load_fraction * peak_load_factor
688
+ ):
689
+ # Automatic post-buckling stop: the descending branch has shed
690
+ # the requested fraction of the peak load, so the post-buckling
691
+ # response is traced and further continuation adds no insight.
692
+ status = "post_buckling_traced"
693
+ break
694
+ if settings.max_translation is not None and max_translation > settings.max_translation:
695
+ status = "displacement_limit_reached"
696
+ break
697
+ if descending_steps >= settings.stop_after_peak_steps:
698
+ status = "peak_confirmed"
699
+ break
700
+ if (
701
+ settings.maximum_absolute_load_factor is not None
702
+ and abs(lam) >= settings.maximum_absolute_load_factor
703
+ ):
704
+ status = "load_factor_limit_reached"
705
+ break
706
+ else:
707
+ status = "maximum_steps_reached"
708
+
709
+ u_final = np.asarray(T @ q + u0, dtype=float).reshape(-1)
710
+ info["failure_reason"] = failure_reason
711
+ info["last_converged_load_factor"] = float(lam)
712
+ info["peak_load_factor"] = float(peak_load_factor)
713
+ info["peak_step_index"] = peak_step_index
714
+ info["descending_steps_after_peak"] = int(descending_steps)
715
+ info["final_max_translation"] = float(max_translation)
716
+ info["load_scaling"] = float(load_scaling)
717
+ info["rotation_length_scale"] = float(rotation_scale)
718
+ info["initial_arc_radius"] = float(settings.initial_load_increment * predictor_norm)
719
+ info["minimum_arc_radius"] = float(min_radius)
720
+ info["maximum_arc_radius"] = float(max_radius)
721
+ info["adaptation_history"] = adaptation_history
722
+ info["strain_summary"] = _nonlinear_state_summary(committed_states)
723
+ info["preload"] = preload_info
724
+ info["total_newton_iterations"] = int(total_iterations)
725
+ info["total_retries"] = int(total_retries)
726
+ info["solve_time"] = float(time.time() - start_time)
727
+ info["result_case"] = make_result_case(
728
+ name="nonlinear_static_arc_length",
729
+ analysis_type="nonlinear_static",
730
+ load_cases=(load_case,) if constant_load_case is None else (constant_load_case, load_case),
731
+ assembly_info=assembly_info,
732
+ solver_info={"convergence_info": {"status": status, "failure_reason": failure_reason}},
733
+ recovery={
734
+ "displacements": True,
735
+ "element_states": True,
736
+ "force_displacement_history": True,
737
+ "arc_length_history": True,
738
+ },
739
+ settings={
740
+ "control": "arc_length",
741
+ "arc_length": settings.to_dict(),
742
+ "max_iterations": int(max_iterations),
743
+ "tolerance": float(tolerance),
744
+ "arc_tolerance": float(arc_tolerance),
745
+ "num_layers": int(num_layers),
746
+ },
747
+ ).to_dict()
748
+
749
+ return ArcLengthResult(
750
+ steps=steps,
751
+ status=status,
752
+ displacements=u_final,
753
+ load_factor=float(lam),
754
+ peak_load_factor=float(peak_load_factor),
755
+ peak_step_index=peak_step_index,
756
+ element_states=committed_states,
757
+ info=info,
758
+ )