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,205 @@
|
|
|
1
|
+
"""Smoke-report helpers for selective recovery and resource policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .mesh_gen import generate_simple_panel_mesh
|
|
12
|
+
from .recovery import RecoveryConfig, ResourceConfig, estimate_model_memory, recover_element_stresses_with_report
|
|
13
|
+
from .results import create_fe_result
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_RECOVERY_POLICY_PATH = Path("reports/recovery_policy/recovery_policy_report.json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_recovery_policy_report() -> Dict[str, Any]:
|
|
20
|
+
"""Generate deterministic selective-recovery/resource-policy smoke metrics."""
|
|
21
|
+
|
|
22
|
+
model = generate_simple_panel_mesh(2.0, 1.0, 0.01, num_divisions_x=2, num_divisions_y=1)
|
|
23
|
+
total_dofs = int(model.mesh.dof_manager.total_dofs)
|
|
24
|
+
displacement = np.zeros(total_dofs, dtype=float)
|
|
25
|
+
|
|
26
|
+
full_recovery = RecoveryConfig()
|
|
27
|
+
selective_recovery = RecoveryConfig(
|
|
28
|
+
node_ids=[1, max(model.mesh.nodes)],
|
|
29
|
+
element_ids=[1],
|
|
30
|
+
components=["von_mises"],
|
|
31
|
+
history_mode="selected",
|
|
32
|
+
store_full_histories=False,
|
|
33
|
+
metadata={"purpose": "batch_10_smoke"},
|
|
34
|
+
)
|
|
35
|
+
resources = ResourceConfig(solver_threads=1, recovery_threads=1, deterministic=True)
|
|
36
|
+
threaded_resources = ResourceConfig(solver_threads=1, recovery_threads=2, deterministic=True)
|
|
37
|
+
|
|
38
|
+
full_result = create_fe_result(
|
|
39
|
+
model,
|
|
40
|
+
displacement,
|
|
41
|
+
{"solver_type": "recovery_policy_smoke"},
|
|
42
|
+
recovery_config=full_recovery,
|
|
43
|
+
resource_config=resources,
|
|
44
|
+
)
|
|
45
|
+
selective_result = create_fe_result(
|
|
46
|
+
model,
|
|
47
|
+
displacement,
|
|
48
|
+
{"solver_type": "recovery_policy_smoke"},
|
|
49
|
+
recovery_config=selective_recovery,
|
|
50
|
+
resource_config=resources,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
full_memory = estimate_model_memory(
|
|
54
|
+
model,
|
|
55
|
+
transient_saved_steps=10,
|
|
56
|
+
store_full_history=True,
|
|
57
|
+
recovery_config=full_recovery,
|
|
58
|
+
)
|
|
59
|
+
selective_memory = estimate_model_memory(
|
|
60
|
+
model,
|
|
61
|
+
transient_saved_steps=10,
|
|
62
|
+
store_full_history=False,
|
|
63
|
+
recovery_config=selective_recovery,
|
|
64
|
+
)
|
|
65
|
+
envelope_recovery = RecoveryConfig(node_ids=[1, max(model.mesh.nodes)], history_mode="envelope", store_full_histories=False)
|
|
66
|
+
envelope_memory = estimate_model_memory(
|
|
67
|
+
model,
|
|
68
|
+
transient_saved_steps=10,
|
|
69
|
+
store_full_history=False,
|
|
70
|
+
recovery_config=envelope_recovery,
|
|
71
|
+
)
|
|
72
|
+
recovery_model = generate_simple_panel_mesh(3.0, 2.0, 0.01, num_divisions_x=6, num_divisions_y=4)
|
|
73
|
+
recovery_displacement = np.zeros(recovery_model.mesh.dof_manager.total_dofs, dtype=float)
|
|
74
|
+
recovery_scope = RecoveryConfig(components=["von_mises"])
|
|
75
|
+
serial_stresses, serial_report = recover_element_stresses_with_report(
|
|
76
|
+
recovery_model,
|
|
77
|
+
recovery_displacement,
|
|
78
|
+
recovery_scope,
|
|
79
|
+
resource_config=resources,
|
|
80
|
+
)
|
|
81
|
+
threaded_stresses, threaded_report = recover_element_stresses_with_report(
|
|
82
|
+
recovery_model,
|
|
83
|
+
recovery_displacement,
|
|
84
|
+
recovery_scope,
|
|
85
|
+
resource_config=threaded_resources,
|
|
86
|
+
)
|
|
87
|
+
stress_keys_match = sorted(serial_stresses) == sorted(threaded_stresses)
|
|
88
|
+
stress_values_match = all(
|
|
89
|
+
np.allclose(serial_stresses[element_id]["von_mises"], threaded_stresses[element_id]["von_mises"])
|
|
90
|
+
for element_id in serial_stresses
|
|
91
|
+
)
|
|
92
|
+
node_reduction = 1.0 - len(selective_result.node_displacements) / max(len(full_result.node_displacements), 1)
|
|
93
|
+
element_reduction = 1.0 - len(selective_result.element_stresses) / max(len(full_result.element_stresses), 1)
|
|
94
|
+
memory_reduction = 1.0 - selective_memory.history_bytes_estimate / max(full_memory.history_bytes_estimate, 1)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"status": "passed",
|
|
98
|
+
"model": {
|
|
99
|
+
"name": model.name,
|
|
100
|
+
"num_nodes": len(model.mesh.nodes),
|
|
101
|
+
"num_elements": len(model.mesh.elements),
|
|
102
|
+
"total_dofs": total_dofs,
|
|
103
|
+
},
|
|
104
|
+
"full_recovery": {
|
|
105
|
+
"num_node_displacements": len(full_result.node_displacements),
|
|
106
|
+
"num_element_stresses": len(full_result.element_stresses),
|
|
107
|
+
"memory_estimate": full_memory.to_dict(),
|
|
108
|
+
},
|
|
109
|
+
"selective_recovery": {
|
|
110
|
+
"config": selective_recovery.to_dict(),
|
|
111
|
+
"num_node_displacements": len(selective_result.node_displacements),
|
|
112
|
+
"num_element_stresses": len(selective_result.element_stresses),
|
|
113
|
+
"stress_components": {
|
|
114
|
+
int(element_id): sorted(values)
|
|
115
|
+
for element_id, values in selective_result.element_stresses.items()
|
|
116
|
+
},
|
|
117
|
+
"memory_estimate": selective_memory.to_dict(),
|
|
118
|
+
},
|
|
119
|
+
"transient_storage_modes": {
|
|
120
|
+
"full_history_bytes": full_memory.history_bytes_estimate,
|
|
121
|
+
"selected_history_bytes": selective_memory.history_bytes_estimate,
|
|
122
|
+
"envelope_history_bytes": envelope_memory.history_bytes_estimate,
|
|
123
|
+
},
|
|
124
|
+
"resource_policy": resources.to_dict(),
|
|
125
|
+
"measured_parallel_recovery": {
|
|
126
|
+
"model": {
|
|
127
|
+
"num_nodes": len(recovery_model.mesh.nodes),
|
|
128
|
+
"num_elements": len(recovery_model.mesh.elements),
|
|
129
|
+
"total_dofs": recovery_model.mesh.dof_manager.total_dofs,
|
|
130
|
+
},
|
|
131
|
+
"serial": serial_report.to_dict(),
|
|
132
|
+
"threaded": threaded_report.to_dict(),
|
|
133
|
+
"results_match": bool(stress_keys_match and stress_values_match),
|
|
134
|
+
"observed_speedup": (
|
|
135
|
+
float(serial_report.elapsed_seconds / threaded_report.elapsed_seconds)
|
|
136
|
+
if threaded_report.elapsed_seconds > 0.0
|
|
137
|
+
else None
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
"reductions": {
|
|
141
|
+
"node_result_reduction_fraction": float(node_reduction),
|
|
142
|
+
"element_result_reduction_fraction": float(element_reduction),
|
|
143
|
+
"history_memory_reduction_fraction": float(memory_reduction),
|
|
144
|
+
},
|
|
145
|
+
"known_limits": [
|
|
146
|
+
"ResourceConfig controls opt-in recovery threading and nonlinear Numba assembly threads; sparse solver thread control remains backend-dependent.",
|
|
147
|
+
"Measured threaded recovery is informational and may be slower than serial for small models.",
|
|
148
|
+
"Full history remains the compatibility default; selected and envelope modes reduce stored transient arrays when explicitly requested.",
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _markdown(report: Dict[str, Any]) -> str:
|
|
154
|
+
lines = [
|
|
155
|
+
"# Selective Recovery And Resource Policy Report",
|
|
156
|
+
"",
|
|
157
|
+
f"- Status: {report['status']}",
|
|
158
|
+
f"- Model: {report['model']['name']}",
|
|
159
|
+
f"- Nodes: {report['model']['num_nodes']}",
|
|
160
|
+
f"- Elements: {report['model']['num_elements']}",
|
|
161
|
+
f"- Total DOFs: {report['model']['total_dofs']}",
|
|
162
|
+
"",
|
|
163
|
+
"## Recovery Scope",
|
|
164
|
+
"",
|
|
165
|
+
f"- Full node results: {report['full_recovery']['num_node_displacements']}",
|
|
166
|
+
f"- Selective node results: {report['selective_recovery']['num_node_displacements']}",
|
|
167
|
+
f"- Full element stress results: {report['full_recovery']['num_element_stresses']}",
|
|
168
|
+
f"- Selective element stress results: {report['selective_recovery']['num_element_stresses']}",
|
|
169
|
+
f"- History memory reduction: {report['reductions']['history_memory_reduction_fraction']:.3f}",
|
|
170
|
+
f"- Envelope history bytes: {report['transient_storage_modes']['envelope_history_bytes']}",
|
|
171
|
+
"",
|
|
172
|
+
"## Measured Parallel Recovery",
|
|
173
|
+
"",
|
|
174
|
+
f"- Results match serial: {report['measured_parallel_recovery']['results_match']}",
|
|
175
|
+
f"- Serial time: {report['measured_parallel_recovery']['serial']['elapsed_seconds']:.6g} s",
|
|
176
|
+
f"- Threaded time: {report['measured_parallel_recovery']['threaded']['elapsed_seconds']:.6g} s",
|
|
177
|
+
f"- Observed speedup: {report['measured_parallel_recovery']['observed_speedup']}",
|
|
178
|
+
"",
|
|
179
|
+
"## Resource Policy",
|
|
180
|
+
"",
|
|
181
|
+
]
|
|
182
|
+
for key, value in report["resource_policy"].items():
|
|
183
|
+
lines.append(f"- {key}: {value}")
|
|
184
|
+
lines.extend(["", "## Known Limits", ""])
|
|
185
|
+
for item in report["known_limits"]:
|
|
186
|
+
lines.append(f"- {item}")
|
|
187
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_recovery_policy_report(
|
|
191
|
+
output: Path | str = DEFAULT_RECOVERY_POLICY_PATH,
|
|
192
|
+
*,
|
|
193
|
+
markdown: Optional[Path | str] = None,
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""Write JSON and optional Markdown recovery-policy report."""
|
|
196
|
+
|
|
197
|
+
output_path = Path(output)
|
|
198
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
report = generate_recovery_policy_report()
|
|
200
|
+
output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
201
|
+
if markdown is not None:
|
|
202
|
+
markdown_path = Path(markdown)
|
|
203
|
+
markdown_path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
markdown_path.write_text(_markdown(report), encoding="utf-8")
|
|
205
|
+
return report
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Automatic discovery of CalculiX/PrePoMax reference cases.
|
|
2
|
+
|
|
3
|
+
Reference cases are discovered as input/result pairs, normally placed under one
|
|
4
|
+
of these directories relative to the repository root:
|
|
5
|
+
|
|
6
|
+
- tests/reference_cases/
|
|
7
|
+
- reference_cases/
|
|
8
|
+
- examples/reference_cases/
|
|
9
|
+
|
|
10
|
+
A case is any ``*.inp`` file with a matching ``*.frd`` file using the same stem
|
|
11
|
+
in the same directory. Optional metadata may be supplied as ``<stem>.json``.
|
|
12
|
+
The discovery is intentionally file-system based so local, non-committed
|
|
13
|
+
benchmark cases can be used during development without changing test code.
|
|
14
|
+
|
|
15
|
+
This module also contains a small manifest for public upstream CalculiX examples
|
|
16
|
+
that are useful reference candidates but are not vendored into this repository.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_REFERENCE_ROOTS = (
|
|
30
|
+
"tests/reference_cases",
|
|
31
|
+
"reference_cases",
|
|
32
|
+
"examples/reference_cases",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
SHELL_CONVERGENCE_ELEMENT_TYPES = ("S3", "S4", "S4R", "S6", "S8", "S8R")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
UPSTREAM_CALCULIX_REFERENCE_CASES: Tuple[Dict[str, Any], ...] = (
|
|
39
|
+
{
|
|
40
|
+
"name": "calculix_examples_shell_convergence",
|
|
41
|
+
"kind": "shell_bending_convergence",
|
|
42
|
+
"repository": "calculix/CalculiX-Examples",
|
|
43
|
+
"ref": "master",
|
|
44
|
+
"directory": "Elements/Shell",
|
|
45
|
+
"readme_url": "https://github.com/calculix/CalculiX-Examples/blob/master/Elements/Shell/README.md",
|
|
46
|
+
"input_url": "https://github.com/calculix/CalculiX-Examples/blob/master/Elements/Shell/shell.inp",
|
|
47
|
+
"raw_base_url": "https://raw.githubusercontent.com/calculix/CalculiX-Examples/master/Elements/Shell",
|
|
48
|
+
"source_files": ["README.md", "shell.inp", "shell.fbd", "test.py"],
|
|
49
|
+
"description": (
|
|
50
|
+
"Mesh convergence benchmark for CalculiX shell elements S3/S4/S4R/S6/S8/S8R. "
|
|
51
|
+
"The upstream input includes generated mesh and boundary include files, steel material, "
|
|
52
|
+
"shell thickness 5 and gravity loading, and requests element stresses and nodal displacements."
|
|
53
|
+
),
|
|
54
|
+
"requires_generated_includes": True,
|
|
55
|
+
"expected_outputs": ["U", "S"],
|
|
56
|
+
"reference_values": {"sref": 1.848, "wref": 0.0587},
|
|
57
|
+
"notes": [
|
|
58
|
+
"This is a useful shell bending/convergence reference candidate, but it is not a directly vendored .inp/.frd pair.",
|
|
59
|
+
"Use the upstream test.py/shell.fbd workflow to generate concrete .inp/.frd files before numerical comparison.",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CalculixReferenceCase:
|
|
67
|
+
"""Discovered CalculiX/PrePoMax reference input/result pair."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
directory: Path
|
|
71
|
+
inp_path: Path
|
|
72
|
+
frd_path: Optional[Path]
|
|
73
|
+
metadata_path: Optional[Path] = None
|
|
74
|
+
kind: str = "unknown"
|
|
75
|
+
node_count: int = 0
|
|
76
|
+
element_count: int = 0
|
|
77
|
+
bbox_min: Tuple[float, float, float] = field(default_factory=lambda: (0.0, 0.0, 0.0))
|
|
78
|
+
bbox_max: Tuple[float, float, float] = field(default_factory=lambda: (0.0, 0.0, 0.0))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def has_results(self) -> bool:
|
|
82
|
+
return self.frd_path is not None and self.frd_path.exists()
|
|
83
|
+
|
|
84
|
+
def metadata(self) -> Dict[str, Any]:
|
|
85
|
+
if self.metadata_path is None or not self.metadata_path.exists():
|
|
86
|
+
return {}
|
|
87
|
+
if self.metadata_path.suffix.lower() != ".json":
|
|
88
|
+
return {}
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(self.metadata_path.read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"name": self.name,
|
|
97
|
+
"directory": str(self.directory),
|
|
98
|
+
"inp_path": str(self.inp_path),
|
|
99
|
+
"frd_path": str(self.frd_path) if self.frd_path is not None else None,
|
|
100
|
+
"metadata_path": str(self.metadata_path) if self.metadata_path is not None else None,
|
|
101
|
+
"kind": self.kind,
|
|
102
|
+
"node_count": self.node_count,
|
|
103
|
+
"element_count": self.element_count,
|
|
104
|
+
"bbox_min": self.bbox_min,
|
|
105
|
+
"bbox_max": self.bbox_max,
|
|
106
|
+
"has_results": self.has_results,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ShellConvergencePoint:
|
|
112
|
+
"""One row from an upstream CalculiX shell convergence result file."""
|
|
113
|
+
|
|
114
|
+
element_type: str
|
|
115
|
+
size: float
|
|
116
|
+
node_count: int
|
|
117
|
+
stress_max: float
|
|
118
|
+
displacement_max: float
|
|
119
|
+
stress_normalized: float
|
|
120
|
+
displacement_normalized: float
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> Dict[str, float | int | str]:
|
|
123
|
+
return {
|
|
124
|
+
"element_type": self.element_type,
|
|
125
|
+
"size": self.size,
|
|
126
|
+
"node_count": self.node_count,
|
|
127
|
+
"stress_max": self.stress_max,
|
|
128
|
+
"displacement_max": self.displacement_max,
|
|
129
|
+
"stress_normalized": self.stress_normalized,
|
|
130
|
+
"displacement_normalized": self.displacement_normalized,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class ShellConvergenceTable:
|
|
136
|
+
"""Parsed upstream shell convergence result table for one element type."""
|
|
137
|
+
|
|
138
|
+
element_type: str
|
|
139
|
+
path: Path
|
|
140
|
+
stress_reference: float
|
|
141
|
+
displacement_reference: float
|
|
142
|
+
points: Tuple[ShellConvergencePoint, ...]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def finest_point(self) -> Optional[ShellConvergencePoint]:
|
|
146
|
+
if not self.points:
|
|
147
|
+
return None
|
|
148
|
+
return min(self.points, key=lambda point: point.size)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def largest_model_point(self) -> Optional[ShellConvergencePoint]:
|
|
152
|
+
if not self.points:
|
|
153
|
+
return None
|
|
154
|
+
return max(self.points, key=lambda point: point.node_count)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"element_type": self.element_type,
|
|
159
|
+
"path": str(self.path),
|
|
160
|
+
"stress_reference": self.stress_reference,
|
|
161
|
+
"displacement_reference": self.displacement_reference,
|
|
162
|
+
"points": [point.to_dict() for point in self.points],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def upstream_calculix_reference_manifest() -> List[Dict[str, Any]]:
|
|
167
|
+
"""Return known upstream CalculiX reference candidates."""
|
|
168
|
+
return [dict(case) for case in UPSTREAM_CALCULIX_REFERENCE_CASES]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def upstream_calculix_shell_reference_values() -> Dict[str, float]:
|
|
172
|
+
"""Return analytical/reference normalizers for the upstream shell benchmark."""
|
|
173
|
+
manifest = upstream_calculix_reference_manifest()
|
|
174
|
+
case = next(entry for entry in manifest if entry["name"] == "calculix_examples_shell_convergence")
|
|
175
|
+
return dict(case.get("reference_values", {}))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _iter_existing_roots(roots: Optional[Sequence[Path | str]], repo_root: Optional[Path | str]) -> List[Path]:
|
|
179
|
+
base = Path.cwd() if repo_root is None else Path(repo_root)
|
|
180
|
+
candidate_roots: Sequence[Path | str] = roots or DEFAULT_REFERENCE_ROOTS
|
|
181
|
+
existing: List[Path] = []
|
|
182
|
+
for root in candidate_roots:
|
|
183
|
+
path = Path(root)
|
|
184
|
+
if not path.is_absolute():
|
|
185
|
+
path = base / path
|
|
186
|
+
if path.exists() and path.is_dir():
|
|
187
|
+
existing.append(path)
|
|
188
|
+
return existing
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _case_insensitive_sidecar(inp_path: Path, suffix: str) -> Optional[Path]:
|
|
192
|
+
suffix = suffix.lower()
|
|
193
|
+
for candidate in inp_path.parent.iterdir():
|
|
194
|
+
if candidate.is_file() and candidate.stem.lower() == inp_path.stem.lower() and candidate.suffix.lower() == suffix:
|
|
195
|
+
return candidate
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_inp_nodes_and_element_count(inp_path: Path, max_lines: int = 200_000) -> Tuple[np.ndarray, int]:
|
|
200
|
+
"""Parse node coordinates and count elements from a CalculiX/Abaqus input file.
|
|
201
|
+
|
|
202
|
+
This parser is deliberately conservative and only reads enough to classify
|
|
203
|
+
and summarize a reference case. It is not a complete input deck parser.
|
|
204
|
+
"""
|
|
205
|
+
nodes: List[Tuple[float, float, float]] = []
|
|
206
|
+
element_count = 0
|
|
207
|
+
section: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
with inp_path.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
211
|
+
for line_number, raw_line in enumerate(handle, start=1):
|
|
212
|
+
if line_number > max_lines:
|
|
213
|
+
break
|
|
214
|
+
line = raw_line.strip()
|
|
215
|
+
if not line or line.startswith("**"):
|
|
216
|
+
continue
|
|
217
|
+
if line.startswith("*"):
|
|
218
|
+
keyword = line.split(",", 1)[0].strip().lower()
|
|
219
|
+
if keyword == "*node":
|
|
220
|
+
section = "node"
|
|
221
|
+
elif keyword == "*element":
|
|
222
|
+
section = "element"
|
|
223
|
+
else:
|
|
224
|
+
section = None
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if section == "node":
|
|
228
|
+
parts = [part.strip() for part in line.split(",")]
|
|
229
|
+
if len(parts) < 4:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
nodes.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
233
|
+
except ValueError:
|
|
234
|
+
continue
|
|
235
|
+
elif section == "element":
|
|
236
|
+
parts = [part.strip() for part in line.split(",")]
|
|
237
|
+
if len(parts) >= 2:
|
|
238
|
+
element_count += 1
|
|
239
|
+
except OSError:
|
|
240
|
+
return np.zeros((0, 3), dtype=float), 0
|
|
241
|
+
|
|
242
|
+
if not nodes:
|
|
243
|
+
return np.zeros((0, 3), dtype=float), element_count
|
|
244
|
+
return np.asarray(nodes, dtype=float), element_count
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def classify_reference_case_from_nodes(nodes: np.ndarray) -> str:
|
|
248
|
+
"""Classify a reference case from node coordinates as flat_plate, cylinder or unknown."""
|
|
249
|
+
nodes = np.asarray(nodes, dtype=float)
|
|
250
|
+
if nodes.ndim != 2 or nodes.shape[1] != 3 or nodes.shape[0] == 0:
|
|
251
|
+
return "unknown"
|
|
252
|
+
|
|
253
|
+
span = np.ptp(nodes, axis=0)
|
|
254
|
+
max_span = max(float(np.max(span)), 1.0)
|
|
255
|
+
if float(np.min(span)) < 1.0e-8 * max_span:
|
|
256
|
+
return "flat_plate"
|
|
257
|
+
|
|
258
|
+
for columns in ((0, 1), (0, 2), (1, 2)):
|
|
259
|
+
radius = np.linalg.norm(nodes[:, columns] - np.mean(nodes[:, columns], axis=0), axis=1)
|
|
260
|
+
radius_mean = float(np.mean(radius))
|
|
261
|
+
radius_std = float(np.std(radius))
|
|
262
|
+
if radius_mean > 0.0 and radius_std / radius_mean < 0.10:
|
|
263
|
+
return "cylinder"
|
|
264
|
+
|
|
265
|
+
return "unknown"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def summarize_inp_geometry(inp_path: Path) -> Dict[str, Any]:
|
|
269
|
+
nodes, element_count = _parse_inp_nodes_and_element_count(inp_path)
|
|
270
|
+
if nodes.size:
|
|
271
|
+
bbox_min = tuple(float(v) for v in np.min(nodes, axis=0))
|
|
272
|
+
bbox_max = tuple(float(v) for v in np.max(nodes, axis=0))
|
|
273
|
+
else:
|
|
274
|
+
bbox_min = (0.0, 0.0, 0.0)
|
|
275
|
+
bbox_max = (0.0, 0.0, 0.0)
|
|
276
|
+
return {
|
|
277
|
+
"kind": classify_reference_case_from_nodes(nodes),
|
|
278
|
+
"node_count": int(nodes.shape[0]),
|
|
279
|
+
"element_count": int(element_count),
|
|
280
|
+
"bbox_min": bbox_min,
|
|
281
|
+
"bbox_max": bbox_max,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse_calculix_shell_convergence_file(
|
|
286
|
+
path: Path | str,
|
|
287
|
+
element_type: Optional[str] = None,
|
|
288
|
+
stress_reference: Optional[float] = None,
|
|
289
|
+
displacement_reference: Optional[float] = None,
|
|
290
|
+
) -> ShellConvergenceTable:
|
|
291
|
+
"""Parse an upstream CalculiX shell convergence table such as ``S4.txt``.
|
|
292
|
+
|
|
293
|
+
Expected rows follow the upstream format::
|
|
294
|
+
|
|
295
|
+
# size NoN smax umax
|
|
296
|
+
100 12 0.616489 0.013121
|
|
297
|
+
|
|
298
|
+
Stress and displacement are normalized by the upstream analytical reference
|
|
299
|
+
values unless explicit reference values are supplied.
|
|
300
|
+
"""
|
|
301
|
+
file_path = Path(path)
|
|
302
|
+
if element_type is None:
|
|
303
|
+
element_type = file_path.stem.upper()
|
|
304
|
+
references = upstream_calculix_shell_reference_values()
|
|
305
|
+
sref = float(stress_reference if stress_reference is not None else references.get("sref", 1.0))
|
|
306
|
+
wref = float(displacement_reference if displacement_reference is not None else references.get("wref", 1.0))
|
|
307
|
+
if sref == 0.0 or wref == 0.0:
|
|
308
|
+
raise ValueError("Shell convergence reference values must be non-zero")
|
|
309
|
+
|
|
310
|
+
points: List[ShellConvergencePoint] = []
|
|
311
|
+
try:
|
|
312
|
+
lines = file_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
313
|
+
except OSError as exc:
|
|
314
|
+
raise FileNotFoundError(f"Could not read shell convergence file: {file_path}") from exc
|
|
315
|
+
|
|
316
|
+
for line_number, line in enumerate(lines, start=1):
|
|
317
|
+
stripped = line.strip()
|
|
318
|
+
if not stripped or stripped.startswith("#"):
|
|
319
|
+
continue
|
|
320
|
+
parts = stripped.split()
|
|
321
|
+
if len(parts) < 4:
|
|
322
|
+
raise ValueError(f"Invalid shell convergence row in {file_path} line {line_number}: {line!r}")
|
|
323
|
+
try:
|
|
324
|
+
size = float(parts[0])
|
|
325
|
+
node_count = int(float(parts[1]))
|
|
326
|
+
stress_max = float(parts[2])
|
|
327
|
+
displacement_max = float(parts[3])
|
|
328
|
+
except ValueError as exc:
|
|
329
|
+
raise ValueError(f"Invalid numeric shell convergence row in {file_path} line {line_number}: {line!r}") from exc
|
|
330
|
+
points.append(
|
|
331
|
+
ShellConvergencePoint(
|
|
332
|
+
element_type=str(element_type),
|
|
333
|
+
size=size,
|
|
334
|
+
node_count=node_count,
|
|
335
|
+
stress_max=stress_max,
|
|
336
|
+
displacement_max=displacement_max,
|
|
337
|
+
stress_normalized=stress_max / sref,
|
|
338
|
+
displacement_normalized=displacement_max / wref,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return ShellConvergenceTable(
|
|
343
|
+
element_type=str(element_type),
|
|
344
|
+
path=file_path,
|
|
345
|
+
stress_reference=sref,
|
|
346
|
+
displacement_reference=wref,
|
|
347
|
+
points=tuple(points),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def discover_calculix_shell_convergence_tables(
|
|
352
|
+
directory: Path | str,
|
|
353
|
+
stress_reference: Optional[float] = None,
|
|
354
|
+
displacement_reference: Optional[float] = None,
|
|
355
|
+
) -> List[ShellConvergenceTable]:
|
|
356
|
+
"""Discover and parse available upstream shell convergence ``*.txt`` files."""
|
|
357
|
+
root = Path(directory)
|
|
358
|
+
tables: List[ShellConvergenceTable] = []
|
|
359
|
+
for element_type in SHELL_CONVERGENCE_ELEMENT_TYPES:
|
|
360
|
+
path = root / f"{element_type}.txt"
|
|
361
|
+
if path.exists():
|
|
362
|
+
tables.append(
|
|
363
|
+
parse_calculix_shell_convergence_file(
|
|
364
|
+
path,
|
|
365
|
+
element_type=element_type,
|
|
366
|
+
stress_reference=stress_reference,
|
|
367
|
+
displacement_reference=displacement_reference,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
return tables
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def discover_calculix_reference_cases(
|
|
374
|
+
roots: Optional[Sequence[Path | str]] = None,
|
|
375
|
+
repo_root: Optional[Path | str] = None,
|
|
376
|
+
require_frd: bool = True,
|
|
377
|
+
) -> List[CalculixReferenceCase]:
|
|
378
|
+
"""Discover CalculiX reference cases from input/result file pairs.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
roots: Directories to search. Relative paths are resolved against
|
|
382
|
+
repo_root or the current working directory.
|
|
383
|
+
repo_root: Optional base directory for relative search roots.
|
|
384
|
+
require_frd: If true, only return cases with matching result files.
|
|
385
|
+
If false, return input-only cases too.
|
|
386
|
+
"""
|
|
387
|
+
cases: List[CalculixReferenceCase] = []
|
|
388
|
+
seen: set[Path] = set()
|
|
389
|
+
for root in _iter_existing_roots(roots, repo_root):
|
|
390
|
+
for inp_path in root.rglob("*"):
|
|
391
|
+
if not inp_path.is_file() or inp_path.suffix.lower() != ".inp":
|
|
392
|
+
continue
|
|
393
|
+
resolved = inp_path.resolve()
|
|
394
|
+
if resolved in seen:
|
|
395
|
+
continue
|
|
396
|
+
seen.add(resolved)
|
|
397
|
+
|
|
398
|
+
frd_path = _case_insensitive_sidecar(inp_path, ".frd")
|
|
399
|
+
if require_frd and frd_path is None:
|
|
400
|
+
continue
|
|
401
|
+
metadata_path = _case_insensitive_sidecar(inp_path, ".json")
|
|
402
|
+
summary = summarize_inp_geometry(inp_path)
|
|
403
|
+
metadata_name = None
|
|
404
|
+
if metadata_path is not None:
|
|
405
|
+
try:
|
|
406
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
407
|
+
metadata_name = metadata.get("name")
|
|
408
|
+
except (OSError, json.JSONDecodeError):
|
|
409
|
+
metadata_name = None
|
|
410
|
+
name = str(metadata_name or inp_path.stem)
|
|
411
|
+
cases.append(
|
|
412
|
+
CalculixReferenceCase(
|
|
413
|
+
name=name,
|
|
414
|
+
directory=inp_path.parent,
|
|
415
|
+
inp_path=inp_path,
|
|
416
|
+
frd_path=frd_path,
|
|
417
|
+
metadata_path=metadata_path,
|
|
418
|
+
kind=str(summary["kind"]),
|
|
419
|
+
node_count=int(summary["node_count"]),
|
|
420
|
+
element_count=int(summary["element_count"]),
|
|
421
|
+
bbox_min=summary["bbox_min"],
|
|
422
|
+
bbox_max=summary["bbox_max"],
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
return sorted(cases, key=lambda case: (str(case.directory), case.name))
|