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
anysolver/s4_validity.py
ADDED
|
@@ -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)
|