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