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/recovery.py ADDED
@@ -0,0 +1,505 @@
1
+ """Selective result recovery and resource-policy helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence, Tuple
9
+
10
+ import numpy as np
11
+
12
+ if TYPE_CHECKING:
13
+ from .fe_core import FEModel
14
+
15
+
16
+ _DOF_COMPONENTS = ("ux", "uy", "uz", "rx", "ry", "rz")
17
+ _HISTORY_MODES = {"full", "selected", "envelope"}
18
+
19
+
20
+ def _optional_int_tuple(values: Optional[Sequence[int]]) -> Optional[Tuple[int, ...]]:
21
+ if values is None:
22
+ return None
23
+ return tuple(int(value) for value in values)
24
+
25
+
26
+ def _optional_str_tuple(values: Optional[Sequence[str]]) -> Optional[Tuple[str, ...]]:
27
+ if values is None:
28
+ return None
29
+ return tuple(str(value) for value in values)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RecoveryConfig:
34
+ """Requested result-recovery scope.
35
+
36
+ ``None`` for node or element ids means recover all available items, matching
37
+ the legacy result behavior. Components filter stress/result dictionaries by
38
+ key; displacement arrays remain six-DOF node vectors.
39
+ """
40
+
41
+ node_ids: Optional[Sequence[int]] = None
42
+ element_ids: Optional[Sequence[int]] = None
43
+ components: Optional[Sequence[str]] = None
44
+ include_displacements: bool = True
45
+ include_stresses: bool = True
46
+ include_reactions: bool = True
47
+ history_mode: str = "full"
48
+ store_full_histories: bool = True
49
+ metadata: Mapping[str, Any] = field(default_factory=dict)
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.history_mode not in _HISTORY_MODES:
53
+ raise ValueError(f"history_mode must be one of {sorted(_HISTORY_MODES)}")
54
+
55
+ def selected_node_ids(self, model: "FEModel") -> Tuple[int, ...]:
56
+ if self.node_ids is None:
57
+ return tuple(int(node_id) for node_id in model.mesh.nodes)
58
+ missing = [int(node_id) for node_id in self.node_ids if int(node_id) not in model.mesh.nodes]
59
+ if missing:
60
+ raise ValueError(f"Requested recovery node ids not found: {missing}")
61
+ return _optional_int_tuple(self.node_ids) or ()
62
+
63
+ def selected_element_ids(self, model: "FEModel") -> Tuple[int, ...]:
64
+ if self.element_ids is None:
65
+ return tuple(int(element_id) for element_id in model.mesh.elements)
66
+ missing = [int(element_id) for element_id in self.element_ids if int(element_id) not in model.mesh.elements]
67
+ if missing:
68
+ raise ValueError(f"Requested recovery element ids not found: {missing}")
69
+ return _optional_int_tuple(self.element_ids) or ()
70
+
71
+ def selected_components(self) -> Optional[Tuple[str, ...]]:
72
+ return _optional_str_tuple(self.components)
73
+
74
+ def to_dict(self) -> Dict[str, Any]:
75
+ return {
76
+ "node_ids": None if self.node_ids is None else list(_optional_int_tuple(self.node_ids) or ()),
77
+ "element_ids": None if self.element_ids is None else list(_optional_int_tuple(self.element_ids) or ()),
78
+ "components": None if self.components is None else list(_optional_str_tuple(self.components) or ()),
79
+ "include_displacements": bool(self.include_displacements),
80
+ "include_stresses": bool(self.include_stresses),
81
+ "include_reactions": bool(self.include_reactions),
82
+ "history_mode": self.history_mode,
83
+ "store_full_histories": bool(self.store_full_histories),
84
+ "metadata": dict(self.metadata),
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ResourceConfig:
90
+ """Bounded resource policy for solver phases.
91
+
92
+ This batch records requested limits and deterministic behavior. It does not
93
+ force parallel execution; later measured-parallelism work can consume the
94
+ same contract.
95
+ """
96
+
97
+ solver_threads: Optional[int] = None
98
+ assembly_threads: Optional[int] = None
99
+ recovery_threads: Optional[int] = None
100
+ process_workers: Optional[int] = None
101
+ deterministic: bool = True
102
+ memory_limit_bytes: Optional[int] = None
103
+ metadata: Mapping[str, Any] = field(default_factory=dict)
104
+
105
+ def __post_init__(self) -> None:
106
+ for name in ("solver_threads", "assembly_threads", "recovery_threads", "process_workers"):
107
+ value = getattr(self, name)
108
+ if value is not None and int(value) <= 0:
109
+ raise ValueError(f"{name} must be positive when provided")
110
+ if self.memory_limit_bytes is not None and int(self.memory_limit_bytes) <= 0:
111
+ raise ValueError("memory_limit_bytes must be positive when provided")
112
+
113
+ def to_dict(self) -> Dict[str, Any]:
114
+ return {
115
+ "solver_threads": None if self.solver_threads is None else int(self.solver_threads),
116
+ "assembly_threads": None if self.assembly_threads is None else int(self.assembly_threads),
117
+ "recovery_threads": None if self.recovery_threads is None else int(self.recovery_threads),
118
+ "process_workers": None if self.process_workers is None else int(self.process_workers),
119
+ "deterministic": bool(self.deterministic),
120
+ "memory_limit_bytes": None if self.memory_limit_bytes is None else int(self.memory_limit_bytes),
121
+ "metadata": dict(self.metadata),
122
+ }
123
+
124
+
125
+ class ResourcePolicyError(ValueError):
126
+ """Raised when a requested resource policy cannot be satisfied."""
127
+
128
+ def __init__(
129
+ self,
130
+ message: str,
131
+ *,
132
+ context: str,
133
+ memory_estimate: Optional["MemoryEstimate"] = None,
134
+ resource_config: Optional[ResourceConfig] = None,
135
+ ) -> None:
136
+ super().__init__(message)
137
+ self.context = context
138
+ self.memory_estimate = memory_estimate
139
+ self.resource_config = resource_config
140
+
141
+ def to_dict(self) -> Dict[str, Any]:
142
+ return {
143
+ "context": self.context,
144
+ "message": str(self),
145
+ "memory_estimate": None if self.memory_estimate is None else self.memory_estimate.to_dict(),
146
+ "resource_config": None if self.resource_config is None else self.resource_config.to_dict(),
147
+ }
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class MemoryEstimate:
152
+ """Conservative byte estimates for common FE storage blocks."""
153
+
154
+ total_dofs: int
155
+ num_nodes: int
156
+ num_elements: int
157
+ matrix_nnz_estimate: int
158
+ csr_bytes_estimate: int
159
+ rhs_bytes_estimate: int
160
+ history_bytes_estimate: int
161
+ eigenvector_bytes_estimate: int
162
+ nonlinear_state_bytes_estimate: int
163
+ total_bytes_estimate: int
164
+ notes: Tuple[str, ...] = ()
165
+
166
+ def to_dict(self) -> Dict[str, Any]:
167
+ return {
168
+ "total_dofs": int(self.total_dofs),
169
+ "num_nodes": int(self.num_nodes),
170
+ "num_elements": int(self.num_elements),
171
+ "matrix_nnz_estimate": int(self.matrix_nnz_estimate),
172
+ "csr_bytes_estimate": int(self.csr_bytes_estimate),
173
+ "rhs_bytes_estimate": int(self.rhs_bytes_estimate),
174
+ "history_bytes_estimate": int(self.history_bytes_estimate),
175
+ "eigenvector_bytes_estimate": int(self.eigenvector_bytes_estimate),
176
+ "nonlinear_state_bytes_estimate": int(self.nonlinear_state_bytes_estimate),
177
+ "total_bytes_estimate": int(self.total_bytes_estimate),
178
+ "notes": list(self.notes),
179
+ }
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class RecoveryExecutionReport:
184
+ """Execution diagnostics for a recovery phase."""
185
+
186
+ phase: str
187
+ item_count: int
188
+ requested_workers: int
189
+ used_workers: int
190
+ backend: str
191
+ deterministic: bool
192
+ elapsed_seconds: float
193
+ reason: str = ""
194
+
195
+ def to_dict(self) -> Dict[str, Any]:
196
+ return {
197
+ "phase": self.phase,
198
+ "item_count": int(self.item_count),
199
+ "requested_workers": int(self.requested_workers),
200
+ "used_workers": int(self.used_workers),
201
+ "backend": self.backend,
202
+ "deterministic": bool(self.deterministic),
203
+ "elapsed_seconds": float(self.elapsed_seconds),
204
+ "reason": self.reason,
205
+ }
206
+
207
+
208
+ def default_recovery_config(config: Optional[RecoveryConfig] = None) -> RecoveryConfig:
209
+ """Return a full-recovery config when none is supplied."""
210
+
211
+ return config if config is not None else RecoveryConfig()
212
+
213
+
214
+ def _estimate_matrix_nnz(model: "FEModel") -> int:
215
+ """Assembled-matrix nonzero count, cached on the mesh topology revision.
216
+
217
+ Only element connectivity determines the sparsity, so the (topological)
218
+ count is cached and reused across the preflight and recovery memory checks
219
+ of a solve, and across repeated solves on the same mesh. The union of
220
+ per-element DOF-pair blocks is computed vectorized rather than with a
221
+ Python set of tuples.
222
+ """
223
+ mesh = model.mesh
224
+ signature = mesh.revision_signature()
225
+ cached = getattr(mesh, "_matrix_nnz_cache", None)
226
+ if cached is not None and cached[0] == signature:
227
+ return cached[1]
228
+ total_dofs = int(mesh.dof_manager.total_dofs)
229
+ encoded_blocks = []
230
+ for element in mesh.elements.values():
231
+ try:
232
+ mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.int64).reshape(-1)
233
+ except Exception:
234
+ continue
235
+ if mapping.size == 0:
236
+ continue
237
+ rows = np.repeat(mapping, mapping.size)
238
+ cols = np.tile(mapping, mapping.size)
239
+ encoded_blocks.append(rows * total_dofs + cols)
240
+ if encoded_blocks:
241
+ matrix_nnz = int(np.unique(np.concatenate(encoded_blocks)).size)
242
+ else:
243
+ matrix_nnz = 0
244
+ mesh._matrix_nnz_cache = (signature, matrix_nnz)
245
+ return matrix_nnz
246
+
247
+
248
+ def estimate_model_memory(
249
+ model: "FEModel",
250
+ *,
251
+ num_rhs: int = 1,
252
+ num_modes: int = 0,
253
+ transient_saved_steps: int = 0,
254
+ store_full_history: bool = True,
255
+ recovery_config: Optional[RecoveryConfig] = None,
256
+ nonlinear_state: bool = False,
257
+ ) -> MemoryEstimate:
258
+ """Estimate matrix/result storage for a model and recovery request."""
259
+
260
+ total_dofs = int(model.mesh.dof_manager.total_dofs)
261
+ num_nodes = int(len(model.mesh.nodes))
262
+ num_elements = int(len(model.mesh.elements))
263
+ notes = []
264
+ matrix_nnz = _estimate_matrix_nnz(model)
265
+ if matrix_nnz == 0 and total_dofs:
266
+ matrix_nnz = total_dofs
267
+ notes.append("matrix sparsity estimated as diagonal because no element mapping was available")
268
+
269
+ csr_bytes = int(matrix_nnz * (8 + 4) + (total_dofs + 1) * 4)
270
+ rhs_bytes = int(max(int(num_rhs), 0) * total_dofs * 8)
271
+
272
+ recovery = default_recovery_config(recovery_config)
273
+ if transient_saved_steps > 0:
274
+ if recovery.history_mode == "envelope":
275
+ selected_history_dofs = 6 * len(recovery.selected_node_ids(model)) if recovery.node_ids is not None and recovery.include_displacements else 0
276
+ history_bytes = int(total_dofs * 8 * 3 + max(int(transient_saved_steps), 0) * selected_history_dofs * 8)
277
+ elif store_full_history and recovery.store_full_histories:
278
+ history_dofs = total_dofs
279
+ history_bytes = int(max(int(transient_saved_steps), 0) * max(history_dofs, 0) * 8 * 3)
280
+ else:
281
+ history_dofs = 6 * len(recovery.selected_node_ids(model)) if recovery.include_displacements else 0
282
+ history_bytes = int(max(int(transient_saved_steps), 0) * max(history_dofs, 0) * 8 * 3)
283
+ else:
284
+ history_bytes = 0
285
+
286
+ eigenvector_bytes = int(max(int(num_modes), 0) * total_dofs * 8)
287
+ if nonlinear_state:
288
+ nonlinear_bytes = int(max(num_elements, 0) * 8 * 64)
289
+ else:
290
+ nonlinear_bytes = 0
291
+ total = int(csr_bytes + rhs_bytes + history_bytes + eigenvector_bytes + nonlinear_bytes)
292
+ return MemoryEstimate(
293
+ total_dofs=total_dofs,
294
+ num_nodes=num_nodes,
295
+ num_elements=num_elements,
296
+ matrix_nnz_estimate=matrix_nnz,
297
+ csr_bytes_estimate=csr_bytes,
298
+ rhs_bytes_estimate=rhs_bytes,
299
+ history_bytes_estimate=history_bytes,
300
+ eigenvector_bytes_estimate=eigenvector_bytes,
301
+ nonlinear_state_bytes_estimate=nonlinear_bytes,
302
+ total_bytes_estimate=total,
303
+ notes=tuple(notes),
304
+ )
305
+
306
+
307
+ def enforce_memory_limit(
308
+ memory_estimate: MemoryEstimate,
309
+ resource_config: Optional[ResourceConfig],
310
+ *,
311
+ context: str,
312
+ ) -> None:
313
+ """Raise when an estimate exceeds ``ResourceConfig.memory_limit_bytes``."""
314
+
315
+ if resource_config is None or resource_config.memory_limit_bytes is None:
316
+ return
317
+ limit = int(resource_config.memory_limit_bytes)
318
+ estimated = int(memory_estimate.total_bytes_estimate)
319
+ if estimated > limit:
320
+ raise ResourcePolicyError(
321
+ f"{context} estimated memory {estimated} bytes exceeds configured limit {limit} bytes",
322
+ context=context,
323
+ memory_estimate=memory_estimate,
324
+ resource_config=resource_config,
325
+ )
326
+
327
+
328
+ def select_node_displacements(
329
+ model: "FEModel",
330
+ displacements: np.ndarray,
331
+ recovery_config: Optional[RecoveryConfig] = None,
332
+ ) -> Dict[int, np.ndarray]:
333
+ """Extract selected nodal displacement vectors."""
334
+
335
+ recovery = default_recovery_config(recovery_config)
336
+ if not recovery.include_displacements:
337
+ return {}
338
+ vector = np.asarray(displacements, dtype=float)
339
+ selected: Dict[int, np.ndarray] = {}
340
+ for node_id in recovery.selected_node_ids(model):
341
+ node = model.mesh.nodes[int(node_id)]
342
+ selected[int(node_id)] = vector[np.asarray(node.dofs, dtype=np.intp)]
343
+ return selected
344
+
345
+
346
+ def _filter_components(values: Mapping[str, Any], components: Optional[Tuple[str, ...]]) -> Dict[str, Any]:
347
+ if components is None:
348
+ return dict(values)
349
+ wanted = set(components)
350
+ return {key: value for key, value in values.items() if str(key) in wanted}
351
+
352
+
353
+ def _recovery_worker_count(resource_config: Optional[ResourceConfig], item_count: int) -> Tuple[int, int, str]:
354
+ requested = 1 if resource_config is None or resource_config.recovery_threads is None else int(resource_config.recovery_threads)
355
+ if item_count <= 1:
356
+ return requested, 1, "serial: item count <= 1"
357
+ if requested <= 1:
358
+ return requested, 1, "serial: recovery_threads not requested"
359
+ return requested, min(requested, int(item_count)), "thread_pool"
360
+
361
+
362
+ def _ordered_element_ids(model: "FEModel", selected: Sequence[int]) -> Tuple[int, ...]:
363
+ wanted = {int(element_id) for element_id in selected}
364
+ return tuple(int(element_id) for element_id in model.mesh.elements if int(element_id) in wanted)
365
+
366
+
367
+ def _compute_one_element_stress(
368
+ model: "FEModel",
369
+ displacements: np.ndarray,
370
+ element_id: int,
371
+ *,
372
+ return_global: bool,
373
+ ) -> Optional[Tuple[int, Dict[str, np.ndarray]]]:
374
+ element = model.mesh.elements[int(element_id)]
375
+ material = model.get_material(element.material_name)
376
+ dof_mapping = np.asarray(element.get_dof_mapping(model.mesh), dtype=np.intp)
377
+ if dof_mapping.size == 0 or int(dof_mapping.max()) >= displacements.size:
378
+ return None
379
+ try:
380
+ return int(element_id), element.compute_stresses(
381
+ model.mesh,
382
+ displacements[dof_mapping],
383
+ material,
384
+ return_global=return_global,
385
+ )
386
+ except (IndexError, ValueError):
387
+ return None
388
+
389
+
390
+ def recover_element_stresses_with_report(
391
+ model: "FEModel",
392
+ displacements: np.ndarray,
393
+ recovery_config: Optional[RecoveryConfig] = None,
394
+ *,
395
+ return_global: bool = False,
396
+ resource_config: Optional[ResourceConfig] = None,
397
+ ) -> Tuple[Dict[int, Dict[str, np.ndarray]], RecoveryExecutionReport]:
398
+ """Recover element stresses and return bounded execution diagnostics."""
399
+
400
+ recovery = default_recovery_config(recovery_config)
401
+ if not recovery.include_stresses:
402
+ report = RecoveryExecutionReport(
403
+ phase="element_stress_recovery",
404
+ item_count=0,
405
+ requested_workers=1 if resource_config is None or resource_config.recovery_threads is None else int(resource_config.recovery_threads),
406
+ used_workers=0,
407
+ backend="disabled",
408
+ deterministic=True if resource_config is None else bool(resource_config.deterministic),
409
+ elapsed_seconds=0.0,
410
+ reason="stress recovery disabled",
411
+ )
412
+ return {}, report
413
+
414
+ selected_ids = _ordered_element_ids(model, recovery.selected_element_ids(model))
415
+ displacements = np.asarray(displacements, dtype=float)
416
+ requested, used_workers, reason = _recovery_worker_count(resource_config, len(selected_ids))
417
+ deterministic = True if resource_config is None else bool(resource_config.deterministic)
418
+ backend = "serial" if used_workers <= 1 else "thread_pool"
419
+ start = time.perf_counter()
420
+ stresses: Dict[int, Dict[str, np.ndarray]] = {}
421
+ if used_workers <= 1:
422
+ for element_id in selected_ids:
423
+ item = _compute_one_element_stress(model, displacements, element_id, return_global=return_global)
424
+ if item is not None:
425
+ stresses[item[0]] = item[1]
426
+ else:
427
+ with ThreadPoolExecutor(max_workers=used_workers) as executor:
428
+ futures = [
429
+ executor.submit(_compute_one_element_stress, model, displacements, element_id, return_global=return_global)
430
+ for element_id in selected_ids
431
+ ]
432
+ results = [future.result() for future in futures]
433
+ for item in results:
434
+ if item is not None:
435
+ stresses[item[0]] = item[1]
436
+
437
+ components = recovery.selected_components()
438
+ if components is not None:
439
+ stresses = {int(element_id): _filter_components(values, components) for element_id, values in stresses.items()}
440
+ elapsed = time.perf_counter() - start
441
+ report = RecoveryExecutionReport(
442
+ phase="element_stress_recovery",
443
+ item_count=len(selected_ids),
444
+ requested_workers=requested,
445
+ used_workers=used_workers,
446
+ backend=backend,
447
+ deterministic=deterministic,
448
+ elapsed_seconds=float(elapsed),
449
+ reason=reason,
450
+ )
451
+ return stresses, report
452
+
453
+
454
+ def recover_element_stresses(
455
+ model: "FEModel",
456
+ displacements: np.ndarray,
457
+ recovery_config: Optional[RecoveryConfig] = None,
458
+ *,
459
+ return_global: bool = False,
460
+ resource_config: Optional[ResourceConfig] = None,
461
+ ) -> Dict[int, Dict[str, np.ndarray]]:
462
+ """Recover selected element stresses with optional component filtering."""
463
+
464
+ stresses, _report = recover_element_stresses_with_report(
465
+ model,
466
+ displacements,
467
+ recovery_config,
468
+ return_global=return_global,
469
+ resource_config=resource_config,
470
+ )
471
+ return stresses
472
+
473
+
474
+ def filter_reactions(
475
+ reactions: Mapping[int, np.ndarray],
476
+ recovery_config: Optional[RecoveryConfig] = None,
477
+ model: Optional["FEModel"] = None,
478
+ ) -> Dict[int, np.ndarray]:
479
+ """Filter reaction dictionary by requested node ids."""
480
+
481
+ recovery = default_recovery_config(recovery_config)
482
+ if not recovery.include_reactions:
483
+ return {}
484
+ if recovery.node_ids is None:
485
+ return {int(node_id): np.asarray(values, dtype=float) for node_id, values in reactions.items()}
486
+ if model is not None:
487
+ selected = set(recovery.selected_node_ids(model))
488
+ else:
489
+ selected = set(_optional_int_tuple(recovery.node_ids) or ())
490
+ return {int(node_id): np.asarray(values, dtype=float) for node_id, values in reactions.items() if int(node_id) in selected}
491
+
492
+
493
+ def recovery_metadata(
494
+ recovery_config: Optional[RecoveryConfig] = None,
495
+ resource_config: Optional[ResourceConfig] = None,
496
+ memory_estimate: Optional[MemoryEstimate] = None,
497
+ ) -> Dict[str, Any]:
498
+ """Serialize recovery/resource policy metadata for provenance records."""
499
+
500
+ payload: Dict[str, Any] = {"recovery": default_recovery_config(recovery_config).to_dict()}
501
+ if resource_config is not None:
502
+ payload["resources"] = resource_config.to_dict()
503
+ if memory_estimate is not None:
504
+ payload["memory_estimate"] = memory_estimate.to_dict()
505
+ return payload