ANYsolver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. anysolver/__init__.py +631 -0
  2. anysolver/anystructure_fem_mode.py +942 -0
  3. anysolver/arc_length.py +758 -0
  4. anysolver/assembly.py +950 -0
  5. anysolver/baselines.py +303 -0
  6. anysolver/beam_shell_verification.py +4276 -0
  7. anysolver/beam_validity.py +110 -0
  8. anysolver/benchmarks.py +495 -0
  9. anysolver/boundary.py +476 -0
  10. anysolver/buckling.py +442 -0
  11. anysolver/buckling_validity.py +91 -0
  12. anysolver/capacity_workflow.py +350 -0
  13. anysolver/cases.py +173 -0
  14. anysolver/composite_strip_verification.py +297 -0
  15. anysolver/contact.py +3461 -0
  16. anysolver/corotational.py +371 -0
  17. anysolver/cylinder_benchmarks.py +364 -0
  18. anysolver/dynamics.py +723 -0
  19. anysolver/element_qualification.py +432 -0
  20. anysolver/elements.py +2724 -0
  21. anysolver/external_references.py +369 -0
  22. anysolver/fe_core.py +327 -0
  23. anysolver/fracture.py +551 -0
  24. anysolver/imperfections.py +390 -0
  25. anysolver/jit_compiler.py +108 -0
  26. anysolver/kernel_warmup.py +180 -0
  27. anysolver/linalg.py +760 -0
  28. anysolver/mass_properties.py +179 -0
  29. anysolver/material_curves.py +231 -0
  30. anysolver/matrix_assembly.py +558 -0
  31. anysolver/mesh_gen.py +1065 -0
  32. anysolver/mesh_load_bc_verification.py +609 -0
  33. anysolver/modal.py +282 -0
  34. anysolver/nonlinear.py +300 -0
  35. anysolver/nonlinear_performance.py +920 -0
  36. anysolver/nonlinear_performance_batch_b.py +592 -0
  37. anysolver/nonlinear_performance_batch_c.py +506 -0
  38. anysolver/nonlinear_performance_bootstrap.py +120 -0
  39. anysolver/nonlinear_reduced_assembly.py +760 -0
  40. anysolver/nonlinear_static.py +1518 -0
  41. anysolver/plasticity.py +419 -0
  42. anysolver/plasticity_qualification.py +314 -0
  43. anysolver/production_readiness.py +304 -0
  44. anysolver/quality_control.py +1497 -0
  45. anysolver/recovery.py +505 -0
  46. anysolver/recovery_policy.py +205 -0
  47. anysolver/reference_cases.py +425 -0
  48. anysolver/results.py +503 -0
  49. anysolver/runtime.py +9030 -0
  50. anysolver/s4_validity.py +326 -0
  51. anysolver/sesam_fem/__init__.py +55 -0
  52. anysolver/sesam_fem/__main__.py +80 -0
  53. anysolver/sesam_fem/diagnostics.py +65 -0
  54. anysolver/sesam_fem/document.py +814 -0
  55. anysolver/sesam_fem/exporter.py +84 -0
  56. anysolver/sesam_fem/importer.py +365 -0
  57. anysolver/sesam_fem/records.py +257 -0
  58. anysolver/sesam_fem/schema.py +107 -0
  59. anysolver/sesam_fem/sif_importer.py +397 -0
  60. anysolver/sesam_fem/validation.py +62 -0
  61. anysolver/shell_benchmarks.py +367 -0
  62. anysolver/test_cases.py +610 -0
  63. anysolver/validation.py +416 -0
  64. anysolver/vectorized_nonlinear.py +334 -0
  65. anysolver/vectorized_stiffness.py +571 -0
  66. anysolver-0.1.0.dist-info/METADATA +165 -0
  67. anysolver-0.1.0.dist-info/RECORD +70 -0
  68. anysolver-0.1.0.dist-info/WHEEL +5 -0
  69. anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/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