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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Rule-aware geometric imperfection helpers for nonlinear capacity checks.
|
|
2
|
+
|
|
3
|
+
Imperfections are represented as stress-free reference-geometry offsets. In
|
|
4
|
+
other words, applying an imperfection modifies nodal coordinates before the
|
|
5
|
+
nonlinear solve; zero displacement in the imperfect model has zero strain and
|
|
6
|
+
zero internal force.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Mapping, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .buckling import BucklingResult
|
|
19
|
+
from .fe_core import FEModel
|
|
20
|
+
from .nonlinear_static import NonlinearStaticResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _unit(vector: Sequence[float], fallback: Sequence[float] = (0.0, 0.0, 1.0)) -> np.ndarray:
|
|
24
|
+
value = np.asarray(vector, dtype=float).reshape(-1)
|
|
25
|
+
if value.size < 3:
|
|
26
|
+
padded = np.zeros(3, dtype=float)
|
|
27
|
+
padded[: value.size] = value
|
|
28
|
+
value = padded
|
|
29
|
+
norm = float(np.linalg.norm(value[:3]))
|
|
30
|
+
if norm <= 1.0e-14:
|
|
31
|
+
return _unit(fallback)
|
|
32
|
+
return value[:3] / norm
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _node_coords(model: "FEModel", node_ids: Iterable[int]) -> Dict[int, np.ndarray]:
|
|
36
|
+
coords: Dict[int, np.ndarray] = {}
|
|
37
|
+
for node_id in node_ids:
|
|
38
|
+
node = model.mesh.get_node(int(node_id))
|
|
39
|
+
if node is None:
|
|
40
|
+
raise ValueError(f"Node {node_id} not found")
|
|
41
|
+
coords[int(node_id)] = node.coords()
|
|
42
|
+
return coords
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _invalidate_element_caches(model: "FEModel") -> None:
|
|
46
|
+
for element in model.mesh.elements.values():
|
|
47
|
+
for name in (
|
|
48
|
+
"_stiffness_matrix",
|
|
49
|
+
"_mass_matrix",
|
|
50
|
+
"_internal_forces",
|
|
51
|
+
"_nl_cache",
|
|
52
|
+
"_hourglass_stiffness_matrix",
|
|
53
|
+
):
|
|
54
|
+
if hasattr(element, name):
|
|
55
|
+
setattr(element, name, None)
|
|
56
|
+
if hasattr(model.mesh, "_sparsity_cache"):
|
|
57
|
+
model.mesh._sparsity_cache = {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ImperfectionField:
|
|
62
|
+
"""Nodal reference-coordinate offsets in metres."""
|
|
63
|
+
|
|
64
|
+
offsets: Mapping[int, Sequence[float]]
|
|
65
|
+
name: str = "imperfection"
|
|
66
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def as_arrays(self) -> Dict[int, np.ndarray]:
|
|
69
|
+
result: Dict[int, np.ndarray] = {}
|
|
70
|
+
for node_id, offset in self.offsets.items():
|
|
71
|
+
vector = np.asarray(offset, dtype=float).reshape(-1)
|
|
72
|
+
if vector.size < 3:
|
|
73
|
+
padded = np.zeros(3, dtype=float)
|
|
74
|
+
padded[: vector.size] = vector
|
|
75
|
+
vector = padded
|
|
76
|
+
result[int(node_id)] = vector[:3].copy()
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def max_offset(self) -> float:
|
|
81
|
+
return max((float(np.linalg.norm(offset)) for offset in self.as_arrays().values()), default=0.0)
|
|
82
|
+
|
|
83
|
+
def combine(self, *others: "ImperfectionField", name: Optional[str] = None) -> "ImperfectionField":
|
|
84
|
+
offsets = self.as_arrays()
|
|
85
|
+
metadata: Dict[str, Any] = {"components": [self.name]}
|
|
86
|
+
for other in others:
|
|
87
|
+
metadata["components"].append(other.name)
|
|
88
|
+
for node_id, offset in other.as_arrays().items():
|
|
89
|
+
offsets[node_id] = offsets.get(node_id, np.zeros(3, dtype=float)) + offset
|
|
90
|
+
return ImperfectionField(offsets, name=name or "+".join(metadata["components"]), metadata=metadata)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class CompositeImperfection:
|
|
95
|
+
"""Combination of local/global imperfection fields."""
|
|
96
|
+
|
|
97
|
+
components: Sequence[Any]
|
|
98
|
+
name: str = "composite_imperfection"
|
|
99
|
+
|
|
100
|
+
def to_field(self, model: "FEModel") -> ImperfectionField:
|
|
101
|
+
fields = [to_imperfection_field(model, component) for component in self.components]
|
|
102
|
+
if not fields:
|
|
103
|
+
return ImperfectionField({}, name=self.name)
|
|
104
|
+
return fields[0].combine(*fields[1:], name=self.name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class EigenmodeImperfection:
|
|
109
|
+
"""Imperfection scaled from a linear buckling mode."""
|
|
110
|
+
|
|
111
|
+
buckling_result: "BucklingResult"
|
|
112
|
+
mode_number: int = 1
|
|
113
|
+
amplitude: float = 0.0
|
|
114
|
+
dof_filter: str = "translations"
|
|
115
|
+
name: str = "eigenmode_imperfection"
|
|
116
|
+
|
|
117
|
+
def to_field(self, model: "FEModel") -> ImperfectionField:
|
|
118
|
+
return imperfection_from_buckling_mode(
|
|
119
|
+
model,
|
|
120
|
+
self.buckling_result,
|
|
121
|
+
self.mode_number,
|
|
122
|
+
self.amplitude,
|
|
123
|
+
dof_filter=self.dof_filter,
|
|
124
|
+
name=self.name,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class StandardImperfection:
|
|
130
|
+
"""Deterministic DNV-style imperfection pattern."""
|
|
131
|
+
|
|
132
|
+
kind: str
|
|
133
|
+
node_ids: Sequence[int]
|
|
134
|
+
amplitude: Optional[float] = None
|
|
135
|
+
direction: Sequence[float] = (0.0, 0.0, 1.0)
|
|
136
|
+
axes: Sequence[int] = (0, 1)
|
|
137
|
+
waves: Tuple[int, int] = (1, 1)
|
|
138
|
+
name: str = "standard_imperfection"
|
|
139
|
+
|
|
140
|
+
def to_field(self, model: "FEModel") -> ImperfectionField:
|
|
141
|
+
kind = self.kind.lower()
|
|
142
|
+
if kind in {"member_bow", "bow"}:
|
|
143
|
+
return standard_member_bow(model, self.node_ids, self.amplitude, self.direction, name=self.name)
|
|
144
|
+
if kind in {"plate_mode", "plate_half_wave", "plate"}:
|
|
145
|
+
return standard_plate_mode(model, self.node_ids, self.amplitude, self.direction, self.axes, self.waves, name=self.name)
|
|
146
|
+
if kind in {"flange_twist", "twist"}:
|
|
147
|
+
return standard_flange_twist(model, self.node_ids, self.amplitude if self.amplitude is not None else 0.02, self.direction, name=self.name)
|
|
148
|
+
raise ValueError(f"Unknown standard imperfection kind {self.kind!r}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ImperfectionCalibrationResult:
|
|
153
|
+
"""Result from binary-search equivalent imperfection calibration."""
|
|
154
|
+
|
|
155
|
+
amplitude: float
|
|
156
|
+
capacity: float
|
|
157
|
+
iterations: int
|
|
158
|
+
converged: bool
|
|
159
|
+
history: Tuple[Dict[str, float], ...]
|
|
160
|
+
result: Optional["NonlinearStaticResult"] = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def to_imperfection_field(model: "FEModel", imperfection: Any) -> ImperfectionField:
|
|
164
|
+
if imperfection is None:
|
|
165
|
+
return ImperfectionField({})
|
|
166
|
+
if isinstance(imperfection, ImperfectionField):
|
|
167
|
+
return imperfection
|
|
168
|
+
converter = getattr(imperfection, "to_field", None)
|
|
169
|
+
if converter is not None:
|
|
170
|
+
return converter(model)
|
|
171
|
+
if isinstance(imperfection, Mapping):
|
|
172
|
+
return ImperfectionField(imperfection)
|
|
173
|
+
raise TypeError(f"Cannot convert {type(imperfection).__name__} to ImperfectionField")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def apply_imperfection(model: "FEModel", imperfection: Any, copy_model: bool = True) -> "FEModel":
|
|
177
|
+
"""Apply a stress-free geometric imperfection to a model."""
|
|
178
|
+
target = copy.deepcopy(model) if copy_model else model
|
|
179
|
+
field = to_imperfection_field(target, imperfection)
|
|
180
|
+
for node_id, offset in field.as_arrays().items():
|
|
181
|
+
node = target.mesh.get_node(node_id)
|
|
182
|
+
if node is None:
|
|
183
|
+
raise ValueError(f"Imperfection references missing node {node_id}")
|
|
184
|
+
node.x += float(offset[0])
|
|
185
|
+
node.y += float(offset[1])
|
|
186
|
+
node.z += float(offset[2])
|
|
187
|
+
if hasattr(target, "bump_revision"):
|
|
188
|
+
target.bump_revision("geometry")
|
|
189
|
+
else:
|
|
190
|
+
_invalidate_element_caches(target)
|
|
191
|
+
if not hasattr(target, "imperfection_metadata"):
|
|
192
|
+
target.imperfection_metadata = []
|
|
193
|
+
target.imperfection_metadata.append(
|
|
194
|
+
{"name": field.name, "max_offset": field.max_offset, "metadata": dict(field.metadata)}
|
|
195
|
+
)
|
|
196
|
+
return target
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def imperfection_from_buckling_mode(
|
|
200
|
+
model: "FEModel",
|
|
201
|
+
buckling_result: "BucklingResult",
|
|
202
|
+
mode_number: int,
|
|
203
|
+
amplitude: float,
|
|
204
|
+
dof_filter: str = "translations",
|
|
205
|
+
name: str = "eigenmode_imperfection",
|
|
206
|
+
) -> ImperfectionField:
|
|
207
|
+
"""Scale a buckling mode so the maximum nodal offset equals amplitude."""
|
|
208
|
+
mode = next((item for item in buckling_result.modes if int(item.mode_number) == int(mode_number)), None)
|
|
209
|
+
if mode is None:
|
|
210
|
+
raise ValueError(f"Buckling mode {mode_number} not available")
|
|
211
|
+
shape = np.asarray(mode.mode_shape, dtype=float).reshape(-1)
|
|
212
|
+
offsets: Dict[int, np.ndarray] = {}
|
|
213
|
+
filter_name = dof_filter.lower()
|
|
214
|
+
for node_id, node in model.mesh.nodes.items():
|
|
215
|
+
if filter_name in {"translations", "translation", "xyz"}:
|
|
216
|
+
vector = shape[node.dofs[:3]]
|
|
217
|
+
elif filter_name in {"z", "uz", "out_of_plane"}:
|
|
218
|
+
vector = np.array([0.0, 0.0, shape[node.dofs[2]]], dtype=float)
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError("dof_filter must be 'translations' or 'out_of_plane'")
|
|
221
|
+
offsets[int(node_id)] = np.asarray(vector, dtype=float)
|
|
222
|
+
max_norm = max((float(np.linalg.norm(value)) for value in offsets.values()), default=0.0)
|
|
223
|
+
if max_norm <= 0.0:
|
|
224
|
+
raise ValueError("Selected buckling mode has zero translational amplitude")
|
|
225
|
+
scale = float(amplitude) / max_norm
|
|
226
|
+
return ImperfectionField(
|
|
227
|
+
{node_id: scale * value for node_id, value in offsets.items()},
|
|
228
|
+
name=name,
|
|
229
|
+
metadata={"source": "buckling_mode", "mode_number": int(mode_number), "amplitude": float(amplitude)},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def standard_member_bow(
|
|
234
|
+
model: "FEModel",
|
|
235
|
+
member_nodes: Sequence[int],
|
|
236
|
+
amplitude: Optional[float] = None,
|
|
237
|
+
direction: Sequence[float] = (0.0, 0.0, 1.0),
|
|
238
|
+
name: str = "member_bow",
|
|
239
|
+
) -> ImperfectionField:
|
|
240
|
+
"""Half-sine member bow, defaulting to DNV-style L/300 amplitude."""
|
|
241
|
+
coords = _node_coords(model, member_nodes)
|
|
242
|
+
ordered_ids = list(coords)
|
|
243
|
+
start = coords[ordered_ids[0]]
|
|
244
|
+
end = coords[ordered_ids[-1]]
|
|
245
|
+
axis = end - start
|
|
246
|
+
length = float(np.linalg.norm(axis))
|
|
247
|
+
if length <= 0.0:
|
|
248
|
+
raise ValueError("member_nodes must span a non-zero length")
|
|
249
|
+
axis /= length
|
|
250
|
+
direction_vector = _unit(direction)
|
|
251
|
+
direction_vector = direction_vector - float(direction_vector @ axis) * axis
|
|
252
|
+
if float(np.linalg.norm(direction_vector)) <= 1.0e-14:
|
|
253
|
+
# Choose the Cartesian axis least aligned with the member, then
|
|
254
|
+
# project it into the transverse plane.
|
|
255
|
+
basis = np.eye(3, dtype=float)[int(np.argmin(np.abs(axis)))]
|
|
256
|
+
direction_vector = basis - float(basis @ axis) * axis
|
|
257
|
+
direction_vector /= float(np.linalg.norm(direction_vector))
|
|
258
|
+
amp = float(length / 300.0 if amplitude is None else amplitude)
|
|
259
|
+
offsets: Dict[int, np.ndarray] = {}
|
|
260
|
+
for node_id, coord in coords.items():
|
|
261
|
+
s = float((coord - start) @ axis) / length
|
|
262
|
+
offsets[node_id] = amp * np.sin(np.pi * np.clip(s, 0.0, 1.0)) * direction_vector
|
|
263
|
+
return ImperfectionField(offsets, name=name, metadata={"kind": "member_bow", "amplitude": amp, "length": length})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _node_ids_from_region(model: "FEModel", shell_region: Sequence[int]) -> Tuple[int, ...]:
|
|
267
|
+
node_ids = set()
|
|
268
|
+
for item_id in shell_region:
|
|
269
|
+
element = model.mesh.get_element(int(item_id))
|
|
270
|
+
if element is not None and hasattr(element, "node_ids"):
|
|
271
|
+
node_ids.update(int(node_id) for node_id in element.node_ids)
|
|
272
|
+
elif model.mesh.get_node(int(item_id)) is not None:
|
|
273
|
+
node_ids.add(int(item_id))
|
|
274
|
+
else:
|
|
275
|
+
raise ValueError(f"Region id {item_id} is neither a node nor an element")
|
|
276
|
+
return tuple(sorted(node_ids))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def standard_plate_mode(
|
|
280
|
+
model: "FEModel",
|
|
281
|
+
shell_region: Sequence[int],
|
|
282
|
+
amplitude: Optional[float] = None,
|
|
283
|
+
direction: Sequence[float] = (0.0, 0.0, 1.0),
|
|
284
|
+
axes: Sequence[int] = (0, 1),
|
|
285
|
+
waves: Tuple[int, int] = (1, 1),
|
|
286
|
+
name: str = "plate_mode",
|
|
287
|
+
) -> ImperfectionField:
|
|
288
|
+
"""Sinusoidal plate imperfection, defaulting to s/200 amplitude."""
|
|
289
|
+
node_ids = _node_ids_from_region(model, shell_region)
|
|
290
|
+
coords = _node_coords(model, node_ids)
|
|
291
|
+
ax0, ax1 = int(axes[0]), int(axes[1])
|
|
292
|
+
values = np.asarray([coord for coord in coords.values()], dtype=float)
|
|
293
|
+
lo = values[:, [ax0, ax1]].min(axis=0)
|
|
294
|
+
hi = values[:, [ax0, ax1]].max(axis=0)
|
|
295
|
+
spans = np.maximum(hi - lo, 1.0e-14)
|
|
296
|
+
amp = float(min(spans) / 200.0 if amplitude is None else amplitude)
|
|
297
|
+
direction_vector = _unit(direction)
|
|
298
|
+
wx, wy = int(waves[0]), int(waves[1])
|
|
299
|
+
offsets: Dict[int, np.ndarray] = {}
|
|
300
|
+
for node_id, coord in coords.items():
|
|
301
|
+
sx = (coord[ax0] - lo[0]) / spans[0]
|
|
302
|
+
sy = (coord[ax1] - lo[1]) / spans[1]
|
|
303
|
+
shape = np.sin(wx * np.pi * sx) * np.sin(wy * np.pi * sy)
|
|
304
|
+
offsets[node_id] = amp * shape * direction_vector
|
|
305
|
+
return ImperfectionField(offsets, name=name, metadata={"kind": "plate_mode", "amplitude": amp, "waves": (wx, wy)})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def standard_flange_twist(
|
|
309
|
+
model: "FEModel",
|
|
310
|
+
node_ids: Sequence[int],
|
|
311
|
+
twist_radians: float = 0.02,
|
|
312
|
+
direction: Sequence[float] = (0.0, 0.0, 1.0),
|
|
313
|
+
name: str = "flange_twist",
|
|
314
|
+
) -> ImperfectionField:
|
|
315
|
+
"""Small rigid twist ``delta x = theta x r`` about ``direction``.
|
|
316
|
+
|
|
317
|
+
``direction`` is the twist axis. The default angle is 0.02 rad.
|
|
318
|
+
"""
|
|
319
|
+
coords = _node_coords(model, node_ids)
|
|
320
|
+
values = np.asarray(list(coords.values()), dtype=float)
|
|
321
|
+
if values.size == 0:
|
|
322
|
+
return ImperfectionField({}, name=name)
|
|
323
|
+
centroid = values.mean(axis=0)
|
|
324
|
+
twist_axis = _unit(direction)
|
|
325
|
+
offsets = {
|
|
326
|
+
node_id: float(twist_radians) * np.cross(twist_axis, coord - centroid)
|
|
327
|
+
for node_id, coord in coords.items()
|
|
328
|
+
}
|
|
329
|
+
return ImperfectionField(
|
|
330
|
+
offsets,
|
|
331
|
+
name=name,
|
|
332
|
+
metadata={
|
|
333
|
+
"kind": "flange_twist",
|
|
334
|
+
"twist_radians": float(twist_radians),
|
|
335
|
+
"twist_axis": twist_axis.tolist(),
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def calibrate_imperfection_amplitude(
|
|
341
|
+
model_builder: Callable[[], "FEModel"],
|
|
342
|
+
target_capacity: float,
|
|
343
|
+
imperfection_builder: Callable[[float], Any],
|
|
344
|
+
load_program: Any,
|
|
345
|
+
bracket: Tuple[float, float],
|
|
346
|
+
tolerance: float = 0.02,
|
|
347
|
+
max_iterations: int = 20,
|
|
348
|
+
solver_kwargs: Optional[Mapping[str, Any]] = None,
|
|
349
|
+
) -> ImperfectionCalibrationResult:
|
|
350
|
+
"""Binary-search an equivalent imperfection amplitude for target capacity."""
|
|
351
|
+
from .nonlinear_static import solve_static_nonlinear
|
|
352
|
+
|
|
353
|
+
lo, hi = float(bracket[0]), float(bracket[1])
|
|
354
|
+
if lo < 0.0 or hi <= lo:
|
|
355
|
+
raise ValueError("bracket must satisfy 0 <= low < high")
|
|
356
|
+
kwargs = dict(solver_kwargs or {})
|
|
357
|
+
history = []
|
|
358
|
+
best_result = None
|
|
359
|
+
best_amp = hi
|
|
360
|
+
best_capacity = float("nan")
|
|
361
|
+
converged = False
|
|
362
|
+
for iteration in range(1, max_iterations + 1):
|
|
363
|
+
amp = 0.5 * (lo + hi)
|
|
364
|
+
model = apply_imperfection(model_builder(), imperfection_builder(amp), copy_model=False)
|
|
365
|
+
if hasattr(load_program, "stages"):
|
|
366
|
+
result = solve_static_nonlinear(model, load_program=load_program, **kwargs)
|
|
367
|
+
else:
|
|
368
|
+
result = solve_static_nonlinear(model, load_case=load_program, **kwargs)
|
|
369
|
+
capacity = float(getattr(result, "peak_load_factor", result.capacity_estimate))
|
|
370
|
+
history.append({"iteration": float(iteration), "amplitude": amp, "capacity": capacity})
|
|
371
|
+
best_result = result
|
|
372
|
+
best_amp = amp
|
|
373
|
+
best_capacity = capacity
|
|
374
|
+
rel_error = abs(capacity - float(target_capacity)) / max(abs(float(target_capacity)), 1.0)
|
|
375
|
+
if rel_error <= tolerance:
|
|
376
|
+
converged = True
|
|
377
|
+
break
|
|
378
|
+
# Larger imperfections normally reduce buckling capacity.
|
|
379
|
+
if capacity > target_capacity:
|
|
380
|
+
lo = amp
|
|
381
|
+
else:
|
|
382
|
+
hi = amp
|
|
383
|
+
return ImperfectionCalibrationResult(
|
|
384
|
+
amplitude=best_amp,
|
|
385
|
+
capacity=best_capacity,
|
|
386
|
+
iterations=len(history),
|
|
387
|
+
converged=converged,
|
|
388
|
+
history=tuple(history),
|
|
389
|
+
result=best_result,
|
|
390
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Centralized Numba JIT compilation helper with PyInstaller fallback.
|
|
2
|
+
|
|
3
|
+
This module exports ``njit`` and ``jit`` decorators. If Numba is installed and
|
|
4
|
+
the application is not running in a frozen PyInstaller state, it uses Numba's
|
|
5
|
+
JIT. Otherwise it falls back to a zero-overhead pass-through decorator.
|
|
6
|
+
|
|
7
|
+
``JIT_ENABLED`` and ``JIT_BACKEND`` are public diagnostics. Performance tests
|
|
8
|
+
must only enforce compiled-kernel timing expectations when ``JIT_ENABLED`` is
|
|
9
|
+
true; correctness tests continue to run with the Python fallback.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any, Callable, TypeVar
|
|
18
|
+
|
|
19
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[F], F]:
|
|
23
|
+
"""Return the decorated function unchanged when Numba is unavailable."""
|
|
24
|
+
|
|
25
|
+
def decorator(func: F) -> F:
|
|
26
|
+
return func
|
|
27
|
+
|
|
28
|
+
if len(args) == 1 and callable(args[0]) and not kwargs:
|
|
29
|
+
return args[0]
|
|
30
|
+
return decorator
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_use_numba = False
|
|
34
|
+
_numba_import_error: str | None = None
|
|
35
|
+
if not getattr(sys, "frozen", False):
|
|
36
|
+
try:
|
|
37
|
+
from numba import jit as _jit
|
|
38
|
+
from numba import njit as _njit
|
|
39
|
+
from numba import get_num_threads as _numba_get_num_threads
|
|
40
|
+
from numba import prange as _numba_prange
|
|
41
|
+
from numba import set_num_threads as _numba_set_num_threads
|
|
42
|
+
|
|
43
|
+
_use_numba = True
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
_numba_import_error = str(exc)
|
|
46
|
+
|
|
47
|
+
if _use_numba:
|
|
48
|
+
njit = _njit
|
|
49
|
+
jit = _jit
|
|
50
|
+
prange = _numba_prange
|
|
51
|
+
else:
|
|
52
|
+
njit = _passthrough_decorator
|
|
53
|
+
jit = _passthrough_decorator
|
|
54
|
+
prange = range
|
|
55
|
+
|
|
56
|
+
JIT_ENABLED: bool = bool(_use_numba)
|
|
57
|
+
JIT_BACKEND: str = "numba" if JIT_ENABLED else "python"
|
|
58
|
+
JIT_DISABLED_REASON: str | None
|
|
59
|
+
if JIT_ENABLED:
|
|
60
|
+
JIT_DISABLED_REASON = None
|
|
61
|
+
elif getattr(sys, "frozen", False):
|
|
62
|
+
JIT_DISABLED_REASON = "frozen_application"
|
|
63
|
+
elif _numba_import_error:
|
|
64
|
+
JIT_DISABLED_REASON = f"numba_import_failed: {_numba_import_error}"
|
|
65
|
+
else:
|
|
66
|
+
JIT_DISABLED_REASON = "numba_not_installed"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def jit_diagnostics() -> dict[str, Any]:
|
|
70
|
+
"""Return the active kernel backend and fallback reason."""
|
|
71
|
+
thread_count = None
|
|
72
|
+
requested_threads = os.environ.get("FE_SOLVER_NUMBA_THREADS")
|
|
73
|
+
if JIT_ENABLED:
|
|
74
|
+
try:
|
|
75
|
+
thread_count = int(_numba_get_num_threads())
|
|
76
|
+
except Exception:
|
|
77
|
+
thread_count = None
|
|
78
|
+
return {
|
|
79
|
+
"enabled": JIT_ENABLED,
|
|
80
|
+
"backend": JIT_BACKEND,
|
|
81
|
+
"disabled_reason": JIT_DISABLED_REASON,
|
|
82
|
+
"frozen": bool(getattr(sys, "frozen", False)),
|
|
83
|
+
"num_threads": thread_count,
|
|
84
|
+
"requested_threads_env": requested_threads,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_numba_threads(thread_count: int | None) -> int | None:
|
|
89
|
+
"""Set the active Numba worker count and return the previous count."""
|
|
90
|
+
if not JIT_ENABLED or thread_count is None:
|
|
91
|
+
return None
|
|
92
|
+
value = int(thread_count)
|
|
93
|
+
if value <= 0:
|
|
94
|
+
raise ValueError("Numba thread count must be positive")
|
|
95
|
+
previous = int(_numba_get_num_threads())
|
|
96
|
+
_numba_set_num_threads(value)
|
|
97
|
+
return previous
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@contextlib.contextmanager
|
|
101
|
+
def numba_thread_scope(thread_count: int | None):
|
|
102
|
+
"""Temporarily set Numba threads for one solver phase."""
|
|
103
|
+
previous = set_numba_threads(thread_count)
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
if previous is not None:
|
|
108
|
+
_numba_set_num_threads(previous)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Optional runtime warmup for FE solver compiled kernels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Iterable, Tuple
|
|
7
|
+
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
|
|
10
|
+
from .jit_compiler import JIT_DISABLED_REASON, JIT_ENABLED, jit_diagnostics
|
|
11
|
+
from .matrix_assembly import assemble_mass_matrix, assemble_stiffness_matrix
|
|
12
|
+
from .mesh_gen import generate_simple_panel_mesh
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_shell_order(shell_order: str) -> str:
|
|
16
|
+
order = str(shell_order).strip().upper()
|
|
17
|
+
aliases = {"S8": "Q8", "S8R": "Q8R"}
|
|
18
|
+
return aliases.get(order, order)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _warmup_model(shell_order: str):
|
|
22
|
+
order = _normalize_shell_order(shell_order)
|
|
23
|
+
if order == "S4":
|
|
24
|
+
model = generate_simple_panel_mesh(1.0, 0.75, 0.01, num_divisions_x=1, num_divisions_y=1)
|
|
25
|
+
elif order in {"Q8", "Q8R"}:
|
|
26
|
+
model = generate_simple_panel_mesh(
|
|
27
|
+
1.0,
|
|
28
|
+
0.75,
|
|
29
|
+
0.01,
|
|
30
|
+
num_divisions_x=1,
|
|
31
|
+
num_divisions_y=1,
|
|
32
|
+
use_8node_elements=True,
|
|
33
|
+
)
|
|
34
|
+
if order == "Q8R":
|
|
35
|
+
for element in model.mesh.elements.values():
|
|
36
|
+
if getattr(element, "_is_8node", False):
|
|
37
|
+
element.reduced_integration = True
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(f"Unsupported shell order for warmup: {shell_order!r}")
|
|
40
|
+
return model
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _matrix_difference_norm(left: sparse.spmatrix, right: sparse.spmatrix) -> float:
|
|
44
|
+
denominator = max(float(sparse.linalg.norm(left)), 1.0)
|
|
45
|
+
return float(sparse.linalg.norm(left - right) / denominator)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _warm_nonlinear_impact_kernel() -> Dict[str, Any]:
|
|
49
|
+
"""Touch the material-nonlinear impact path without running a real case."""
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
|
|
53
|
+
from .boundary import BoundaryCondition
|
|
54
|
+
from .contact import (
|
|
55
|
+
NonlinearTransientConfig,
|
|
56
|
+
RigidSphereImpact,
|
|
57
|
+
SphereContactConfig,
|
|
58
|
+
solve_transient_sphere_impact,
|
|
59
|
+
)
|
|
60
|
+
from .dynamics import TransientConfig
|
|
61
|
+
from .elements import ShellElement
|
|
62
|
+
from .fe_core import FEModel
|
|
63
|
+
from .fracture import PlasticImpactDamageConfig
|
|
64
|
+
from .material_curves import DNVC208MaterialCurve
|
|
65
|
+
|
|
66
|
+
model = FEModel("kernel_warmup_nonlinear_impact")
|
|
67
|
+
model.add_material("soft", 1.0e5, 0.3, density=20.0)
|
|
68
|
+
model.materials["soft"].hardening_curve = DNVC208MaterialCurve(
|
|
69
|
+
sigma_prop=800.0,
|
|
70
|
+
sigma_yield=1000.0,
|
|
71
|
+
sigma_yield_2=1200.0,
|
|
72
|
+
eps_p_y1=1.0e-5,
|
|
73
|
+
eps_p_y2=1.0e-3,
|
|
74
|
+
K=2000.0,
|
|
75
|
+
n=0.1,
|
|
76
|
+
)
|
|
77
|
+
for node_id, xyz in {
|
|
78
|
+
1: (0.0, 0.0, 0.0),
|
|
79
|
+
2: (1.0, 0.0, 0.0),
|
|
80
|
+
3: (1.0, 1.0, 0.0),
|
|
81
|
+
4: (0.0, 1.0, 0.0),
|
|
82
|
+
}.items():
|
|
83
|
+
model.add_node(node_id, *xyz)
|
|
84
|
+
model.add_element(1, ShellElement(1, [1, 2, 3, 4], "soft", thickness=0.05))
|
|
85
|
+
model.add_boundary_condition(
|
|
86
|
+
BoundaryCondition(
|
|
87
|
+
"warmup_restrain",
|
|
88
|
+
[1, 2, 3, 4],
|
|
89
|
+
{"ux": 0.0, "uy": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0},
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
start = time.perf_counter()
|
|
94
|
+
result = solve_transient_sphere_impact(
|
|
95
|
+
model,
|
|
96
|
+
TransientConfig(dt=0.01, t_end=0.01),
|
|
97
|
+
RigidSphereImpact(
|
|
98
|
+
"warmup",
|
|
99
|
+
radius=0.2,
|
|
100
|
+
mass=5.0,
|
|
101
|
+
start_point=(0.5, 0.5, 0.25),
|
|
102
|
+
travel_direction=(0.0, 0.0, -1.0),
|
|
103
|
+
speed=1.0,
|
|
104
|
+
),
|
|
105
|
+
SphereContactConfig(penalty_stiffness=500.0, max_contact_iterations=8),
|
|
106
|
+
nonlinear_config=NonlinearTransientConfig(enabled=True, max_iterations=6, max_cutbacks=1),
|
|
107
|
+
plastic_damage_config=PlasticImpactDamageConfig(threshold=0.01, max_deleted_fraction=1.0),
|
|
108
|
+
)
|
|
109
|
+
elapsed = time.perf_counter() - start
|
|
110
|
+
return {
|
|
111
|
+
"status": str(result.status),
|
|
112
|
+
"seconds": float(elapsed),
|
|
113
|
+
"method": str(result.diagnostics.get("method", "")),
|
|
114
|
+
"stiffness_assembly_skipped": bool((result.diagnostics.get("stiffness") or {}).get("assembly_skipped", False)),
|
|
115
|
+
"strain_summary_available": "strain_summary" in result.diagnostics,
|
|
116
|
+
"num_saved_steps": int(len(result.times)),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def warm_fe_solver_kernels(
|
|
121
|
+
shell_orders: Iterable[str] = ("S4", "Q8", "Q8R"),
|
|
122
|
+
*,
|
|
123
|
+
include_nonlinear_impact: bool = False,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Warm representative FE kernels and return timing/correctness diagnostics.
|
|
126
|
+
|
|
127
|
+
The helper is intentionally optional and side-effect free apart from Numba's
|
|
128
|
+
normal in-process/disk cache behavior. It is suitable for runtime
|
|
129
|
+
applications that want to absorb first-call compilation before an analysis.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
results: Dict[str, Any] = {}
|
|
133
|
+
total_start = time.perf_counter()
|
|
134
|
+
jit = jit_diagnostics()
|
|
135
|
+
for requested_order in shell_orders:
|
|
136
|
+
order = _normalize_shell_order(requested_order)
|
|
137
|
+
model = _warmup_model(order)
|
|
138
|
+
start = time.perf_counter()
|
|
139
|
+
K_first, first_info = assemble_stiffness_matrix(model)
|
|
140
|
+
first_seconds = time.perf_counter() - start
|
|
141
|
+
start = time.perf_counter()
|
|
142
|
+
K_second, second_info = assemble_stiffness_matrix(model)
|
|
143
|
+
second_seconds = time.perf_counter() - start
|
|
144
|
+
start = time.perf_counter()
|
|
145
|
+
M_first, _mass_first_info = assemble_mass_matrix(model)
|
|
146
|
+
mass_first_seconds = time.perf_counter() - start
|
|
147
|
+
start = time.perf_counter()
|
|
148
|
+
M_second, _mass_second_info = assemble_mass_matrix(model)
|
|
149
|
+
mass_second_seconds = time.perf_counter() - start
|
|
150
|
+
results[order] = {
|
|
151
|
+
"status": "completed",
|
|
152
|
+
"shell_order": order,
|
|
153
|
+
"element_count": int(model.mesh.num_elements),
|
|
154
|
+
"jit_enabled": bool(JIT_ENABLED),
|
|
155
|
+
"jit_disabled_reason": JIT_DISABLED_REASON,
|
|
156
|
+
"jit_backend": jit.get("backend"),
|
|
157
|
+
"parallel_threads": jit.get("num_threads"),
|
|
158
|
+
"cold_assembly_seconds": float(first_seconds),
|
|
159
|
+
"warm_assembly_seconds": float(second_seconds),
|
|
160
|
+
"warm_speedup": float(first_seconds / second_seconds) if second_seconds > 0.0 else 0.0,
|
|
161
|
+
"matrix_difference_norm": _matrix_difference_norm(K_first, K_second),
|
|
162
|
+
"mass_cold_assembly_seconds": float(mass_first_seconds),
|
|
163
|
+
"mass_warm_assembly_seconds": float(mass_second_seconds),
|
|
164
|
+
"mass_matrix_difference_norm": _matrix_difference_norm(M_first, M_second),
|
|
165
|
+
"first_assembly": first_info.get("diagnostics", {}),
|
|
166
|
+
"second_assembly": second_info.get("diagnostics", {}),
|
|
167
|
+
}
|
|
168
|
+
nonlinear_impact = None
|
|
169
|
+
if include_nonlinear_impact:
|
|
170
|
+
nonlinear_impact = _warm_nonlinear_impact_kernel()
|
|
171
|
+
return {
|
|
172
|
+
"status": "completed",
|
|
173
|
+
"jit": jit,
|
|
174
|
+
"total_seconds": float(time.perf_counter() - total_start),
|
|
175
|
+
"shell_orders": results,
|
|
176
|
+
"nonlinear_impact": nonlinear_impact,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__: Tuple[str, ...] = ("warm_fe_solver_kernels",)
|