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/contact.py ADDED
@@ -0,0 +1,3461 @@
1
+ """Limited rigid-sphere structural impact dynamics.
2
+
3
+ The contact scope is one rigid sphere with frictionless normal penalty contact
4
+ against shell midsurface/thickness-offset targets and opt-in beam-axis segment
5
+ targets. Optional linear or material-nonlinear structural response and several
6
+ engineering damage/erosion screens are available. This is not arbitrary
7
+ contact, profile-resolved beam contact, or crack propagation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
14
+
15
+ import numpy as np
16
+ from scipy import sparse
17
+
18
+ from .assembly import build_constraint_transformation, reconstruct_full_solution
19
+ from .boundary import BoundaryCondition, LoadCase
20
+ from .cases import make_result_case
21
+ from .dynamics import (
22
+ TransientConfig,
23
+ _full_initial_vector,
24
+ _node_dof_indices,
25
+ _reduced_load,
26
+ _saved_step_count,
27
+ _time_grid,
28
+ _translation_peak,
29
+ )
30
+ from .elements import ShellElement
31
+ from .fracture import (
32
+ DeletedElementRecord,
33
+ ImpactDamageConfig,
34
+ ImpactFractureConfig,
35
+ PlasticImpactDamageConfig,
36
+ element_fracture_category,
37
+ element_measure,
38
+ filtered_load_case_for_deleted_elements,
39
+ fracture_summary,
40
+ state_equivalent_plastic_strain,
41
+ state_rtcl_increment,
42
+ )
43
+ from .linalg import MatrixClass, factorize
44
+ from .matrix_assembly import assemble_load_vector, assemble_mass_matrix, assemble_stiffness_matrix
45
+ from .recovery import enforce_memory_limit, estimate_model_memory, recovery_metadata
46
+ from .validation import ProductionValidationIssue, ProductionValidationReport, load_vector_resultant
47
+
48
+ if TYPE_CHECKING:
49
+ from .fe_core import FEModel
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RigidSphereImpact:
54
+ """Input state for a rigid sphere impact."""
55
+
56
+ name: str
57
+ radius: float
58
+ mass: float
59
+ start_point: Sequence[float]
60
+ travel_direction: Sequence[float]
61
+ speed: float
62
+ t_start: float = 0.0
63
+
64
+ def __post_init__(self) -> None:
65
+ if self.radius <= 0.0:
66
+ raise ValueError("sphere radius must be positive")
67
+ if self.mass <= 0.0:
68
+ raise ValueError("sphere mass must be positive")
69
+ if self.speed < 0.0:
70
+ raise ValueError("sphere speed must be non-negative")
71
+ if self.t_start < 0.0:
72
+ raise ValueError("sphere t_start must be non-negative")
73
+ start = np.asarray(self.start_point, dtype=float).reshape(-1)
74
+ direction = np.asarray(self.travel_direction, dtype=float).reshape(-1)
75
+ if start.shape != (3,):
76
+ raise ValueError("sphere start_point must contain three coordinates")
77
+ if direction.shape != (3,):
78
+ raise ValueError("sphere travel_direction must contain three coordinates")
79
+ if float(np.linalg.norm(direction)) <= 0.0:
80
+ raise ValueError("sphere travel_direction must be non-zero")
81
+
82
+ @property
83
+ def direction_unit(self) -> np.ndarray:
84
+ direction = np.asarray(self.travel_direction, dtype=float)
85
+ return direction / float(np.linalg.norm(direction))
86
+
87
+ @property
88
+ def initial_position(self) -> np.ndarray:
89
+ return np.asarray(self.start_point, dtype=float).copy()
90
+
91
+ @property
92
+ def travel_velocity(self) -> np.ndarray:
93
+ return self.direction_unit * float(self.speed)
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SphereContactConfig:
98
+ """Penalty-contact controls for rigid-sphere impact."""
99
+
100
+ penalty_stiffness: Optional[float] = None
101
+ contact_damping: float = 0.0
102
+ search_margin: float = 0.0
103
+ max_contact_iterations: int = 25
104
+ penetration_tolerance: float = 1.0e-8
105
+ force_tolerance: float = 1.0e-6
106
+ side_mode: str = "both"
107
+ contact_surface: str = "midsurface"
108
+ signed_surface_offset: float = 0.0
109
+ target_penetration_fraction: float = 0.01
110
+ auto_penalty_safety_factor: float = 1.0
111
+ max_sphere_travel_fraction: float = 0.25
112
+ max_event_substeps: int = 16
113
+ max_active_contacts: int = 1
114
+ load_patch_radius_factor: float = 1.25
115
+ min_load_patch_nodes: int = 4
116
+ max_load_patch_nodes: int = 12
117
+ save_contact_history: bool = True
118
+ post_separation_time: float = 0.0
119
+ contact_relaxation: str = "aitken"
120
+ beam_contact: bool = False
121
+
122
+ def __post_init__(self) -> None:
123
+ if self.penalty_stiffness is not None and self.penalty_stiffness <= 0.0:
124
+ raise ValueError("penalty_stiffness must be positive")
125
+ if self.contact_damping < 0.0:
126
+ raise ValueError("contact_damping must be non-negative")
127
+ if self.search_margin < 0.0:
128
+ raise ValueError("search_margin must be non-negative")
129
+ if self.max_contact_iterations <= 0:
130
+ raise ValueError("max_contact_iterations must be positive")
131
+ if self.penetration_tolerance <= 0.0:
132
+ raise ValueError("penetration_tolerance must be positive")
133
+ if self.force_tolerance <= 0.0:
134
+ raise ValueError("force_tolerance must be positive")
135
+ if self.side_mode != "both":
136
+ raise NotImplementedError("SphereContactConfig v1 supports side_mode='both' only")
137
+ if self.contact_surface not in {"midsurface", "top", "bottom", "signed"}:
138
+ raise ValueError("contact_surface must be 'midsurface', 'top', 'bottom', or 'signed'")
139
+ if self.contact_surface == "signed" and abs(float(self.signed_surface_offset)) > 1.0:
140
+ raise ValueError("signed_surface_offset must be in [-1, 1] as a fraction of half thickness")
141
+ if self.target_penetration_fraction <= 0.0:
142
+ raise ValueError("target_penetration_fraction must be positive")
143
+ if self.auto_penalty_safety_factor <= 0.0:
144
+ raise ValueError("auto_penalty_safety_factor must be positive")
145
+ if self.max_sphere_travel_fraction <= 0.0:
146
+ raise ValueError("max_sphere_travel_fraction must be positive")
147
+ if self.max_event_substeps <= 0:
148
+ raise ValueError("max_event_substeps must be positive")
149
+ if self.max_active_contacts <= 0:
150
+ raise ValueError("max_active_contacts must be positive")
151
+ if self.load_patch_radius_factor <= 0.0:
152
+ raise ValueError("load_patch_radius_factor must be positive")
153
+ if self.min_load_patch_nodes <= 0:
154
+ raise ValueError("min_load_patch_nodes must be positive")
155
+ if self.max_load_patch_nodes < self.min_load_patch_nodes:
156
+ raise ValueError("max_load_patch_nodes must be greater than or equal to min_load_patch_nodes")
157
+ if self.post_separation_time < 0.0:
158
+ raise ValueError("post_separation_time must be non-negative")
159
+ if self.contact_relaxation not in {"aitken", "none"}:
160
+ raise ValueError("contact_relaxation must be 'aitken' or 'none'")
161
+
162
+ def to_dict(self) -> Dict[str, Any]:
163
+ return {
164
+ "penalty_stiffness": None if self.penalty_stiffness is None else float(self.penalty_stiffness),
165
+ "contact_damping": float(self.contact_damping),
166
+ "search_margin": float(self.search_margin),
167
+ "max_contact_iterations": int(self.max_contact_iterations),
168
+ "penetration_tolerance": float(self.penetration_tolerance),
169
+ "force_tolerance": float(self.force_tolerance),
170
+ "side_mode": self.side_mode,
171
+ "contact_surface": self.contact_surface,
172
+ "signed_surface_offset": float(self.signed_surface_offset),
173
+ "target_penetration_fraction": float(self.target_penetration_fraction),
174
+ "auto_penalty_safety_factor": float(self.auto_penalty_safety_factor),
175
+ "max_sphere_travel_fraction": float(self.max_sphere_travel_fraction),
176
+ "max_event_substeps": int(self.max_event_substeps),
177
+ "max_active_contacts": int(self.max_active_contacts),
178
+ "load_patch_radius_factor": float(self.load_patch_radius_factor),
179
+ "min_load_patch_nodes": int(self.min_load_patch_nodes),
180
+ "max_load_patch_nodes": int(self.max_load_patch_nodes),
181
+ "save_contact_history": bool(self.save_contact_history),
182
+ "post_separation_time": float(self.post_separation_time),
183
+ "contact_relaxation": self.contact_relaxation,
184
+ "beam_contact": bool(self.beam_contact),
185
+ }
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class NonlinearTransientConfig:
190
+ """Material/geometric nonlinear implicit Newmark controls for impact."""
191
+
192
+ enabled: bool = False
193
+ num_layers: int = 5
194
+ max_iterations: int = 20
195
+ residual_tolerance: float = 2.0e-3
196
+ displacement_tolerance: float = 1.0e-8
197
+ contact_force_tolerance: float = 2.0e-3
198
+ line_search: bool = True
199
+ min_line_search_factor: float = 0.125
200
+ max_cutbacks: int = 12
201
+ min_dt: float = 1.0e-8
202
+ tangent_reuse_iterations: int = 0
203
+ record_element_state_history: bool = True
204
+ kinematics: str = "von_karman"
205
+ # Energy-based stagnation acceptance: when the Newton iterate is a fixed
206
+ # point (displacement increment and contact change below their limits)
207
+ # and the residual's energy content |delta . r| is a negligible fraction
208
+ # of the impact energy, the step is accepted instead of burning cutbacks
209
+ # on a residual criterion that cannot be met (e.g. force/tangent noise
210
+ # floors on rotational equations). Set to 0 to disable.
211
+ stagnation_energy_tolerance: float = 1.0e-8
212
+ # Start the transient from the static equilibrium of the base load case
213
+ # instead of applying it as a sudden step (which rings the structure at
214
+ # its axial period through the whole impact).
215
+ equilibrate_base_load: bool = True
216
+
217
+ def __post_init__(self) -> None:
218
+ if str(self.kinematics).lower() not in {"von_karman", "corotational"}:
219
+ raise ValueError("NonlinearTransientConfig.kinematics must be 'von_karman' or 'corotational'")
220
+ object.__setattr__(self, "kinematics", str(self.kinematics).lower())
221
+ if self.num_layers <= 0:
222
+ raise ValueError("NonlinearTransientConfig.num_layers must be positive")
223
+ if self.max_iterations <= 0:
224
+ raise ValueError("NonlinearTransientConfig.max_iterations must be positive")
225
+ if self.residual_tolerance <= 0.0:
226
+ raise ValueError("NonlinearTransientConfig.residual_tolerance must be positive")
227
+ if self.displacement_tolerance <= 0.0:
228
+ raise ValueError("NonlinearTransientConfig.displacement_tolerance must be positive")
229
+ if self.contact_force_tolerance <= 0.0:
230
+ raise ValueError("NonlinearTransientConfig.contact_force_tolerance must be positive")
231
+ if not (0.0 < self.min_line_search_factor <= 1.0):
232
+ raise ValueError("NonlinearTransientConfig.min_line_search_factor must be in (0, 1]")
233
+ if self.max_cutbacks < 0:
234
+ raise ValueError("NonlinearTransientConfig.max_cutbacks must be non-negative")
235
+ if self.min_dt <= 0.0:
236
+ raise ValueError("NonlinearTransientConfig.min_dt must be positive")
237
+ if self.tangent_reuse_iterations < 0:
238
+ raise ValueError("NonlinearTransientConfig.tangent_reuse_iterations must be non-negative")
239
+ if self.stagnation_energy_tolerance < 0.0:
240
+ raise ValueError("NonlinearTransientConfig.stagnation_energy_tolerance must be non-negative")
241
+
242
+ def to_dict(self) -> Dict[str, Any]:
243
+ return {
244
+ "enabled": bool(self.enabled),
245
+ "num_layers": int(self.num_layers),
246
+ "max_iterations": int(self.max_iterations),
247
+ "residual_tolerance": float(self.residual_tolerance),
248
+ "displacement_tolerance": float(self.displacement_tolerance),
249
+ "contact_force_tolerance": float(self.contact_force_tolerance),
250
+ "line_search": bool(self.line_search),
251
+ "min_line_search_factor": float(self.min_line_search_factor),
252
+ "max_cutbacks": int(self.max_cutbacks),
253
+ "min_dt": float(self.min_dt),
254
+ "tangent_reuse_iterations": int(self.tangent_reuse_iterations),
255
+ "record_element_state_history": bool(self.record_element_state_history),
256
+ "kinematics": self.kinematics,
257
+ "stagnation_energy_tolerance": float(self.stagnation_energy_tolerance),
258
+ "equilibrate_base_load": bool(self.equilibrate_base_load),
259
+ }
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class SphereContactRecord:
264
+ """One active contact point at a saved or iterated state."""
265
+
266
+ element_id: int
267
+ local_coordinates: Tuple[float, float]
268
+ contact_point: np.ndarray
269
+ normal: np.ndarray
270
+ penetration: float
271
+ normal_force: float
272
+ sphere_force: np.ndarray
273
+ structure_force: np.ndarray
274
+ contact_classification: str = "face"
275
+ nodal_forces: Mapping[int, np.ndarray] = field(default_factory=dict)
276
+
277
+ def to_dict(self) -> Dict[str, Any]:
278
+ return {
279
+ "element_id": int(self.element_id),
280
+ "local_coordinates": [float(self.local_coordinates[0]), float(self.local_coordinates[1])],
281
+ "contact_point": self.contact_point.tolist(),
282
+ "normal": self.normal.tolist(),
283
+ "penetration": float(self.penetration),
284
+ "normal_force": float(self.normal_force),
285
+ "sphere_force": self.sphere_force.tolist(),
286
+ "structure_force": self.structure_force.tolist(),
287
+ "contact_classification": self.contact_classification,
288
+ "nodal_forces": {int(node_id): value.tolist() for node_id, value in self.nodal_forces.items()},
289
+ }
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class SphereImpactResult:
294
+ """Structural transient histories plus rigid-sphere contact histories."""
295
+
296
+ times: np.ndarray
297
+ displacements: np.ndarray
298
+ velocities: np.ndarray
299
+ accelerations: np.ndarray
300
+ node_histories: Dict[int, np.ndarray]
301
+ sphere_positions: np.ndarray
302
+ sphere_velocities: np.ndarray
303
+ sphere_accelerations: np.ndarray
304
+ contact_force_history: np.ndarray
305
+ active_contact_history: Tuple[Tuple[Dict[str, Any], ...], ...]
306
+ load_impulse: np.ndarray
307
+ force_impulse: np.ndarray
308
+ moment_impulse: np.ndarray
309
+ sphere_impulse: np.ndarray
310
+ max_penetration: float
311
+ max_penetration_ratio: float
312
+ peak_contact_force: float
313
+ contact_duration: float
314
+ sphere_momentum_balance_error: float
315
+ peak_displacement: float
316
+ peak_displacement_node: Optional[int]
317
+ status: str
318
+ diagnostics: Dict[str, Any]
319
+ result_case: Optional[Dict[str, Any]] = None
320
+
321
+
322
+ def _project_local_coordinates(
323
+ element: ShellElement, coords: np.ndarray, point: np.ndarray
324
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
325
+ triangular = element.num_nodes in (3, 6)
326
+ if triangular:
327
+ local = np.array([1.0 / 3.0, 1.0 / 3.0], dtype=float)
328
+ else:
329
+ local = np.zeros(2, dtype=float)
330
+ # Closest-point Newton projection. A closed-form 2x2 solve and a
331
+ # contact-appropriate tolerance (1e-9 in natural coordinates) keep this
332
+ # inner loop cheap -- it is the dominant cost of penalty contact assembly.
333
+ N, dN_dxi, dN_deta = element.compute_shape_functions(float(local[0]), float(local[1]))
334
+ for _ in range(8):
335
+ surface_point = N @ coords
336
+ j0 = dN_dxi @ coords
337
+ j1 = dN_deta @ coords
338
+ residual = surface_point - point
339
+ a = float(j0 @ j0)
340
+ b = float(j0 @ j1)
341
+ d = float(j1 @ j1)
342
+ r0 = float(j0 @ residual)
343
+ r1 = float(j1 @ residual)
344
+ det = a * d - b * b
345
+ if abs(det) <= 1.0e-30:
346
+ break
347
+ delta0 = (d * r0 - b * r1) / det
348
+ delta1 = (a * r1 - b * r0) / det
349
+ local[0] -= delta0
350
+ local[1] -= delta1
351
+ if triangular:
352
+ local[0] = max(float(local[0]), 0.0)
353
+ local[1] = max(float(local[1]), 0.0)
354
+ total = float(local[0] + local[1])
355
+ if total > 1.0:
356
+ local /= total
357
+ else:
358
+ local[0] = min(max(float(local[0]), -1.0), 1.0)
359
+ local[1] = min(max(float(local[1]), -1.0), 1.0)
360
+ N, dN_dxi, dN_deta = element.compute_shape_functions(float(local[0]), float(local[1]))
361
+ if delta0 * delta0 + delta1 * delta1 < 1.0e-18:
362
+ break
363
+ surface_point = N @ coords
364
+ return local, N, dN_dxi, dN_deta, surface_point
365
+
366
+
367
+ def _contact_classification(element: ShellElement, local: np.ndarray, tol: float = 1.0e-7) -> str:
368
+ if element.num_nodes in (3, 6):
369
+ bary = (float(local[0]), float(local[1]), float(1.0 - local[0] - local[1]))
370
+ near = sum(1 for value in bary if abs(value) <= tol)
371
+ if near >= 2:
372
+ return "corner"
373
+ if near == 1:
374
+ return "edge"
375
+ return "face"
376
+ on_x = abs(abs(float(local[0])) - 1.0) <= tol
377
+ on_y = abs(abs(float(local[1])) - 1.0) <= tol
378
+ if on_x and on_y:
379
+ return "corner"
380
+ if on_x or on_y:
381
+ return "edge"
382
+ return "face"
383
+
384
+
385
+ def _surface_offset(element: ShellElement, config: SphereContactConfig) -> float:
386
+ thickness = float(getattr(element, "thickness", 0.0) or 0.0)
387
+ if config.contact_surface == "top":
388
+ return 0.5 * thickness
389
+ if config.contact_surface == "bottom":
390
+ return -0.5 * thickness
391
+ if config.contact_surface == "signed":
392
+ return float(config.signed_surface_offset) * 0.5 * thickness
393
+ return 0.0
394
+
395
+
396
+ def _shell_contact_candidates(model: "FEModel") -> List[ShellElement]:
397
+ return [element for element in model.mesh.elements.values() if isinstance(element, ShellElement)]
398
+
399
+
400
+ def _beam_contact_candidates(model: "FEModel") -> List[Any]:
401
+ from .elements import BeamElement
402
+
403
+ return [element for element in model.mesh.elements.values() if isinstance(element, BeamElement)]
404
+
405
+
406
+ def _beam_contact_radius(element: Any) -> float:
407
+ """Circular contact-radius proxy for a beam section.
408
+
409
+ ``cross_section["contact_radius"]`` takes precedence; the default is the
410
+ equivalent-area circle radius, an engineering proxy for the physical
411
+ stiffener/girder profile.
412
+ """
413
+ section = getattr(element, "cross_section", {}) or {}
414
+ explicit = section.get("contact_radius")
415
+ if explicit is not None:
416
+ return max(float(explicit), 0.0)
417
+ area = float(section.get("area", 0.0) or 0.0)
418
+ return float(np.sqrt(max(area, 0.0) / np.pi))
419
+
420
+
421
+ class _ContactGeometry:
422
+ """Mesh-constant shell contact geometry arrays, cached on the mesh revision.
423
+
424
+ The contact load assembly runs inside contact/Newton iterations, so anything
425
+ that only depends on the undeformed mesh (node coordinates, translation DOF
426
+ indices, element connectivity, representative edge length) is precomputed
427
+ once and reused until the mesh revision changes.
428
+ """
429
+
430
+ __slots__ = (
431
+ "elements",
432
+ "element_ids",
433
+ "node_ids",
434
+ "node_id_to_slot",
435
+ "node_base",
436
+ "node_dofs",
437
+ "element_node_slots",
438
+ "element_node_counts",
439
+ "node_mask",
440
+ "representative_edge_length",
441
+ "beam_segment_slots",
442
+ "beam_segment_radii",
443
+ "beam_segment_element_ids",
444
+ )
445
+
446
+ def __init__(self, model: "FEModel"):
447
+ elements = _shell_contact_candidates(model)
448
+ beams = _beam_contact_candidates(model)
449
+ self.elements = elements
450
+ self.element_ids = np.asarray([int(element.element_id) for element in elements], dtype=np.int64)
451
+ node_id_to_slot: Dict[int, int] = {}
452
+ for element in elements:
453
+ for node_id in element.node_ids:
454
+ node_id_to_slot.setdefault(int(node_id), len(node_id_to_slot))
455
+ for beam in beams:
456
+ for node_id in beam.node_ids:
457
+ node_id_to_slot.setdefault(int(node_id), len(node_id_to_slot))
458
+ self.node_id_to_slot = node_id_to_slot
459
+ self.node_ids = np.asarray(list(node_id_to_slot), dtype=np.int64)
460
+ n_nodes = len(node_id_to_slot)
461
+ self.node_base = np.zeros((max(n_nodes, 1), 3), dtype=float)
462
+ self.node_dofs = np.zeros((max(n_nodes, 1), 3), dtype=np.intp)
463
+ for node_id, slot in node_id_to_slot.items():
464
+ node = model.mesh.get_node(node_id)
465
+ self.node_base[slot] = (node.x, node.y, node.z)
466
+ self.node_dofs[slot] = np.asarray(node.dofs[:3], dtype=np.intp)
467
+ n_elem = len(elements)
468
+ max_nodes = max((element.num_nodes for element in elements), default=1)
469
+ self.element_node_slots = np.zeros((max(n_elem, 1), max_nodes), dtype=np.intp)
470
+ self.element_node_counts = np.zeros(max(n_elem, 1), dtype=np.intp)
471
+ lengths: List[float] = []
472
+ for index, element in enumerate(elements):
473
+ slots = [node_id_to_slot[int(node_id)] for node_id in element.node_ids]
474
+ self.element_node_counts[index] = len(slots)
475
+ self.element_node_slots[index, : len(slots)] = slots
476
+ self.element_node_slots[index, len(slots):] = slots[0]
477
+ corner_count = 3 if element.num_nodes in (3, 6) else 4
478
+ corners = self.node_base[slots[:corner_count]]
479
+ for corner in range(corner_count):
480
+ lengths.append(float(np.linalg.norm(corners[(corner + 1) % corner_count] - corners[corner])))
481
+ self.node_mask = np.arange(max_nodes)[None, :] < self.element_node_counts[:, None]
482
+ self.representative_edge_length = float(np.median(lengths)) if lengths else 0.0
483
+ # Beam contact segments: 2-node beams contribute one segment, 3-node
484
+ # quadratic beams two (end-mid, mid-end). Only used when
485
+ # SphereContactConfig.beam_contact is enabled.
486
+ segment_slots: List[Tuple[int, int]] = []
487
+ segment_radii: List[float] = []
488
+ segment_element_ids: List[int] = []
489
+ for beam in beams:
490
+ radius = _beam_contact_radius(beam)
491
+ slots = [node_id_to_slot[int(node_id)] for node_id in beam.node_ids]
492
+ if len(slots) == 2:
493
+ pairs = [(slots[0], slots[1])]
494
+ elif len(slots) == 3:
495
+ pairs = [(slots[0], slots[1]), (slots[1], slots[2])]
496
+ else:
497
+ continue
498
+ for pair in pairs:
499
+ segment_slots.append(pair)
500
+ segment_radii.append(radius)
501
+ segment_element_ids.append(int(beam.element_id))
502
+ self.beam_segment_slots = np.asarray(segment_slots, dtype=np.intp).reshape(-1, 2)
503
+ self.beam_segment_radii = np.asarray(segment_radii, dtype=float)
504
+ self.beam_segment_element_ids = np.asarray(segment_element_ids, dtype=np.int64)
505
+
506
+ def deformed_node_positions(self, displacement: Optional[np.ndarray]) -> np.ndarray:
507
+ if displacement is None:
508
+ return self.node_base
509
+ return self.node_base + np.asarray(displacement, dtype=float)[self.node_dofs]
510
+
511
+ def element_centroids_and_radii(self, node_positions: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
512
+ """Return per-element deformed node blocks, centroids and bounding radii."""
513
+ element_nodes = node_positions[self.element_node_slots]
514
+ counts = np.maximum(self.element_node_counts, 1)
515
+ sums = np.where(self.node_mask[..., None], element_nodes, 0.0).sum(axis=1)
516
+ centroids = sums / counts[:, None]
517
+ deltas = np.where(self.node_mask[..., None], element_nodes - centroids[:, None, :], 0.0)
518
+ radii = np.sqrt((deltas**2).sum(axis=2)).max(axis=1)
519
+ return element_nodes, centroids, radii
520
+
521
+ def active_element_mask(self, deleted_element_ids: Sequence[int]) -> np.ndarray:
522
+ if not deleted_element_ids:
523
+ return np.ones(self.element_ids.shape[0], dtype=bool)
524
+ deleted = np.asarray(sorted({int(element_id) for element_id in deleted_element_ids}), dtype=np.int64)
525
+ return ~np.isin(self.element_ids, deleted)
526
+
527
+
528
+ def _contact_geometry(model: "FEModel") -> _ContactGeometry:
529
+ mesh = model.mesh
530
+ signature = mesh.revision_signature()
531
+ cached = getattr(mesh, "_contact_geometry_cache", None)
532
+ if cached is not None and cached[0] == signature:
533
+ return cached[1]
534
+ geometry = _ContactGeometry(model)
535
+ mesh._contact_geometry_cache = (signature, geometry)
536
+ return geometry
537
+
538
+
539
+ def _contact_patch_nodal_weights(
540
+ geometry: _ContactGeometry,
541
+ node_positions: np.ndarray,
542
+ centroids: np.ndarray,
543
+ radii: np.ndarray,
544
+ active_mask: np.ndarray,
545
+ contact_point: np.ndarray,
546
+ contact_config: SphereContactConfig,
547
+ sphere: RigidSphereImpact,
548
+ penetration: float,
549
+ ) -> Dict[int, float]:
550
+ """Return smooth local nodal weights (keyed by node slot) for one contact force."""
551
+ patch_radius = max(
552
+ np.sqrt(max(float(sphere.radius) * float(penetration), 0.0)),
553
+ float(contact_config.load_patch_radius_factor) * max(float(geometry.representative_edge_length), 1.0e-12),
554
+ 1.0e-12,
555
+ )
556
+ point = np.asarray(contact_point, dtype=float).reshape(3)
557
+ near = active_mask & (np.linalg.norm(centroids - point[None, :], axis=1) <= patch_radius + radii)
558
+ if not near.any():
559
+ return {}
560
+ slots = np.unique(geometry.element_node_slots[near][geometry.node_mask[near]])
561
+ distances = np.linalg.norm(node_positions[slots] - point[None, :], axis=1)
562
+ order = np.argsort(distances, kind="stable")
563
+ slots = slots[order]
564
+ distances = distances[order]
565
+ within = int(np.searchsorted(distances, patch_radius, side="right"))
566
+ min_nodes = min(int(contact_config.min_load_patch_nodes), slots.shape[0])
567
+ selected_count = max(within, min_nodes)
568
+ max_nodes = max(int(contact_config.max_load_patch_nodes), min_nodes)
569
+ selected_count = min(selected_count, max_nodes)
570
+ if selected_count <= 0:
571
+ return {}
572
+ slots = slots[:selected_count]
573
+ selected_distances = distances[:selected_count]
574
+ # Compactly supported kernel: the weight decays to zero at the selection
575
+ # boundary, so a node entering or leaving the patch as the deformed
576
+ # geometry changes does not discontinuously redistribute the contact load.
577
+ # A hard-cutoff kernel (e.g. a truncated Gaussian) makes the contact
578
+ # fixed-point map discontinuous and produces non-converging chatter cycles.
579
+ support_radius = max(patch_radius, float(selected_distances[-1]) * (1.0 + 1.0e-6), 1.0e-12)
580
+ ratios = np.minimum(selected_distances / support_radius, 1.0)
581
+ raw = (1.0 - ratios * ratios) ** 2
582
+ total = float(raw.sum())
583
+ if total <= 0.0:
584
+ raw = np.full(selected_count, 1.0 / selected_count)
585
+ total = float(selected_count) * (1.0 / selected_count)
586
+ weights = raw / total
587
+ return {int(slot): float(weight) for slot, weight in zip(slots, weights)}
588
+
589
+
590
+ def _representative_shell_edge_length(model: "FEModel") -> float:
591
+ return _contact_geometry(model).representative_edge_length
592
+
593
+
594
+ def _representative_shell_stiffness(model: "FEModel") -> float:
595
+ """Median shell membrane stiffness scale ``E * t`` in N/m."""
596
+ values: List[float] = []
597
+ for element in _shell_contact_candidates(model):
598
+ material = model.get_material(element.material_name)
599
+ values.append(float(material.elastic_modulus) * float(element.thickness))
600
+ return float(np.median(values)) if values else 0.0
601
+
602
+
603
+ def recommend_sphere_contact_penalty(
604
+ model: "FEModel",
605
+ sphere: RigidSphereImpact,
606
+ target_penetration_fraction: float = 0.01,
607
+ safety_factor: float = 1.0,
608
+ ) -> float:
609
+ """Recommend a conservative normal penalty stiffness [N/m] for v1 contact.
610
+
611
+ Two dimensionally consistent stiffness scales are combined:
612
+
613
+ - impact energy: ``k = m v^2 / delta^2`` stops the sphere's kinetic energy
614
+ within the target penetration ``delta = R * target_penetration_fraction``
615
+ (from ``1/2 k delta^2 = 1/2 m v^2``);
616
+ - structural: the representative shell membrane stiffness ``E t`` [N/m]
617
+ divided by the target penetration fraction, so the penalty spring is much
618
+ stiffer than the contacted structure and the penetration stays a small
619
+ fraction of the structural response.
620
+ """
621
+
622
+ if target_penetration_fraction <= 0.0:
623
+ raise ValueError("target_penetration_fraction must be positive")
624
+ if safety_factor <= 0.0:
625
+ raise ValueError("safety_factor must be positive")
626
+ target_penetration = max(float(sphere.radius) * float(target_penetration_fraction), 1.0e-12)
627
+ impact_stiffness = float(sphere.mass) * max(float(sphere.speed), 1.0e-9) ** 2 / target_penetration**2
628
+ shell_stiffness = _representative_shell_stiffness(model) / max(float(target_penetration_fraction), 1.0e-12)
629
+ return float(safety_factor) * max(impact_stiffness, shell_stiffness, 1.0)
630
+
631
+
632
+ def _resolved_contact_config(model: "FEModel", sphere: RigidSphereImpact, config: Optional[SphereContactConfig]) -> SphereContactConfig:
633
+ raw = config or SphereContactConfig()
634
+ if raw.penalty_stiffness is not None:
635
+ return raw
636
+ return SphereContactConfig(
637
+ penalty_stiffness=recommend_sphere_contact_penalty(
638
+ model,
639
+ sphere,
640
+ target_penetration_fraction=raw.target_penetration_fraction,
641
+ safety_factor=raw.auto_penalty_safety_factor,
642
+ ),
643
+ contact_damping=raw.contact_damping,
644
+ search_margin=raw.search_margin,
645
+ max_contact_iterations=raw.max_contact_iterations,
646
+ penetration_tolerance=raw.penetration_tolerance,
647
+ force_tolerance=raw.force_tolerance,
648
+ side_mode=raw.side_mode,
649
+ contact_surface=raw.contact_surface,
650
+ signed_surface_offset=raw.signed_surface_offset,
651
+ target_penetration_fraction=raw.target_penetration_fraction,
652
+ auto_penalty_safety_factor=raw.auto_penalty_safety_factor,
653
+ max_sphere_travel_fraction=raw.max_sphere_travel_fraction,
654
+ max_event_substeps=raw.max_event_substeps,
655
+ max_active_contacts=raw.max_active_contacts,
656
+ load_patch_radius_factor=raw.load_patch_radius_factor,
657
+ min_load_patch_nodes=raw.min_load_patch_nodes,
658
+ max_load_patch_nodes=raw.max_load_patch_nodes,
659
+ save_contact_history=raw.save_contact_history,
660
+ post_separation_time=raw.post_separation_time,
661
+ contact_relaxation=raw.contact_relaxation,
662
+ beam_contact=raw.beam_contact,
663
+ )
664
+
665
+
666
+ def _contact_issue(
667
+ code: str,
668
+ severity: str,
669
+ entity_type: str,
670
+ entity_id: Optional[int],
671
+ message: str,
672
+ measured: Optional[float] = None,
673
+ limit: Optional[float] = None,
674
+ suggestion: str = "",
675
+ ) -> ProductionValidationIssue:
676
+ return ProductionValidationIssue(code, severity, entity_type, entity_id, message, measured, limit, suggestion)
677
+
678
+
679
+ def validate_contact_configuration(
680
+ model: "FEModel",
681
+ sphere: RigidSphereImpact,
682
+ contact_config: Optional[SphereContactConfig],
683
+ transient_config: TransientConfig,
684
+ ) -> ProductionValidationReport:
685
+ """Validate limited rigid-sphere contact inputs before production use."""
686
+
687
+ issues: List[ProductionValidationIssue] = []
688
+ shells = _shell_contact_candidates(model)
689
+ beam_targets = bool(contact_config is not None and contact_config.beam_contact) and bool(_beam_contact_candidates(model))
690
+ if not shells and not beam_targets:
691
+ issues.append(
692
+ _contact_issue(
693
+ "CONTACT001",
694
+ "error",
695
+ "model",
696
+ None,
697
+ "Sphere contact requires at least one shell target element.",
698
+ suggestion="Add shell elements or use a different load/contact model.",
699
+ )
700
+ )
701
+ used_materials = {element.material_name for element in model.mesh.elements.values() if hasattr(element, "material_name")}
702
+ for name in sorted(used_materials):
703
+ material = model.get_material(name)
704
+ if float(material.density) <= 0.0:
705
+ issues.append(
706
+ _contact_issue(
707
+ "CONTACT002",
708
+ "error",
709
+ "material",
710
+ None,
711
+ f"Material {name!r} has non-positive density for transient contact.",
712
+ measured=float(material.density),
713
+ limit=0.0,
714
+ suggestion="Set density on all structural materials used by the impact model.",
715
+ )
716
+ )
717
+ if contact_config is not None and contact_config.side_mode != "both":
718
+ issues.append(
719
+ _contact_issue(
720
+ "CONTACT003",
721
+ "error",
722
+ "contact_config",
723
+ None,
724
+ "Only side_mode='both' is supported for limited sphere-shell contact.",
725
+ suggestion="Use side_mode='both'.",
726
+ )
727
+ )
728
+ config = _resolved_contact_config(model, sphere, contact_config)
729
+ if transient_config.dt <= 0.0:
730
+ issues.append(_contact_issue("CONTACT004", "error", "transient_config", None, "Transient dt must be positive."))
731
+ else:
732
+ travel = float(sphere.speed) * float(transient_config.dt)
733
+ limit = 0.5 * float(sphere.radius)
734
+ effective_travel = travel / max(int(config.max_event_substeps), 1)
735
+ if effective_travel > limit:
736
+ issues.append(
737
+ _contact_issue(
738
+ "CONTACT005",
739
+ "error",
740
+ "transient_config",
741
+ None,
742
+ "Sphere travel per time step is too large for production contact without event substepping.",
743
+ measured=effective_travel,
744
+ limit=limit,
745
+ suggestion="Reduce dt or increase event substepping controls.",
746
+ )
747
+ )
748
+ period_dt = 0.2 * np.sqrt(float(sphere.mass) / float(config.penalty_stiffness))
749
+ if transient_config.dt > period_dt:
750
+ issues.append(
751
+ _contact_issue(
752
+ "CONTACT006",
753
+ "warning",
754
+ "transient_config",
755
+ None,
756
+ "Time step is large relative to the contact penalty period; the linear "
757
+ "impact solver auto-substeps during contact to compensate (capped by "
758
+ "max_event_substeps).",
759
+ measured=float(transient_config.dt),
760
+ limit=float(period_dt),
761
+ suggestion="No action needed unless max_event_substeps is reached; then reduce dt or lower penalty stiffness.",
762
+ )
763
+ )
764
+ mesh_quality = {
765
+ "shell_contact_targets": len(shells),
766
+ "representative_shell_edge_length": _representative_shell_edge_length(model),
767
+ "recommended_penalty_stiffness": float(config.penalty_stiffness),
768
+ "sphere_travel_per_step": float(sphere.speed) * float(transient_config.dt),
769
+ "sphere_travel_radius_ratio": float(sphere.speed) * float(transient_config.dt) / max(float(sphere.radius), 1.0e-12),
770
+ }
771
+ status = "invalid" if any(issue.severity == "error" for issue in issues) else ("warning" if issues else "ok")
772
+ return ProductionValidationReport(status, tuple(issues), mesh_quality, model.mesh.revision_signature())
773
+
774
+
775
+ def assemble_sphere_contact_load_vector(
776
+ model: "FEModel",
777
+ sphere: RigidSphereImpact,
778
+ contact_config: SphereContactConfig,
779
+ sphere_position: np.ndarray,
780
+ sphere_velocity: np.ndarray,
781
+ structural_displacement: Optional[np.ndarray] = None,
782
+ structural_velocity: Optional[np.ndarray] = None,
783
+ deleted_element_ids: Sequence[int] = (),
784
+ contact_scale_by_element: Optional[Mapping[int, float]] = None,
785
+ preferred_element_ids: Sequence[int] = (),
786
+ ) -> Tuple[np.ndarray, np.ndarray, Tuple[SphereContactRecord, ...]]:
787
+ """Assemble shell nodal loads from current rigid-sphere contact state.
788
+
789
+ ``preferred_element_ids`` stabilizes the ``max_active_contacts`` reduction:
790
+ adjacent coplanar elements report near-identical penetrations for one
791
+ physical contact, and a bare deepest-penetration argmax then flips between
792
+ them from iteration to iteration, which makes the contact fixed-point map
793
+ discontinuous and can lock the iteration into a non-converging chatter
794
+ cycle. Within a small penetration tie band the previously selected
795
+ elements are kept instead.
796
+ """
797
+
798
+ total_dofs = model.mesh.dof_manager.total_dofs
799
+ u = None if structural_displacement is None else np.asarray(structural_displacement, dtype=float)
800
+ v = np.zeros(total_dofs, dtype=float) if structural_velocity is None else np.asarray(structural_velocity, dtype=float)
801
+ center = np.asarray(sphere_position, dtype=float).reshape(3)
802
+ velocity = np.asarray(sphere_velocity, dtype=float).reshape(3)
803
+ load = np.zeros(total_dofs, dtype=float)
804
+ sphere_force_total = np.zeros(3, dtype=float)
805
+ records: List[SphereContactRecord] = []
806
+ contact_scales = {} if contact_scale_by_element is None else {int(k): float(v) for k, v in contact_scale_by_element.items()}
807
+ geometry = _contact_geometry(model)
808
+ has_beam_targets = bool(contact_config.beam_contact) and geometry.beam_segment_slots.shape[0] > 0
809
+ if geometry.element_ids.shape[0] == 0 and not has_beam_targets:
810
+ return load, sphere_force_total, tuple(records)
811
+ node_positions = geometry.deformed_node_positions(u)
812
+ element_nodes, centroids, radii = geometry.element_centroids_and_radii(node_positions)
813
+ active_mask = geometry.active_element_mask(deleted_element_ids)
814
+ if geometry.element_ids.shape[0] == 0:
815
+ near_mask = np.zeros(0, dtype=bool)
816
+ else:
817
+ near_mask = active_mask & (
818
+ np.linalg.norm(centroids - center[None, :], axis=1) <= sphere.radius + radii + contact_config.search_margin
819
+ )
820
+
821
+ for element_index in np.flatnonzero(near_mask):
822
+ element = geometry.elements[element_index]
823
+ contact_scale = float(contact_scales.get(int(element.element_id), 1.0))
824
+ if contact_scale <= 0.0:
825
+ continue
826
+ node_count = int(geometry.element_node_counts[element_index])
827
+ element_slots = geometry.element_node_slots[element_index, :node_count]
828
+ coords = element_nodes[element_index, :node_count]
829
+ local, N, dN_dxi, dN_deta, surface_point = _project_local_coordinates(element, coords, center)
830
+ gap_vector = center - surface_point
831
+ distance = float(np.linalg.norm(gap_vector))
832
+ if distance > sphere.radius + contact_config.search_margin:
833
+ continue
834
+ tangent_xi = dN_dxi @ coords
835
+ tangent_eta = dN_deta @ coords
836
+ fallback_normal = np.cross(tangent_xi, tangent_eta)
837
+ fallback_norm = float(np.linalg.norm(fallback_normal))
838
+ if fallback_norm <= 1.0e-14:
839
+ continue
840
+ surface_normal = fallback_normal / fallback_norm
841
+ offset = _surface_offset(element, contact_config)
842
+ if offset != 0.0:
843
+ surface_point = surface_point + offset * surface_normal
844
+ gap_vector = center - surface_point
845
+ distance = float(np.linalg.norm(gap_vector))
846
+ if distance > sphere.radius + contact_config.search_margin:
847
+ continue
848
+ if distance > 1.0e-14:
849
+ normal = gap_vector / distance
850
+ elif fallback_norm > 1.0e-14:
851
+ normal = surface_normal
852
+ else:
853
+ continue
854
+ penetration = max(float(sphere.radius - distance), 0.0)
855
+ if penetration <= 0.0:
856
+ continue
857
+ surface_velocity = np.asarray(N, dtype=float) @ v[geometry.node_dofs[element_slots]]
858
+ relative_normal_velocity = float(np.dot(velocity - surface_velocity, normal))
859
+ normal_force = max(
860
+ float(contact_config.penalty_stiffness) * penetration - float(contact_config.contact_damping) * relative_normal_velocity,
861
+ 0.0,
862
+ )
863
+ normal_force *= min(max(contact_scale, 0.0), 1.0)
864
+ if normal_force <= 0.0:
865
+ continue
866
+ sphere_force = normal_force * normal
867
+ structure_force = -sphere_force
868
+ patch_weights = _contact_patch_nodal_weights(
869
+ geometry,
870
+ node_positions,
871
+ centroids,
872
+ radii,
873
+ active_mask,
874
+ surface_point,
875
+ contact_config,
876
+ sphere,
877
+ penetration,
878
+ )
879
+ if not patch_weights:
880
+ patch_weights = {int(slot): float(N[local_index]) for local_index, slot in enumerate(element_slots)}
881
+ nodal_forces: Dict[int, np.ndarray] = {}
882
+ for slot, weight in patch_weights.items():
883
+ nodal = float(weight) * structure_force
884
+ load[geometry.node_dofs[slot]] += nodal
885
+ nodal_forces[int(geometry.node_ids[slot])] = nodal
886
+ sphere_force_total += sphere_force
887
+ records.append(
888
+ SphereContactRecord(
889
+ element_id=int(element.element_id),
890
+ local_coordinates=(float(local[0]), float(local[1])),
891
+ contact_point=surface_point,
892
+ normal=normal,
893
+ penetration=penetration,
894
+ normal_force=normal_force,
895
+ sphere_force=sphere_force,
896
+ structure_force=structure_force,
897
+ contact_classification=_contact_classification(element, local),
898
+ nodal_forces=nodal_forces,
899
+ )
900
+ )
901
+
902
+ if bool(contact_config.beam_contact) and geometry.beam_segment_slots.shape[0]:
903
+ deleted = {int(element_id) for element_id in deleted_element_ids}
904
+ seg_a = node_positions[geometry.beam_segment_slots[:, 0]]
905
+ seg_b = node_positions[geometry.beam_segment_slots[:, 1]]
906
+ axis = seg_b - seg_a
907
+ length_sq = np.maximum(np.einsum("ij,ij->i", axis, axis), 1.0e-30)
908
+ t_param = np.clip(np.einsum("ij,ij->i", center[None, :] - seg_a, axis) / length_sq, 0.0, 1.0)
909
+ closest = seg_a + t_param[:, None] * axis
910
+ gap_vectors = center[None, :] - closest
911
+ distances = np.linalg.norm(gap_vectors, axis=1)
912
+ reach = sphere.radius + geometry.beam_segment_radii + contact_config.search_margin
913
+ for segment_index in np.flatnonzero(distances <= reach):
914
+ beam_id = int(geometry.beam_segment_element_ids[segment_index])
915
+ if beam_id in deleted:
916
+ continue
917
+ contact_scale = float(contact_scales.get(beam_id, 1.0))
918
+ if contact_scale <= 0.0:
919
+ continue
920
+ distance = float(distances[segment_index])
921
+ penetration = float(sphere.radius + geometry.beam_segment_radii[segment_index] - distance)
922
+ if penetration <= 0.0 or distance <= 1.0e-14:
923
+ continue
924
+ normal = gap_vectors[segment_index] / distance
925
+ t_value = float(t_param[segment_index])
926
+ slot_a = int(geometry.beam_segment_slots[segment_index, 0])
927
+ slot_b = int(geometry.beam_segment_slots[segment_index, 1])
928
+ surface_velocity = (1.0 - t_value) * v[geometry.node_dofs[slot_a]] + t_value * v[geometry.node_dofs[slot_b]]
929
+ relative_normal_velocity = float(np.dot(velocity - surface_velocity, normal))
930
+ normal_force = max(
931
+ float(contact_config.penalty_stiffness) * penetration
932
+ - float(contact_config.contact_damping) * relative_normal_velocity,
933
+ 0.0,
934
+ )
935
+ normal_force *= min(max(contact_scale, 0.0), 1.0)
936
+ if normal_force <= 0.0:
937
+ continue
938
+ sphere_force = normal_force * normal
939
+ structure_force = -sphere_force
940
+ nodal_forces = {
941
+ int(geometry.node_ids[slot_a]): (1.0 - t_value) * structure_force,
942
+ int(geometry.node_ids[slot_b]): t_value * structure_force,
943
+ }
944
+ load[geometry.node_dofs[slot_a]] += nodal_forces[int(geometry.node_ids[slot_a])]
945
+ load[geometry.node_dofs[slot_b]] += nodal_forces[int(geometry.node_ids[slot_b])]
946
+ sphere_force_total += sphere_force
947
+ records.append(
948
+ SphereContactRecord(
949
+ element_id=beam_id,
950
+ local_coordinates=(2.0 * t_value - 1.0, 0.0),
951
+ contact_point=closest[segment_index].copy(),
952
+ normal=normal.copy(),
953
+ penetration=penetration,
954
+ normal_force=normal_force,
955
+ sphere_force=sphere_force,
956
+ structure_force=structure_force,
957
+ contact_classification="beam",
958
+ nodal_forces=nodal_forces,
959
+ )
960
+ )
961
+
962
+ if len(records) > int(contact_config.max_active_contacts):
963
+ deepest = max(record.penetration for record in records)
964
+ tie_band = 0.95 * deepest
965
+ preferred = {int(element_id) for element_id in preferred_element_ids}
966
+ records = sorted(
967
+ records,
968
+ key=lambda item: (
969
+ item.penetration >= tie_band and int(item.element_id) in preferred,
970
+ item.penetration,
971
+ item.normal_force,
972
+ ),
973
+ reverse=True,
974
+ )[: int(contact_config.max_active_contacts)]
975
+ load = np.zeros(total_dofs, dtype=float)
976
+ sphere_force_total = np.zeros(3, dtype=float)
977
+ for record in records:
978
+ sphere_force_total += record.sphere_force
979
+ for node_id, nodal in record.nodal_forces.items():
980
+ load[geometry.node_dofs[geometry.node_id_to_slot[int(node_id)]]] += nodal
981
+
982
+ return load, sphere_force_total, tuple(records)
983
+
984
+
985
+ def _contact_default_config(sphere: RigidSphereImpact) -> SphereContactConfig:
986
+ stiffness = max(float(sphere.mass) * max(float(sphere.speed), 1.0) ** 2 / max(float(sphere.radius) * 0.01, 1.0e-9), 1.0)
987
+ return SphereContactConfig(penalty_stiffness=stiffness)
988
+
989
+
990
+ LinearElementMatrixTerms = Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray]
991
+
992
+
993
+ def _contact_erodible_element_count(model: "FEModel") -> int:
994
+ """Count elements that impact fracture can erode (shell midsurfaces + beams)."""
995
+ return sum(1 for element in model.mesh.elements.values() if element_fracture_category(element) is not None)
996
+
997
+
998
+ def _linear_element_matrix_terms(model: "FEModel") -> Tuple[int, Tuple[LinearElementMatrixTerms, ...]]:
999
+ """Cache unscaled element K/M contributions for repeated damage rescaling."""
1000
+ terms: List[LinearElementMatrixTerms] = []
1001
+ total_dofs = model.mesh.dof_manager.total_dofs
1002
+ for element_id, element in model.mesh.elements.items():
1003
+ if not hasattr(element, "get_dof_mapping"):
1004
+ continue
1005
+ mapping = np.asarray(element.get_dof_mapping(model.mesh), dtype=np.intp).reshape(-1)
1006
+ if mapping.size == 0:
1007
+ continue
1008
+ material = model.get_material(element.material_name)
1009
+ k_elem = np.asarray(element.compute_stiffness_matrix(model.mesh, material), dtype=float)
1010
+ m_elem = np.asarray(element.compute_mass_matrix(model.mesh, material), dtype=float)
1011
+ if k_elem.shape != (mapping.size, mapping.size) or m_elem.shape != (mapping.size, mapping.size):
1012
+ continue
1013
+ row = np.repeat(mapping, mapping.size)
1014
+ col = np.tile(mapping, mapping.size)
1015
+ terms.append((int(element_id), row, col, k_elem.reshape(-1), m_elem.reshape(-1)))
1016
+ return total_dofs, tuple(terms)
1017
+
1018
+
1019
+ def _assemble_damaged_linear_matrices(
1020
+ model: "FEModel",
1021
+ element_scales: Mapping[int, float],
1022
+ cached_terms: Optional[Tuple[int, Tuple[LinearElementMatrixTerms, ...]]] = None,
1023
+ ) -> Tuple[sparse.csr_matrix, sparse.csr_matrix]:
1024
+ """Assemble K and M with per-element damage stiffness/mass scales."""
1025
+ if cached_terms is None:
1026
+ cached_terms = _linear_element_matrix_terms(model)
1027
+ total_dofs, terms = cached_terms
1028
+ scales = {int(element_id): float(scale) for element_id, scale in element_scales.items()}
1029
+ rows: List[np.ndarray] = []
1030
+ cols: List[np.ndarray] = []
1031
+ k_data: List[np.ndarray] = []
1032
+ m_data: List[np.ndarray] = []
1033
+ for element_id, row, col, k_flat, m_flat in terms:
1034
+ scale = min(max(float(scales.get(int(element_id), 1.0)), 0.0), 1.0)
1035
+ rows.append(row)
1036
+ cols.append(col)
1037
+ k_data.append(scale * k_flat)
1038
+ m_data.append(scale * m_flat)
1039
+ if not rows:
1040
+ empty = sparse.csr_matrix((total_dofs, total_dofs), dtype=float)
1041
+ return empty, empty
1042
+ row_all = np.concatenate(rows)
1043
+ col_all = np.concatenate(cols)
1044
+ K = sparse.coo_matrix((np.concatenate(k_data), (row_all, col_all)), shape=(total_dofs, total_dofs)).tocsr()
1045
+ M = sparse.coo_matrix((np.concatenate(m_data), (row_all, col_all)), shape=(total_dofs, total_dofs)).tocsr()
1046
+ # Preserve added point masses across erosion-driven matrix rebuilds.
1047
+ point_masses = getattr(model.mesh, "point_masses", None)
1048
+ if point_masses:
1049
+ diagonal = np.zeros(total_dofs, dtype=float)
1050
+ for node_id, mass in point_masses.items():
1051
+ node = model.mesh.get_node(int(node_id))
1052
+ if node is None or float(mass) == 0.0:
1053
+ continue
1054
+ for axis in range(3):
1055
+ diagonal[node.dofs[axis]] += float(mass)
1056
+ if diagonal.any():
1057
+ M = (M + sparse.diags(diagonal, 0, shape=(total_dofs, total_dofs), format="csr")).tocsr()
1058
+ return K, M
1059
+
1060
+
1061
+ def _assemble_eroded_linear_matrices(
1062
+ model: "FEModel",
1063
+ deleted_element_ids: Sequence[int],
1064
+ residual_fraction: float,
1065
+ ) -> Tuple[sparse.csr_matrix, sparse.csr_matrix]:
1066
+ """Assemble K and M with residual contribution from deleted elements."""
1067
+ scales = {int(element_id): float(residual_fraction) for element_id in deleted_element_ids}
1068
+ return _assemble_damaged_linear_matrices(model, scales)
1069
+
1070
+
1071
+ def _impact_contact_patch_area(
1072
+ record: SphereContactRecord,
1073
+ element: ShellElement,
1074
+ config: ImpactDamageConfig,
1075
+ sphere: RigidSphereImpact,
1076
+ ) -> float:
1077
+ """Estimate the local shell contact patch area used by damage checks."""
1078
+ penetration_radius = np.sqrt(max(float(sphere.radius) * float(record.penetration), 0.0))
1079
+ fraction_radius = max(float(sphere.radius) * float(config.contact_area_radius_fraction), 0.0)
1080
+ thickness_radius = max(float(getattr(element, "thickness", 0.0)), 0.0)
1081
+ min_radius = np.sqrt(float(config.min_contact_area) / np.pi)
1082
+ patch_radius = max(penetration_radius, fraction_radius, thickness_radius, min_radius)
1083
+ return max(float(np.pi * patch_radius**2), float(config.min_contact_area))
1084
+
1085
+
1086
+ def _impact_material_capacity(model: "FEModel", element: ShellElement, config: ImpactDamageConfig) -> Tuple[float, str]:
1087
+ """Return yield/ultimate/user stress capacity in Pa for impact damage."""
1088
+ if config.capacity_basis == "user":
1089
+ return float(config.user_capacity), "user"
1090
+ material = model.get_material(element.material_name)
1091
+ curve = getattr(material, "hardening_curve", None)
1092
+ if config.capacity_basis == "yield":
1093
+ if curve is not None and hasattr(curve, "sigma_yield"):
1094
+ return float(curve.sigma_yield), "hardening_curve.sigma_yield"
1095
+ if float(getattr(material, "yield_stress", 0.0)) > 0.0:
1096
+ return float(material.yield_stress), "material.yield_stress"
1097
+ else:
1098
+ if curve is not None:
1099
+ candidates = []
1100
+ for name in ("sigma_yield_2", "sigma_yield"):
1101
+ value = getattr(curve, name, None)
1102
+ if value is not None:
1103
+ candidates.append(float(value))
1104
+ if hasattr(curve, "flow_stress"):
1105
+ try:
1106
+ candidates.append(float(np.asarray(curve.flow_stress(np.asarray([0.05], dtype=float))).reshape(-1)[0]))
1107
+ except Exception:
1108
+ pass
1109
+ if candidates:
1110
+ return max(candidates), "hardening_curve.ultimate_proxy"
1111
+ if float(getattr(material, "yield_stress", 0.0)) > 0.0:
1112
+ return 1.15 * float(material.yield_stress), "material.yield_stress_ultimate_proxy"
1113
+ elastic_proxy = max(0.002 * float(getattr(material, "elastic_modulus", 0.0)), 1.0)
1114
+ return elastic_proxy, "elastic_0.2pct_proxy"
1115
+
1116
+
1117
+ def _impact_damage_metrics(
1118
+ model: "FEModel",
1119
+ record: SphereContactRecord,
1120
+ config: ImpactDamageConfig,
1121
+ sphere: RigidSphereImpact,
1122
+ dt: float,
1123
+ ) -> Dict[str, Any]:
1124
+ element = model.mesh.get_element(int(record.element_id))
1125
+ if not isinstance(element, ShellElement):
1126
+ return {}
1127
+ area = _impact_contact_patch_area(record, element, config, sphere)
1128
+ capacity, capacity_source = _impact_material_capacity(model, element, config)
1129
+ pressure = float(record.normal_force) / max(area, 1.0e-30)
1130
+ impulse_density = float(record.normal_force) * float(dt) / max(area, 1.0e-30)
1131
+ pressure_utilization = pressure / max(capacity, 1.0e-30)
1132
+ impulse_utilization = impulse_density / max(capacity * float(config.impulse_reference_time), 1.0e-30)
1133
+ material = model.get_material(element.material_name)
1134
+ elastic_modulus = max(float(getattr(material, "elastic_modulus", 0.0)), 1.0)
1135
+ equivalent_plastic_strain_estimate = max(pressure - capacity, 0.0) / elastic_modulus * float(config.strain_scale)
1136
+ strain_utilization = equivalent_plastic_strain_estimate / max(float(config.plastic_strain_capacity), 1.0e-30)
1137
+ components = {
1138
+ "contact_pressure": float(pressure_utilization),
1139
+ "impulse_per_area": float(impulse_utilization),
1140
+ "equivalent_plastic_strain_estimate": float(strain_utilization),
1141
+ }
1142
+ governing_component = max(components, key=components.get)
1143
+ combined = max(float(value) for value in components.values())
1144
+ return {
1145
+ "element_id": int(record.element_id),
1146
+ "contact_patch_area": float(area),
1147
+ "contact_pressure": float(pressure),
1148
+ "impulse_density_increment": float(impulse_density),
1149
+ "capacity": float(capacity),
1150
+ "capacity_source": capacity_source,
1151
+ "equivalent_plastic_strain_estimate": float(equivalent_plastic_strain_estimate),
1152
+ "utilizations": components,
1153
+ "combined_utilization": float(combined),
1154
+ "governing_component": governing_component,
1155
+ }
1156
+
1157
+
1158
+ def _impact_damage_scale(damage: float, config: ImpactDamageConfig) -> float:
1159
+ if damage < float(config.softening_start):
1160
+ return 1.0
1161
+ if damage >= float(config.delete_at):
1162
+ return float(config.residual_stiffness_fraction)
1163
+ span = max(float(config.delete_at) - float(config.softening_start), 1.0e-12)
1164
+ fraction = (float(damage) - float(config.softening_start)) / span
1165
+ return float(1.0 - fraction * (1.0 - float(config.residual_stiffness_fraction)))
1166
+
1167
+
1168
+ def _update_impact_damage_states(
1169
+ model: "FEModel",
1170
+ records: Sequence[SphereContactRecord],
1171
+ config: ImpactDamageConfig,
1172
+ sphere: RigidSphereImpact,
1173
+ states: Dict[int, Dict[str, Any]],
1174
+ deleted_element_ids: Sequence[int],
1175
+ *,
1176
+ step_index: int,
1177
+ time_value: float,
1178
+ dt: float,
1179
+ ) -> Tuple[Tuple[DeletedElementRecord, ...], float, Tuple[Dict[str, Any], ...], bool]:
1180
+ deleted = {int(element_id) for element_id in deleted_element_ids}
1181
+ new_deleted: List[DeletedElementRecord] = []
1182
+ diagnostics: List[Dict[str, Any]] = []
1183
+ changed = False
1184
+ max_utilization = 0.0
1185
+ for record in records:
1186
+ element_id = int(record.element_id)
1187
+ if element_id in deleted:
1188
+ continue
1189
+ element = model.mesh.get_element(element_id)
1190
+ # Capacity-based impact damage is an area/contact-pressure screening
1191
+ # model, which maps to shell midsurface contact only. Beam line
1192
+ # contact (force per length) is out of scope here; use
1193
+ # ImpactFractureConfig (contact force/penetration) or the nonlinear
1194
+ # PlasticImpactDamageConfig path for beam erosion.
1195
+ if element is None or element_fracture_category(element) != "shell":
1196
+ continue
1197
+ metrics = _impact_damage_metrics(model, record, config, sphere, dt)
1198
+ if not metrics:
1199
+ continue
1200
+ utilization = float(metrics["combined_utilization"])
1201
+ max_utilization = max(max_utilization, utilization)
1202
+ state = states.setdefault(
1203
+ element_id,
1204
+ {
1205
+ "damage": 0.0,
1206
+ "max_utilization": 0.0,
1207
+ "governing_component": "",
1208
+ "accumulated_impulse_density": 0.0,
1209
+ "max_contact_patch_area": 0.0,
1210
+ "contact_count": 0,
1211
+ "history": [],
1212
+ "deleted_time": None,
1213
+ },
1214
+ )
1215
+ old_damage = float(state["damage"])
1216
+ old_scale = float(state.get("scale", _impact_damage_scale(old_damage, config)))
1217
+ state["contact_count"] = int(state.get("contact_count", 0)) + 1
1218
+ state["max_utilization"] = max(float(state.get("max_utilization", 0.0)), utilization)
1219
+ if utilization >= float(state.get("max_utilization", 0.0)) - 1.0e-12:
1220
+ state["governing_component"] = str(metrics["governing_component"])
1221
+ state["accumulated_impulse_density"] = float(state.get("accumulated_impulse_density", 0.0)) + float(
1222
+ metrics["impulse_density_increment"]
1223
+ )
1224
+ state["max_contact_patch_area"] = max(float(state.get("max_contact_patch_area", 0.0)), float(metrics["contact_patch_area"]))
1225
+ if config.mode == "instant_threshold":
1226
+ new_damage = max(old_damage, utilization / float(config.damage_threshold))
1227
+ else:
1228
+ increment = max(utilization / float(config.damage_threshold), 0.0) * float(dt) / float(config.impulse_reference_time)
1229
+ new_damage = old_damage + increment
1230
+ state["damage"] = float(new_damage)
1231
+ scale = _impact_damage_scale(new_damage, config)
1232
+ state["scale"] = float(scale)
1233
+ diag = {
1234
+ "element_id": element_id,
1235
+ "time": float(time_value),
1236
+ "damage": float(new_damage),
1237
+ "scale": float(scale),
1238
+ **metrics,
1239
+ }
1240
+ diagnostics.append(diag)
1241
+ if bool(config.record_history):
1242
+ history = state.setdefault("history", [])
1243
+ history.append(diag)
1244
+ if abs(scale - old_scale) > 1.0e-12:
1245
+ changed = True
1246
+ deletion_allowed = True
1247
+ if bool(config.neighbor_smoothing) and int(state.get("contact_count", 0)) < 2:
1248
+ deletion_allowed = False
1249
+ diag["neighbor_smoothing_hold"] = True
1250
+ if new_damage >= float(config.delete_at) and deletion_allowed:
1251
+ state["deleted_time"] = float(time_value)
1252
+ new_deleted.append(
1253
+ DeletedElementRecord(
1254
+ element_id=element_id,
1255
+ element_type="shell",
1256
+ step_index=int(step_index),
1257
+ load_factor=float(time_value),
1258
+ trigger_name=f"impact_damage:{metrics['governing_component']}",
1259
+ trigger_value=float(new_damage),
1260
+ threshold=float(config.delete_at),
1261
+ location=f"contact:{record.contact_classification}",
1262
+ measure=element_measure(model.mesh, element),
1263
+ )
1264
+ )
1265
+ return tuple(new_deleted), max_utilization, tuple(diagnostics), changed
1266
+
1267
+
1268
+ def _impact_damage_summary(
1269
+ model: "FEModel",
1270
+ config: Optional[ImpactDamageConfig],
1271
+ states: Mapping[int, Mapping[str, Any]],
1272
+ deleted_element_ids: Sequence[int],
1273
+ *,
1274
+ deletion_records: Sequence[DeletedElementRecord] = (),
1275
+ warnings: Sequence[str] = (),
1276
+ ) -> Dict[str, Any]:
1277
+ if config is None:
1278
+ return {"enabled": False, "records": [], "warnings": list(warnings)}
1279
+ deleted = {int(element_id) for element_id in deleted_element_ids}
1280
+ shell_count = sum(1 for element in model.mesh.elements.values() if element_fracture_category(element) == "shell")
1281
+ active_states = {int(element_id): state for element_id, state in states.items()}
1282
+ softened = [
1283
+ element_id
1284
+ for element_id, state in active_states.items()
1285
+ if float(state.get("damage", 0.0)) >= float(config.softening_start) and element_id not in deleted
1286
+ ]
1287
+ records = []
1288
+ for element_id, state in sorted(active_states.items()):
1289
+ history = state.get("history", []) if bool(config.record_history) else []
1290
+ records.append(
1291
+ {
1292
+ "element_id": int(element_id),
1293
+ "damage": float(state.get("damage", 0.0)),
1294
+ "scale": float(state.get("scale", _impact_damage_scale(float(state.get("damage", 0.0)), config))),
1295
+ "max_utilization": float(state.get("max_utilization", 0.0)),
1296
+ "governing_component": str(state.get("governing_component", "")),
1297
+ "accumulated_impulse_density": float(state.get("accumulated_impulse_density", 0.0)),
1298
+ "max_contact_patch_area": float(state.get("max_contact_patch_area", 0.0)),
1299
+ "contact_count": int(state.get("contact_count", 0)),
1300
+ "deleted_time": state.get("deleted_time"),
1301
+ "history": history,
1302
+ }
1303
+ )
1304
+ return {
1305
+ "enabled": True,
1306
+ "config": config.to_dict(),
1307
+ "deleted_count": len(deleted),
1308
+ "softened_count": len(softened),
1309
+ "deleted_fraction": float(len(deleted) / max(shell_count, 1)),
1310
+ "deleted_element_ids": sorted(deleted),
1311
+ "softened_element_ids": sorted(softened),
1312
+ "max_damage": max((float(state.get("damage", 0.0)) for state in active_states.values()), default=0.0),
1313
+ "max_utilization": max((float(state.get("max_utilization", 0.0)) for state in active_states.values()), default=0.0),
1314
+ "records": records,
1315
+ "deletion_records": [record.to_dict() for record in deletion_records],
1316
+ "warnings": list(warnings),
1317
+ }
1318
+
1319
+
1320
+ def _impact_damage_preflight_warnings(
1321
+ model: "FEModel",
1322
+ config: Optional[ImpactDamageConfig],
1323
+ sphere: RigidSphereImpact,
1324
+ *,
1325
+ fracture_config: Optional[ImpactFractureConfig] = None,
1326
+ beam_contact: bool = False,
1327
+ ) -> Tuple[str, ...]:
1328
+ if config is None:
1329
+ return ()
1330
+ warnings: List[str] = []
1331
+ if fracture_config is not None:
1332
+ warnings.append(
1333
+ "IMPACT_DAMAGE010: both ImpactFractureConfig and ImpactDamageConfig are active; "
1334
+ "either path may erode shell elements, and summaries report their own trigger ownership."
1335
+ )
1336
+ if beam_contact and _beam_contact_candidates(model):
1337
+ warnings.append(
1338
+ "IMPACT_DAMAGE014: beam contact targets are active but capacity-based ImpactDamageConfig "
1339
+ "screens shell midsurface contact only; beam erosion needs ImpactFractureConfig "
1340
+ "(contact force/penetration) or the nonlinear PlasticImpactDamageConfig path."
1341
+ )
1342
+ shell_elements = [element for element in model.mesh.elements.values() if element_fracture_category(element) == "shell"]
1343
+ if not shell_elements:
1344
+ return tuple(warnings)
1345
+ fallback_capacity = False
1346
+ for element in shell_elements:
1347
+ if not isinstance(element, ShellElement):
1348
+ continue
1349
+ _capacity, source = _impact_material_capacity(model, element, config)
1350
+ if source == "elastic_0.2pct_proxy":
1351
+ fallback_capacity = True
1352
+ break
1353
+ if fallback_capacity and config.capacity_basis != "user":
1354
+ warnings.append(
1355
+ "IMPACT_DAMAGE011: one or more shell materials lack yield stress or RP-C208 hardening curve; "
1356
+ "impact damage capacity is using a 0.2% elastic proxy."
1357
+ )
1358
+ representative_edge = _representative_shell_edge_length(model)
1359
+ if representative_edge > 0.0:
1360
+ representative_area = representative_edge**2
1361
+ if float(config.min_contact_area) < 1.0e-5 * representative_area:
1362
+ warnings.append(
1363
+ "IMPACT_DAMAGE012: min_contact_area is very small relative to representative shell area; "
1364
+ "damage may be mesh sensitive unless calibrated."
1365
+ )
1366
+ if float(config.contact_area_radius_fraction) * float(sphere.radius) < 0.05 * max(representative_edge, 1.0e-12):
1367
+ warnings.append(
1368
+ "IMPACT_DAMAGE013: contact patch radius fraction is small relative to mesh edge length; "
1369
+ "consider a larger minimum area or finer mesh for production screening."
1370
+ )
1371
+ return tuple(warnings)
1372
+
1373
+
1374
+ def _warnings_with_prefixes(warnings: Sequence[str], prefixes: Sequence[str]) -> Tuple[str, ...]:
1375
+ prefix_tuple = tuple(str(prefix) for prefix in prefixes)
1376
+ return tuple(str(warning) for warning in warnings if str(warning).startswith(prefix_tuple))
1377
+
1378
+
1379
+ def _impact_fracture_trigger_value(
1380
+ record: SphereContactRecord,
1381
+ config: ImpactFractureConfig,
1382
+ sphere: RigidSphereImpact,
1383
+ ) -> float:
1384
+ if config.trigger == "contact_force":
1385
+ return float(record.normal_force)
1386
+ if config.trigger == "penetration_ratio":
1387
+ return float(record.penetration) / max(float(sphere.radius), 1.0e-12)
1388
+ contact_radius = max(float(sphere.radius) * float(config.contact_area_radius_fraction), 1.0e-12)
1389
+ area = np.pi * contact_radius**2
1390
+ return float(record.normal_force) / area
1391
+
1392
+
1393
+ def _detect_impact_fracture_records(
1394
+ model: "FEModel",
1395
+ records: Sequence[SphereContactRecord],
1396
+ config: ImpactFractureConfig,
1397
+ sphere: RigidSphereImpact,
1398
+ deleted_element_ids: Sequence[int],
1399
+ *,
1400
+ step_index: int,
1401
+ time_value: float,
1402
+ ) -> Tuple[Tuple[DeletedElementRecord, ...], float]:
1403
+ if float(time_value) + 1.0e-12 < config.min_time:
1404
+ return (), 0.0
1405
+ deleted = {int(element_id) for element_id in deleted_element_ids}
1406
+ new_records: List[DeletedElementRecord] = []
1407
+ max_utilization = 0.0
1408
+ for contact_record in records:
1409
+ element_id = int(contact_record.element_id)
1410
+ if element_id in deleted:
1411
+ continue
1412
+ element = model.mesh.get_element(element_id)
1413
+ if element is None:
1414
+ continue
1415
+ category = element_fracture_category(element)
1416
+ # Contact-observable fracture triggers (force, penetration ratio,
1417
+ # sphere-area pressure proxy) are geometry-agnostic, so any erodible
1418
+ # contact target -- shell midsurface or beam segment -- can fracture.
1419
+ if category is None:
1420
+ continue
1421
+ trigger_value = _impact_fracture_trigger_value(contact_record, config, sphere)
1422
+ utilization = trigger_value / config.threshold
1423
+ max_utilization = max(max_utilization, float(utilization))
1424
+ if trigger_value >= config.threshold:
1425
+ new_records.append(
1426
+ DeletedElementRecord(
1427
+ element_id=element_id,
1428
+ element_type=category,
1429
+ step_index=int(step_index),
1430
+ load_factor=float(time_value),
1431
+ trigger_name=str(config.trigger),
1432
+ trigger_value=float(trigger_value),
1433
+ threshold=float(config.threshold),
1434
+ location=f"contact:{contact_record.contact_classification}",
1435
+ measure=element_measure(model.mesh, element),
1436
+ )
1437
+ )
1438
+ return tuple(new_records), max_utilization
1439
+
1440
+
1441
+ def _plastic_damage_scale(utilization: float, config: PlasticImpactDamageConfig) -> float:
1442
+ value = max(float(utilization), 0.0)
1443
+ if value < float(config.softening_start):
1444
+ return 1.0
1445
+ if value >= float(config.delete_at):
1446
+ return float(config.residual_stiffness_fraction)
1447
+ span = max(float(config.delete_at) - float(config.softening_start), 1.0e-12)
1448
+ fraction = (value - float(config.softening_start)) / span
1449
+ return 1.0 - fraction * (1.0 - float(config.residual_stiffness_fraction))
1450
+
1451
+
1452
+ def _plastic_impact_damage_update(
1453
+ model: "FEModel",
1454
+ committed_states: Mapping[int, Any],
1455
+ config: Optional[PlasticImpactDamageConfig],
1456
+ deleted_element_ids: set[int],
1457
+ damage_states: Dict[int, Dict[str, Any]],
1458
+ *,
1459
+ step_index: int,
1460
+ time_value: float,
1461
+ ) -> Tuple[List[DeletedElementRecord], bool, float]:
1462
+ if config is None:
1463
+ return [], False, 0.0
1464
+ changed = False
1465
+ new_records: List[DeletedElementRecord] = []
1466
+ max_utilization = 0.0
1467
+ criterion = str(getattr(config, "criterion", "fixed"))
1468
+ material_constants: Dict[str, Tuple[float, float]] = {}
1469
+ for element_id, state in committed_states.items():
1470
+ element = model.mesh.elements.get(int(element_id))
1471
+ if element is None:
1472
+ continue
1473
+ category = element_fracture_category(element)
1474
+ if category not in set(config.element_scope):
1475
+ continue
1476
+ trigger_value, location = state_equivalent_plastic_strain(state)
1477
+
1478
+ threshold = float(config.threshold)
1479
+ if criterion in {"mesh_scaled_gl", "rtcl", "rtcl_modified"}:
1480
+ # GL/RP-C208 mesh scaling of the critical strain: coarse shells
1481
+ # cannot resolve necking, so the limit shrinks with element size.
1482
+ if category == "shell":
1483
+ thickness = float(getattr(element, "thickness", 0.0))
1484
+ area = float(element_measure(model.mesh, element))
1485
+ l_e = float(np.sqrt(area)) if area > 0.0 else 0.0
1486
+ if l_e > 1.0e-6 and thickness > 1.0e-6:
1487
+ threshold = 0.056 + 0.54 * (thickness / l_e)
1488
+ elif category == "beam":
1489
+ thickness = float(getattr(element, "cross_section", {}).get("web_thickness", 0.0))
1490
+ l_e = float(element_measure(model.mesh, element))
1491
+ if l_e > 1.0e-6 and thickness > 1.0e-6:
1492
+ threshold = 0.056 + 0.54 * (thickness / l_e)
1493
+ if criterion == "rtcl_modified":
1494
+ # Calibrate the critical strain so plane-strain tension
1495
+ # (eta = 1/sqrt(3), the governing state for plate necking and
1496
+ # the state the GL curve was derived from) reaches utilization
1497
+ # 1 exactly at the mesh-scaled limit: eps_cr is scaled by the
1498
+ # RTCL weight at plane strain, w_ps = exp(sqrt(3)/2 - 1/2).
1499
+ weight_ps = float(np.exp(0.5 * np.sqrt(3.0) - 0.5))
1500
+ threshold = threshold * weight_ps
1501
+
1502
+ previous = damage_states.get(int(element_id), {})
1503
+ previous_damage = float(previous.get("damage", 0.0) or 0.0)
1504
+ if criterion in {"rtcl", "rtcl_modified"}:
1505
+ # RTCL: accumulate triaxiality-weighted plastic strain per
1506
+ # integration point, D = sum(w(eta) d_alpha) / eps_cr. Damage is
1507
+ # zero in compression and reduced in shear, which keeps erosion
1508
+ # away from the compressed side of a dent (the classic source of
1509
+ # spurious deletions with plain plastic-strain thresholds).
1510
+ name = str(element.material_name)
1511
+ if name not in material_constants:
1512
+ material = model.get_material(name)
1513
+ material_constants[name] = (float(material.elastic_modulus), float(material.poisson_ratio))
1514
+ E, nu = material_constants[name]
1515
+ rtcl = state_rtcl_increment(state, previous.get("rtcl_alpha"), E, nu)
1516
+ accumulated = np.asarray(previous.get("rtcl_damage", ()), dtype=float).reshape(-1)
1517
+ if rtcl is None:
1518
+ utilization = previous_damage
1519
+ rtcl_alpha = previous.get("rtcl_alpha")
1520
+ else:
1521
+ alpha_now, weighted_delta = rtcl
1522
+ if accumulated.size != alpha_now.size:
1523
+ accumulated = np.zeros_like(alpha_now)
1524
+ accumulated = accumulated + weighted_delta / max(threshold, 1.0e-30)
1525
+ utilization = float(np.max(accumulated)) if accumulated.size else previous_damage
1526
+ rtcl_alpha = alpha_now
1527
+ location = f"rtcl[{int(np.argmax(accumulated))}]" if accumulated.size else location
1528
+ else:
1529
+ utilization = float(trigger_value) / max(threshold, 1.0e-30)
1530
+ max_utilization = max(max_utilization, utilization)
1531
+ damage = max(previous_damage, utilization)
1532
+ scale = _plastic_damage_scale(damage, config)
1533
+ history = list(previous.get("history", [])) if bool(config.record_history) else []
1534
+ if bool(config.record_history):
1535
+ history.append(
1536
+ {
1537
+ "time": float(time_value),
1538
+ "step_index": int(step_index),
1539
+ "equivalent_plastic_strain": float(trigger_value),
1540
+ "utilization": float(utilization),
1541
+ "damage": float(damage),
1542
+ "scale": float(scale),
1543
+ "location": str(location),
1544
+ }
1545
+ )
1546
+ if damage > previous_damage + 1.0e-15 or abs(scale - float(previous.get("scale", 1.0) or 1.0)) > 1.0e-15:
1547
+ changed = True
1548
+ entry = {
1549
+ "element_id": int(element_id),
1550
+ "damage": float(damage),
1551
+ "scale": float(scale),
1552
+ "max_equivalent_plastic_strain": max(float(previous.get("max_equivalent_plastic_strain", 0.0) or 0.0), float(trigger_value)),
1553
+ "max_utilization": max(float(previous.get("max_utilization", 0.0) or 0.0), float(utilization)),
1554
+ "location": str(location),
1555
+ "history": history,
1556
+ }
1557
+ if criterion in {"rtcl", "rtcl_modified"}:
1558
+ entry["rtcl_damage"] = accumulated
1559
+ entry["rtcl_alpha"] = rtcl_alpha
1560
+ damage_states[int(element_id)] = entry
1561
+ if int(element_id) not in deleted_element_ids and damage >= float(config.delete_at) - 1.0e-15:
1562
+ record = DeletedElementRecord(
1563
+ element_id=int(element_id),
1564
+ element_type=type(element).__name__,
1565
+ step_index=int(step_index),
1566
+ load_factor=float(time_value),
1567
+ trigger_name="rtcl_damage" if criterion in {"rtcl", "rtcl_modified"} else "max_equivalent_plastic_strain",
1568
+ trigger_value=float(damage if criterion in {"rtcl", "rtcl_modified"} else trigger_value),
1569
+ threshold=float(threshold),
1570
+ location=str(location),
1571
+ measure=element_measure(model.mesh, element),
1572
+ )
1573
+ new_records.append(record)
1574
+ deleted_element_ids.add(int(element_id))
1575
+ changed = True
1576
+ return new_records, changed, max_utilization
1577
+
1578
+
1579
+ def _plastic_impact_damage_summary(
1580
+ model: "FEModel",
1581
+ config: Optional[PlasticImpactDamageConfig],
1582
+ damage_states: Mapping[int, Mapping[str, Any]],
1583
+ deleted_element_ids: Sequence[int],
1584
+ deletion_records: Sequence[DeletedElementRecord],
1585
+ *,
1586
+ warnings: Sequence[str] = (),
1587
+ ) -> Dict[str, Any]:
1588
+ if config is None:
1589
+ return {"enabled": False, "records": [], "warnings": list(warnings)}
1590
+ scope = set(config.element_scope)
1591
+ scoped_count = sum(
1592
+ 1
1593
+ for element in model.mesh.elements.values()
1594
+ if element_fracture_category(element) in scope
1595
+ )
1596
+ deleted = sorted(int(element_id) for element_id in deleted_element_ids)
1597
+ records = []
1598
+ for element_id, state in sorted((int(k), v) for k, v in damage_states.items()):
1599
+ records.append(
1600
+ {
1601
+ "element_id": int(element_id),
1602
+ "damage": float(state.get("damage", 0.0) or 0.0),
1603
+ "scale": float(state.get("scale", 1.0) or 1.0),
1604
+ "max_equivalent_plastic_strain": float(state.get("max_equivalent_plastic_strain", 0.0) or 0.0),
1605
+ "max_utilization": float(state.get("max_utilization", 0.0) or 0.0),
1606
+ "location": str(state.get("location", "")),
1607
+ "history": list(state.get("history", []) or []),
1608
+ }
1609
+ )
1610
+ return {
1611
+ "enabled": True,
1612
+ "mode": "material_nonlinear_plastic_strain",
1613
+ "config": config.to_dict(),
1614
+ "deleted_count": len(deleted),
1615
+ "deleted_fraction": float(len(deleted) / max(scoped_count, 1)),
1616
+ "deleted_element_ids": deleted,
1617
+ "softened_element_ids": sorted(
1618
+ int(element_id)
1619
+ for element_id, state in damage_states.items()
1620
+ if int(element_id) not in set(deleted) and float(state.get("scale", 1.0) or 1.0) < 1.0
1621
+ ),
1622
+ "max_damage": max((float(state.get("damage", 0.0) or 0.0) for state in damage_states.values()), default=0.0),
1623
+ "max_utilization": max((float(state.get("max_utilization", 0.0) or 0.0) for state in damage_states.values()), default=0.0),
1624
+ "max_equivalent_plastic_strain": max(
1625
+ (float(state.get("max_equivalent_plastic_strain", 0.0) or 0.0) for state in damage_states.values()),
1626
+ default=0.0,
1627
+ ),
1628
+ "records": records,
1629
+ "deletion_records": [record.to_dict() for record in deletion_records],
1630
+ "warnings": list(warnings),
1631
+ }
1632
+
1633
+
1634
+ def _solve_transient_sphere_impact_nonlinear(
1635
+ model: "FEModel",
1636
+ transient_config: TransientConfig,
1637
+ sphere: RigidSphereImpact,
1638
+ config: SphereContactConfig,
1639
+ validation: ProductionValidationReport,
1640
+ *,
1641
+ base_load_case: Optional[LoadCase] = None,
1642
+ nonlinear_config: Optional[NonlinearTransientConfig] = None,
1643
+ plastic_damage_config: Optional[PlasticImpactDamageConfig] = None,
1644
+ progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
1645
+ status_callback: Optional[Callable[[str], None]] = None,
1646
+ ) -> SphereImpactResult:
1647
+ """Implicit Newmark nonlinear impact with material/geometric element response."""
1648
+ from .nonlinear_static import _assemble_nonlinear_system, _nonlinear_state_summary, states_von_mises_map
1649
+
1650
+ def assemble_nonlinear(
1651
+ displacements: np.ndarray,
1652
+ states: Dict[int, Any],
1653
+ *,
1654
+ tangent: bool,
1655
+ scales: Optional[Mapping[int, float]] = None,
1656
+ ) -> Tuple[np.ndarray, Any, Dict[int, Any]]:
1657
+ kwargs = {
1658
+ "deleted_element_ids": tuple(deleted_element_ids),
1659
+ "residual_stiffness_fraction": float(
1660
+ plastic_damage_config.residual_stiffness_fraction if plastic_damage_config is not None else 1.0
1661
+ ),
1662
+ "kinematics": str(nl.kinematics),
1663
+ }
1664
+ if scales:
1665
+ kwargs["element_stiffness_scales"] = scales
1666
+ try:
1667
+ return _assemble_nonlinear_system(
1668
+ model,
1669
+ displacements,
1670
+ states,
1671
+ int(nl.num_layers),
1672
+ tangent=tangent,
1673
+ **kwargs,
1674
+ )
1675
+ except TypeError as exc:
1676
+ if "element_stiffness_scales" not in str(exc):
1677
+ raise
1678
+ kwargs.pop("element_stiffness_scales", None)
1679
+ return _assemble_nonlinear_system(
1680
+ model,
1681
+ displacements,
1682
+ states,
1683
+ int(nl.num_layers),
1684
+ tangent=tangent,
1685
+ **kwargs,
1686
+ )
1687
+
1688
+ nl = nonlinear_config or NonlinearTransientConfig(enabled=True)
1689
+ model.apply_boundary_conditions()
1690
+ total_dofs = model.mesh.dof_manager.total_dofs
1691
+ needs_linear_stiffness = abs(float(transient_config.rayleigh_beta)) > 0.0
1692
+ if needs_linear_stiffness:
1693
+ K0, stiffness_info = assemble_stiffness_matrix(model)
1694
+ else:
1695
+ K0 = sparse.eye(total_dofs, format="csr", dtype=float)
1696
+ stiffness_info = {
1697
+ "assembly_skipped": True,
1698
+ "reason": "nonlinear_impact_no_stiffness_proportional_damping",
1699
+ }
1700
+ M, mass_info = assemble_mass_matrix(model)
1701
+ base_load, base_load_info = assemble_load_vector(model, base_load_case)
1702
+ zero_load = np.zeros(total_dofs, dtype=float)
1703
+ _K_red0, _zero_red, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K0, zero_load, model)
1704
+ K_red0 = (T.T @ K0 @ T).tocsr()
1705
+ K_energy_red = K_red0 if needs_linear_stiffness else None
1706
+ M_red = (T.T @ M @ T).tocsr()
1707
+ C_red = (transient_config.rayleigh_alpha * M_red + transient_config.rayleigh_beta * K_red0).tocsr()
1708
+ if float(np.linalg.norm(M_red.diagonal())) <= 0.0 and M_red.nnz == 0:
1709
+ raise ValueError("Nonlinear sphere impact requires a non-zero structural mass matrix; set material density values.")
1710
+
1711
+ times = _time_grid(transient_config)
1712
+ recovery = transient_config.recovery
1713
+ output_node_ids = tuple(int(node_id) for node_id in (transient_config.output_nodes or ()))
1714
+ if recovery is not None and not output_node_ids and recovery.node_ids is not None:
1715
+ output_node_ids = tuple(int(node_id) for node_id in recovery.node_ids)
1716
+ history_storage_mode = "full" if recovery is None else str(recovery.history_mode)
1717
+ if recovery is not None and history_storage_mode == "full" and not recovery.store_full_histories:
1718
+ history_storage_mode = "selected"
1719
+ if history_storage_mode == "selected" and not output_node_ids and (recovery is None or recovery.include_displacements):
1720
+ output_node_ids = tuple(int(node_id) for node_id in model.mesh.nodes)
1721
+ history_dof_indices = _node_dof_indices(model, output_node_ids) if history_storage_mode == "selected" else None
1722
+ estimated_saved_steps = _saved_step_count(times, transient_config.save_every)
1723
+ preflight_memory = estimate_model_memory(
1724
+ model,
1725
+ transient_saved_steps=estimated_saved_steps,
1726
+ store_full_history=history_storage_mode == "full",
1727
+ recovery_config=recovery,
1728
+ )
1729
+ enforce_memory_limit(preflight_memory, transient_config.resource_config, context="solve_transient_sphere_impact.nonlinear")
1730
+
1731
+ q = _full_initial_vector(transient_config.initial_displacement, total_dofs)
1732
+ v_full = _full_initial_vector(transient_config.initial_velocity, total_dofs)
1733
+ q_red = np.asarray((q - u0)[np.asarray(independent_dofs, dtype=int)], dtype=float).reshape(-1)
1734
+ v_red = np.asarray(v_full[np.asarray(independent_dofs, dtype=int)], dtype=float).reshape(-1)
1735
+ impact_energy_reference = max(0.5 * float(sphere.mass) * float(sphere.speed) ** 2, 1.0)
1736
+ base_load_equilibrated = False
1737
+ committed_states: Dict[int, Any] = {}
1738
+ deleted_element_ids: set[int] = set()
1739
+ damage_deleted_element_ids: set[int] = set()
1740
+ plastic_damage_records: List[DeletedElementRecord] = []
1741
+ plastic_damage_states: Dict[int, Dict[str, Any]] = {}
1742
+ damage_scale_by_element: Dict[int, float] = {}
1743
+ linear_matrix_terms: Optional[Tuple[int, Tuple[LinearElementMatrixTerms, ...]]] = None
1744
+ if (
1745
+ bool(nl.equilibrate_base_load)
1746
+ and transient_config.initial_displacement is None
1747
+ and float(np.linalg.norm(base_load)) > 0.0
1748
+ ):
1749
+ # Start from the NONLINEAR static equilibrium of the base load
1750
+ # instead of applying it as a step at t = 0: a stepped preload rings
1751
+ # the structure at its axial period through the whole impact,
1752
+ # distorting stresses and degrading Newton convergence. A short
1753
+ # Newton solve with residual backtracking finds the equilibrium; if
1754
+ # it cannot converge (e.g. the preload alone is beyond a limit
1755
+ # point), the solver falls back to the stepped start.
1756
+ base_reference = max(float(np.linalg.norm(np.asarray(T.T @ base_load, dtype=float))), 1.0e-30)
1757
+ q_static = np.zeros_like(q_red)
1758
+ try:
1759
+ for static_iteration in range(25):
1760
+ F_int_static, K_static, _trial_unused = assemble_nonlinear(
1761
+ reconstruct_full_solution(T, q_static, u0), {}, tangent=True
1762
+ )
1763
+ residual_static = np.asarray(T.T @ (base_load - F_int_static), dtype=float).reshape(-1)
1764
+ residual_static_norm = float(np.linalg.norm(residual_static))
1765
+ if residual_static_norm <= 1.0e-3 * base_reference:
1766
+ base_load_equilibrated = True
1767
+ break
1768
+ K_static_red = (T.T @ K_static @ T).tocsr()
1769
+ static_handle = factorize(
1770
+ K_static_red,
1771
+ MatrixClass.SYMMETRIC_INDEFINITE,
1772
+ signature=f"sphere_impact.nl.base_equilibrium:{static_iteration}",
1773
+ )
1774
+ dq_static = np.asarray(static_handle.solve(residual_static), dtype=float).reshape(-1)
1775
+ if not np.all(np.isfinite(dq_static)):
1776
+ break
1777
+ accepted_q = None
1778
+ step_factor = 1.0
1779
+ for _backtrack in range(8):
1780
+ q_candidate = q_static + step_factor * dq_static
1781
+ F_candidate, _K_unused, _states_unused = assemble_nonlinear(
1782
+ reconstruct_full_solution(T, q_candidate, u0), {}, tangent=False
1783
+ )
1784
+ candidate_norm = float(
1785
+ np.linalg.norm(np.asarray(T.T @ (base_load - F_candidate), dtype=float))
1786
+ )
1787
+ if candidate_norm < residual_static_norm:
1788
+ accepted_q = q_candidate
1789
+ break
1790
+ step_factor *= 0.5
1791
+ if accepted_q is None:
1792
+ break
1793
+ q_static = accepted_q
1794
+ except Exception:
1795
+ base_load_equilibrated = False
1796
+ if base_load_equilibrated:
1797
+ q_red = q_static
1798
+ F_int0, _K_dummy, trial0 = assemble_nonlinear(
1799
+ reconstruct_full_solution(T, q_red, u0),
1800
+ committed_states,
1801
+ tangent=False,
1802
+ )
1803
+ committed_states = trial0
1804
+ F_int_prev = np.asarray(F_int0, dtype=float).copy()
1805
+ F0_red = np.asarray(T.T @ (base_load - F_int0), dtype=float).reshape(-1)
1806
+ mass_handle = factorize(M_red, MatrixClass.SYMMETRIC_SEMIDEFINITE, signature="sphere_impact.nl.initial_mass")
1807
+ a_red = np.asarray(mass_handle.solve(F0_red - C_red @ v_red), dtype=float).reshape(-1)
1808
+
1809
+ sphere_position = sphere.initial_position
1810
+ sphere_velocity = sphere.travel_velocity if float(times[0]) >= sphere.t_start else np.zeros(3, dtype=float)
1811
+ sphere_acceleration = np.zeros(3, dtype=float)
1812
+
1813
+ saved_times: List[float] = []
1814
+ saved_u: List[np.ndarray] = []
1815
+ saved_v: List[np.ndarray] = []
1816
+ saved_a: List[np.ndarray] = []
1817
+ saved_sphere_x: List[np.ndarray] = []
1818
+ saved_sphere_v: List[np.ndarray] = []
1819
+ saved_sphere_a: List[np.ndarray] = []
1820
+ saved_contact_force: List[np.ndarray] = []
1821
+ saved_contacts: List[Tuple[Dict[str, Any], ...]] = []
1822
+ node_history_values: Dict[int, list[np.ndarray]] = {int(node_id): [] for node_id in output_node_ids}
1823
+ element_state_history: List[Dict[str, Any]] = []
1824
+ state_von_mises_history: List[Dict[int, float]] = []
1825
+ peak_displacement = 0.0
1826
+ peak_displacement_node = None
1827
+ max_penetration = 0.0
1828
+ peak_contact_force = 0.0
1829
+ contact_step_count = 0
1830
+ active_contact_duration = 0.0
1831
+ iteration_counts: List[int] = []
1832
+ energy_kinetic: List[float] = []
1833
+ energy_strain: List[float] = []
1834
+ sphere_kinetic: List[float] = []
1835
+ plastic_work_history: List[float] = []
1836
+
1837
+ def save_state(
1838
+ time: float,
1839
+ q_state: np.ndarray,
1840
+ v_state: np.ndarray,
1841
+ a_state: np.ndarray,
1842
+ sphere_x: np.ndarray,
1843
+ sphere_v: np.ndarray,
1844
+ sphere_a: np.ndarray,
1845
+ sphere_force: np.ndarray,
1846
+ records: Tuple[SphereContactRecord, ...],
1847
+ state_summary: Optional[Dict[str, Any]] = None,
1848
+ ) -> None:
1849
+ nonlocal peak_displacement, peak_displacement_node, max_penetration, peak_contact_force
1850
+ full_u = reconstruct_full_solution(T, q_state, u0)
1851
+ full_v = np.asarray(T @ v_state, dtype=float).reshape(-1)
1852
+ full_a = np.asarray(T @ a_state, dtype=float).reshape(-1)
1853
+ saved_times.append(float(time))
1854
+ if history_storage_mode == "full":
1855
+ saved_u.append(full_u)
1856
+ saved_v.append(full_v)
1857
+ saved_a.append(full_a)
1858
+ elif history_storage_mode == "selected":
1859
+ indices = np.asarray(history_dof_indices if history_dof_indices is not None else (), dtype=np.intp)
1860
+ saved_u.append(full_u[indices])
1861
+ saved_v.append(full_v[indices])
1862
+ saved_a.append(full_a[indices])
1863
+ for node_id in output_node_ids:
1864
+ node = model.mesh.get_node(int(node_id))
1865
+ if node is not None:
1866
+ node_history_values[int(node_id)].append(full_u[np.asarray(node.dofs, dtype=np.intp)])
1867
+ current_peak, current_node = _translation_peak(model, full_u)
1868
+ if current_peak > peak_displacement:
1869
+ peak_displacement = current_peak
1870
+ peak_displacement_node = current_node
1871
+ if records:
1872
+ max_penetration = max(max_penetration, max(float(record.penetration) for record in records))
1873
+ peak_contact_force = max(peak_contact_force, max(float(record.normal_force) for record in records))
1874
+ saved_sphere_x.append(np.asarray(sphere_x, dtype=float).copy())
1875
+ saved_sphere_v.append(np.asarray(sphere_v, dtype=float).copy())
1876
+ saved_sphere_a.append(np.asarray(sphere_a, dtype=float).copy())
1877
+ saved_contact_force.append(np.asarray(sphere_force, dtype=float).copy())
1878
+ saved_contacts.append(tuple(record.to_dict() for record in records) if config.save_contact_history else tuple())
1879
+ energy_kinetic.append(0.5 * float(v_state @ (M_red @ v_state)))
1880
+ sphere_kinetic.append(0.5 * float(sphere.mass) * float(sphere_v @ sphere_v))
1881
+ if K_energy_red is not None:
1882
+ energy_strain.append(0.5 * float(q_state @ (K_energy_red @ q_state)))
1883
+ else:
1884
+ # internal work measure from the committed nonlinear internal
1885
+ # force: exact strain energy for the elastic response, and an
1886
+ # internal-work proxy (elastic + dissipated) once plasticity is
1887
+ # active.
1888
+ energy_strain.append(0.5 * float(full_u @ F_int_prev))
1889
+ # One summary pass per saved step: it walks every element state, so
1890
+ # repeating it for the plastic-work history, the state history and
1891
+ # the progress payload triples a cost that rivals the Newton solves.
1892
+ # The stepping loop passes its own summary in to avoid recomputing.
1893
+ summary = state_summary if state_summary is not None else _nonlinear_state_summary(committed_states)
1894
+ max_alpha = float(summary.get("max_equivalent_plastic_strain", 0.0) or 0.0)
1895
+ plastic_work_history.append(max_alpha)
1896
+ if bool(nl.record_element_state_history):
1897
+ element_state_history.append({"time": float(time), **summary})
1898
+ # True (return-mapped) stress envelope per element at this saved
1899
+ # step: an elastic recovery from the saved displacements ignores
1900
+ # the plastic strain and overshoots the material curve.
1901
+ state_von_mises_history.append(states_von_mises_map(model, committed_states))
1902
+ if progress_callback is not None and history_storage_mode == "full":
1903
+ try:
1904
+ progress_callback(
1905
+ {
1906
+ "type": "sphere_impact_live_step",
1907
+ "time_s": float(time),
1908
+ "step_index": int(len(saved_times) - 1),
1909
+ "displacement": full_u.copy(),
1910
+ "sphere_position": np.asarray(sphere_x, dtype=float).copy(),
1911
+ "sphere_radius": float(sphere.radius),
1912
+ "contact_force": np.asarray(sphere_force, dtype=float).copy(),
1913
+ "active_contacts": tuple(record.to_dict() for record in records) if config.save_contact_history else tuple(),
1914
+ "nonlinear": True,
1915
+ "max_equivalent_plastic_strain": max_alpha,
1916
+ }
1917
+ )
1918
+ except Exception:
1919
+ pass
1920
+
1921
+ initial_load, initial_sphere_force, initial_records = assemble_sphere_contact_load_vector(
1922
+ model,
1923
+ sphere,
1924
+ config,
1925
+ sphere_position,
1926
+ sphere_velocity,
1927
+ structural_displacement=reconstruct_full_solution(T, q_red, u0),
1928
+ structural_velocity=np.asarray(T @ v_red, dtype=float).reshape(-1),
1929
+ deleted_element_ids=tuple(deleted_element_ids),
1930
+ contact_scale_by_element=damage_scale_by_element,
1931
+ )
1932
+ save_state(float(times[0]), q_red, v_red, a_red, sphere_position, sphere_velocity, sphere_acceleration, initial_sphere_force, initial_records)
1933
+ sticky_contact_ids: Tuple[int, ...] = tuple(int(record.element_id) for record in initial_records)
1934
+
1935
+ load_prev = base_load + initial_load
1936
+ sphere_force_prev = initial_sphere_force.copy()
1937
+ impulse = np.zeros(total_dofs, dtype=float)
1938
+ sphere_impulse = np.zeros(3, dtype=float)
1939
+ status = "completed"
1940
+ stop_reason = "completed"
1941
+ warnings: List[str] = [f"{issue.code}: {issue.message}" for issue in validation.warnings]
1942
+ warnings.append(
1943
+ "NONLINEAR_IMPACT001: material-nonlinear impact uses implicit Newmark with structural nonlinear tangent "
1944
+ "and iterative penalty contact; exact contact tangent, friction, spin, tearing paths and FSI are unsupported."
1945
+ )
1946
+ factorization_count = 0
1947
+ solve_count = 0
1948
+ total_substep_count = 0
1949
+ event_substep_count = 0
1950
+ cutback_count = 0
1951
+ step_diagnostics: List[Dict[str, Any]] = []
1952
+ nonlinear_failure_summary: Dict[str, Any] = {}
1953
+ alpha_h, beta, gamma = transient_config.integration_parameters()
1954
+ one_plus_alpha = 1.0 + alpha_h
1955
+ separation_elapsed = 0.0
1956
+ separation_stop_time = float(config.post_separation_time)
1957
+
1958
+ segments: List[Tuple[int, float, float, bool]] = [
1959
+ (int(index), float(times[index - 1]), float(times[index]), True)
1960
+ for index in range(1, len(times))
1961
+ ]
1962
+ travel_scale = max(float(sphere.radius) * float(config.max_sphere_travel_fraction), 1.0e-12)
1963
+ pending_save: Optional[Tuple[float, np.ndarray, Tuple[SphereContactRecord, ...]]] = None
1964
+ # Convergence-distress carryover: after a cutback, upcoming base steps start
1965
+ # pre-halved (up to quartered) instead of retrying the full dt, then the
1966
+ # subdivision decays as Newton recovers. This avoids paying a full failed
1967
+ # Newton budget on every step of a difficult (yielding/limit-point) phase.
1968
+ distress_halvings = 0
1969
+ preemptive_substep_count = 0
1970
+ while segments:
1971
+ step_index, segment_start, sub_time, needs_travel_check = segments.pop(0)
1972
+ dt = float(sub_time - segment_start)
1973
+ if dt <= 0.0:
1974
+ continue
1975
+ if needs_travel_check:
1976
+ predicted_travel = max(float(np.linalg.norm(sphere_velocity)) * dt, float(sphere.speed) * dt)
1977
+ n_travel = int(np.ceil(predicted_travel / travel_scale))
1978
+ if distress_halvings > 0:
1979
+ n_travel = max(n_travel, 2 ** distress_halvings)
1980
+ n_travel = min(max(n_travel, 1), int(config.max_event_substeps))
1981
+ if n_travel > 1:
1982
+ pieces: List[Tuple[int, float, float, bool]] = []
1983
+ piece_start = float(segment_start)
1984
+ for piece in range(1, n_travel + 1):
1985
+ piece_end = float(sub_time) if piece == n_travel else float(segment_start) + piece * dt / n_travel
1986
+ pieces.append((step_index, piece_start, piece_end, False))
1987
+ piece_start = piece_end
1988
+ segments[:0] = pieces
1989
+ event_substep_count += n_travel - 1
1990
+ if distress_halvings > 0:
1991
+ preemptive_substep_count += n_travel - 1
1992
+ continue
1993
+ total_substep_count += 1
1994
+ pre_state = (
1995
+ q_red.copy(),
1996
+ v_red.copy(),
1997
+ a_red.copy(),
1998
+ sphere_position.copy(),
1999
+ sphere_velocity.copy(),
2000
+ sphere_acceleration.copy(),
2001
+ dict(committed_states),
2002
+ set(deleted_element_ids),
2003
+ dict(plastic_damage_states),
2004
+ dict(damage_scale_by_element),
2005
+ load_prev.copy(),
2006
+ sphere_force_prev.copy(),
2007
+ F_int_prev.copy(),
2008
+ )
2009
+ if sub_time - dt < sphere.t_start <= sub_time and np.linalg.norm(sphere_velocity) == 0.0:
2010
+ sphere_velocity = sphere.travel_velocity.copy()
2011
+ a0 = 1.0 / (beta * dt**2)
2012
+ a1 = gamma / (beta * dt)
2013
+ a2 = 1.0 / (beta * dt)
2014
+ a3 = 1.0 / (2.0 * beta) - 1.0
2015
+ a4 = gamma / beta - 1.0
2016
+ a5 = dt * (gamma / (2.0 * beta) - 1.0)
2017
+ q_trial = q_red.copy()
2018
+ contact_load = np.zeros(total_dofs, dtype=float)
2019
+ sphere_force = np.zeros(3, dtype=float)
2020
+ records: Tuple[SphereContactRecord, ...] = tuple()
2021
+ converged = False
2022
+ residual_norm = float("inf")
2023
+ displacement_increment = float("inf")
2024
+ residual_energy_increment = float("inf")
2025
+ contact_change = float("inf")
2026
+ reference = 1.0
2027
+ contact_scale = 1.0
2028
+ trial_states: Dict[int, Any] = committed_states
2029
+ q_next = q_red.copy()
2030
+ v_next = v_red.copy()
2031
+ a_next = a_red.copy()
2032
+ sphere_x_next = sphere_position.copy()
2033
+ sphere_v_next = sphere_velocity.copy()
2034
+ sphere_a_next = sphere_acceleration.copy()
2035
+ factor_diagnostics: Dict[str, Any] = {}
2036
+ convergence_reason = "standard"
2037
+ for iteration in range(1, int(nl.max_iterations) + 1):
2038
+ if status_callback:
2039
+ status_callback(f"\r Time {sub_time:.4f} s, Iteration {iteration}: Res {residual_norm:.2e}")
2040
+ full_u = reconstruct_full_solution(T, q_trial, u0)
2041
+ F_int, K_T, trial_states = assemble_nonlinear(
2042
+ full_u,
2043
+ committed_states,
2044
+ tangent=True,
2045
+ scales=damage_scale_by_element,
2046
+ )
2047
+ a_trial = a0 * (q_trial - q_red) - a2 * v_red - a3 * a_red
2048
+ v_trial = v_red + dt * ((1.0 - gamma) * a_red + gamma * a_trial)
2049
+ if alpha_h != 0.0:
2050
+ weighted_force = one_plus_alpha * (base_load + contact_load - F_int) - alpha_h * (load_prev - F_int_prev)
2051
+ residual = (
2052
+ np.asarray(T.T @ weighted_force, dtype=float).reshape(-1)
2053
+ - one_plus_alpha * (C_red @ v_trial)
2054
+ + alpha_h * (C_red @ v_red)
2055
+ - M_red @ a_trial
2056
+ )
2057
+ else:
2058
+ residual = np.asarray(T.T @ (base_load + contact_load - F_int), dtype=float).reshape(-1) - C_red @ v_trial - M_red @ a_trial
2059
+ residual_norm = float(np.linalg.norm(residual))
2060
+ reference = max(float(np.linalg.norm(np.asarray(T.T @ (base_load + contact_load), dtype=float))), float(np.linalg.norm(np.asarray(T.T @ F_int, dtype=float))), 1.0)
2061
+ K_eff = (one_plus_alpha * (T.T @ K_T @ T) + a0 * M_red + one_plus_alpha * a1 * C_red).tocsr()
2062
+ try:
2063
+ handle = factorize(K_eff, MatrixClass.SYMMETRIC_INDEFINITE, signature=f"sphere_impact.nl.effective:{dt:.16g}:{iteration}")
2064
+ factor_diagnostics = handle.diagnostics()
2065
+ delta = np.asarray(handle.solve(residual), dtype=float).reshape(-1)
2066
+ factorization_count += 1
2067
+ solve_count += 1
2068
+ except Exception:
2069
+ status = "nonlinear_tangent_failed"
2070
+ stop_reason = "nonlinear_tangent_factorization_failed"
2071
+ break
2072
+ if np.any(~np.isfinite(delta)):
2073
+ status = "nonlinear_iteration_failed"
2074
+ stop_reason = "nonfinite_nonlinear_increment"
2075
+ break
2076
+ factor = 1.0
2077
+ if bool(nl.line_search):
2078
+ factor = 1.0
2079
+ while factor > float(nl.min_line_search_factor) and float(np.linalg.norm(factor * delta)) > 10.0 * max(float(np.linalg.norm(q_trial)), 1.0):
2080
+ factor *= 0.5
2081
+ q_next = q_trial + factor * delta
2082
+ displacement_increment = float(np.linalg.norm(factor * delta))
2083
+ residual_energy_increment = abs(float(np.dot(factor * delta, residual)))
2084
+ a_next = a0 * (q_next - q_red) - a2 * v_red - a3 * a_red
2085
+ v_next = v_red + dt * ((1.0 - gamma) * a_red + gamma * a_next)
2086
+ sphere_x_pred = sphere_position + dt * sphere_velocity + dt**2 * (0.5 - beta) * sphere_acceleration
2087
+ sphere_v_pred = sphere_velocity + dt * (1.0 - gamma) * sphere_acceleration
2088
+ sphere_a_next = sphere_force / float(sphere.mass)
2089
+ sphere_x_next = sphere_x_pred + beta * dt**2 * sphere_a_next
2090
+ sphere_v_next = sphere_v_pred + gamma * dt * sphere_a_next
2091
+ full_u_next = reconstruct_full_solution(T, q_next, u0)
2092
+ full_v_next = np.asarray(T @ v_next, dtype=float).reshape(-1)
2093
+ new_contact_load, new_sphere_force, new_records = assemble_sphere_contact_load_vector(
2094
+ model,
2095
+ sphere,
2096
+ config,
2097
+ sphere_x_next,
2098
+ sphere_v_next,
2099
+ structural_displacement=full_u_next,
2100
+ structural_velocity=full_v_next,
2101
+ deleted_element_ids=tuple(deleted_element_ids),
2102
+ contact_scale_by_element=damage_scale_by_element,
2103
+ preferred_element_ids=sticky_contact_ids,
2104
+ )
2105
+ if new_records:
2106
+ sticky_contact_ids = tuple(int(record.element_id) for record in new_records)
2107
+ contact_scale = max(float(np.linalg.norm(new_sphere_force)), 1.0)
2108
+ contact_change = float(np.linalg.norm(new_sphere_force - sphere_force))
2109
+ contact_load, sphere_force, records = new_contact_load, new_sphere_force, new_records
2110
+ q_trial = q_next
2111
+ displacement_limit = float(nl.displacement_tolerance) * max(float(np.linalg.norm(q_next)), 1.0)
2112
+ residual_limit = float(nl.residual_tolerance) * reference
2113
+ force_limit = max(float(nl.contact_force_tolerance) * contact_scale, float(config.force_tolerance))
2114
+ standard_converged = (
2115
+ residual_norm <= residual_limit
2116
+ and displacement_increment <= displacement_limit
2117
+ and contact_change <= force_limit
2118
+ )
2119
+ contact_stall_converged = (
2120
+ iteration >= max(3, int(nl.max_iterations) // 2)
2121
+ and residual_norm <= 1.0e-2 * reference
2122
+ and displacement_increment <= max(10.0 * displacement_limit, 1.0e-6)
2123
+ and contact_change <= max(5.0e-3 * contact_scale, 10.0 * float(config.force_tolerance))
2124
+ )
2125
+ # Stagnation acceptance: the iterate is a fixed point (nanometre
2126
+ # displacement increments, settled contact) and the residual's
2127
+ # energy content is a negligible fraction of the impact energy.
2128
+ # Cutting dt cannot reduce such a residual (it is a force/tangent
2129
+ # noise floor, typically on rotational equations), so the step is
2130
+ # accepted rather than exhausting the cutback budget.
2131
+ stagnation_converged = (
2132
+ float(nl.stagnation_energy_tolerance) > 0.0
2133
+ and iteration >= 5
2134
+ and displacement_increment <= displacement_limit
2135
+ and contact_change <= force_limit
2136
+ and residual_energy_increment <= float(nl.stagnation_energy_tolerance) * impact_energy_reference
2137
+ )
2138
+ if standard_converged or contact_stall_converged or stagnation_converged:
2139
+ converged = True
2140
+ if standard_converged:
2141
+ convergence_reason = "standard"
2142
+ elif contact_stall_converged:
2143
+ convergence_reason = "contact_stall"
2144
+ else:
2145
+ convergence_reason = "stagnation_energy"
2146
+ iteration_counts.append(iteration)
2147
+ # Newton recovered comfortably: relax the distress carryover so
2148
+ # the base time step is restored once the difficult phase ends.
2149
+ if iteration <= max(4, int(nl.max_iterations) // 3):
2150
+ distress_halvings = max(distress_halvings - 1, 0)
2151
+ elif iteration >= int(nl.max_iterations) - 2:
2152
+ distress_halvings = min(distress_halvings + 1, 2)
2153
+ break
2154
+ if not converged:
2155
+ if status in {"nonlinear_tangent_failed", "nonlinear_iteration_failed"}:
2156
+ pass
2157
+ elif cutback_count < int(nl.max_cutbacks) and dt * 0.5 >= float(nl.min_dt):
2158
+ (
2159
+ q_red,
2160
+ v_red,
2161
+ a_red,
2162
+ sphere_position,
2163
+ sphere_velocity,
2164
+ sphere_acceleration,
2165
+ committed_states,
2166
+ deleted_element_ids,
2167
+ plastic_damage_states,
2168
+ damage_scale_by_element,
2169
+ load_prev,
2170
+ sphere_force_prev,
2171
+ F_int_prev,
2172
+ ) = pre_state
2173
+ midpoint = 0.5 * (float(segment_start) + float(sub_time))
2174
+ segments.insert(0, (step_index, midpoint, float(sub_time), False))
2175
+ segments.insert(0, (step_index, float(segment_start), midpoint, False))
2176
+ cutback_count += 1
2177
+ event_substep_count += 1
2178
+ distress_halvings = min(distress_halvings + 1, 2)
2179
+ step_diagnostics.append(
2180
+ {
2181
+ "step_index": int(step_index),
2182
+ "time": float(sub_time),
2183
+ "dt": float(dt),
2184
+ "status": "nonlinear_cutback_retry",
2185
+ "residual_norm": float(residual_norm),
2186
+ "effective_residual_tolerance": float(nl.residual_tolerance) * float(reference),
2187
+ "displacement_increment": float(displacement_increment),
2188
+ "contact_force_change": float(contact_change),
2189
+ "effective_force_tolerance": max(float(nl.contact_force_tolerance) * float(contact_scale), float(config.force_tolerance)),
2190
+ }
2191
+ )
2192
+ nonlinear_failure_summary = dict(step_diagnostics[-1])
2193
+ nonlinear_failure_summary["suggestion"] = (
2194
+ "The nonlinear contact/plasticity iteration recovered by cutting the time step. "
2195
+ "If failures persist, reduce dt/penalty, allow more cutbacks, or loosen the nonlinear contact tolerance."
2196
+ )
2197
+ continue
2198
+ else:
2199
+ status = "nonlinear_iteration_failed"
2200
+ stop_reason = "maximum_iterations_or_cutbacks_reached"
2201
+ active_ids = [int(record.element_id) for record in records]
2202
+ # Residual decomposition at the failed iterate: which physics
2203
+ # carries the unconverged force imbalance. Essential for
2204
+ # diagnosing stagnation (tiny displacement increments with a
2205
+ # stuck residual) versus genuine divergence.
2206
+ decomposition: Dict[str, float] = {}
2207
+ try:
2208
+ full_u_fail = reconstruct_full_solution(T, q_trial, u0)
2209
+ F_int_fail, _K_unused, _states_unused = assemble_nonlinear(
2210
+ full_u_fail, committed_states, tangent=False, scales=damage_scale_by_element
2211
+ )
2212
+ a_fail = a0 * (q_trial - q_red) - a2 * v_red - a3 * a_red
2213
+ v_fail = v_red + dt * ((1.0 - gamma) * a_red + gamma * a_fail)
2214
+ decomposition = {
2215
+ "residual_contact_norm": float(np.linalg.norm(np.asarray(T.T @ contact_load, dtype=float))),
2216
+ "residual_base_load_norm": float(np.linalg.norm(np.asarray(T.T @ base_load, dtype=float))),
2217
+ "residual_internal_norm": float(np.linalg.norm(np.asarray(T.T @ F_int_fail, dtype=float))),
2218
+ "residual_damping_norm": float(np.linalg.norm(C_red @ v_fail)),
2219
+ "residual_inertia_norm": float(np.linalg.norm(M_red @ a_fail)),
2220
+ }
2221
+ except Exception:
2222
+ decomposition = {}
2223
+ nonlinear_failure_summary = {
2224
+ "step_index": int(step_index),
2225
+ "time": float(sub_time),
2226
+ "dt": float(dt),
2227
+ "status": str(status),
2228
+ "stop_reason": str(stop_reason),
2229
+ "iterations": int(nl.max_iterations),
2230
+ "cutbacks": int(cutback_count),
2231
+ "residual_norm": float(residual_norm),
2232
+ "effective_residual_tolerance": float(nl.residual_tolerance) * float(reference),
2233
+ "displacement_increment": float(displacement_increment),
2234
+ "residual_energy_increment": float(residual_energy_increment),
2235
+ "impact_energy_reference": float(impact_energy_reference),
2236
+ "contact_force_change": float(contact_change),
2237
+ "effective_force_tolerance": max(float(nl.contact_force_tolerance) * float(contact_scale), float(config.force_tolerance)),
2238
+ "active_element_ids": active_ids,
2239
+ "max_penetration": float(max((record.penetration for record in records), default=0.0)),
2240
+ **decomposition,
2241
+ "suggestion": (
2242
+ "The nonlinear impact solve exhausted its time-step cutbacks. "
2243
+ "Use a smaller manual dt, increase NL cutbacks, reduce contact penalty/target penetration stiffness, "
2244
+ "or run the linear collision path first to confirm the contact setup."
2245
+ ),
2246
+ }
2247
+ if not iteration_counts:
2248
+ iteration_counts.append(int(nl.max_iterations))
2249
+ break
2250
+
2251
+ committed_states = trial_states
2252
+ contact_norm = float(np.linalg.norm(sphere_force))
2253
+ if contact_norm > 0.0:
2254
+ contact_step_count += 1
2255
+ active_contact_duration += dt
2256
+ separation_elapsed = 0.0
2257
+ elif contact_step_count > 0 and separation_stop_time > 0.0:
2258
+ separation_elapsed += dt
2259
+ if records:
2260
+ max_penetration = max(max_penetration, max(float(record.penetration) for record in records))
2261
+ peak_contact_force = max(peak_contact_force, max(float(record.normal_force) for record in records))
2262
+ impulse += 0.5 * (load_prev + base_load + contact_load) * dt
2263
+ sphere_impulse += 0.5 * (sphere_force_prev + sphere_force) * dt
2264
+ load_prev = base_load + contact_load
2265
+ F_int_prev = F_int
2266
+ sphere_force_prev = sphere_force.copy()
2267
+ q_red, v_red, a_red = q_next, v_next, a_next
2268
+ sphere_position, sphere_velocity, sphere_acceleration = sphere_x_next, sphere_v_next, sphere_a_next
2269
+ new_damage_records, damage_changed, max_damage_util = _plastic_impact_damage_update(
2270
+ model,
2271
+ committed_states,
2272
+ plastic_damage_config,
2273
+ deleted_element_ids,
2274
+ plastic_damage_states,
2275
+ step_index=int(step_index),
2276
+ time_value=float(sub_time),
2277
+ )
2278
+ if new_damage_records:
2279
+ plastic_damage_records.extend(new_damage_records)
2280
+ damage_deleted_element_ids.update(record.element_id for record in new_damage_records)
2281
+ if plastic_damage_config is not None:
2282
+ damage_scale_by_element = {
2283
+ int(element_id): float(state.get("scale", 1.0) or 1.0)
2284
+ for element_id, state in plastic_damage_states.items()
2285
+ if float(state.get("scale", 1.0) or 1.0) < 1.0
2286
+ }
2287
+ for element_id in deleted_element_ids:
2288
+ damage_scale_by_element[int(element_id)] = float(plastic_damage_config.residual_stiffness_fraction)
2289
+ if damage_changed:
2290
+ filtered_base = filtered_load_case_for_deleted_elements(base_load_case, deleted_element_ids)
2291
+ base_load, base_load_info = assemble_load_vector(model, filtered_base)
2292
+ if linear_matrix_terms is None:
2293
+ linear_matrix_terms = _linear_element_matrix_terms(model)
2294
+ _K_scaled, M_scaled = _assemble_damaged_linear_matrices(model, damage_scale_by_element, cached_terms=linear_matrix_terms)
2295
+ M_red = (T.T @ M_scaled @ T).tocsr()
2296
+ C_red = (transient_config.rayleigh_alpha * M_red + transient_config.rayleigh_beta * K_red0).tocsr()
2297
+ scoped_total = sum(
2298
+ 1
2299
+ for element in model.mesh.elements.values()
2300
+ if element_fracture_category(element) in set(plastic_damage_config.element_scope)
2301
+ )
2302
+ if len(deleted_element_ids) / max(scoped_total, 1) > float(plastic_damage_config.max_deleted_fraction) + 1.0e-12:
2303
+ status = "max_deleted_fraction_reached"
2304
+ stop_reason = "plastic_impact_damage_max_deleted_fraction_reached"
2305
+ state_summary = _nonlinear_state_summary(committed_states)
2306
+ step_diagnostics.append(
2307
+ {
2308
+ "step_index": int(step_index),
2309
+ "time": float(sub_time),
2310
+ "dt": float(dt),
2311
+ "status": "converged",
2312
+ "iterations": int(iteration_counts[-1]),
2313
+ "residual_norm": float(residual_norm),
2314
+ "displacement_increment": float(displacement_increment),
2315
+ "contact_force_change": float(contact_change),
2316
+ "convergence_reason": str(convergence_reason),
2317
+ "active_element_ids": [int(record.element_id) for record in records],
2318
+ "max_penetration": float(max((record.penetration for record in records), default=0.0)),
2319
+ "max_equivalent_plastic_strain": float(state_summary.get("max_equivalent_plastic_strain", 0.0) or 0.0),
2320
+ "max_damage_utilization": float(max_damage_util),
2321
+ }
2322
+ )
2323
+ step_complete = float(sub_time) >= float(times[step_index]) - 1.0e-12 * max(abs(float(times[step_index])), 1.0)
2324
+ if step_complete and (step_index % int(transient_config.save_every) == 0 or step_index == len(times) - 1):
2325
+ save_state(sub_time, q_red, v_red, a_red, sphere_position, sphere_velocity, sphere_acceleration, sphere_force, records, state_summary=state_summary)
2326
+ pending_save = None
2327
+ else:
2328
+ pending_save = (float(sub_time), sphere_force.copy(), records)
2329
+ if status == "max_deleted_fraction_reached":
2330
+ break
2331
+ if status == "completed" and contact_step_count > 0 and separation_stop_time > 0.0 and separation_elapsed >= separation_stop_time:
2332
+ stop_reason = "completed_after_contact_separation"
2333
+ break
2334
+
2335
+ if pending_save is not None:
2336
+ save_state(
2337
+ pending_save[0], q_red, v_red, a_red, sphere_position, sphere_velocity, sphere_acceleration, pending_save[1], pending_save[2]
2338
+ )
2339
+
2340
+ if contact_step_count == 0 and status == "completed":
2341
+ status = "no_contact"
2342
+ stop_reason = "no_contact"
2343
+ impulse_resultant = load_vector_resultant(model, impulse)
2344
+ max_penetration_ratio = float(max_penetration) / max(float(sphere.radius), 1.0e-12)
2345
+ contact_duration = float(active_contact_duration)
2346
+ initial_sphere_velocity = sphere.travel_velocity if float(times[0]) >= sphere.t_start else np.zeros(3, dtype=float)
2347
+ final_sphere_velocity = np.asarray(saved_sphere_v[-1], dtype=float) if saved_sphere_v else sphere_velocity
2348
+ sphere_momentum_change = float(sphere.mass) * (final_sphere_velocity - initial_sphere_velocity)
2349
+ sphere_momentum_balance_error = float(np.linalg.norm(sphere_impulse - sphere_momentum_change))
2350
+ history_width = total_dofs if history_storage_mode == "full" else int(0 if history_dof_indices is None else len(history_dof_indices))
2351
+ saved_u_array = np.vstack(saved_u) if saved_u else np.zeros((0, history_width), dtype=float)
2352
+ saved_v_array = np.vstack(saved_v) if saved_v else np.zeros((0, history_width), dtype=float)
2353
+ saved_a_array = np.vstack(saved_a) if saved_a else np.zeros((0, history_width), dtype=float)
2354
+ node_histories: Dict[int, np.ndarray] = {}
2355
+ for node_id in output_node_ids:
2356
+ values = node_history_values.get(int(node_id), [])
2357
+ node_histories[int(node_id)] = np.vstack(values) if values else np.zeros((0, 6), dtype=float)
2358
+ recovery_memory = estimate_model_memory(
2359
+ model,
2360
+ transient_saved_steps=len(saved_times),
2361
+ store_full_history=history_storage_mode == "full",
2362
+ recovery_config=recovery,
2363
+ )
2364
+ enforce_memory_limit(recovery_memory, transient_config.resource_config, context="solve_transient_sphere_impact.nonlinear.recovery")
2365
+ policy_metadata = recovery_metadata(recovery, transient_config.resource_config, recovery_memory)
2366
+ assembly_info = {"stiffness": stiffness_info, "mass": mass_info, "load": base_load_info}
2367
+ plastic_damage_summary = _plastic_impact_damage_summary(
2368
+ model,
2369
+ plastic_damage_config,
2370
+ plastic_damage_states,
2371
+ damage_deleted_element_ids,
2372
+ plastic_damage_records,
2373
+ warnings=[warning for warning in warnings if str(warning).startswith("NONLINEAR_IMPACT")],
2374
+ )
2375
+ erosion_summary = {
2376
+ "all_eroded_element_ids": sorted(int(element_id) for element_id in deleted_element_ids),
2377
+ "damage_triggered_element_ids": sorted(int(element_id) for element_id in damage_deleted_element_ids),
2378
+ "active_softened_element_ids": sorted(
2379
+ int(element_id)
2380
+ for element_id, scale in damage_scale_by_element.items()
2381
+ if int(element_id) not in deleted_element_ids and float(scale) < 1.0
2382
+ ),
2383
+ "residual_stiffness_model": "nonlinear element force/tangent and mass scaling; topology, nodes, MPCs retained",
2384
+ }
2385
+ result_case = make_result_case(
2386
+ name=f"sphere_impact_nonlinear:{sphere.name}",
2387
+ analysis_type="sphere_impact_nonlinear_transient",
2388
+ load_cases=() if base_load_case is None else (base_load_case,),
2389
+ assembly_info=assembly_info,
2390
+ solver_info={"backend": factor_diagnostics, "convergence_info": {"status": status, "stop_reason": stop_reason}},
2391
+ recovery={
2392
+ "displacement_history": True,
2393
+ "velocity_history": True,
2394
+ "acceleration_history": True,
2395
+ "sphere_history": True,
2396
+ "contact_history": bool(config.save_contact_history),
2397
+ "element_state_history": bool(nl.record_element_state_history),
2398
+ "history_storage_mode": history_storage_mode,
2399
+ **policy_metadata["recovery"],
2400
+ },
2401
+ settings={
2402
+ "dt": transient_config.dt,
2403
+ "t_end": transient_config.t_end,
2404
+ "beta": beta,
2405
+ "gamma": gamma,
2406
+ "hht_alpha": alpha_h,
2407
+ "rayleigh_alpha": transient_config.rayleigh_alpha,
2408
+ "rayleigh_beta": transient_config.rayleigh_beta,
2409
+ "sphere": {
2410
+ "name": sphere.name,
2411
+ "radius": float(sphere.radius),
2412
+ "mass": float(sphere.mass),
2413
+ "start_point": sphere.initial_position.tolist(),
2414
+ "travel_direction": sphere.direction_unit.tolist(),
2415
+ "speed": float(sphere.speed),
2416
+ "t_start": float(sphere.t_start),
2417
+ },
2418
+ "contact": config.to_dict(),
2419
+ "nonlinear": nl.to_dict(),
2420
+ "plastic_damage": None if plastic_damage_config is None else plastic_damage_config.to_dict(),
2421
+ },
2422
+ metadata={
2423
+ "resources": policy_metadata.get("resources"),
2424
+ "memory_estimate": policy_metadata.get("memory_estimate"),
2425
+ "contact_scope": "single rigid sphere to shell target; frictionless normal penalty with iterative active-set updates",
2426
+ "nonlinear_scope": "implicit Newmark; current shell/beam nonlinear element formulations; approximate contact tangent",
2427
+ "solution_control": "implicit_newmark_time_domain",
2428
+ "arc_length_applicability": "not_applicable_to_dynamic_impact",
2429
+ "solver_convergence": {"status": status, "stop_reason": stop_reason},
2430
+ "verification_gate": "nonlinear_contact",
2431
+ },
2432
+ ).to_dict()
2433
+ total_energy = (
2434
+ np.asarray(energy_kinetic, dtype=float)
2435
+ + np.asarray(energy_strain, dtype=float)
2436
+ + np.asarray(sphere_kinetic, dtype=float)
2437
+ )
2438
+ nonzero_energy = total_energy[np.abs(total_energy) > 1.0e-30]
2439
+ energy_drift = 0.0
2440
+ if nonzero_energy.size:
2441
+ energy_drift = float((np.max(nonzero_energy) - np.min(nonzero_energy)) / max(abs(nonzero_energy[0]), 1.0e-30))
2442
+ diagnostics = {
2443
+ "method": "nonlinear_newmark_sphere_penalty_contact",
2444
+ "solution_control": "implicit_newmark_time_domain",
2445
+ "arc_length_applicability": "not_applicable_to_dynamic_impact",
2446
+ "status": status,
2447
+ "stop_reason": stop_reason,
2448
+ "warnings": warnings,
2449
+ "num_steps": max(int(len(times) - 1), 0),
2450
+ "num_substeps": int(total_substep_count),
2451
+ "event_substep_count": int(event_substep_count),
2452
+ "preemptive_substep_count": int(preemptive_substep_count),
2453
+ "cutback_count": int(cutback_count),
2454
+ "num_saved_steps": len(saved_times),
2455
+ "num_reduced_dofs": int(M_red.shape[0]),
2456
+ "contact_step_count": int(contact_step_count),
2457
+ "active_contact_duration": float(contact_duration),
2458
+ "separation_stop_time": float(separation_stop_time),
2459
+ "post_contact_separation_time": float(separation_elapsed if contact_step_count > 0 else 0.0),
2460
+ "max_penetration_ratio": float(max_penetration_ratio),
2461
+ "sphere_momentum_balance_error": float(sphere_momentum_balance_error),
2462
+ "iteration_counts": iteration_counts,
2463
+ "contact_step_diagnostics": step_diagnostics,
2464
+ "nonlinear_failure_summary": nonlinear_failure_summary,
2465
+ "factorization_count": int(factorization_count),
2466
+ "solve_count": int(solve_count),
2467
+ "initial_mass_factorization": mass_handle.diagnostics(),
2468
+ "effective_stiffness_factorization": factor_diagnostics,
2469
+ "constraint_info": constraint_info,
2470
+ "stiffness": stiffness_info,
2471
+ "mass": mass_info,
2472
+ "base_load": base_load_info,
2473
+ "contact_config": config.to_dict(),
2474
+ "nonlinear_config": nl.to_dict(),
2475
+ "base_load_equilibrated": bool(base_load_equilibrated),
2476
+ "strain_summary": _nonlinear_state_summary(committed_states),
2477
+ "element_states": committed_states,
2478
+ "element_state_history": element_state_history,
2479
+ "state_von_mises_history": tuple(state_von_mises_history),
2480
+ "plastic_impact_damage_summary": plastic_damage_summary,
2481
+ "impact_damage_summary": plastic_damage_summary,
2482
+ "erosion_summary": erosion_summary,
2483
+ "contact_validation": validation.to_dict(),
2484
+ "sphere": result_case["analysis_case"]["settings"]["sphere"],
2485
+ "kinetic_energy": energy_kinetic,
2486
+ "strain_energy": energy_strain,
2487
+ "sphere_kinetic_energy": sphere_kinetic,
2488
+ "plastic_work_proxy": plastic_work_history,
2489
+ "max_relative_energy_drift": energy_drift,
2490
+ "result_case": result_case,
2491
+ }
2492
+ return SphereImpactResult(
2493
+ times=np.asarray(saved_times, dtype=float),
2494
+ displacements=saved_u_array,
2495
+ velocities=saved_v_array,
2496
+ accelerations=saved_a_array,
2497
+ node_histories=node_histories,
2498
+ sphere_positions=np.vstack(saved_sphere_x) if saved_sphere_x else np.zeros((0, 3), dtype=float),
2499
+ sphere_velocities=np.vstack(saved_sphere_v) if saved_sphere_v else np.zeros((0, 3), dtype=float),
2500
+ sphere_accelerations=np.vstack(saved_sphere_a) if saved_sphere_a else np.zeros((0, 3), dtype=float),
2501
+ contact_force_history=np.vstack(saved_contact_force) if saved_contact_force else np.zeros((0, 3), dtype=float),
2502
+ active_contact_history=tuple(saved_contacts),
2503
+ load_impulse=impulse,
2504
+ force_impulse=impulse_resultant.force,
2505
+ moment_impulse=impulse_resultant.moment,
2506
+ sphere_impulse=sphere_impulse,
2507
+ max_penetration=float(max_penetration),
2508
+ max_penetration_ratio=float(max_penetration_ratio),
2509
+ peak_contact_force=float(peak_contact_force),
2510
+ contact_duration=float(contact_duration),
2511
+ sphere_momentum_balance_error=float(sphere_momentum_balance_error),
2512
+ peak_displacement=float(peak_displacement),
2513
+ peak_displacement_node=peak_displacement_node,
2514
+ status=status,
2515
+ diagnostics=diagnostics,
2516
+ result_case=result_case,
2517
+ )
2518
+
2519
+
2520
+ def solve_transient_sphere_impact(
2521
+ model: "FEModel",
2522
+ transient_config: TransientConfig,
2523
+ sphere: RigidSphereImpact,
2524
+ contact_config: Optional[SphereContactConfig] = None,
2525
+ base_load_case: Optional[LoadCase] = None,
2526
+ fracture_config: Optional[ImpactFractureConfig] = None,
2527
+ damage_config: Optional[ImpactDamageConfig] = None,
2528
+ nonlinear_config: Optional[NonlinearTransientConfig] = None,
2529
+ plastic_damage_config: Optional[PlasticImpactDamageConfig] = None,
2530
+ progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
2531
+ status_callback: Optional[Callable[[str], None]] = None,
2532
+ ) -> SphereImpactResult:
2533
+ """Solve a limited rigid-sphere-to-shell impact transient."""
2534
+
2535
+ config = _resolved_contact_config(model, sphere, contact_config)
2536
+ if fracture_config is not None and not isinstance(fracture_config, ImpactFractureConfig):
2537
+ raise TypeError("fracture_config must be an ImpactFractureConfig or None")
2538
+ if damage_config is not None and not isinstance(damage_config, ImpactDamageConfig):
2539
+ raise TypeError("damage_config must be an ImpactDamageConfig or None")
2540
+ if nonlinear_config is not None and not isinstance(nonlinear_config, NonlinearTransientConfig):
2541
+ raise TypeError("nonlinear_config must be a NonlinearTransientConfig or None")
2542
+ if plastic_damage_config is not None and not isinstance(plastic_damage_config, PlasticImpactDamageConfig):
2543
+ raise TypeError("plastic_damage_config must be a PlasticImpactDamageConfig or None")
2544
+ validation = validate_contact_configuration(model, sphere, config, transient_config)
2545
+ if validation.errors:
2546
+ codes = ", ".join(issue.code for issue in validation.errors)
2547
+ raise ValueError(f"Invalid sphere contact configuration: {codes}")
2548
+ if nonlinear_config is not None and bool(nonlinear_config.enabled):
2549
+ return _solve_transient_sphere_impact_nonlinear(
2550
+ model,
2551
+ transient_config,
2552
+ sphere,
2553
+ config,
2554
+ validation,
2555
+ base_load_case=base_load_case,
2556
+ nonlinear_config=nonlinear_config,
2557
+ plastic_damage_config=plastic_damage_config,
2558
+ progress_callback=progress_callback,
2559
+ status_callback=status_callback,
2560
+ )
2561
+ model.apply_boundary_conditions()
2562
+ K, stiffness_info = assemble_stiffness_matrix(model)
2563
+ M, mass_info = assemble_mass_matrix(model)
2564
+ total_dofs = model.mesh.dof_manager.total_dofs
2565
+ deleted_element_ids: set[int] = set()
2566
+ fracture_deleted_element_ids: set[int] = set()
2567
+ damage_deleted_element_ids: set[int] = set()
2568
+ fracture_records: List[DeletedElementRecord] = []
2569
+ damage_records: List[DeletedElementRecord] = []
2570
+ max_fracture_utilization = 0.0
2571
+ impact_damage_states: Dict[int, Dict[str, Any]] = {}
2572
+ max_damage_utilization = 0.0
2573
+ damage_scale_by_element: Dict[int, float] = {}
2574
+ linear_matrix_terms: Optional[Tuple[int, Tuple[LinearElementMatrixTerms, ...]]] = None
2575
+ base_load, base_load_info = assemble_load_vector(model, base_load_case)
2576
+ zero_load = np.zeros(total_dofs, dtype=float)
2577
+ K_red, _zero_red, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K, zero_load, model)
2578
+ M_red = (T.T @ M @ T).tocsr()
2579
+ C_red = (transient_config.rayleigh_alpha * M_red + transient_config.rayleigh_beta * K_red).tocsr()
2580
+ if float(np.linalg.norm(M_red.diagonal())) <= 0.0 and M_red.nnz == 0:
2581
+ raise ValueError("Sphere impact transient requires a non-zero structural mass matrix; set material density values.")
2582
+
2583
+ times = _time_grid(transient_config)
2584
+ recovery = transient_config.recovery
2585
+ output_node_ids = tuple(int(node_id) for node_id in (transient_config.output_nodes or ()))
2586
+ if recovery is not None and not output_node_ids and recovery.node_ids is not None:
2587
+ output_node_ids = tuple(int(node_id) for node_id in recovery.node_ids)
2588
+ history_storage_mode = "full" if recovery is None else str(recovery.history_mode)
2589
+ if recovery is not None and history_storage_mode == "full" and not recovery.store_full_histories:
2590
+ history_storage_mode = "selected"
2591
+ if history_storage_mode == "selected" and not output_node_ids and (recovery is None or recovery.include_displacements):
2592
+ output_node_ids = tuple(int(node_id) for node_id in model.mesh.nodes)
2593
+ history_dof_indices = _node_dof_indices(model, output_node_ids) if history_storage_mode == "selected" else None
2594
+ estimated_saved_steps = _saved_step_count(times, transient_config.save_every)
2595
+ preflight_memory = estimate_model_memory(
2596
+ model,
2597
+ transient_saved_steps=estimated_saved_steps,
2598
+ store_full_history=history_storage_mode == "full",
2599
+ recovery_config=recovery,
2600
+ )
2601
+ enforce_memory_limit(preflight_memory, transient_config.resource_config, context="solve_transient_sphere_impact")
2602
+
2603
+ q = _full_initial_vector(transient_config.initial_displacement, total_dofs)
2604
+ v_full = _full_initial_vector(transient_config.initial_velocity, total_dofs)
2605
+ q_red = np.asarray((q - u0)[np.asarray(independent_dofs, dtype=int)], dtype=float).reshape(-1)
2606
+ v_red = np.asarray(v_full[np.asarray(independent_dofs, dtype=int)], dtype=float).reshape(-1)
2607
+ F0_red = _reduced_load(T, K, u0, base_load)
2608
+ mass_handle = factorize(M_red, MatrixClass.SYMMETRIC_SEMIDEFINITE, signature="sphere_impact.initial_mass")
2609
+ a_red = np.asarray(mass_handle.solve(F0_red - C_red @ v_red - K_red @ q_red), dtype=float).reshape(-1)
2610
+
2611
+ sphere_position = sphere.initial_position
2612
+ sphere_velocity = sphere.travel_velocity if float(times[0]) >= sphere.t_start else np.zeros(3, dtype=float)
2613
+ sphere_acceleration = np.zeros(3, dtype=float)
2614
+
2615
+ saved_times: List[float] = []
2616
+ saved_u: List[np.ndarray] = []
2617
+ saved_v: List[np.ndarray] = []
2618
+ saved_a: List[np.ndarray] = []
2619
+ saved_sphere_x: List[np.ndarray] = []
2620
+ saved_sphere_v: List[np.ndarray] = []
2621
+ saved_sphere_a: List[np.ndarray] = []
2622
+ saved_contact_force: List[np.ndarray] = []
2623
+ saved_contacts: List[Tuple[Dict[str, Any], ...]] = []
2624
+ node_history_values: Dict[int, list[np.ndarray]] = {int(node_id): [] for node_id in output_node_ids}
2625
+ peak_displacement = 0.0
2626
+ peak_displacement_node = None
2627
+ max_penetration = 0.0
2628
+ peak_contact_force = 0.0
2629
+ contact_step_count = 0
2630
+ active_contact_duration = 0.0
2631
+ iteration_counts: List[int] = []
2632
+ energy_kinetic: List[float] = []
2633
+ energy_strain: List[float] = []
2634
+ sphere_kinetic: List[float] = []
2635
+
2636
+ def save_state(
2637
+ time: float,
2638
+ q_state: np.ndarray,
2639
+ v_state: np.ndarray,
2640
+ a_state: np.ndarray,
2641
+ sphere_x: np.ndarray,
2642
+ sphere_v: np.ndarray,
2643
+ sphere_a: np.ndarray,
2644
+ sphere_force: np.ndarray,
2645
+ records: Tuple[SphereContactRecord, ...],
2646
+ ) -> None:
2647
+ nonlocal peak_displacement, peak_displacement_node, max_penetration, peak_contact_force
2648
+ full_u = reconstruct_full_solution(T, q_state, u0)
2649
+ full_v = np.asarray(T @ v_state, dtype=float).reshape(-1)
2650
+ full_a = np.asarray(T @ a_state, dtype=float).reshape(-1)
2651
+ saved_times.append(float(time))
2652
+ if history_storage_mode == "full":
2653
+ saved_u.append(full_u)
2654
+ saved_v.append(full_v)
2655
+ saved_a.append(full_a)
2656
+ elif history_storage_mode == "selected":
2657
+ indices = np.asarray(history_dof_indices if history_dof_indices is not None else (), dtype=np.intp)
2658
+ saved_u.append(full_u[indices])
2659
+ saved_v.append(full_v[indices])
2660
+ saved_a.append(full_a[indices])
2661
+ for node_id in output_node_ids:
2662
+ node = model.mesh.get_node(int(node_id))
2663
+ if node is not None:
2664
+ node_history_values[int(node_id)].append(full_u[np.asarray(node.dofs, dtype=np.intp)])
2665
+ current_peak, current_node = _translation_peak(model, full_u)
2666
+ if current_peak > peak_displacement:
2667
+ peak_displacement = current_peak
2668
+ peak_displacement_node = current_node
2669
+ if records:
2670
+ max_penetration = max(max_penetration, max(float(record.penetration) for record in records))
2671
+ peak_contact_force = max(peak_contact_force, max(float(record.normal_force) for record in records))
2672
+ saved_sphere_x.append(np.asarray(sphere_x, dtype=float).copy())
2673
+ saved_sphere_v.append(np.asarray(sphere_v, dtype=float).copy())
2674
+ saved_sphere_a.append(np.asarray(sphere_a, dtype=float).copy())
2675
+ saved_contact_force.append(np.asarray(sphere_force, dtype=float).copy())
2676
+ saved_contacts.append(tuple(record.to_dict() for record in records) if config.save_contact_history else tuple())
2677
+ energy_kinetic.append(0.5 * float(v_state @ (M_red @ v_state)))
2678
+ energy_strain.append(0.5 * float(q_state @ (K_red @ q_state)))
2679
+ sphere_kinetic.append(0.5 * float(sphere.mass) * float(sphere_v @ sphere_v))
2680
+ if progress_callback is not None and history_storage_mode == "full":
2681
+ try:
2682
+ progress_callback(
2683
+ {
2684
+ "type": "sphere_impact_live_step",
2685
+ "time_s": float(time),
2686
+ "step_index": int(len(saved_times) - 1),
2687
+ "displacement": full_u.copy(),
2688
+ "sphere_position": np.asarray(sphere_x, dtype=float).copy(),
2689
+ "sphere_radius": float(sphere.radius),
2690
+ "contact_force": np.asarray(sphere_force, dtype=float).copy(),
2691
+ "active_contacts": tuple(record.to_dict() for record in records) if config.save_contact_history else tuple(),
2692
+ }
2693
+ )
2694
+ except Exception:
2695
+ pass
2696
+
2697
+ initial_load, initial_sphere_force, initial_records = assemble_sphere_contact_load_vector(
2698
+ model,
2699
+ sphere,
2700
+ config,
2701
+ sphere_position,
2702
+ sphere_velocity,
2703
+ structural_displacement=reconstruct_full_solution(T, q_red, u0),
2704
+ structural_velocity=np.asarray(T @ v_red, dtype=float).reshape(-1),
2705
+ deleted_element_ids=tuple(deleted_element_ids),
2706
+ contact_scale_by_element=damage_scale_by_element,
2707
+ )
2708
+ save_state(float(times[0]), q_red, v_red, a_red, sphere_position, sphere_velocity, sphere_acceleration, initial_sphere_force, initial_records)
2709
+ sticky_contact_ids: Tuple[int, ...] = tuple(int(record.element_id) for record in initial_records)
2710
+ load_prev = base_load + initial_load
2711
+ sphere_force_prev = initial_sphere_force.copy()
2712
+ impulse = np.zeros(total_dofs, dtype=float)
2713
+ sphere_impulse = np.zeros(3, dtype=float)
2714
+
2715
+ factorization_count = 0
2716
+ solve_count = 0
2717
+ cached_dt = None
2718
+ cached_solver = None
2719
+ cached_solver_diagnostics: Dict[str, Any] = {}
2720
+ status = "completed"
2721
+ stop_reason = "completed"
2722
+ warnings: List[str] = []
2723
+ for issue in validation.warnings:
2724
+ warnings.append(f"{issue.code}: {issue.message}")
2725
+ warnings.extend(
2726
+ _impact_damage_preflight_warnings(
2727
+ model, damage_config, sphere, fracture_config=fracture_config, beam_contact=bool(config.beam_contact)
2728
+ )
2729
+ )
2730
+ if fracture_config is not None:
2731
+ warnings.append(
2732
+ "IMPACT_FRACTURE001: impact fracture uses contact-observable thresholds and residual element erosion; "
2733
+ "it is not ductile crack propagation or material nonlinear impact."
2734
+ )
2735
+ if damage_config is not None:
2736
+ warnings.append(
2737
+ "IMPACT_DAMAGE001: impact damage uses engineering contact-demand utilization and element softening/erosion; "
2738
+ "it is not crack propagation, material nonlinear impact, or validated fracture mechanics."
2739
+ )
2740
+
2741
+ alpha_h, beta, gamma = transient_config.integration_parameters()
2742
+ one_plus_alpha = 1.0 + alpha_h
2743
+ F_red_prev = _reduced_load(T, K, u0, load_prev)
2744
+ total_substep_count = 0
2745
+ event_substep_count = 0
2746
+ step_diagnostics: List[Dict[str, Any]] = []
2747
+ fracture_limit_warning_emitted = False
2748
+ damage_deletion_warning_emitted = False
2749
+ damage_limit_warning_emitted = False
2750
+ eroded_matrix_rebuild_count = 0
2751
+ damage_state_update_count = 0
2752
+ separation_elapsed = 0.0
2753
+ separation_stop_time = float(config.post_separation_time)
2754
+ stop_after_separation = False
2755
+ # Automatic contact-period substepping: while contact is active a coarse
2756
+ # step under-resolves the penalty oscillation and produces spurious forces
2757
+ # or non-convergence (the CONTACT006 warning condition). Refine the step
2758
+ # to keep dt below a fraction of the penalty period, capped by
2759
+ # max_event_substeps, and only while contact is active so free flight and
2760
+ # post-separation stay fast.
2761
+ contact_period_dt = 0.2 * np.sqrt(float(sphere.mass) / max(float(config.penalty_stiffness), 1.0e-30))
2762
+ contact_active_last_step = bool(initial_records)
2763
+ for step_index in range(1, len(times)):
2764
+ target_time = float(times[step_index])
2765
+ dt_total = float(times[step_index] - times[step_index - 1])
2766
+ if dt_total <= 0.0:
2767
+ continue
2768
+ travel_scale = max(float(sphere.radius) * float(config.max_sphere_travel_fraction), 1.0e-12)
2769
+ predicted_travel = max(float(np.linalg.norm(sphere_velocity)) * dt_total, float(sphere.speed) * dt_total)
2770
+ n_travel = max(int(np.ceil(predicted_travel / travel_scale)), 1)
2771
+ n_contact = int(np.ceil(dt_total / contact_period_dt)) if contact_active_last_step and contact_period_dt > 0.0 else 1
2772
+ n_substeps = min(max(n_travel, n_contact, 1), int(config.max_event_substeps))
2773
+ contact_active_this_step = False
2774
+ total_substep_count += n_substeps
2775
+ if n_substeps > 1:
2776
+ event_substep_count += n_substeps - 1
2777
+ last_records: Tuple[SphereContactRecord, ...] = tuple()
2778
+ last_sphere_force = np.zeros(3, dtype=float)
2779
+ last_step_diag: Dict[str, Any] = {}
2780
+ for substep in range(n_substeps):
2781
+ sub_time = float(times[step_index - 1]) + (substep + 1) * dt_total / n_substeps
2782
+ dt = dt_total / n_substeps
2783
+ if sub_time - dt < sphere.t_start <= sub_time and np.linalg.norm(sphere_velocity) == 0.0:
2784
+ sphere_velocity = sphere.travel_velocity.copy()
2785
+ a0 = 1.0 / (beta * dt**2)
2786
+ a1 = gamma / (beta * dt)
2787
+ a2 = 1.0 / (beta * dt)
2788
+ a3 = 1.0 / (2.0 * beta) - 1.0
2789
+ a4 = gamma / beta - 1.0
2790
+ a5 = dt * (gamma / (2.0 * beta) - 1.0)
2791
+ if cached_solver is None or cached_dt is None or not np.isclose(dt, cached_dt):
2792
+ K_eff = (one_plus_alpha * K_red + a0 * M_red + one_plus_alpha * a1 * C_red).tocsr()
2793
+ cached_solver = factorize(K_eff, MatrixClass.SYMMETRIC_INDEFINITE, signature=f"sphere_impact.effective:{dt:.16g}")
2794
+ cached_solver_diagnostics = cached_solver.diagnostics()
2795
+ cached_dt = dt
2796
+ factorization_count += 1
2797
+
2798
+ sphere_x_pred = sphere_position + dt * sphere_velocity + dt**2 * (0.5 - beta) * sphere_acceleration
2799
+ sphere_v_pred = sphere_velocity + dt * (1.0 - gamma) * sphere_acceleration
2800
+ contact_load = np.zeros(total_dofs, dtype=float)
2801
+ sphere_force = np.zeros(3, dtype=float)
2802
+ records: Tuple[SphereContactRecord, ...] = tuple()
2803
+ converged = False
2804
+ q_next = q_red.copy()
2805
+ v_next = v_red.copy()
2806
+ a_next = a_red.copy()
2807
+ sphere_x_next = sphere_x_pred.copy()
2808
+ sphere_v_next = sphere_v_pred.copy()
2809
+ sphere_a_next = np.zeros(3, dtype=float)
2810
+ force_change = 0.0
2811
+ penetration_change = 0.0
2812
+ use_aitken = str(config.contact_relaxation) == "aitken"
2813
+ relaxation = 1.0
2814
+ fixed_point_residual_prev: Optional[np.ndarray] = None
2815
+ for iteration in range(1, int(config.max_contact_iterations) + 1):
2816
+ load_next = base_load + contact_load
2817
+ F_red = _reduced_load(T, K, u0, load_next)
2818
+ rhs = (
2819
+ one_plus_alpha * F_red
2820
+ - alpha_h * F_red_prev
2821
+ + M_red @ (a0 * q_red + a2 * v_red + a3 * a_red)
2822
+ + one_plus_alpha * (C_red @ (a1 * q_red + a4 * v_red + a5 * a_red))
2823
+ )
2824
+ if alpha_h != 0.0:
2825
+ rhs += alpha_h * (K_red @ q_red) + alpha_h * (C_red @ v_red)
2826
+ q_next = np.asarray(cached_solver.solve(rhs), dtype=float).reshape(-1)
2827
+ solve_count += 1
2828
+ a_next = a0 * (q_next - q_red) - a2 * v_red - a3 * a_red
2829
+ v_next = v_red + dt * ((1.0 - gamma) * a_red + gamma * a_next)
2830
+ sphere_a_next = sphere_force / float(sphere.mass)
2831
+ sphere_x_next = sphere_x_pred + beta * dt**2 * sphere_a_next
2832
+ sphere_v_next = sphere_v_pred + gamma * dt * sphere_a_next
2833
+ full_u = reconstruct_full_solution(T, q_next, u0)
2834
+ full_v = np.asarray(T @ v_next, dtype=float).reshape(-1)
2835
+ new_contact_load, new_sphere_force, new_records = assemble_sphere_contact_load_vector(
2836
+ model,
2837
+ sphere,
2838
+ config,
2839
+ sphere_x_next,
2840
+ sphere_v_next,
2841
+ structural_displacement=full_u,
2842
+ structural_velocity=full_v,
2843
+ deleted_element_ids=tuple(deleted_element_ids),
2844
+ contact_scale_by_element=damage_scale_by_element,
2845
+ preferred_element_ids=sticky_contact_ids,
2846
+ )
2847
+ if new_records:
2848
+ sticky_contact_ids = tuple(int(record.element_id) for record in new_records)
2849
+ force_change = float(np.linalg.norm(new_sphere_force - sphere_force))
2850
+ penetration_change = abs(
2851
+ (max((record.penetration for record in new_records), default=0.0))
2852
+ - (max((record.penetration for record in records), default=0.0))
2853
+ )
2854
+ scale = max(float(np.linalg.norm(new_sphere_force)), 1.0)
2855
+ fixed_point_residual = new_sphere_force - sphere_force
2856
+ if use_aitken and fixed_point_residual_prev is not None:
2857
+ residual_difference = fixed_point_residual - fixed_point_residual_prev
2858
+ denominator = float(residual_difference @ residual_difference)
2859
+ if denominator > 1.0e-30:
2860
+ relaxation = -relaxation * float(fixed_point_residual_prev @ residual_difference) / denominator
2861
+ relaxation = float(min(max(relaxation, 1.0e-4), 1.5))
2862
+ else:
2863
+ relaxation = 1.0
2864
+ fixed_point_residual_prev = fixed_point_residual.copy()
2865
+ if use_aitken and relaxation != 1.0:
2866
+ contact_load = contact_load + relaxation * (new_contact_load - contact_load)
2867
+ sphere_force = sphere_force + relaxation * (new_sphere_force - sphere_force)
2868
+ records = new_records
2869
+ else:
2870
+ contact_load, sphere_force, records = new_contact_load, new_sphere_force, new_records
2871
+ if force_change <= config.force_tolerance * scale and penetration_change <= config.penetration_tolerance:
2872
+ converged = True
2873
+ iteration_counts.append(iteration)
2874
+ break
2875
+ if not converged:
2876
+ status = "contact_iteration_failed"
2877
+ stop_reason = "contact_iteration_failed"
2878
+ iteration_counts.append(int(config.max_contact_iterations))
2879
+
2880
+ contact_norm = float(np.linalg.norm(sphere_force))
2881
+ if contact_norm > 0.0:
2882
+ contact_step_count += 1
2883
+ active_contact_duration += dt
2884
+ separation_elapsed = 0.0
2885
+ contact_active_this_step = True
2886
+ elif contact_step_count > 0 and separation_stop_time > 0.0:
2887
+ separation_elapsed += dt
2888
+ if records:
2889
+ max_penetration = max(max_penetration, max(float(record.penetration) for record in records))
2890
+ peak_contact_force = max(peak_contact_force, max(float(record.normal_force) for record in records))
2891
+ load_next = base_load + contact_load
2892
+ impulse += 0.5 * (load_prev + load_next) * dt
2893
+ sphere_impulse += 0.5 * (sphere_force_prev + sphere_force) * dt
2894
+ load_prev = load_next
2895
+ F_red_prev = F_red
2896
+ sphere_force_prev = sphere_force.copy()
2897
+ q_red, v_red, a_red = q_next, v_next, a_next
2898
+ sphere_position, sphere_velocity, sphere_acceleration = sphere_x_next, sphere_v_next, sphere_a_next
2899
+ last_records = records
2900
+ last_sphere_force = sphere_force.copy()
2901
+ last_step_diag = {
2902
+ "step_index": int(step_index),
2903
+ "substep_index": int(substep),
2904
+ "time": float(sub_time),
2905
+ "contact_iterations": int(iteration_counts[-1]),
2906
+ "contact_relaxation_factor": float(relaxation),
2907
+ "force_change_norm": float(force_change),
2908
+ "penetration_change": float(penetration_change),
2909
+ "active_element_ids": [int(record.element_id) for record in records],
2910
+ "max_penetration": float(max((record.penetration for record in records), default=0.0)),
2911
+ "status": "converged" if converged else "contact_iteration_failed",
2912
+ }
2913
+ if fracture_config is not None and records and status != "contact_iteration_failed":
2914
+ new_fracture_records, substep_utilization = _detect_impact_fracture_records(
2915
+ model,
2916
+ records,
2917
+ fracture_config,
2918
+ sphere,
2919
+ tuple(deleted_element_ids),
2920
+ step_index=int(step_index),
2921
+ time_value=float(sub_time),
2922
+ )
2923
+ max_fracture_utilization = max(max_fracture_utilization, substep_utilization)
2924
+ if new_fracture_records:
2925
+ fracture_records.extend(new_fracture_records)
2926
+ deleted_element_ids.update(record.element_id for record in new_fracture_records)
2927
+ fracture_deleted_element_ids.update(record.element_id for record in new_fracture_records)
2928
+ filtered_base = filtered_load_case_for_deleted_elements(base_load_case, deleted_element_ids)
2929
+ base_load, base_load_info = assemble_load_vector(model, filtered_base)
2930
+ if linear_matrix_terms is None:
2931
+ linear_matrix_terms = _linear_element_matrix_terms(model)
2932
+ fracture_scales = {int(element_id): float(scale) for element_id, scale in damage_scale_by_element.items()}
2933
+ for element_id in deleted_element_ids:
2934
+ fracture_scales[int(element_id)] = min(
2935
+ fracture_scales.get(int(element_id), 1.0),
2936
+ float(fracture_config.residual_stiffness_fraction),
2937
+ )
2938
+ K, M = _assemble_damaged_linear_matrices(model, fracture_scales, cached_terms=linear_matrix_terms)
2939
+ eroded_matrix_rebuild_count += 1
2940
+ K_red = (T.T @ K @ T).tocsr()
2941
+ M_red = (T.T @ M @ T).tocsr()
2942
+ C_red = (transient_config.rayleigh_alpha * M_red + transient_config.rayleigh_beta * K_red).tocsr()
2943
+ cached_solver = None
2944
+ cached_dt = None
2945
+ mass_handle = factorize(
2946
+ M_red,
2947
+ MatrixClass.SYMMETRIC_SEMIDEFINITE,
2948
+ signature=f"sphere_impact.fracture_mass:{len(deleted_element_ids)}",
2949
+ )
2950
+ current_red_load = _reduced_load(T, K, u0, base_load + contact_load)
2951
+ F_red_prev = current_red_load
2952
+ a_red = np.asarray(mass_handle.solve(current_red_load - C_red @ v_red - K_red @ q_red), dtype=float).reshape(-1)
2953
+ scoped_total = _contact_erodible_element_count(model)
2954
+ deleted_fraction = len(deleted_element_ids) / max(scoped_total, 1)
2955
+ last_step_diag["fracture_new_deleted_element_ids"] = [record.element_id for record in new_fracture_records]
2956
+ last_step_diag["fracture_deleted_fraction"] = float(deleted_fraction)
2957
+ last_step_diag["fracture_max_utilization"] = float(max_fracture_utilization)
2958
+ if deleted_fraction > fracture_config.max_deleted_fraction + 1.0e-12:
2959
+ status = "max_deleted_fraction_reached"
2960
+ stop_reason = "impact_fracture_max_deleted_fraction_reached"
2961
+ if not fracture_limit_warning_emitted:
2962
+ warnings.append("IMPACT_FRACTURE002: maximum deleted contact-target fraction reached; transient stopped at last eroded state.")
2963
+ fracture_limit_warning_emitted = True
2964
+ if damage_config is not None and records and status not in {"contact_iteration_failed", "max_deleted_fraction_reached"}:
2965
+ new_damage_deletions, substep_damage_utilization, damage_diags, damage_changed = _update_impact_damage_states(
2966
+ model,
2967
+ records,
2968
+ damage_config,
2969
+ sphere,
2970
+ impact_damage_states,
2971
+ tuple(deleted_element_ids),
2972
+ step_index=int(step_index),
2973
+ time_value=float(sub_time),
2974
+ dt=float(dt),
2975
+ )
2976
+ max_damage_utilization = max(max_damage_utilization, substep_damage_utilization)
2977
+ if damage_diags:
2978
+ damage_state_update_count += len(damage_diags)
2979
+ if damage_diags:
2980
+ last_step_diag["impact_damage"] = list(damage_diags)
2981
+ last_step_diag["impact_damage_max_utilization"] = float(max_damage_utilization)
2982
+ if new_damage_deletions:
2983
+ damage_records.extend(new_damage_deletions)
2984
+ deleted_element_ids.update(record.element_id for record in new_damage_deletions)
2985
+ damage_deleted_element_ids.update(record.element_id for record in new_damage_deletions)
2986
+ for deleted_id in deleted_element_ids:
2987
+ state = impact_damage_states.setdefault(int(deleted_id), {})
2988
+ state["damage"] = max(float(state.get("damage", 0.0)), float(damage_config.delete_at))
2989
+ state["scale"] = float(damage_config.residual_stiffness_fraction)
2990
+ last_step_diag["impact_damage_new_deleted_element_ids"] = [record.element_id for record in new_damage_deletions]
2991
+ if not damage_deletion_warning_emitted:
2992
+ warnings.append("IMPACT_DAMAGE002: one or more shell elements were eroded by impact damage.")
2993
+ damage_deletion_warning_emitted = True
2994
+ damage_changed = True
2995
+ if damage_changed:
2996
+ damage_scale_by_element = {
2997
+ int(element_id): _impact_damage_scale(float(state.get("damage", 0.0)), damage_config)
2998
+ for element_id, state in impact_damage_states.items()
2999
+ }
3000
+ for element_id in deleted_element_ids:
3001
+ damage_scale_by_element[int(element_id)] = min(
3002
+ damage_scale_by_element.get(int(element_id), 1.0),
3003
+ float(damage_config.residual_stiffness_fraction),
3004
+ )
3005
+ filtered_base = filtered_load_case_for_deleted_elements(base_load_case, deleted_element_ids)
3006
+ base_load, base_load_info = assemble_load_vector(model, filtered_base)
3007
+ if linear_matrix_terms is None:
3008
+ linear_matrix_terms = _linear_element_matrix_terms(model)
3009
+ K, M = _assemble_damaged_linear_matrices(model, damage_scale_by_element, cached_terms=linear_matrix_terms)
3010
+ eroded_matrix_rebuild_count += 1
3011
+ K_red = (T.T @ K @ T).tocsr()
3012
+ M_red = (T.T @ M @ T).tocsr()
3013
+ C_red = (transient_config.rayleigh_alpha * M_red + transient_config.rayleigh_beta * K_red).tocsr()
3014
+ cached_solver = None
3015
+ cached_dt = None
3016
+ mass_handle = factorize(
3017
+ M_red,
3018
+ MatrixClass.SYMMETRIC_SEMIDEFINITE,
3019
+ signature=f"sphere_impact.damage_mass:{len(damage_scale_by_element)}:{len(deleted_element_ids)}",
3020
+ )
3021
+ current_red_load = _reduced_load(T, K, u0, base_load + contact_load)
3022
+ F_red_prev = current_red_load
3023
+ a_red = np.asarray(mass_handle.solve(current_red_load - C_red @ v_red - K_red @ q_red), dtype=float).reshape(-1)
3024
+ scoped_total = sum(1 for element in model.mesh.elements.values() if element_fracture_category(element) == "shell")
3025
+ deleted_fraction = len(deleted_element_ids) / max(scoped_total, 1)
3026
+ last_step_diag["impact_damage_deleted_fraction"] = float(deleted_fraction)
3027
+ if deleted_fraction > float(damage_config.max_deleted_fraction) + 1.0e-12:
3028
+ status = "max_deleted_fraction_reached"
3029
+ stop_reason = "impact_damage_max_deleted_fraction_reached"
3030
+ if not damage_limit_warning_emitted:
3031
+ warnings.append(
3032
+ "IMPACT_DAMAGE003: maximum configured shell damage deletion fraction reached; "
3033
+ "transient stopped at last eroded state."
3034
+ )
3035
+ damage_limit_warning_emitted = True
3036
+ step_diagnostics.append(last_step_diag)
3037
+ if status in {"contact_iteration_failed", "max_deleted_fraction_reached"}:
3038
+ break
3039
+ if status == "completed" and contact_step_count > 0 and separation_stop_time > 0.0 and separation_elapsed >= separation_stop_time:
3040
+ stop_reason = "completed_after_contact_separation"
3041
+ stop_after_separation = True
3042
+ break
3043
+
3044
+ contact_active_last_step = contact_active_this_step
3045
+ save_time = float(last_step_diag.get("time", target_time)) if last_step_diag else target_time
3046
+ if step_index % int(transient_config.save_every) == 0 or step_index == len(times) - 1 or stop_after_separation:
3047
+ save_state(save_time, q_red, v_red, a_red, sphere_position, sphere_velocity, sphere_acceleration, last_sphere_force, last_records)
3048
+ if status in {"contact_iteration_failed", "max_deleted_fraction_reached"} or stop_after_separation:
3049
+ break
3050
+
3051
+ if contact_step_count == 0 and status == "completed":
3052
+ status = "no_contact"
3053
+ stop_reason = "no_contact"
3054
+ impulse_resultant = load_vector_resultant(model, impulse)
3055
+ total_energy = np.asarray(energy_kinetic, dtype=float) + np.asarray(energy_strain, dtype=float) + np.asarray(sphere_kinetic, dtype=float)
3056
+ nonzero_energy = total_energy[np.abs(total_energy) > 1.0e-30]
3057
+ energy_drift = 0.0
3058
+ if nonzero_energy.size:
3059
+ energy_drift = float((np.max(nonzero_energy) - np.min(nonzero_energy)) / max(abs(nonzero_energy[0]), 1.0e-30))
3060
+ max_penetration_ratio = float(max_penetration) / max(float(sphere.radius), 1.0e-12)
3061
+ contact_duration = float(active_contact_duration)
3062
+ initial_sphere_velocity = sphere.travel_velocity if float(times[0]) >= sphere.t_start else np.zeros(3, dtype=float)
3063
+ if saved_sphere_v:
3064
+ final_sphere_velocity = np.asarray(saved_sphere_v[-1], dtype=float)
3065
+ else:
3066
+ final_sphere_velocity = sphere_velocity
3067
+ sphere_momentum_change = float(sphere.mass) * (final_sphere_velocity - initial_sphere_velocity)
3068
+ sphere_momentum_balance_error = float(np.linalg.norm(sphere_impulse - sphere_momentum_change))
3069
+
3070
+ history_width = total_dofs if history_storage_mode == "full" else int(0 if history_dof_indices is None else len(history_dof_indices))
3071
+ saved_u_array = np.vstack(saved_u) if saved_u else np.zeros((0, history_width), dtype=float)
3072
+ saved_v_array = np.vstack(saved_v) if saved_v else np.zeros((0, history_width), dtype=float)
3073
+ saved_a_array = np.vstack(saved_a) if saved_a else np.zeros((0, history_width), dtype=float)
3074
+ node_histories: Dict[int, np.ndarray] = {}
3075
+ for node_id in output_node_ids:
3076
+ values = node_history_values.get(int(node_id), [])
3077
+ node_histories[int(node_id)] = np.vstack(values) if values else np.zeros((0, 6), dtype=float)
3078
+
3079
+ recovery_memory = estimate_model_memory(
3080
+ model,
3081
+ transient_saved_steps=len(saved_times),
3082
+ store_full_history=history_storage_mode == "full",
3083
+ recovery_config=recovery,
3084
+ )
3085
+ enforce_memory_limit(recovery_memory, transient_config.resource_config, context="solve_transient_sphere_impact.recovery")
3086
+ policy_metadata = recovery_metadata(recovery, transient_config.resource_config, recovery_memory)
3087
+ assembly_info = {"stiffness": stiffness_info, "mass": mass_info, "load": base_load_info}
3088
+ result_case = make_result_case(
3089
+ name=f"sphere_impact:{sphere.name}",
3090
+ analysis_type="sphere_impact_transient",
3091
+ load_cases=() if base_load_case is None else (base_load_case,),
3092
+ assembly_info=assembly_info,
3093
+ solver_info={"backend": cached_solver_diagnostics, "convergence_info": {"status": status, "stop_reason": stop_reason}},
3094
+ recovery={
3095
+ "displacement_history": True,
3096
+ "velocity_history": True,
3097
+ "acceleration_history": True,
3098
+ "sphere_history": True,
3099
+ "contact_history": bool(config.save_contact_history),
3100
+ "history_storage_mode": history_storage_mode,
3101
+ **policy_metadata["recovery"],
3102
+ },
3103
+ settings={
3104
+ "dt": transient_config.dt,
3105
+ "t_end": transient_config.t_end,
3106
+ "beta": beta,
3107
+ "gamma": gamma,
3108
+ "hht_alpha": alpha_h,
3109
+ "rayleigh_alpha": transient_config.rayleigh_alpha,
3110
+ "rayleigh_beta": transient_config.rayleigh_beta,
3111
+ "sphere": {
3112
+ "name": sphere.name,
3113
+ "radius": float(sphere.radius),
3114
+ "mass": float(sphere.mass),
3115
+ "start_point": sphere.initial_position.tolist(),
3116
+ "travel_direction": sphere.direction_unit.tolist(),
3117
+ "speed": float(sphere.speed),
3118
+ "t_start": float(sphere.t_start),
3119
+ },
3120
+ "contact": config.to_dict(),
3121
+ "fracture": None if fracture_config is None else fracture_config.to_dict(),
3122
+ "damage": None if damage_config is None else damage_config.to_dict(),
3123
+ },
3124
+ metadata={
3125
+ "resources": policy_metadata.get("resources"),
3126
+ "memory_estimate": policy_metadata.get("memory_estimate"),
3127
+ "contact_scope": "single rigid sphere to shell midsurface/thickness-offset surface; frictionless normal penalty",
3128
+ "solution_control": "implicit_newmark_time_domain",
3129
+ "arc_length_applicability": "not_applicable_to_dynamic_impact",
3130
+ "target_element_types": ["S3", "S4", "S6", "S8", "S8R"],
3131
+ "beam_response": "beams respond only through existing shell-beam coupling; direct beam contact is unsupported",
3132
+ "impact_fracture_scope": (
3133
+ "disabled"
3134
+ if fracture_config is None
3135
+ else "contact-observable shell erosion after converged contact substeps"
3136
+ ),
3137
+ "impact_damage_scope": (
3138
+ "disabled"
3139
+ if damage_config is None
3140
+ else "engineering capacity-based shell softening/erosion after converged contact substeps"
3141
+ ),
3142
+ "erosion_scope": (
3143
+ "disabled"
3144
+ if not deleted_element_ids and not damage_scale_by_element
3145
+ else "residual stiffness/mass/contact scaling; no node, MPC, beam-shell coupling or topology deletion"
3146
+ ),
3147
+ "solver_convergence": {"status": status, "stop_reason": stop_reason},
3148
+ "verification_gate": "contact",
3149
+ },
3150
+ ).to_dict()
3151
+ impact_fracture_summary = fracture_summary(
3152
+ model,
3153
+ fracture_config,
3154
+ fracture_records,
3155
+ fracture_deleted_element_ids,
3156
+ max_utilization=max_fracture_utilization,
3157
+ warnings=_warnings_with_prefixes(warnings, ("IMPACT_FRACTURE",)) if fracture_config is not None else (),
3158
+ )
3159
+ impact_damage_summary = _impact_damage_summary(
3160
+ model,
3161
+ damage_config,
3162
+ impact_damage_states,
3163
+ damage_deleted_element_ids,
3164
+ deletion_records=damage_records,
3165
+ warnings=_warnings_with_prefixes(warnings, ("IMPACT_DAMAGE",)) if damage_config is not None else (),
3166
+ )
3167
+ erosion_summary = {
3168
+ "all_eroded_element_ids": sorted(int(element_id) for element_id in deleted_element_ids),
3169
+ "fracture_triggered_element_ids": sorted(int(element_id) for element_id in fracture_deleted_element_ids),
3170
+ "damage_triggered_element_ids": sorted(int(element_id) for element_id in damage_deleted_element_ids),
3171
+ "active_softened_element_ids": sorted(
3172
+ int(element_id)
3173
+ for element_id, scale in damage_scale_by_element.items()
3174
+ if int(element_id) not in deleted_element_ids and float(scale) < 1.0
3175
+ ),
3176
+ "residual_stiffness_model": "element stiffness/mass scaling; topology, nodes, MPCs and beam-shell couplings retained",
3177
+ }
3178
+ diagnostics = {
3179
+ "method": "newmark_sphere_penalty_contact",
3180
+ "solution_control": "implicit_newmark_time_domain",
3181
+ "arc_length_applicability": "not_applicable_to_dynamic_impact",
3182
+ "status": status,
3183
+ "stop_reason": stop_reason,
3184
+ "warnings": warnings,
3185
+ "num_steps": max(int(len(times) - 1), 0),
3186
+ "num_substeps": int(total_substep_count),
3187
+ "event_substep_count": int(event_substep_count),
3188
+ "num_saved_steps": len(saved_times),
3189
+ "num_reduced_dofs": int(K_red.shape[0]),
3190
+ "contact_step_count": int(contact_step_count),
3191
+ "active_contact_duration": float(contact_duration),
3192
+ "separation_stop_time": float(separation_stop_time),
3193
+ "post_contact_separation_time": float(separation_elapsed if contact_step_count > 0 else 0.0),
3194
+ "max_penetration_ratio": float(max_penetration_ratio),
3195
+ "sphere_momentum_balance_error": float(sphere_momentum_balance_error),
3196
+ "iteration_counts": iteration_counts,
3197
+ "contact_step_diagnostics": step_diagnostics,
3198
+ "factorization_count": int(factorization_count),
3199
+ "solve_count": int(solve_count),
3200
+ "eroded_matrix_rebuild_count": int(eroded_matrix_rebuild_count),
3201
+ "damage_state_update_count": int(damage_state_update_count),
3202
+ "linear_matrix_terms_cached": linear_matrix_terms is not None,
3203
+ "initial_mass_factorization": mass_handle.diagnostics(),
3204
+ "effective_stiffness_factorization": cached_solver_diagnostics,
3205
+ "constraint_info": constraint_info,
3206
+ "stiffness": stiffness_info,
3207
+ "mass": mass_info,
3208
+ "base_load": base_load_info,
3209
+ "contact_config": config.to_dict(),
3210
+ "impact_fracture_summary": impact_fracture_summary,
3211
+ "impact_damage_summary": impact_damage_summary,
3212
+ "erosion_summary": erosion_summary,
3213
+ "contact_validation": validation.to_dict(),
3214
+ "sphere": result_case["analysis_case"]["settings"]["sphere"],
3215
+ "kinetic_energy": energy_kinetic,
3216
+ "strain_energy": energy_strain,
3217
+ "sphere_kinetic_energy": sphere_kinetic,
3218
+ "max_relative_energy_drift": energy_drift,
3219
+ "result_case": result_case,
3220
+ }
3221
+ return SphereImpactResult(
3222
+ times=np.asarray(saved_times, dtype=float),
3223
+ displacements=saved_u_array,
3224
+ velocities=saved_v_array,
3225
+ accelerations=saved_a_array,
3226
+ node_histories=node_histories,
3227
+ sphere_positions=np.vstack(saved_sphere_x) if saved_sphere_x else np.zeros((0, 3), dtype=float),
3228
+ sphere_velocities=np.vstack(saved_sphere_v) if saved_sphere_v else np.zeros((0, 3), dtype=float),
3229
+ sphere_accelerations=np.vstack(saved_sphere_a) if saved_sphere_a else np.zeros((0, 3), dtype=float),
3230
+ contact_force_history=np.vstack(saved_contact_force) if saved_contact_force else np.zeros((0, 3), dtype=float),
3231
+ active_contact_history=tuple(saved_contacts),
3232
+ load_impulse=impulse,
3233
+ force_impulse=impulse_resultant.force,
3234
+ moment_impulse=impulse_resultant.moment,
3235
+ sphere_impulse=sphere_impulse,
3236
+ max_penetration=float(max_penetration),
3237
+ max_penetration_ratio=float(max_penetration_ratio),
3238
+ peak_contact_force=float(peak_contact_force),
3239
+ contact_duration=float(contact_duration),
3240
+ sphere_momentum_balance_error=float(sphere_momentum_balance_error),
3241
+ peak_displacement=float(peak_displacement),
3242
+ peak_displacement_node=peak_displacement_node,
3243
+ status=status,
3244
+ diagnostics=diagnostics,
3245
+ result_case=result_case,
3246
+ )
3247
+
3248
+
3249
+ def _verification_contact_panel(stiffener: bool = False) -> "FEModel":
3250
+ from .elements import BeamElement, CoupledBeamShellElement
3251
+ from .fe_core import FEModel
3252
+
3253
+ model = FEModel("contact_verification_panel")
3254
+ model.add_material("soft", 1.0e5, 0.3, density=20.0)
3255
+ model.add_node(1, 0.0, 0.0, 0.0)
3256
+ model.add_node(2, 1.0, 0.0, 0.0)
3257
+ model.add_node(3, 1.0, 1.0, 0.0)
3258
+ model.add_node(4, 0.0, 1.0, 0.0)
3259
+ model.add_element(1, ShellElement(1, [1, 2, 3, 4], "soft", thickness=0.05))
3260
+ model.add_boundary_condition(
3261
+ BoundaryCondition(
3262
+ "restrain_shell_nonimpact_modes",
3263
+ [1, 2, 3, 4],
3264
+ {"ux": 0.0, "uy": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0},
3265
+ )
3266
+ )
3267
+ if stiffener:
3268
+ section = {"area": 0.05, "Iy": 1.0e-3, "Iz": 1.0e-3, "J": 1.0e-3}
3269
+ model.add_node(10, 0.5, 0.0, 0.05)
3270
+ model.add_node(11, 0.5, 1.0, 0.05)
3271
+ model.add_element(10, BeamElement(10, [10, 11], "soft", section))
3272
+ model.add_element(20, CoupledBeamShellElement(20, beam_node_id=10, shell_node_id=1, material_name="soft"))
3273
+ model.add_element(21, CoupledBeamShellElement(21, beam_node_id=11, shell_node_id=4, material_name="soft"))
3274
+ return model
3275
+
3276
+
3277
+ def _two_shell_contact_verification_panel() -> "FEModel":
3278
+ from .fe_core import FEModel
3279
+
3280
+ model = FEModel("two_shell_contact_verification_panel")
3281
+ model.add_material("soft", 1.0e5, 0.3, density=20.0)
3282
+ for node_id, xyz in {
3283
+ 1: (0.0, 0.0, 0.0),
3284
+ 2: (1.0, 0.0, 0.0),
3285
+ 3: (2.0, 0.0, 0.0),
3286
+ 4: (0.0, 1.0, 0.0),
3287
+ 5: (1.0, 1.0, 0.0),
3288
+ 6: (2.0, 1.0, 0.0),
3289
+ }.items():
3290
+ model.add_node(node_id, *xyz)
3291
+ model.add_element(1, ShellElement(1, [1, 2, 5, 4], "soft", thickness=0.05))
3292
+ model.add_element(2, ShellElement(2, [2, 3, 6, 5], "soft", thickness=0.05))
3293
+ return model
3294
+
3295
+
3296
+ def contact_verification_metrics() -> Dict[str, Dict[str, Any]]:
3297
+ """Return compact metrics for the rigid-sphere contact verification ledger."""
3298
+
3299
+ metrics: Dict[str, Dict[str, Any]] = {}
3300
+ no_contact = solve_transient_sphere_impact(
3301
+ _verification_contact_panel(),
3302
+ TransientConfig(dt=0.01, t_end=0.03),
3303
+ RigidSphereImpact("miss", radius=0.1, mass=2.0, start_point=(0.5, 0.5, 2.0), travel_direction=(1.0, 0.0, 0.0), speed=3.0),
3304
+ SphereContactConfig(penalty_stiffness=1000.0),
3305
+ )
3306
+ metrics["CONTACT-001"] = {
3307
+ "status": no_contact.status,
3308
+ "trajectory_error": float(np.linalg.norm(no_contact.sphere_positions[-1] - np.array([0.59, 0.5, 2.0]))),
3309
+ "peak_contact_force": float(no_contact.peak_contact_force),
3310
+ }
3311
+
3312
+ sphere = RigidSphereImpact("unit", radius=0.2, mass=1.0, start_point=(0.5, 0.5, 0.1), travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3313
+ config = SphereContactConfig(penalty_stiffness=1000.0)
3314
+ contact_model = _verification_contact_panel()
3315
+ vector, sphere_force, records = assemble_sphere_contact_load_vector(
3316
+ contact_model,
3317
+ sphere,
3318
+ config,
3319
+ sphere_position=np.array([0.5, 0.5, 0.1]),
3320
+ sphere_velocity=np.zeros(3),
3321
+ )
3322
+ resultant = load_vector_resultant(contact_model, vector)
3323
+ expected_force = 100.0
3324
+ metrics["CONTACT-002"] = {
3325
+ "normal_force": float(records[0].normal_force if records else 0.0),
3326
+ "expected_normal_force": expected_force,
3327
+ "relative_error": abs(float(records[0].normal_force if records else 0.0) - expected_force) / expected_force,
3328
+ }
3329
+ metrics["CONTACT-003"] = {
3330
+ "sphere_force": sphere_force.tolist(),
3331
+ "structure_force_resultant": resultant.force.tolist(),
3332
+ "balance_error": float(np.linalg.norm(sphere_force + resultant.force)),
3333
+ "moment_norm": float(resultant.moment_norm),
3334
+ }
3335
+
3336
+ hit = solve_transient_sphere_impact(
3337
+ _verification_contact_panel(),
3338
+ TransientConfig(dt=0.0025, t_end=0.12, output_nodes=[1]),
3339
+ RigidSphereImpact("hit", radius=0.1, mass=1.0, start_point=(0.5, 0.5, 0.25), travel_direction=(0.0, 0.0, -1.0), speed=2.0),
3340
+ SphereContactConfig(penalty_stiffness=4000.0, contact_damping=0.0, max_contact_iterations=40),
3341
+ )
3342
+ momentum_change = hit.sphere_velocities[-1] - np.array([0.0, 0.0, -2.0])
3343
+ metrics["CONTACT-004"] = {
3344
+ "impulse_error": float(np.linalg.norm(hit.sphere_impulse - momentum_change)),
3345
+ "impulse_norm": float(np.linalg.norm(hit.sphere_impulse)),
3346
+ "status": hit.status,
3347
+ }
3348
+ metrics["CONTACT-005"] = {
3349
+ "status": hit.status,
3350
+ "peak_contact_force": float(hit.peak_contact_force),
3351
+ "max_penetration": float(hit.max_penetration),
3352
+ "peak_displacement": float(hit.peak_displacement),
3353
+ "contact_step_count": int(hit.diagnostics["contact_step_count"]),
3354
+ }
3355
+ stiffened = solve_transient_sphere_impact(
3356
+ _verification_contact_panel(stiffener=True),
3357
+ TransientConfig(dt=0.0025, t_end=0.11, output_nodes=[10, 11]),
3358
+ RigidSphereImpact("stiffened", radius=0.1, mass=1.0, start_point=(0.25, 0.25, 0.22), travel_direction=(0.0, 0.0, -1.0), speed=2.0),
3359
+ SphereContactConfig(penalty_stiffness=3000.0, max_contact_iterations=40),
3360
+ )
3361
+ metrics["CONTACT-006"] = {
3362
+ "status": stiffened.status,
3363
+ "peak_contact_force": float(stiffened.peak_contact_force),
3364
+ "beam_node_10_peak_uz": float(np.max(np.abs(stiffened.node_histories[10][:, 2]))),
3365
+ "beam_node_11_peak_uz": float(np.max(np.abs(stiffened.node_histories[11][:, 2]))),
3366
+ }
3367
+ projection_cases = [
3368
+ (_verification_contact_panel(), np.array([0.5, 0.5, 0.08]), "face"),
3369
+ (_verification_contact_panel(), np.array([1.0, 0.5, 0.08]), "edge"),
3370
+ (_verification_contact_panel(), np.array([1.0, 1.0, 0.08]), "corner"),
3371
+ ]
3372
+ projection_ok = True
3373
+ projection_rows = []
3374
+ for model, position, expected in projection_cases:
3375
+ sphere_case = RigidSphereImpact("projection", radius=0.1, mass=1.0, start_point=position, travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3376
+ _vector, _force, case_records = assemble_sphere_contact_load_vector(
3377
+ model,
3378
+ sphere_case,
3379
+ SphereContactConfig(penalty_stiffness=1000.0),
3380
+ sphere_position=position,
3381
+ sphere_velocity=np.zeros(3),
3382
+ )
3383
+ actual = case_records[0].contact_classification if case_records else "none"
3384
+ projection_ok = projection_ok and actual == expected
3385
+ projection_rows.append({"expected": expected, "actual": actual})
3386
+ metrics["CONTACT-007"] = {"projection_classification_ok": projection_ok, "rows": projection_rows}
3387
+
3388
+ top_sphere = RigidSphereImpact("surface", radius=0.1, mass=1.0, start_point=(0.5, 0.5, 0.1), travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3389
+ _mid_vector, _mid_force, mid_records = assemble_sphere_contact_load_vector(
3390
+ _verification_contact_panel(),
3391
+ top_sphere,
3392
+ SphereContactConfig(penalty_stiffness=1000.0, contact_surface="midsurface"),
3393
+ sphere_position=np.array([0.5, 0.5, 0.1]),
3394
+ sphere_velocity=np.zeros(3),
3395
+ )
3396
+ _top_vector, _top_force, top_records = assemble_sphere_contact_load_vector(
3397
+ _verification_contact_panel(),
3398
+ top_sphere,
3399
+ SphereContactConfig(penalty_stiffness=1000.0, contact_surface="top"),
3400
+ sphere_position=np.array([0.5, 0.5, 0.1]),
3401
+ sphere_velocity=np.zeros(3),
3402
+ )
3403
+ metrics["CONTACT-008"] = {
3404
+ "midsurface_records": len(mid_records),
3405
+ "top_penetration": float(top_records[0].penetration if top_records else 0.0),
3406
+ "expected_top_penetration": 0.025,
3407
+ }
3408
+
3409
+ two_shell = _two_shell_contact_verification_panel()
3410
+ shared = RigidSphereImpact("shared_edge", radius=0.2, mass=1.0, start_point=(1.0, 0.5, 0.1), travel_direction=(0.0, 0.0, -1.0), speed=0.0)
3411
+ _shared_vector, shared_force, shared_records = assemble_sphere_contact_load_vector(
3412
+ two_shell,
3413
+ shared,
3414
+ SphereContactConfig(penalty_stiffness=1000.0, max_active_contacts=1),
3415
+ sphere_position=np.array([1.0, 0.5, 0.1]),
3416
+ sphere_velocity=np.zeros(3),
3417
+ )
3418
+ metrics["CONTACT-009"] = {
3419
+ "active_records": len(shared_records),
3420
+ "force_norm": float(np.linalg.norm(shared_force)),
3421
+ "expected_force_norm": 100.0,
3422
+ }
3423
+
3424
+ auto_sphere = RigidSphereImpact("auto", radius=0.1, mass=1.0, start_point=(0.5, 0.5, 0.25), travel_direction=(0.0, 0.0, -1.0), speed=2.0)
3425
+ auto = solve_transient_sphere_impact(
3426
+ _verification_contact_panel(),
3427
+ TransientConfig(dt=0.0015, t_end=0.11),
3428
+ auto_sphere,
3429
+ SphereContactConfig(target_penetration_fraction=0.05, auto_penalty_safety_factor=5.0, max_contact_iterations=40),
3430
+ )
3431
+ metrics["CONTACT-010"] = {
3432
+ "status": auto.status,
3433
+ "max_penetration_ratio": float(auto.max_penetration_ratio),
3434
+ "resolved_penalty": float(auto.diagnostics["contact_config"]["penalty_stiffness"]),
3435
+ }
3436
+
3437
+ fast = solve_transient_sphere_impact(
3438
+ _verification_contact_panel(),
3439
+ TransientConfig(dt=0.05, t_end=0.05),
3440
+ RigidSphereImpact("fast", radius=0.1, mass=1.0, start_point=(0.5, 0.5, 0.45), travel_direction=(0.0, 0.0, -1.0), speed=20.0),
3441
+ SphereContactConfig(penalty_stiffness=4000.0, max_event_substeps=64, max_contact_iterations=40),
3442
+ )
3443
+ metrics["CONTACT-011"] = {
3444
+ "status": fast.status,
3445
+ "event_substep_count": int(fast.diagnostics["event_substep_count"]),
3446
+ "peak_contact_force": float(fast.peak_contact_force),
3447
+ }
3448
+
3449
+ invalid = _verification_contact_panel()
3450
+ invalid.materials["soft"].density = 0.0
3451
+ invalid_report = validate_contact_configuration(
3452
+ invalid,
3453
+ RigidSphereImpact("bad_config", radius=0.1, mass=1.0, start_point=(0.5, 0.5, 1.0), travel_direction=(0.0, 0.0, -1.0), speed=10.0),
3454
+ SphereContactConfig(penalty_stiffness=1000.0, max_event_substeps=1),
3455
+ TransientConfig(dt=0.1, t_end=0.1),
3456
+ )
3457
+ metrics["CONTACT-012"] = {
3458
+ "validation_status": invalid_report.status,
3459
+ "issue_codes": [issue.code for issue in invalid_report.issues],
3460
+ }
3461
+ return metrics