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/fracture.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Simplified nonlinear-static fracture and element erosion helpers.
|
|
2
|
+
|
|
3
|
+
This module implements a deliberately narrow damage model: elements are
|
|
4
|
+
softened after a converged nonlinear-static increment when a scalar material
|
|
5
|
+
state exceeds a threshold. It is not crack propagation or fracture mechanics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Set, Tuple
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FractureConfig:
|
|
18
|
+
"""Configuration for strain-triggered element erosion.
|
|
19
|
+
|
|
20
|
+
The v1 trigger is the maximum equivalent plastic strain stored in an
|
|
21
|
+
element state. Deleted elements keep a small residual stiffness by default
|
|
22
|
+
to avoid creating abrupt mechanisms in the next increment.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
threshold: float
|
|
26
|
+
residual_stiffness_fraction: float = 1.0e-6
|
|
27
|
+
max_deleted_fraction: float = 0.25
|
|
28
|
+
min_load_factor: float = 0.0
|
|
29
|
+
element_scope: Tuple[str, ...] = ("shell", "beam")
|
|
30
|
+
delete_after_converged_increment: bool = True
|
|
31
|
+
record_history: bool = True
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not np.isfinite(self.threshold) or self.threshold <= 0.0:
|
|
35
|
+
raise ValueError("FractureConfig.threshold must be positive")
|
|
36
|
+
if (
|
|
37
|
+
not np.isfinite(self.residual_stiffness_fraction)
|
|
38
|
+
or self.residual_stiffness_fraction < 0.0
|
|
39
|
+
or self.residual_stiffness_fraction > 1.0
|
|
40
|
+
):
|
|
41
|
+
raise ValueError("FractureConfig.residual_stiffness_fraction must be in [0, 1]")
|
|
42
|
+
if not np.isfinite(self.max_deleted_fraction) or not (0.0 < self.max_deleted_fraction <= 1.0):
|
|
43
|
+
raise ValueError("FractureConfig.max_deleted_fraction must be in (0, 1]")
|
|
44
|
+
if not np.isfinite(self.min_load_factor) or self.min_load_factor < 0.0:
|
|
45
|
+
raise ValueError("FractureConfig.min_load_factor must be non-negative")
|
|
46
|
+
scope = tuple(str(item).lower() for item in self.element_scope)
|
|
47
|
+
invalid = sorted(set(scope) - {"shell", "beam"})
|
|
48
|
+
if invalid:
|
|
49
|
+
raise ValueError(f"Unsupported fracture element_scope entries: {invalid}")
|
|
50
|
+
object.__setattr__(self, "element_scope", scope)
|
|
51
|
+
if not self.delete_after_converged_increment:
|
|
52
|
+
raise ValueError("Fracture v1 only supports delete_after_converged_increment=True")
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"threshold": float(self.threshold),
|
|
57
|
+
"residual_stiffness_fraction": float(self.residual_stiffness_fraction),
|
|
58
|
+
"max_deleted_fraction": float(self.max_deleted_fraction),
|
|
59
|
+
"min_load_factor": float(self.min_load_factor),
|
|
60
|
+
"element_scope": list(self.element_scope),
|
|
61
|
+
"delete_after_converged_increment": bool(self.delete_after_converged_increment),
|
|
62
|
+
"record_history": bool(self.record_history),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ElementDeletionConfig = FractureConfig
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ImpactFractureConfig:
|
|
71
|
+
"""Contact-triggered erosion for rigid-sphere impact analyses.
|
|
72
|
+
|
|
73
|
+
This is intentionally separate from nonlinear-static plastic strain
|
|
74
|
+
fracture. Linear impact runs do not carry plastic integration-point state,
|
|
75
|
+
so v1 impact erosion uses contact observables after a converged substep.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
threshold: float
|
|
79
|
+
trigger: str = "contact_force"
|
|
80
|
+
residual_stiffness_fraction: float = 1.0e-6
|
|
81
|
+
max_deleted_fraction: float = 0.25
|
|
82
|
+
min_time: float = 0.0
|
|
83
|
+
contact_area_radius_fraction: float = 0.25
|
|
84
|
+
record_history: bool = True
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
trigger = str(self.trigger).lower()
|
|
88
|
+
if trigger not in {"contact_force", "penetration_ratio", "contact_pressure"}:
|
|
89
|
+
raise ValueError("ImpactFractureConfig.trigger must be 'contact_force', 'penetration_ratio', or 'contact_pressure'")
|
|
90
|
+
if not np.isfinite(self.threshold) or self.threshold <= 0.0:
|
|
91
|
+
raise ValueError("ImpactFractureConfig.threshold must be positive")
|
|
92
|
+
if (
|
|
93
|
+
not np.isfinite(self.residual_stiffness_fraction)
|
|
94
|
+
or self.residual_stiffness_fraction < 0.0
|
|
95
|
+
or self.residual_stiffness_fraction > 1.0
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("ImpactFractureConfig.residual_stiffness_fraction must be in [0, 1]")
|
|
98
|
+
if not np.isfinite(self.max_deleted_fraction) or not (0.0 < self.max_deleted_fraction <= 1.0):
|
|
99
|
+
raise ValueError("ImpactFractureConfig.max_deleted_fraction must be in (0, 1]")
|
|
100
|
+
if not np.isfinite(self.min_time) or self.min_time < 0.0:
|
|
101
|
+
raise ValueError("ImpactFractureConfig.min_time must be non-negative")
|
|
102
|
+
if not np.isfinite(self.contact_area_radius_fraction) or self.contact_area_radius_fraction <= 0.0:
|
|
103
|
+
raise ValueError("ImpactFractureConfig.contact_area_radius_fraction must be positive")
|
|
104
|
+
object.__setattr__(self, "trigger", trigger)
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"threshold": float(self.threshold),
|
|
109
|
+
"trigger": self.trigger,
|
|
110
|
+
"residual_stiffness_fraction": float(self.residual_stiffness_fraction),
|
|
111
|
+
"max_deleted_fraction": float(self.max_deleted_fraction),
|
|
112
|
+
"min_time": float(self.min_time),
|
|
113
|
+
"contact_area_radius_fraction": float(self.contact_area_radius_fraction),
|
|
114
|
+
"record_history": bool(self.record_history),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class ImpactDamageConfig:
|
|
120
|
+
"""Capacity-based engineering damage for rigid-sphere shell impact.
|
|
121
|
+
|
|
122
|
+
This layer estimates contact-driven shell demand in an otherwise linear
|
|
123
|
+
transient impact solve. It is an erosion/screening model, not fracture
|
|
124
|
+
mechanics, crack growth, or material nonlinear impact.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
mode: str = "accumulated_damage"
|
|
128
|
+
capacity_basis: str = "yield"
|
|
129
|
+
damage_threshold: float = 1.0
|
|
130
|
+
softening_start: float = 0.6
|
|
131
|
+
delete_at: float = 1.0
|
|
132
|
+
min_contact_area: float = 1.0e-6
|
|
133
|
+
neighbor_smoothing: bool = False
|
|
134
|
+
residual_stiffness_fraction: float = 1.0e-6
|
|
135
|
+
max_deleted_fraction: float = 0.25
|
|
136
|
+
user_capacity: Optional[float] = None
|
|
137
|
+
contact_area_radius_fraction: float = 0.25
|
|
138
|
+
impulse_reference_time: float = 0.01
|
|
139
|
+
plastic_strain_capacity: float = 0.01
|
|
140
|
+
strain_scale: float = 1.0
|
|
141
|
+
record_history: bool = True
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
mode = str(self.mode).lower()
|
|
145
|
+
if mode not in {"accumulated_damage", "instant_threshold"}:
|
|
146
|
+
raise ValueError("ImpactDamageConfig.mode must be 'accumulated_damage' or 'instant_threshold'")
|
|
147
|
+
capacity_basis = str(self.capacity_basis).lower()
|
|
148
|
+
if capacity_basis not in {"yield", "ultimate_proxy", "user"}:
|
|
149
|
+
raise ValueError("ImpactDamageConfig.capacity_basis must be 'yield', 'ultimate_proxy', or 'user'")
|
|
150
|
+
if capacity_basis == "user" and (
|
|
151
|
+
self.user_capacity is None or not np.isfinite(self.user_capacity) or self.user_capacity <= 0.0
|
|
152
|
+
):
|
|
153
|
+
raise ValueError("ImpactDamageConfig.user_capacity must be positive when capacity_basis='user'")
|
|
154
|
+
if not np.isfinite(self.damage_threshold) or self.damage_threshold <= 0.0:
|
|
155
|
+
raise ValueError("ImpactDamageConfig.damage_threshold must be positive")
|
|
156
|
+
if not np.isfinite(self.softening_start) or self.softening_start < 0.0:
|
|
157
|
+
raise ValueError("ImpactDamageConfig.softening_start must be non-negative")
|
|
158
|
+
if not np.isfinite(self.delete_at) or self.delete_at <= self.softening_start:
|
|
159
|
+
raise ValueError("ImpactDamageConfig.delete_at must be greater than softening_start")
|
|
160
|
+
if not np.isfinite(self.min_contact_area) or self.min_contact_area <= 0.0:
|
|
161
|
+
raise ValueError("ImpactDamageConfig.min_contact_area must be positive")
|
|
162
|
+
if (
|
|
163
|
+
not np.isfinite(self.residual_stiffness_fraction)
|
|
164
|
+
or self.residual_stiffness_fraction < 0.0
|
|
165
|
+
or self.residual_stiffness_fraction > 1.0
|
|
166
|
+
):
|
|
167
|
+
raise ValueError("ImpactDamageConfig.residual_stiffness_fraction must be in [0, 1]")
|
|
168
|
+
if not np.isfinite(self.max_deleted_fraction) or not (0.0 < self.max_deleted_fraction <= 1.0):
|
|
169
|
+
raise ValueError("ImpactDamageConfig.max_deleted_fraction must be in (0, 1]")
|
|
170
|
+
if not np.isfinite(self.contact_area_radius_fraction) or self.contact_area_radius_fraction <= 0.0:
|
|
171
|
+
raise ValueError("ImpactDamageConfig.contact_area_radius_fraction must be positive")
|
|
172
|
+
if not np.isfinite(self.impulse_reference_time) or self.impulse_reference_time <= 0.0:
|
|
173
|
+
raise ValueError("ImpactDamageConfig.impulse_reference_time must be positive")
|
|
174
|
+
if not np.isfinite(self.plastic_strain_capacity) or self.plastic_strain_capacity <= 0.0:
|
|
175
|
+
raise ValueError("ImpactDamageConfig.plastic_strain_capacity must be positive")
|
|
176
|
+
if not np.isfinite(self.strain_scale) or self.strain_scale <= 0.0:
|
|
177
|
+
raise ValueError("ImpactDamageConfig.strain_scale must be positive")
|
|
178
|
+
object.__setattr__(self, "mode", mode)
|
|
179
|
+
object.__setattr__(self, "capacity_basis", capacity_basis)
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
"mode": self.mode,
|
|
184
|
+
"capacity_basis": self.capacity_basis,
|
|
185
|
+
"damage_threshold": float(self.damage_threshold),
|
|
186
|
+
"softening_start": float(self.softening_start),
|
|
187
|
+
"delete_at": float(self.delete_at),
|
|
188
|
+
"min_contact_area": float(self.min_contact_area),
|
|
189
|
+
"neighbor_smoothing": bool(self.neighbor_smoothing),
|
|
190
|
+
"residual_stiffness_fraction": float(self.residual_stiffness_fraction),
|
|
191
|
+
"max_deleted_fraction": float(self.max_deleted_fraction),
|
|
192
|
+
"user_capacity": None if self.user_capacity is None else float(self.user_capacity),
|
|
193
|
+
"contact_area_radius_fraction": float(self.contact_area_radius_fraction),
|
|
194
|
+
"impulse_reference_time": float(self.impulse_reference_time),
|
|
195
|
+
"plastic_strain_capacity": float(self.plastic_strain_capacity),
|
|
196
|
+
"strain_scale": float(self.strain_scale),
|
|
197
|
+
"record_history": bool(self.record_history),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class PlasticImpactDamageConfig:
|
|
203
|
+
"""Plastic-strain-driven erosion for nonlinear impact solves.
|
|
204
|
+
|
|
205
|
+
Damage is evaluated from committed element plastic state after a converged
|
|
206
|
+
nonlinear transient substep. It is still engineering erosion, not crack
|
|
207
|
+
propagation or cohesive fracture.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
threshold: float = 0.01
|
|
211
|
+
criterion: str = "fixed"
|
|
212
|
+
softening_start: float = 0.6
|
|
213
|
+
delete_at: float = 1.0
|
|
214
|
+
residual_stiffness_fraction: float = 1.0e-6
|
|
215
|
+
max_deleted_fraction: float = 0.25
|
|
216
|
+
element_scope: Sequence[str] = ("shell", "beam")
|
|
217
|
+
record_history: bool = True
|
|
218
|
+
|
|
219
|
+
def __post_init__(self) -> None:
|
|
220
|
+
if not np.isfinite(self.threshold) or self.threshold <= 0.0:
|
|
221
|
+
raise ValueError("PlasticImpactDamageConfig.threshold must be positive")
|
|
222
|
+
if self.criterion not in {"fixed", "mesh_scaled_gl", "rtcl", "rtcl_modified"}:
|
|
223
|
+
raise ValueError("PlasticImpactDamageConfig.criterion must be 'fixed', 'mesh_scaled_gl', 'rtcl' or 'rtcl_modified'")
|
|
224
|
+
if not np.isfinite(self.softening_start) or not (0.0 <= self.softening_start < 1.0):
|
|
225
|
+
raise ValueError("PlasticImpactDamageConfig.softening_start must be in [0, 1)")
|
|
226
|
+
if not np.isfinite(self.delete_at) or self.delete_at <= self.softening_start:
|
|
227
|
+
raise ValueError("PlasticImpactDamageConfig.delete_at must be greater than softening_start")
|
|
228
|
+
if (
|
|
229
|
+
not np.isfinite(self.residual_stiffness_fraction)
|
|
230
|
+
or self.residual_stiffness_fraction < 0.0
|
|
231
|
+
or self.residual_stiffness_fraction > 1.0
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("PlasticImpactDamageConfig.residual_stiffness_fraction must be in [0, 1]")
|
|
234
|
+
if not np.isfinite(self.max_deleted_fraction) or not (0.0 < self.max_deleted_fraction <= 1.0):
|
|
235
|
+
raise ValueError("PlasticImpactDamageConfig.max_deleted_fraction must be in (0, 1]")
|
|
236
|
+
scope = tuple(str(item).lower() for item in self.element_scope)
|
|
237
|
+
unsupported = [item for item in scope if item not in {"shell", "beam"}]
|
|
238
|
+
if unsupported:
|
|
239
|
+
raise ValueError("PlasticImpactDamageConfig.element_scope supports only 'shell' and 'beam'")
|
|
240
|
+
object.__setattr__(self, "element_scope", scope)
|
|
241
|
+
|
|
242
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
243
|
+
return {
|
|
244
|
+
"trigger": "equivalent_plastic_strain",
|
|
245
|
+
"threshold": float(self.threshold),
|
|
246
|
+
"criterion": str(self.criterion),
|
|
247
|
+
"softening_start": float(self.softening_start),
|
|
248
|
+
"delete_at": float(self.delete_at),
|
|
249
|
+
"residual_stiffness_fraction": float(self.residual_stiffness_fraction),
|
|
250
|
+
"max_deleted_fraction": float(self.max_deleted_fraction),
|
|
251
|
+
"element_scope": list(self.element_scope),
|
|
252
|
+
"record_history": bool(self.record_history),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class DeletedElementRecord:
|
|
258
|
+
"""One element erosion event."""
|
|
259
|
+
|
|
260
|
+
element_id: int
|
|
261
|
+
element_type: str
|
|
262
|
+
step_index: int
|
|
263
|
+
load_factor: float
|
|
264
|
+
trigger_name: str
|
|
265
|
+
trigger_value: float
|
|
266
|
+
threshold: float
|
|
267
|
+
location: str
|
|
268
|
+
measure: float
|
|
269
|
+
|
|
270
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
271
|
+
return {
|
|
272
|
+
"element_id": int(self.element_id),
|
|
273
|
+
"element_type": self.element_type,
|
|
274
|
+
"step_index": int(self.step_index),
|
|
275
|
+
"load_factor": float(self.load_factor),
|
|
276
|
+
"trigger_name": self.trigger_name,
|
|
277
|
+
"trigger_value": float(self.trigger_value),
|
|
278
|
+
"threshold": float(self.threshold),
|
|
279
|
+
"location": self.location,
|
|
280
|
+
"measure": float(self.measure),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def element_fracture_category(element: Any) -> Optional[str]:
|
|
285
|
+
from .elements import BeamElement, ShellElement
|
|
286
|
+
|
|
287
|
+
if isinstance(element, ShellElement):
|
|
288
|
+
return "shell"
|
|
289
|
+
if isinstance(element, BeamElement):
|
|
290
|
+
return "beam"
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def element_measure(mesh: Any, element: Any) -> float:
|
|
295
|
+
"""Return shell area or beam length for diagnostics."""
|
|
296
|
+
category = element_fracture_category(element)
|
|
297
|
+
if category == "shell":
|
|
298
|
+
try:
|
|
299
|
+
cache = element._nonlinear_geometry(mesh)
|
|
300
|
+
return float(np.sum(np.asarray(cache.get("detw_all", []), dtype=float)))
|
|
301
|
+
except Exception:
|
|
302
|
+
coords = np.asarray(element.get_node_coordinates(mesh), dtype=float)
|
|
303
|
+
if coords.shape[0] < 3:
|
|
304
|
+
return 0.0
|
|
305
|
+
area = 0.0
|
|
306
|
+
p0 = coords[0]
|
|
307
|
+
for idx in range(1, coords.shape[0] - 1):
|
|
308
|
+
area += 0.5 * float(np.linalg.norm(np.cross(coords[idx] - p0, coords[idx + 1] - p0)))
|
|
309
|
+
return area
|
|
310
|
+
if category == "beam":
|
|
311
|
+
try:
|
|
312
|
+
coords = np.asarray(element.get_node_coordinates(mesh), dtype=float)
|
|
313
|
+
if coords.shape[0] >= 2:
|
|
314
|
+
return float(np.linalg.norm(coords[-1] - coords[0]))
|
|
315
|
+
except Exception:
|
|
316
|
+
return 0.0
|
|
317
|
+
return 0.0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def rtcl_triaxiality_weight(triaxiality: np.ndarray) -> np.ndarray:
|
|
321
|
+
"""RTCL damage weight versus stress triaxiality (Tornqvist 2003).
|
|
322
|
+
|
|
323
|
+
Combines Cockcroft-Latham for shear-dominated states with Rice-Tracey
|
|
324
|
+
void growth for tension-dominated states, normalised so uniaxial tension
|
|
325
|
+
(eta = 1/3) weighs 1:
|
|
326
|
+
|
|
327
|
+
eta <= -1/3 : 0 (no ductile damage in compression)
|
|
328
|
+
-1/3 < eta < 1/3 : (2 + 2 eta sqrt(12 - 27 eta^2))
|
|
329
|
+
/ (3 eta + sqrt(12 - 27 eta^2))
|
|
330
|
+
eta >= 1/3 : exp(1.5 eta - 0.5)
|
|
331
|
+
|
|
332
|
+
Pure shear (eta = 0) weighs ~0.577 and equibiaxial tension (eta = 2/3,
|
|
333
|
+
the plane-stress maximum) ~1.65, matching the published RTCL curve used
|
|
334
|
+
in ship-collision studies.
|
|
335
|
+
"""
|
|
336
|
+
eta = np.asarray(triaxiality, dtype=float)
|
|
337
|
+
eta_mid = np.clip(eta, -1.0 / 3.0, 1.0 / 3.0)
|
|
338
|
+
root = np.sqrt(np.maximum(12.0 - 27.0 * eta_mid**2, 0.0))
|
|
339
|
+
cockcroft_latham = np.where(
|
|
340
|
+
3.0 * eta_mid + root > 1.0e-12,
|
|
341
|
+
(2.0 + 2.0 * eta_mid * root) / np.maximum(3.0 * eta_mid + root, 1.0e-12),
|
|
342
|
+
0.0,
|
|
343
|
+
)
|
|
344
|
+
rice_tracey = np.exp(1.5 * eta - 0.5)
|
|
345
|
+
return np.where(eta <= -1.0 / 3.0, 0.0, np.where(eta < 1.0 / 3.0, cockcroft_latham, rice_tracey))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def state_rtcl_increment(
|
|
349
|
+
state: Any,
|
|
350
|
+
previous_alpha: Optional[np.ndarray],
|
|
351
|
+
elastic_modulus: float,
|
|
352
|
+
poisson_ratio: float,
|
|
353
|
+
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
|
|
354
|
+
"""RTCL-weighted plastic strain increments for one committed element state.
|
|
355
|
+
|
|
356
|
+
Returns ``(alpha, weighted_increment)`` per integration point, where the
|
|
357
|
+
increment is ``rtcl_weight(eta) * max(alpha - previous_alpha, 0)`` with the
|
|
358
|
+
triaxiality ``eta`` evaluated from the return-mapped plane-stress state
|
|
359
|
+
(``sigma = C_el (eps - eps_p)``). Beam fiber states use the uniaxial
|
|
360
|
+
limits: weight 1 for fibers in tension, 0 in compression. Returns ``None``
|
|
361
|
+
when the state carries no usable plastic data.
|
|
362
|
+
"""
|
|
363
|
+
if not isinstance(state, Mapping):
|
|
364
|
+
return None
|
|
365
|
+
alpha = np.asarray(state.get("alpha", ()), dtype=float).reshape(-1)
|
|
366
|
+
if alpha.size == 0:
|
|
367
|
+
return None
|
|
368
|
+
if previous_alpha is None or np.asarray(previous_alpha).size != alpha.size:
|
|
369
|
+
previous = np.zeros_like(alpha)
|
|
370
|
+
else:
|
|
371
|
+
previous = np.asarray(previous_alpha, dtype=float).reshape(-1)
|
|
372
|
+
delta = np.maximum(alpha - previous, 0.0)
|
|
373
|
+
|
|
374
|
+
fiber_stress = np.asarray(state.get("fiber_stress", ()), dtype=float).reshape(-1)
|
|
375
|
+
if fiber_stress.size == alpha.size:
|
|
376
|
+
# Uniaxial fiber section: eta = +-1/3 exactly.
|
|
377
|
+
weight = np.where(fiber_stress > 0.0, 1.0, 0.0)
|
|
378
|
+
return alpha, weight * delta
|
|
379
|
+
|
|
380
|
+
layer = np.asarray(state.get("layer_strain", ()), dtype=float)
|
|
381
|
+
plastic = np.asarray(state.get("plastic_strain", ()), dtype=float)
|
|
382
|
+
if layer.size == 0 or layer.shape != plastic.shape:
|
|
383
|
+
return None
|
|
384
|
+
layer = layer.reshape(-1, layer.shape[-1]) if layer.ndim > 1 else layer.reshape(-1, 1)
|
|
385
|
+
if layer.shape[-1] != 3 or layer.shape[0] != alpha.size:
|
|
386
|
+
return None
|
|
387
|
+
elastic = layer - plastic.reshape(layer.shape)
|
|
388
|
+
factor = float(elastic_modulus) / (1.0 - float(poisson_ratio) ** 2)
|
|
389
|
+
sxx = factor * (elastic[:, 0] + float(poisson_ratio) * elastic[:, 1])
|
|
390
|
+
syy = factor * (elastic[:, 1] + float(poisson_ratio) * elastic[:, 0])
|
|
391
|
+
sxy = factor * (1.0 - float(poisson_ratio)) / 2.0 * elastic[:, 2]
|
|
392
|
+
von_mises = np.sqrt(np.maximum(sxx * sxx - sxx * syy + syy * syy + 3.0 * sxy * sxy, 0.0))
|
|
393
|
+
mean_stress = (sxx + syy) / 3.0
|
|
394
|
+
triaxiality = np.where(von_mises > 1.0e-9 * max(float(elastic_modulus), 1.0), mean_stress / np.maximum(von_mises, 1.0e-30), 0.0)
|
|
395
|
+
return alpha, rtcl_triaxiality_weight(triaxiality) * delta
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def state_equivalent_plastic_strain(state: Any) -> Tuple[float, str]:
|
|
399
|
+
"""Return max equivalent plastic strain and a compact location label."""
|
|
400
|
+
if not isinstance(state, Mapping):
|
|
401
|
+
return 0.0, "no_state"
|
|
402
|
+
alpha = np.asarray(state.get("alpha", []), dtype=float).reshape(-1)
|
|
403
|
+
if alpha.size == 0:
|
|
404
|
+
return 0.0, "no_alpha"
|
|
405
|
+
index = int(np.argmax(alpha))
|
|
406
|
+
return float(alpha[index]), f"alpha[{index}]"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def detect_new_deletions(
|
|
410
|
+
model: Any,
|
|
411
|
+
states: Mapping[int, Any],
|
|
412
|
+
config: FractureConfig,
|
|
413
|
+
deleted_element_ids: Iterable[int],
|
|
414
|
+
*,
|
|
415
|
+
step_index: int,
|
|
416
|
+
load_factor: float,
|
|
417
|
+
) -> Tuple[Tuple[DeletedElementRecord, ...], float]:
|
|
418
|
+
"""Find newly failed elements from committed nonlinear states."""
|
|
419
|
+
if float(load_factor) + 1.0e-12 < config.min_load_factor:
|
|
420
|
+
return (), 0.0
|
|
421
|
+
|
|
422
|
+
deleted: Set[int] = {int(element_id) for element_id in deleted_element_ids}
|
|
423
|
+
records = []
|
|
424
|
+
max_utilization = 0.0
|
|
425
|
+
for element_id, state in states.items():
|
|
426
|
+
if not isinstance(element_id, int) or element_id in deleted:
|
|
427
|
+
continue
|
|
428
|
+
element = model.mesh.get_element(int(element_id))
|
|
429
|
+
if element is None:
|
|
430
|
+
continue
|
|
431
|
+
category = element_fracture_category(element)
|
|
432
|
+
if category is None or category not in config.element_scope:
|
|
433
|
+
continue
|
|
434
|
+
trigger_value, location = state_equivalent_plastic_strain(state)
|
|
435
|
+
utilization = trigger_value / config.threshold if config.threshold > 0.0 else 0.0
|
|
436
|
+
max_utilization = max(max_utilization, float(utilization))
|
|
437
|
+
if trigger_value >= config.threshold:
|
|
438
|
+
records.append(
|
|
439
|
+
DeletedElementRecord(
|
|
440
|
+
element_id=int(element_id),
|
|
441
|
+
element_type=category,
|
|
442
|
+
step_index=int(step_index),
|
|
443
|
+
load_factor=float(load_factor),
|
|
444
|
+
trigger_name="max_equivalent_plastic_strain",
|
|
445
|
+
trigger_value=float(trigger_value),
|
|
446
|
+
threshold=float(config.threshold),
|
|
447
|
+
location=location,
|
|
448
|
+
measure=element_measure(model.mesh, element),
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return tuple(records), max_utilization
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def deleted_pressure_load_resultant(model: Any, load_case: Optional[Any], deleted_element_ids: Iterable[int]) -> np.ndarray:
|
|
455
|
+
"""Assemble the resultant force removed from deleted pressure elements."""
|
|
456
|
+
deleted = {int(element_id) for element_id in deleted_element_ids}
|
|
457
|
+
result = np.zeros(3, dtype=float)
|
|
458
|
+
if load_case is None or not deleted:
|
|
459
|
+
return result
|
|
460
|
+
for element_id, pressure in getattr(load_case, "pressure_loads", {}).items():
|
|
461
|
+
if int(element_id) not in deleted:
|
|
462
|
+
continue
|
|
463
|
+
element = model.mesh.get_element(int(element_id))
|
|
464
|
+
if element is None:
|
|
465
|
+
continue
|
|
466
|
+
f_elem = load_case._consistent_pressure_load(element, model.mesh, float(pressure))
|
|
467
|
+
for idx in range(0, len(f_elem), 6):
|
|
468
|
+
result += f_elem[idx:idx + 3]
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def filtered_load_case_for_deleted_elements(load_case: Optional[Any], deleted_element_ids: Iterable[int]) -> Optional[Any]:
|
|
473
|
+
"""Return a shallow load-case copy with deleted pressure elements removed."""
|
|
474
|
+
if load_case is None:
|
|
475
|
+
return None
|
|
476
|
+
deleted = {int(element_id) for element_id in deleted_element_ids}
|
|
477
|
+
if not deleted:
|
|
478
|
+
return load_case
|
|
479
|
+
import copy
|
|
480
|
+
|
|
481
|
+
filtered = copy.copy(load_case)
|
|
482
|
+
filtered.pressure_loads = {
|
|
483
|
+
int(element_id): float(pressure)
|
|
484
|
+
for element_id, pressure in getattr(load_case, "pressure_loads", {}).items()
|
|
485
|
+
if int(element_id) not in deleted
|
|
486
|
+
}
|
|
487
|
+
return filtered
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def fracture_summary(
|
|
491
|
+
model: Any,
|
|
492
|
+
config: Optional[FractureConfig],
|
|
493
|
+
records: Sequence[DeletedElementRecord],
|
|
494
|
+
deleted_element_ids: Iterable[int],
|
|
495
|
+
*,
|
|
496
|
+
max_utilization: float = 0.0,
|
|
497
|
+
warnings: Sequence[str] = (),
|
|
498
|
+
) -> Dict[str, Any]:
|
|
499
|
+
deleted = {int(element_id) for element_id in deleted_element_ids}
|
|
500
|
+
shell_area = 0.0
|
|
501
|
+
beam_length = 0.0
|
|
502
|
+
for element_id in deleted:
|
|
503
|
+
element = model.mesh.get_element(element_id)
|
|
504
|
+
if element is None:
|
|
505
|
+
continue
|
|
506
|
+
category = element_fracture_category(element)
|
|
507
|
+
if category == "shell":
|
|
508
|
+
shell_area += element_measure(model.mesh, element)
|
|
509
|
+
elif category == "beam":
|
|
510
|
+
beam_length += element_measure(model.mesh, element)
|
|
511
|
+
first = min((record.load_factor for record in records), default=None)
|
|
512
|
+
peak_trigger = max((record.trigger_value for record in records), default=0.0)
|
|
513
|
+
return {
|
|
514
|
+
"enabled": config is not None,
|
|
515
|
+
"config": None if config is None else config.to_dict(),
|
|
516
|
+
"deleted_count": len(deleted),
|
|
517
|
+
"deleted_element_ids": sorted(deleted),
|
|
518
|
+
"deleted_shell_area": float(shell_area),
|
|
519
|
+
"deleted_beam_length": float(beam_length),
|
|
520
|
+
"first_deletion_load_factor": None if first is None else float(first),
|
|
521
|
+
"max_trigger_value": float(peak_trigger),
|
|
522
|
+
"max_fracture_utilization": float(max_utilization),
|
|
523
|
+
"records": [record.to_dict() for record in records],
|
|
524
|
+
"warnings": list(warnings),
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def mpc_warning_for_deleted_shells(model: Any, deleted_element_ids: Iterable[int]) -> Optional[str]:
|
|
529
|
+
"""Warn when eroded shell nodes still participate in MPC topology."""
|
|
530
|
+
shell_dofs = set()
|
|
531
|
+
for element_id in deleted_element_ids:
|
|
532
|
+
element = model.mesh.get_element(int(element_id))
|
|
533
|
+
if element is None or element_fracture_category(element) != "shell":
|
|
534
|
+
continue
|
|
535
|
+
for node_id in getattr(element, "node_ids", []):
|
|
536
|
+
node = model.mesh.get_node(int(node_id))
|
|
537
|
+
if node is not None:
|
|
538
|
+
shell_dofs.update(int(dof) for dof in node.dofs)
|
|
539
|
+
if not shell_dofs:
|
|
540
|
+
return None
|
|
541
|
+
for element in model.mesh.elements.values():
|
|
542
|
+
if not hasattr(element, "get_mpc_constraints"):
|
|
543
|
+
continue
|
|
544
|
+
for constraint in element.get_mpc_constraints(model.mesh):
|
|
545
|
+
masters = constraint.get("masters", {}) or {}
|
|
546
|
+
if shell_dofs.intersection(int(dof) for dof in masters):
|
|
547
|
+
return (
|
|
548
|
+
"Fracture v1 softens/deactivates elements only; nodes, DOFs, "
|
|
549
|
+
"MPCs and beam-shell couplings remain intact after shell erosion."
|
|
550
|
+
)
|
|
551
|
+
return None
|