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.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
|