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
@@ -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",)