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,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
|
+
)
|