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,326 @@
1
+ """S4 shell validity metrics and local report generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple
13
+
14
+ import numpy as np
15
+
16
+ from .assembly import solve_linear
17
+ from .boundary import FixedSupport, LoadCase
18
+ from .elements import ShellElement
19
+ from .fe_core import FEModel
20
+ from .mesh_gen import generate_simple_panel_mesh
21
+ from .shell_benchmarks import run_simple_supported_shell_benchmark
22
+
23
+
24
+ DEFAULT_S4_VALIDITY_PATH = Path("reports/s4_validity/s4_validity_report.json")
25
+
26
+ E_STEEL = 210.0e9
27
+ NU_STEEL = 0.3
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class S4Metric:
32
+ """One scalar S4 validity metric."""
33
+
34
+ name: str
35
+ value: float
36
+ unit: str = ""
37
+ metadata: Mapping[str, Any] | None = None
38
+
39
+ def to_dict(self) -> Dict[str, Any]:
40
+ return {
41
+ "name": self.name,
42
+ "value": float(self.value),
43
+ "unit": self.unit,
44
+ "metadata": dict(self.metadata or {}),
45
+ }
46
+
47
+
48
+ def _git_sha() -> str | None:
49
+ try:
50
+ result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
51
+ except Exception:
52
+ return None
53
+ return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
54
+
55
+
56
+ def _single_s4_model(coords: Sequence[Sequence[float]], thickness: float = 0.01) -> FEModel:
57
+ model = FEModel("single_s4")
58
+ model.add_material("steel", E_STEEL, NU_STEEL)
59
+ for node_id, coord in enumerate(coords, start=1):
60
+ model.add_node(node_id, float(coord[0]), float(coord[1]), float(coord[2]))
61
+ model.add_element(1, ShellElement(1, [1, 2, 3, 4], "steel", thickness))
62
+ return model
63
+
64
+
65
+ def reference_s4_geometries() -> Dict[str, Tuple[Tuple[float, float, float], ...]]:
66
+ """Representative S4 geometries used by validity metrics."""
67
+ return {
68
+ "square": ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0)),
69
+ "parallelogram": ((0.0, 0.0, 0.0), (1.2, 0.0, 0.0), (1.55, 0.9, 0.0), (0.35, 0.9, 0.0)),
70
+ "skew": ((0.0, 0.0, 0.0), (1.35, 0.08, 0.0), (1.08, 0.95, 0.0), (-0.18, 1.12, 0.0)),
71
+ "mild_warp": ((0.0, 0.0, 0.0), (1.0, 0.0, 0.015), (1.0, 1.0, -0.012), (0.0, 1.0, 0.008)),
72
+ }
73
+
74
+
75
+ def free_element_mode_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
76
+ """Return free S4 eigenvalue/null-mode diagnostics."""
77
+ model = _single_s4_model(coords)
78
+ element = model.mesh.get_element(1)
79
+ K = element.compute_stiffness_matrix(model.mesh, model.get_material("steel"))
80
+ eig = np.linalg.eigvalsh(0.5 * (K + K.T))
81
+ max_eig = max(float(np.max(np.abs(eig))), 1.0)
82
+ threshold = 1.0e-9 * max_eig
83
+ return {
84
+ "zero_mode_count": int(np.sum(np.abs(eig) < threshold)),
85
+ "threshold": float(threshold),
86
+ "min_eigenvalue": float(np.min(eig)),
87
+ "max_eigenvalue": float(np.max(eig)),
88
+ "relative_negative_eigenvalue": float(min(np.min(eig), 0.0) / max_eig),
89
+ }
90
+
91
+
92
+ def _membrane_displacement(model: FEModel, eps_x: float, eps_y: float, gamma_xy: float) -> np.ndarray:
93
+ element = model.mesh.get_element(1)
94
+ coords = element.get_node_coordinates(model.mesh)
95
+ u = np.zeros(element.total_dofs, dtype=float)
96
+ for local_node_index, coord in enumerate(coords):
97
+ x, y, _z = coord
98
+ base = local_node_index * 6
99
+ u[base + 0] = eps_x * x
100
+ u[base + 1] = eps_y * y + gamma_xy * x
101
+ return u
102
+
103
+
104
+ def membrane_patch_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
105
+ """Constant membrane strain patch error on one S4 element.
106
+
107
+ The element's default stress output is expressed in the per-integration-point
108
+ local shell frame (local x follows the xi tangent), which rotates relative to
109
+ the global axes on distorted geometry. The patch comparison therefore uses
110
+ the ``return_global=True`` surface stresses so that exact constant-strain
111
+ reproduction is measured frame-consistently.
112
+ """
113
+ model = _single_s4_model(coords)
114
+ element = model.mesh.get_element(1)
115
+ material = model.get_material("steel")
116
+ eps_x, eps_y, gamma_xy = 1.2e-4, -0.4e-4, 0.7e-4
117
+ u = _membrane_displacement(model, eps_x, eps_y, gamma_xy)
118
+ stresses = element.compute_stresses(model.mesh, u, material, return_global=True)
119
+ E = material.elastic_modulus
120
+ nu = material.poisson_ratio
121
+ expected = {
122
+ "membrane_xx": ("xx", E / (1.0 - nu**2) * (eps_x + nu * eps_y)),
123
+ "membrane_yy": ("yy", E / (1.0 - nu**2) * (eps_y + nu * eps_x)),
124
+ "membrane_xy": ("xy", E / (2.0 * (1.0 + nu)) * gamma_xy),
125
+ }
126
+ errors = {}
127
+ spreads = {}
128
+ for key, (component, target) in expected.items():
129
+ top = np.asarray(stresses[f"global_{component}_top"], dtype=float)
130
+ bottom = np.asarray(stresses[f"global_{component}_bot"], dtype=float)
131
+ values = 0.5 * (top + bottom)
132
+ errors[key] = float(np.max(np.abs(values - target)) / max(abs(float(target)), 1.0))
133
+ spreads[key] = float((np.max(values) - np.min(values)) / max(abs(float(target)), 1.0))
134
+ return {"relative_errors": errors, "relative_spreads": spreads, "stress_frame": "global"}
135
+
136
+
137
+ def bending_patch_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
138
+ """Constant curvature patch error on one S4 element."""
139
+ model = _single_s4_model(coords)
140
+ element = model.mesh.get_element(1)
141
+ material = model.get_material("steel")
142
+ kappa_x = 1.1e-3
143
+ coords_array = element.get_node_coordinates(model.mesh)
144
+ u = np.zeros(element.total_dofs, dtype=float)
145
+ for local_node_index, coord in enumerate(coords_array):
146
+ x = coord[0]
147
+ u[local_node_index * 6 + 4] = kappa_x * x
148
+ stresses = element.compute_stresses(model.mesh, u, material, return_global=True)
149
+ expected = material.elastic_modulus * element.thickness / (2.0 * (1.0 - material.poisson_ratio**2)) * kappa_x
150
+ top = np.asarray(stresses["global_xx_top"], dtype=float)
151
+ bottom = np.asarray(stresses["global_xx_bot"], dtype=float)
152
+ values = 0.5 * (top - bottom)
153
+ return {
154
+ "relative_error": float(np.max(np.abs(values - expected)) / max(abs(expected), 1.0)),
155
+ "relative_spread": float((np.max(values) - np.min(values)) / max(abs(expected), 1.0)),
156
+ "stress_frame": "global",
157
+ }
158
+
159
+
160
+ def shear_patch_metric(coords: Sequence[Sequence[float]]) -> Dict[str, Any]:
161
+ """Constant transverse shear patch on one S4 element.
162
+
163
+ Compared in the local shell frame. MITC4 assumed transverse shear keeps a
164
+ small interpolation residual on distorted (non-affine) geometry; that is
165
+ expected element behavior rather than a stress-frame artifact.
166
+ """
167
+ model = _single_s4_model(coords)
168
+ element = model.mesh.get_element(1)
169
+ material = model.get_material("steel")
170
+ gamma_xz = 2.0e-4
171
+ coords_array = element.get_node_coordinates(model.mesh)
172
+ u = np.zeros(element.total_dofs, dtype=float)
173
+ for local_node_index, coord in enumerate(coords_array):
174
+ x = coord[0]
175
+ u[local_node_index * 6 + 2] = gamma_xz * x
176
+ stresses = element.compute_stresses(model.mesh, u, material)
177
+ expected = material.shear_modulus * (5.0 / 6.0) * gamma_xz
178
+ values = np.asarray(stresses["shear_xz"], dtype=float)
179
+ return {
180
+ "relative_error": float(np.max(np.abs(values - expected)) / max(abs(expected), 1.0)),
181
+ "relative_spread": float((np.max(values) - np.min(values)) / max(abs(expected), 1.0)),
182
+ "target_shear_pa": float(expected),
183
+ }
184
+
185
+
186
+ def thin_plate_locking_sweep(
187
+ thicknesses: Sequence[float] = (0.01, 0.003, 0.001),
188
+ length: float = 1.0,
189
+ width: float = 0.1,
190
+ num_divisions: int = 10,
191
+ ) -> Tuple[Dict[str, Any], ...]:
192
+ """Cantilever strip sweep against beam bending reference."""
193
+ rows: List[Dict[str, Any]] = []
194
+ for thickness in thicknesses:
195
+ model = FEModel(name="s4_locking_strip")
196
+ model.add_material("steel", E_STEEL, NU_STEEL)
197
+ nid = {}
198
+ node_id = 1
199
+ for i in range(num_divisions + 1):
200
+ for j in range(2):
201
+ model.add_node(node_id, length * i / num_divisions, width * j, 0.0)
202
+ nid[(i, j)] = node_id
203
+ node_id += 1
204
+ for i in range(num_divisions):
205
+ model.add_element(
206
+ i + 1,
207
+ ShellElement(i + 1, [nid[(i, 0)], nid[(i + 1, 0)], nid[(i + 1, 1)], nid[(i, 1)]], "steel", thickness),
208
+ )
209
+ model.add_boundary_condition(FixedSupport("fixed", [nid[(0, 0)], nid[(0, 1)]]))
210
+ load_case = LoadCase("tip")
211
+ load_case.add_nodal_load(nid[(num_divisions, 0)], [0.0, 0.0, 0.5, 0.0, 0.0, 0.0])
212
+ load_case.add_nodal_load(nid[(num_divisions, 1)], [0.0, 0.0, 0.5, 0.0, 0.0, 0.0])
213
+ displacements, solver_info = solve_linear(model, load_case)
214
+ w_tip = 0.5 * (
215
+ displacements[model.mesh.get_node(nid[(num_divisions, 0)]).dofs[2]]
216
+ + displacements[model.mesh.get_node(nid[(num_divisions, 1)]).dofs[2]]
217
+ )
218
+ reference = length**3 / (3.0 * E_STEEL * width * thickness**3 / 12.0)
219
+ rows.append(
220
+ {
221
+ "thickness": float(thickness),
222
+ "span_to_thickness": float(length / thickness),
223
+ "tip_displacement": float(w_tip),
224
+ "beam_reference_displacement": float(reference),
225
+ "ratio_to_reference": float(w_tip / reference),
226
+ "relative_error": float(abs(w_tip - reference) / max(abs(reference), 1.0e-30)),
227
+ "solver_status": str((solver_info.get("convergence_info") or {}).get("status", "unknown")),
228
+ }
229
+ )
230
+ return tuple(rows)
231
+
232
+
233
+ def _s4_recovered_von_mises(division: int, thickness: float) -> float:
234
+ from .boundary import LoadCase
235
+ from .mesh_gen import generate_simple_panel_mesh
236
+ from .results import recover_nodal_stresses
237
+
238
+ model = generate_simple_panel_mesh(1.0, 1.0, thickness, num_divisions_x=int(division), num_divisions_y=int(division), use_8node_elements=False)
239
+ load_case = LoadCase("constant_surface_load")
240
+ for element_id in model.mesh.elements:
241
+ load_case.add_pressure_load(element_id, 1000.0)
242
+ displacements, _info = solve_linear(model, load_case)
243
+ return float(recover_nodal_stresses(model, displacements)["max_von_mises"])
244
+
245
+
246
+ def s4_s8_comparison(divisions: Sequence[int] = (2, 4), thickness: float = 0.01) -> Tuple[Dict[str, Any], ...]:
247
+ """Compare S4 and S8 responses for matching simple supported panel sweeps.
248
+
249
+ ``s4_von_mises`` is the raw integration-point peak; ``s4_von_mises_recovered``
250
+ is the Gauss-to-node extrapolated, patch-averaged nodal peak. Note that the
251
+ coarse-mesh S8 von Mises overshoots the converged value near the hard
252
+ simply-supported corners, so the S4/S8 stress ratio at coarse divisions is
253
+ not a pure S4 accuracy measure.
254
+ """
255
+ rows: List[Dict[str, Any]] = []
256
+ for division in divisions:
257
+ s4 = run_simple_supported_shell_benchmark(divisions_x=int(division), divisions_y=int(division), thickness=thickness, use_8node_elements=False)
258
+ s8 = run_simple_supported_shell_benchmark(divisions_x=int(division), divisions_y=int(division), thickness=thickness, use_8node_elements=True)
259
+ recovered = _s4_recovered_von_mises(int(division), thickness)
260
+ rows.append(
261
+ {
262
+ "division": int(division),
263
+ "s4_nodes": int(s4.node_count),
264
+ "s8_nodes": int(s8.node_count),
265
+ "s4_displacement": float(s4.max_out_of_plane_displacement),
266
+ "s8_displacement": float(s8.max_out_of_plane_displacement),
267
+ "displacement_ratio_s4_to_s8": float(s4.max_out_of_plane_displacement / max(s8.max_out_of_plane_displacement, 1.0e-30)),
268
+ "s4_von_mises": float(s4.max_von_mises_stress),
269
+ "s4_von_mises_recovered": float(recovered),
270
+ "s8_von_mises": float(s8.max_von_mises_stress),
271
+ "stress_ratio_s4_to_s8": float(s4.max_von_mises_stress / max(s8.max_von_mises_stress, 1.0e-30)),
272
+ "recovered_stress_ratio_s4_to_s8": float(recovered / max(s8.max_von_mises_stress, 1.0e-30)),
273
+ "s4_status": s4.solver_status,
274
+ "s8_status": s8.solver_status,
275
+ }
276
+ )
277
+ return tuple(rows)
278
+
279
+
280
+ def generate_s4_validity_report() -> Dict[str, Any]:
281
+ """Generate a local S4 validity report."""
282
+ geometries = reference_s4_geometries()
283
+ geometry_metrics: Dict[str, Any] = {}
284
+ for name, coords in geometries.items():
285
+ metrics = {"free_modes": free_element_mode_metric(coords)}
286
+ if name != "mild_warp":
287
+ metrics["membrane_patch"] = membrane_patch_metric(coords)
288
+ metrics["bending_patch"] = bending_patch_metric(coords)
289
+ metrics["shear_patch"] = shear_patch_metric(coords)
290
+ else:
291
+ model = _single_s4_model(coords)
292
+ element = model.mesh.get_element(1)
293
+ K = element.compute_stiffness_matrix(model.mesh, model.get_material("steel"))
294
+ metrics["warped_quad"] = {
295
+ "stiffness_finite": bool(np.all(np.isfinite(K))),
296
+ "relative_symmetry_error": float(np.linalg.norm(K - K.T) / max(np.linalg.norm(K), 1.0)),
297
+ }
298
+ geometry_metrics[name] = metrics
299
+
300
+ return {
301
+ "schema_version": 1,
302
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
303
+ "commit": _git_sha(),
304
+ "environment": {
305
+ "platform": platform.platform(),
306
+ "python": sys.version.split()[0],
307
+ "numpy": np.__version__,
308
+ },
309
+ "element": "S4",
310
+ "theory_notes": [
311
+ "S4 uses a Mindlin-Reissner shell with MITC-style assumed transverse shear for 4-node quadrilaterals.",
312
+ "Metrics are local regression evidence; external CalculiX/SESTRA comparisons remain a separate validation layer.",
313
+ ],
314
+ "geometry_metrics": geometry_metrics,
315
+ "thin_plate_locking_sweep": list(thin_plate_locking_sweep()),
316
+ "s4_s8_comparison": list(s4_s8_comparison()),
317
+ }
318
+
319
+
320
+ def write_s4_validity_report(path: Path | str = DEFAULT_S4_VALIDITY_PATH) -> Dict[str, Any]:
321
+ """Write the S4 validity report as JSON."""
322
+ report = generate_s4_validity_report()
323
+ output = Path(path)
324
+ output.parent.mkdir(parents=True, exist_ok=True)
325
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
326
+ return report
@@ -0,0 +1,55 @@
1
+ """Pure-Python SESAM formatted FEM import/export support."""
2
+
3
+ from .diagnostics import FemDiagnostic, SesamFemError
4
+ from .document import (
5
+ FemBoundary,
6
+ FemCoordinate,
7
+ FemCoordinateTransform,
8
+ FemElement,
9
+ FemElementReference,
10
+ FemHeader,
11
+ FemLoadRecord,
12
+ FemMaterial,
13
+ FemNode,
14
+ FemSection,
15
+ FemUnitVector,
16
+ SesamFemDocument,
17
+ parse_sesam_fem_records,
18
+ read_sesam_fem_document,
19
+ )
20
+ from .exporter import SesamFemExportReport, export_sesam_fem, write_sesam_fem_document
21
+ from .importer import SesamFemImportResult, build_fe_model_from_sesam_document, import_sesam_fem
22
+ from .records import FemRawRecord, read_raw_records, strict_int
23
+ from .schema import SESAM_ELEMENT_REGISTRY, SesamElementSpec
24
+ from .validation import validate_sesam_fem_document
25
+
26
+ __all__ = [
27
+ "FemBoundary",
28
+ "FemCoordinate",
29
+ "FemCoordinateTransform",
30
+ "FemDiagnostic",
31
+ "FemElement",
32
+ "FemElementReference",
33
+ "FemHeader",
34
+ "FemLoadRecord",
35
+ "FemMaterial",
36
+ "FemNode",
37
+ "FemRawRecord",
38
+ "FemSection",
39
+ "FemUnitVector",
40
+ "SESAM_ELEMENT_REGISTRY",
41
+ "SesamElementSpec",
42
+ "SesamFemDocument",
43
+ "SesamFemError",
44
+ "SesamFemExportReport",
45
+ "SesamFemImportResult",
46
+ "build_fe_model_from_sesam_document",
47
+ "export_sesam_fem",
48
+ "import_sesam_fem",
49
+ "parse_sesam_fem_records",
50
+ "read_raw_records",
51
+ "read_sesam_fem_document",
52
+ "strict_int",
53
+ "validate_sesam_fem_document",
54
+ "write_sesam_fem_document",
55
+ ]
@@ -0,0 +1,80 @@
1
+ """Command line helpers for SESAM formatted FEM files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+
10
+ from .diagnostics import SesamFemError
11
+ from .document import read_sesam_fem_document
12
+ from .exporter import write_sesam_fem_document
13
+ from .importer import import_sesam_fem
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(prog="python -m anysolver.sesam_fem")
18
+ parser.add_argument("--lenient", action="store_true", help="collect diagnostics instead of failing on errors")
19
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
20
+ sub = parser.add_subparsers(dest="command", required=True)
21
+
22
+ inspect_parser = sub.add_parser("inspect", help="show document summary")
23
+ inspect_parser.add_argument("input")
24
+
25
+ validate_parser = sub.add_parser("validate", help="validate a FEM file")
26
+ validate_parser.add_argument("input")
27
+
28
+ roundtrip_parser = sub.add_parser("roundtrip", help="canonicalize a FEM file")
29
+ roundtrip_parser.add_argument("input")
30
+ roundtrip_parser.add_argument("output")
31
+ roundtrip_parser.add_argument("--overwrite", action="store_true")
32
+
33
+ summary_parser = sub.add_parser("import-summary", help="read and optionally build a solver model")
34
+ summary_parser.add_argument("input")
35
+ summary_parser.add_argument("--summary-json", dest="summary_json")
36
+ summary_parser.add_argument("--no-build-model", action="store_true")
37
+
38
+ args = parser.parse_args(argv)
39
+ strict = not args.lenient
40
+ try:
41
+ if args.command == "inspect":
42
+ document = read_sesam_fem_document(args.input, strict=strict)
43
+ return _print(document.summary(), args.json)
44
+ if args.command == "validate":
45
+ document = read_sesam_fem_document(args.input, strict=strict)
46
+ payload = {"ok": not any(item.severity == "error" for item in document.diagnostics), "diagnostics": [item.as_dict() for item in document.diagnostics]}
47
+ return _print(payload, args.json)
48
+ if args.command == "roundtrip":
49
+ document = read_sesam_fem_document(args.input, strict=strict)
50
+ report = write_sesam_fem_document(document, args.output, overwrite=args.overwrite)
51
+ return _print({"output": str(report.path), "records_written": report.records_written, "bytes_written": report.bytes_written}, args.json)
52
+ if args.command == "import-summary":
53
+ result = import_sesam_fem(args.input, strict=strict, build_model=not args.no_build_model)
54
+ payload = result.document.summary()
55
+ payload["element_count_by_type"] = result.element_count_by_type
56
+ payload["model_built"] = result.model is not None
57
+ if args.summary_json:
58
+ Path(args.summary_json).write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
59
+ return _print(payload, args.json)
60
+ except SesamFemError as exc:
61
+ payload = {"ok": False, "code": exc.code, "message": str(exc), "diagnostics": [item.as_dict() for item in exc.diagnostics]}
62
+ _print(payload, True)
63
+ return 2
64
+ return 1
65
+
66
+
67
+ def _print(payload: object, as_json: bool) -> int:
68
+ if as_json:
69
+ print(json.dumps(payload, indent=2, sort_keys=True))
70
+ else:
71
+ if isinstance(payload, dict):
72
+ for key, value in payload.items():
73
+ print(f"{key}: {value}")
74
+ else:
75
+ print(payload)
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ """Diagnostics and exceptions for SESAM formatted FEM support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable, Mapping, Optional, Tuple
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class FemDiagnostic:
11
+ """A structured SESAM FEM import/export diagnostic."""
12
+
13
+ code: str
14
+ message: str
15
+ severity: str = "error"
16
+ record_name: Optional[str] = None
17
+ line_start: Optional[int] = None
18
+ line_end: Optional[int] = None
19
+ context: Mapping[str, Any] | None = None
20
+
21
+ def as_dict(self) -> dict[str, Any]:
22
+ data: dict[str, Any] = {
23
+ "code": self.code,
24
+ "severity": self.severity,
25
+ "message": self.message,
26
+ }
27
+ if self.record_name is not None:
28
+ data["record_name"] = self.record_name
29
+ if self.line_start is not None:
30
+ data["line_start"] = self.line_start
31
+ if self.line_end is not None:
32
+ data["line_end"] = self.line_end
33
+ if self.context:
34
+ data["context"] = dict(self.context)
35
+ return data
36
+
37
+
38
+ class SesamFemError(ValueError):
39
+ """Raised when a SESAM FEM file cannot be safely read or written."""
40
+
41
+ def __init__(
42
+ self,
43
+ message: str,
44
+ *,
45
+ code: str = "FEM000",
46
+ diagnostics: Iterable[FemDiagnostic] | None = None,
47
+ ) -> None:
48
+ self.code = code
49
+ self.diagnostics: Tuple[FemDiagnostic, ...] = tuple(diagnostics or ())
50
+ super().__init__(f"{code}: {message}")
51
+
52
+
53
+ def has_errors(diagnostics: Iterable[FemDiagnostic]) -> bool:
54
+ """Return True when the diagnostic collection contains errors."""
55
+
56
+ return any(item.severity.lower() == "error" for item in diagnostics)
57
+
58
+
59
+ def raise_if_errors(diagnostics: Iterable[FemDiagnostic], message: str) -> None:
60
+ """Raise a SesamFemError when any diagnostic has error severity."""
61
+
62
+ items = tuple(diagnostics)
63
+ if has_errors(items):
64
+ first = next(item for item in items if item.severity.lower() == "error")
65
+ raise SesamFemError(message, code=first.code, diagnostics=items)