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,350 @@
1
+ """Traceable nonlinear capacity workflow.
2
+
3
+ The workflow composes existing solver pieces without changing nonlinear
4
+ physics:
5
+
6
+ 1. linear static solve,
7
+ 2. prestress recovery,
8
+ 3. eigenvalue buckling,
9
+ 4. stress-free imperfection application,
10
+ 5. nonlinear static capacity solve.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import platform
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Sequence, Tuple
23
+
24
+ import numpy as np
25
+
26
+ from .assembly import solve_linear
27
+ from .buckling import BucklingResult, solve_eigenvalue_buckling
28
+ from .imperfections import EigenmodeImperfection, ImperfectionField, apply_imperfection, to_imperfection_field
29
+ from .nonlinear_static import (
30
+ NonlinearConvergenceSettings,
31
+ NonlinearLoadProgram,
32
+ NonlinearStaticResult,
33
+ solve_static_nonlinear,
34
+ )
35
+ from .recovery import ResourceConfig
36
+
37
+ if TYPE_CHECKING:
38
+ from .boundary import LoadCase
39
+ from .fe_core import FEModel
40
+
41
+
42
+ DEFAULT_CAPACITY_WORKFLOW_PATH = Path("reports/capacity_workflow/capacity_workflow_report.json")
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CapacityWorkflowConfig:
47
+ """Settings for the nonlinear capacity workflow."""
48
+
49
+ num_buckling_modes: int = 3
50
+ buckling_mode_number: int = 1
51
+ eigenmode_imperfection_amplitude: float = 0.0
52
+ imperfection_dof_filter: str = "translations"
53
+ nonlinear_num_steps: int = 10
54
+ nonlinear_max_load_factor: float = 1.0
55
+ nonlinear_max_iterations: int = 25
56
+ nonlinear_tolerance: float = 1.0e-6
57
+ nonlinear_num_layers: int = 5
58
+ nonlinear_convergence_settings: Optional[NonlinearConvergenceSettings | str | Mapping[str, Any]] = None
59
+ nonlinear_resource_config: Optional[ResourceConfig] = None
60
+ mesh_min_elements_per_half_wave: int = 4
61
+ copy_model: bool = True
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MeshModeAdequacy:
66
+ """Coarse diagnostic for whether a mode is represented by enough nodes."""
67
+
68
+ status: str
69
+ active_node_count: int
70
+ active_element_count: int
71
+ estimated_half_waves: int
72
+ elements_per_half_wave: float
73
+ warnings: Tuple[str, ...] = ()
74
+
75
+ def to_dict(self) -> Dict[str, Any]:
76
+ return {
77
+ "status": self.status,
78
+ "active_node_count": self.active_node_count,
79
+ "active_element_count": self.active_element_count,
80
+ "estimated_half_waves": self.estimated_half_waves,
81
+ "elements_per_half_wave": self.elements_per_half_wave,
82
+ "warnings": list(self.warnings),
83
+ }
84
+
85
+
86
+ @dataclass
87
+ class CapacityWorkflowResult:
88
+ """Complete result from the capacity workflow."""
89
+
90
+ status: str
91
+ static_displacements: np.ndarray
92
+ static_solver_info: Dict[str, Any]
93
+ prestress_states: Dict[int, Dict[str, float]]
94
+ prestress_summary: Dict[str, Any]
95
+ buckling_result: BucklingResult
96
+ imperfection: ImperfectionField
97
+ imperfect_model: "FEModel"
98
+ nonlinear_result: NonlinearStaticResult
99
+ mesh_adequacy: MeshModeAdequacy
100
+ diagnostics: Dict[str, Any] = field(default_factory=dict)
101
+
102
+ @property
103
+ def critical_load_factor(self) -> Optional[float]:
104
+ return self.buckling_result.critical_load_factor
105
+
106
+ @property
107
+ def capacity_factor(self) -> float:
108
+ return float(self.nonlinear_result.peak_load_factor)
109
+
110
+ def to_dict(self) -> Dict[str, Any]:
111
+ return {
112
+ "status": self.status,
113
+ "static_solver_status": str((self.static_solver_info.get("convergence_info") or {}).get("status", "unknown")),
114
+ "critical_load_factor": self.critical_load_factor,
115
+ "buckling_solver_status": self.buckling_result.solver_status,
116
+ "capacity_factor": self.capacity_factor,
117
+ "nonlinear_status": self.nonlinear_result.status,
118
+ "last_converged_load_factor": self.nonlinear_result.last_converged_load_factor,
119
+ "failure_reason": self.nonlinear_result.failure_reason,
120
+ "imperfection": {
121
+ "name": self.imperfection.name,
122
+ "max_offset": self.imperfection.max_offset,
123
+ "metadata": dict(self.imperfection.metadata),
124
+ },
125
+ "mesh_adequacy": self.mesh_adequacy.to_dict(),
126
+ "prestress_summary": self.prestress_summary,
127
+ "diagnostics": self.diagnostics,
128
+ "buckling_result": self.buckling_result.to_dict(),
129
+ "nonlinear_result": self.nonlinear_result.to_dict(),
130
+ }
131
+
132
+
133
+ def _git_sha() -> Optional[str]:
134
+ try:
135
+ result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
136
+ except Exception:
137
+ return None
138
+ return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
139
+
140
+
141
+ def _recover_prestress(model: "FEModel", displacements: np.ndarray) -> Tuple[Dict[int, Dict[str, float]], Dict[str, Any]]:
142
+ from .anystructure_fem_mode import recover_prestress_from_static_result
143
+
144
+ return recover_prestress_from_static_result(model, displacements)
145
+
146
+
147
+ def _mode_shape_for(buckling_result: BucklingResult, mode_number: int) -> Optional[np.ndarray]:
148
+ mode = next((item for item in buckling_result.modes if int(item.mode_number) == int(mode_number)), None)
149
+ if mode is None:
150
+ return None
151
+ return np.asarray(mode.mode_shape, dtype=float).reshape(-1)
152
+
153
+
154
+ def evaluate_mode_mesh_adequacy(
155
+ model: "FEModel",
156
+ buckling_result: BucklingResult,
157
+ mode_number: int = 1,
158
+ min_elements_per_half_wave: int = 4,
159
+ activity_tolerance: float = 0.05,
160
+ ) -> MeshModeAdequacy:
161
+ """Estimate whether the selected mode is adequately represented by the mesh.
162
+
163
+ The diagnostic is deliberately conservative and topology-agnostic. It
164
+ counts nodes with translational mode amplitude above a fraction of the peak
165
+ and estimates half-waves from sign changes along the longest coordinate
166
+ axis among active nodes.
167
+ """
168
+ shape = _mode_shape_for(buckling_result, mode_number)
169
+ if shape is None or shape.size == 0:
170
+ return MeshModeAdequacy("no_mode", 0, 0, 0, 0.0, ("Selected buckling mode is unavailable.",))
171
+
172
+ node_values = []
173
+ for node_id, node in model.mesh.nodes.items():
174
+ translation = shape[node.dofs[:3]]
175
+ node_values.append((int(node_id), node.coords(), float(np.linalg.norm(translation)), float(translation[np.argmax(np.abs(translation))]) if translation.size else 0.0))
176
+ peak = max((item[2] for item in node_values), default=0.0)
177
+ if peak <= 0.0:
178
+ return MeshModeAdequacy("zero_mode_amplitude", 0, 0, 0, 0.0, ("Selected buckling mode has no translational amplitude.",))
179
+
180
+ active = [item for item in node_values if item[2] >= float(activity_tolerance) * peak]
181
+ active_ids = {item[0] for item in active}
182
+ active_element_count = sum(
183
+ 1
184
+ for element in model.mesh.elements.values()
185
+ if any(int(node_id) in active_ids for node_id in getattr(element, "node_ids", ()))
186
+ )
187
+ if not active:
188
+ return MeshModeAdequacy("no_active_nodes", 0, active_element_count, 0, 0.0, ("No active nodes exceeded mode activity threshold.",))
189
+
190
+ coords = np.asarray([item[1] for item in active], dtype=float)
191
+ spans = np.ptp(coords, axis=0)
192
+ axis = int(np.argmax(spans))
193
+ active_sorted = sorted(active, key=lambda item: (item[1][axis], item[0]))
194
+ signs = []
195
+ for _node_id, _coord, _norm, signed_value in active_sorted:
196
+ if abs(signed_value) < 1.0e-12 * peak:
197
+ continue
198
+ signs.append(1 if signed_value > 0.0 else -1)
199
+ sign_changes = sum(1 for previous, current in zip(signs, signs[1:]) if previous != current)
200
+ estimated_half_waves = max(sign_changes + 1, 1 if signs else 0)
201
+ elements_per_half_wave = float(active_element_count / max(estimated_half_waves, 1))
202
+ warnings = []
203
+ if elements_per_half_wave < float(min_elements_per_half_wave):
204
+ warnings.append(
205
+ f"Mode mesh representation is coarse: {elements_per_half_wave:.2f} active elements per estimated half-wave "
206
+ f"(target {min_elements_per_half_wave})."
207
+ )
208
+ status = "ok" if not warnings else "warning"
209
+ return MeshModeAdequacy(
210
+ status=status,
211
+ active_node_count=len(active),
212
+ active_element_count=int(active_element_count),
213
+ estimated_half_waves=int(estimated_half_waves),
214
+ elements_per_half_wave=elements_per_half_wave,
215
+ warnings=tuple(warnings),
216
+ )
217
+
218
+
219
+ def default_eigenmode_imperfection(
220
+ buckling_result: BucklingResult,
221
+ config: CapacityWorkflowConfig,
222
+ ) -> EigenmodeImperfection:
223
+ """Create the configured eigenmode imperfection."""
224
+ return EigenmodeImperfection(
225
+ buckling_result=buckling_result,
226
+ mode_number=config.buckling_mode_number,
227
+ amplitude=config.eigenmode_imperfection_amplitude,
228
+ dof_filter=config.imperfection_dof_filter,
229
+ )
230
+
231
+
232
+ def run_nonlinear_capacity_workflow(
233
+ model: "FEModel",
234
+ reference_load_case: "LoadCase",
235
+ *,
236
+ nonlinear_load_case: Optional["LoadCase"] = None,
237
+ nonlinear_load_program: Optional[NonlinearLoadProgram] = None,
238
+ imperfection: Optional[Any] = None,
239
+ config: Optional[CapacityWorkflowConfig] = None,
240
+ status_callback: Optional[Callable[[str], None]] = None,
241
+ ) -> CapacityWorkflowResult:
242
+ """Run linear static -> buckling -> imperfection -> nonlinear capacity."""
243
+ config = config or CapacityWorkflowConfig()
244
+ start = time.perf_counter()
245
+ static_displacements, static_info = solve_linear(model, reference_load_case)
246
+ static_status = str((static_info.get("convergence_info") or {}).get("status", "unknown"))
247
+ if static_status != "converged":
248
+ raise RuntimeError(f"Static prestress solve did not converge: {static_status}")
249
+
250
+ prestress_states, prestress_summary = _recover_prestress(model, static_displacements)
251
+ buckling = solve_eigenvalue_buckling(model, prestress_states, num_modes=config.num_buckling_modes)
252
+ if not buckling.modes:
253
+ raise RuntimeError(f"Buckling solve returned no usable modes: {buckling.solver_status}")
254
+
255
+ mesh_adequacy = evaluate_mode_mesh_adequacy(
256
+ model,
257
+ buckling,
258
+ mode_number=config.buckling_mode_number,
259
+ min_elements_per_half_wave=config.mesh_min_elements_per_half_wave,
260
+ )
261
+ selected_imperfection = imperfection
262
+ if selected_imperfection is None:
263
+ selected_imperfection = default_eigenmode_imperfection(buckling, config)
264
+ imperfection_field = to_imperfection_field(model, selected_imperfection)
265
+ imperfect_model = apply_imperfection(model, imperfection_field, copy_model=config.copy_model)
266
+
267
+ if nonlinear_load_program is not None:
268
+ nonlinear_result = solve_static_nonlinear(
269
+ imperfect_model,
270
+ load_program=nonlinear_load_program,
271
+ max_load_factor=config.nonlinear_max_load_factor,
272
+ num_steps=config.nonlinear_num_steps,
273
+ max_iterations=config.nonlinear_max_iterations,
274
+ tolerance=config.nonlinear_tolerance,
275
+ num_layers=config.nonlinear_num_layers,
276
+ convergence_settings=config.nonlinear_convergence_settings,
277
+ resource_config=config.nonlinear_resource_config,
278
+ status_callback=status_callback,
279
+ )
280
+ else:
281
+ nonlinear_result = solve_static_nonlinear(
282
+ imperfect_model,
283
+ load_case=nonlinear_load_case or reference_load_case,
284
+ max_load_factor=config.nonlinear_max_load_factor,
285
+ num_steps=config.nonlinear_num_steps,
286
+ max_iterations=config.nonlinear_max_iterations,
287
+ tolerance=config.nonlinear_tolerance,
288
+ num_layers=config.nonlinear_num_layers,
289
+ convergence_settings=config.nonlinear_convergence_settings,
290
+ resource_config=config.nonlinear_resource_config,
291
+ status_callback=status_callback,
292
+ )
293
+
294
+ status = "completed" if nonlinear_result.converged else "nonlinear_not_converged"
295
+ diagnostics = {
296
+ "workflow_seconds": float(time.perf_counter() - start),
297
+ "model_name": model.name,
298
+ "node_count": int(model.mesh.num_nodes),
299
+ "element_count": int(model.mesh.num_elements),
300
+ "reference_load_case": getattr(reference_load_case, "name", None),
301
+ "nonlinear_load_case": getattr(nonlinear_load_case or reference_load_case, "name", None),
302
+ "config": config.__dict__,
303
+ "environment": {
304
+ "platform": platform.platform(),
305
+ "python": sys.version.split()[0],
306
+ "commit": _git_sha(),
307
+ },
308
+ }
309
+ if mesh_adequacy.warnings:
310
+ diagnostics["warnings"] = list(mesh_adequacy.warnings)
311
+
312
+ return CapacityWorkflowResult(
313
+ status=status,
314
+ static_displacements=static_displacements,
315
+ static_solver_info=static_info,
316
+ prestress_states=prestress_states,
317
+ prestress_summary=prestress_summary,
318
+ buckling_result=buckling,
319
+ imperfection=imperfection_field,
320
+ imperfect_model=imperfect_model,
321
+ nonlinear_result=nonlinear_result,
322
+ mesh_adequacy=mesh_adequacy,
323
+ diagnostics=diagnostics,
324
+ )
325
+
326
+
327
+ def write_capacity_workflow_report(
328
+ result: CapacityWorkflowResult,
329
+ path: Path | str = DEFAULT_CAPACITY_WORKFLOW_PATH,
330
+ ) -> Path:
331
+ """Write a capacity workflow result report."""
332
+ output = Path(path)
333
+ output.parent.mkdir(parents=True, exist_ok=True)
334
+ output.write_text(json.dumps(result.to_dict(), indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
335
+ return output
336
+
337
+
338
+ def run_capacity_workflow_from_builder(
339
+ model_builder: Callable[[], "FEModel"],
340
+ load_case_builder: Callable[["FEModel"], "LoadCase"],
341
+ *,
342
+ config: Optional[CapacityWorkflowConfig] = None,
343
+ report_path: Path | str = DEFAULT_CAPACITY_WORKFLOW_PATH,
344
+ ) -> CapacityWorkflowResult:
345
+ """Convenience helper for scripted examples/tests."""
346
+ model = model_builder()
347
+ load_case = load_case_builder(model)
348
+ result = run_nonlinear_capacity_workflow(model, load_case, config=config)
349
+ write_capacity_workflow_report(result, report_path)
350
+ return result
anysolver/cases.py ADDED
@@ -0,0 +1,173 @@
1
+ """Analysis/result provenance containers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ import hashlib
7
+ import json
8
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class LoadCaseRef:
13
+ """Stable reference to an input load case or combination."""
14
+
15
+ name: str
16
+ factor: float = 1.0
17
+ metadata: Mapping[str, Any] = field(default_factory=dict)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class AnalysisCase:
22
+ """Minimal traceable analysis-case descriptor."""
23
+
24
+ name: str
25
+ analysis_type: str
26
+ load_cases: Sequence[LoadCaseRef] = field(default_factory=tuple)
27
+ settings: Mapping[str, Any] = field(default_factory=dict)
28
+ metadata: Mapping[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PrestressCase:
33
+ """Reference to a prestress source used by KG or stress-stiffened analyses."""
34
+
35
+ name: str
36
+ source_result_case: str
37
+ sign_convention: str = "tension_positive_resultants_to_destabilizing_KG"
38
+ metadata: Mapping[str, Any] = field(default_factory=dict)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ResultCase:
43
+ """Traceable result provenance for an analysis run."""
44
+
45
+ name: str
46
+ analysis_case: AnalysisCase
47
+ matrix_signature: Optional[str] = None
48
+ load_signature: Optional[str] = None
49
+ solver_backend: Optional[str] = None
50
+ recovery: Mapping[str, Any] = field(default_factory=dict)
51
+ warnings: Tuple[str, ...] = ()
52
+ metadata: Mapping[str, Any] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ return {
56
+ "name": self.name,
57
+ "analysis_case": {
58
+ "name": self.analysis_case.name,
59
+ "analysis_type": self.analysis_case.analysis_type,
60
+ "load_cases": [
61
+ {"name": item.name, "factor": item.factor, "metadata": dict(item.metadata)}
62
+ for item in self.analysis_case.load_cases
63
+ ],
64
+ "settings": dict(self.analysis_case.settings),
65
+ "metadata": dict(self.analysis_case.metadata),
66
+ },
67
+ "matrix_signature": self.matrix_signature,
68
+ "load_signature": self.load_signature,
69
+ "solver_backend": self.solver_backend,
70
+ "recovery": dict(self.recovery),
71
+ "warnings": list(self.warnings),
72
+ "metadata": dict(self.metadata),
73
+ }
74
+
75
+
76
+ def load_case_ref(load_case: Any, factor: float = 1.0, metadata: Optional[Mapping[str, Any]] = None) -> LoadCaseRef:
77
+ """Build a stable provenance reference from a load-case-like object."""
78
+ name = "none" if load_case is None else str(getattr(load_case, "name", load_case))
79
+ return LoadCaseRef(name=name, factor=float(factor), metadata=dict(metadata or {}))
80
+
81
+
82
+ def _json_signature(payload: Any) -> str:
83
+ text = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
84
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
85
+
86
+
87
+ def matrix_signature_from_info(assembly_info: Mapping[str, Any]) -> Optional[str]:
88
+ """Hash matrix topology/revision data from assembly diagnostics."""
89
+ if not assembly_info:
90
+ return None
91
+ payload: Dict[str, Any] = {}
92
+ for key in ("stiffness", "mass", "geometric_stiffness", "damping"):
93
+ info = assembly_info.get(key)
94
+ if isinstance(info, Mapping):
95
+ payload[key] = {
96
+ "sparsity_signature": info.get("sparsity_signature"),
97
+ "revision_signature": info.get("revision_signature"),
98
+ "matrix_type": info.get("matrix_type"),
99
+ "total_dofs": info.get("total_dofs"),
100
+ "num_elements": info.get("num_elements"),
101
+ }
102
+ if not payload and isinstance(assembly_info.get("revision_signature"), Mapping):
103
+ payload["revision_signature"] = assembly_info.get("revision_signature")
104
+ return _json_signature(payload) if payload else None
105
+
106
+
107
+ def load_signature_from_info(load_info: Mapping[str, Any]) -> Optional[str]:
108
+ """Hash load provenance data without storing the load vector itself."""
109
+ if not load_info:
110
+ return None
111
+ payload = {
112
+ "vector_type": load_info.get("vector_type"),
113
+ "load_case": load_info.get("load_case"),
114
+ "load_cases": load_info.get("load_cases"),
115
+ "load_norm": load_info.get("load_norm"),
116
+ "load_norms": load_info.get("load_norms"),
117
+ "revision_signature": load_info.get("revision_signature"),
118
+ }
119
+ return _json_signature(payload)
120
+
121
+
122
+ def solver_backend_from_info(solver_info: Mapping[str, Any]) -> Optional[str]:
123
+ """Extract the sparse backend name from standard solver diagnostics."""
124
+ backend = solver_info.get("backend")
125
+ if isinstance(backend, Mapping):
126
+ return backend.get("backend")
127
+ convergence = solver_info.get("convergence_info")
128
+ if isinstance(convergence, Mapping):
129
+ backend = convergence.get("backend")
130
+ if isinstance(backend, Mapping):
131
+ return backend.get("backend")
132
+ return None
133
+
134
+
135
+ def make_result_case(
136
+ *,
137
+ name: str,
138
+ analysis_type: str,
139
+ load_cases: Sequence[Any] = (),
140
+ assembly_info: Optional[Mapping[str, Any]] = None,
141
+ solver_info: Optional[Mapping[str, Any]] = None,
142
+ recovery: Optional[Mapping[str, Any]] = None,
143
+ settings: Optional[Mapping[str, Any]] = None,
144
+ metadata: Optional[Mapping[str, Any]] = None,
145
+ warnings: Sequence[str] = (),
146
+ ) -> ResultCase:
147
+ """Create a ResultCase from existing solver metadata."""
148
+ assembly_info = assembly_info or {}
149
+ load_info = assembly_info.get("load_matrix", assembly_info.get("load", {}))
150
+ analysis_case = AnalysisCase(
151
+ name=name,
152
+ analysis_type=analysis_type,
153
+ load_cases=tuple(load_case_ref(item) for item in load_cases),
154
+ settings=dict(settings or {}),
155
+ metadata=dict(metadata or {}),
156
+ )
157
+ return ResultCase(
158
+ name=name,
159
+ analysis_case=analysis_case,
160
+ matrix_signature=matrix_signature_from_info(assembly_info),
161
+ load_signature=load_signature_from_info(load_info if isinstance(load_info, Mapping) else {}),
162
+ solver_backend=solver_backend_from_info(solver_info or {}),
163
+ recovery=dict(recovery or {}),
164
+ warnings=tuple(str(item) for item in warnings),
165
+ metadata={
166
+ "matrix_revisions": {
167
+ key: value.get("revision_signature")
168
+ for key, value in assembly_info.items()
169
+ if isinstance(value, Mapping) and value.get("revision_signature") is not None
170
+ },
171
+ **dict(metadata or {}),
172
+ },
173
+ )