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,609 @@
|
|
|
1
|
+
"""Focused mesh, load and boundary-condition verification report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import sparse
|
|
17
|
+
|
|
18
|
+
from .anystructure_fem_mode import AnyStructureFEMConfig, build_fe_model_from_generated_geometry, build_symmetric_load_case
|
|
19
|
+
from .assembly import build_constraint_transformation, solve_linear
|
|
20
|
+
from .boundary import BoundaryCondition, FixedSupport, LoadCase, PinnedSupport, RollerSupport, SymmetryBC
|
|
21
|
+
from .dynamics import PressurePatch, assemble_pressure_patch_load_vector
|
|
22
|
+
from .elements import BeamElement, CoupledBeamShellElement, ShellElement
|
|
23
|
+
from .fe_core import FEModel
|
|
24
|
+
from .validation import load_case_resultant, load_vector_resultant, validate_production_model
|
|
25
|
+
|
|
26
|
+
DEFAULT_MESH_LOAD_BC_VERIFICATION_PATH = Path("reports/mesh_load_bc_verification/mesh_load_bc_verification_report.json")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MeshLoadBCCase:
|
|
31
|
+
case_id: str
|
|
32
|
+
category: str
|
|
33
|
+
title: str
|
|
34
|
+
required: bool = True
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"case_id": self.case_id,
|
|
39
|
+
"category": self.category,
|
|
40
|
+
"title": self.title,
|
|
41
|
+
"required": bool(self.required),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class MeshLoadBCCaseResult:
|
|
47
|
+
case_id: str
|
|
48
|
+
status: str
|
|
49
|
+
category: str
|
|
50
|
+
title: str
|
|
51
|
+
checks: Mapping[str, Any] = field(default_factory=dict)
|
|
52
|
+
measured: Mapping[str, Any] = field(default_factory=dict)
|
|
53
|
+
tolerance: Mapping[str, Any] = field(default_factory=dict)
|
|
54
|
+
diagnostics: Mapping[str, Any] = field(default_factory=dict)
|
|
55
|
+
reason: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
58
|
+
payload = {
|
|
59
|
+
"case_id": self.case_id,
|
|
60
|
+
"status": self.status,
|
|
61
|
+
"category": self.category,
|
|
62
|
+
"title": self.title,
|
|
63
|
+
"checks": dict(self.checks),
|
|
64
|
+
"measured": dict(self.measured),
|
|
65
|
+
"tolerance": dict(self.tolerance),
|
|
66
|
+
"diagnostics": dict(self.diagnostics),
|
|
67
|
+
}
|
|
68
|
+
if self.reason:
|
|
69
|
+
payload["reason"] = self.reason
|
|
70
|
+
return payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
CASE_ROWS: Tuple[MeshLoadBCCase, ...] = (
|
|
74
|
+
MeshLoadBCCase("MLBC-001", "mesh", "Flat stiffened mesh topology and member-line alignment"),
|
|
75
|
+
MeshLoadBCCase("MLBC-002", "mesh", "Cylinder seam closure, ring-frame topology and shell orientation"),
|
|
76
|
+
MeshLoadBCCase("MLBC-003", "mesh", "Q8 midside placement and distorted-mesh guardrails"),
|
|
77
|
+
MeshLoadBCCase("MLBC-004", "load", "Pressure direction and resultant sign conventions"),
|
|
78
|
+
MeshLoadBCCase("MLBC-005", "load", "Pressure patch area selection and resultant"),
|
|
79
|
+
MeshLoadBCCase("MLBC-006", "load", "Edge load and nodal moment resultant balance"),
|
|
80
|
+
MeshLoadBCCase("MLBC-007", "boundary", "Fixed, pinned, roller and symmetry support DOF semantics"),
|
|
81
|
+
MeshLoadBCCase("MLBC-008", "mpc", "MPC duplicate ownership and fixed-slave rejection"),
|
|
82
|
+
MeshLoadBCCase("MLBC-009", "nullspace", "Self-equilibrated free-free load nullspace consistency"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mesh_load_bc_manifest_cases() -> List[MeshLoadBCCase]:
|
|
87
|
+
return list(CASE_ROWS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_sha() -> Optional[str]:
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=False)
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pass(case: MeshLoadBCCase, **kwargs: Any) -> MeshLoadBCCaseResult:
|
|
99
|
+
return MeshLoadBCCaseResult(case.case_id, "PASS", case.category, case.title, **kwargs)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fail(case: MeshLoadBCCase, reason: str, **kwargs: Any) -> MeshLoadBCCaseResult:
|
|
103
|
+
return MeshLoadBCCaseResult(case.case_id, "FAIL", case.category, case.title, reason=reason, **kwargs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _assert(condition: bool, message: str) -> None:
|
|
107
|
+
if not condition:
|
|
108
|
+
raise AssertionError(message)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _node_coords(model: FEModel, node_ids: Sequence[int]) -> np.ndarray:
|
|
112
|
+
return np.asarray([model.mesh.get_node(int(node_id)).coords() for node_id in node_ids], dtype=float)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _shell_normal(model: FEModel, shell: ShellElement) -> np.ndarray:
|
|
116
|
+
coords = _node_coords(model, shell.node_ids[:4])
|
|
117
|
+
normal = np.cross(coords[1] - coords[0], coords[2] - coords[0])
|
|
118
|
+
norm = float(np.linalg.norm(normal))
|
|
119
|
+
if norm <= 0.0:
|
|
120
|
+
return np.zeros(3)
|
|
121
|
+
return normal / norm
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _coords_key(coords: Sequence[float]) -> Tuple[int, int, int]:
|
|
125
|
+
return tuple(int(round(float(value) * 1.0e9)) for value in coords)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _flat_stiffened_geometry() -> Dict[str, Any]:
|
|
129
|
+
nodes = [
|
|
130
|
+
{"id": 1, "coords": [0.0, 0.0, 0.0]},
|
|
131
|
+
{"id": 2, "coords": [1.0, 0.0, 0.0]},
|
|
132
|
+
{"id": 3, "coords": [2.0, 0.0, 0.0]},
|
|
133
|
+
{"id": 4, "coords": [0.0, 1.0, 0.0]},
|
|
134
|
+
{"id": 5, "coords": [1.0, 1.0, 0.0]},
|
|
135
|
+
{"id": 6, "coords": [2.0, 1.0, 0.0]},
|
|
136
|
+
{"id": 100, "coords": [0.0, 0.5, 0.2]},
|
|
137
|
+
{"id": 101, "coords": [1.0, 0.5, 0.2]},
|
|
138
|
+
{"id": 102, "coords": [2.0, 0.5, 0.2]},
|
|
139
|
+
{"id": 200, "coords": [1.0, 0.0, 0.3]},
|
|
140
|
+
{"id": 201, "coords": [1.0, 1.0, 0.3]},
|
|
141
|
+
]
|
|
142
|
+
section = {"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}
|
|
143
|
+
return {
|
|
144
|
+
"nodes": nodes,
|
|
145
|
+
"shells": [
|
|
146
|
+
{"id": 1, "node_ids": [1, 2, 5, 4], "thickness": 0.02, "role": "skin"},
|
|
147
|
+
{"id": 2, "node_ids": [2, 3, 6, 5], "thickness": 0.02, "role": "skin"},
|
|
148
|
+
],
|
|
149
|
+
"stiffeners": [{"id": 20, "node_ids": [100, 101, 102], "cross_section": section}],
|
|
150
|
+
"girders": [{"id": 21, "node_ids": [200, 201], "cross_section": section}],
|
|
151
|
+
"couplings": [
|
|
152
|
+
{"id": 30, "beam_node_id": 101, "shell_node_ids": [1, 2, 5, 4], "shape_weights": [0.25] * 4},
|
|
153
|
+
{"id": 31, "beam_node_id": 200, "shell_node_ids": [1, 2, 5, 4], "shape_weights": [0.25] * 4},
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cylinder_geometry(num_circ: int = 12) -> Dict[str, Any]:
|
|
159
|
+
radius = 1.0
|
|
160
|
+
height = 1.5
|
|
161
|
+
nodes = []
|
|
162
|
+
for iz, z in enumerate([0.0, 0.75, height]):
|
|
163
|
+
for itheta in range(num_circ):
|
|
164
|
+
theta = 2.0 * math.pi * itheta / num_circ
|
|
165
|
+
nodes.append({"id": iz * num_circ + itheta + 1, "coords": [radius * math.cos(theta), radius * math.sin(theta), z]})
|
|
166
|
+
shells = []
|
|
167
|
+
for iz in range(2):
|
|
168
|
+
for itheta in range(num_circ):
|
|
169
|
+
nxt = (itheta + 1) % num_circ
|
|
170
|
+
shells.append(
|
|
171
|
+
{
|
|
172
|
+
"id": iz * num_circ + itheta + 1,
|
|
173
|
+
"node_ids": [
|
|
174
|
+
iz * num_circ + itheta + 1,
|
|
175
|
+
iz * num_circ + nxt + 1,
|
|
176
|
+
(iz + 1) * num_circ + nxt + 1,
|
|
177
|
+
(iz + 1) * num_circ + itheta + 1,
|
|
178
|
+
],
|
|
179
|
+
"thickness": 0.02,
|
|
180
|
+
"role": "skin",
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
section = {"area": 0.004, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}
|
|
184
|
+
return {
|
|
185
|
+
"plot_type": "cylinder",
|
|
186
|
+
"radius_m": radius,
|
|
187
|
+
"nodes": nodes,
|
|
188
|
+
"shells": shells,
|
|
189
|
+
"girders": [
|
|
190
|
+
{"id": 1000 + i, "node_ids": [num_circ + i + 1, num_circ + ((i + 1) % num_circ) + 1], "cross_section": section, "role": "girder"}
|
|
191
|
+
for i in range(num_circ)
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _q8_model(midside_offset: float = 0.0, warp: float = 0.0) -> FEModel:
|
|
197
|
+
model = FEModel("q8_mesh_check")
|
|
198
|
+
model.add_material("steel", 210.0e9, 0.3)
|
|
199
|
+
coords = {
|
|
200
|
+
1: (0.0, 0.0, 0.0),
|
|
201
|
+
2: (1.0, 0.0, 0.0),
|
|
202
|
+
3: (1.0, 1.0, 0.0),
|
|
203
|
+
4: (0.0, 1.0, warp),
|
|
204
|
+
5: (0.5 + midside_offset, 0.0, 0.0),
|
|
205
|
+
6: (1.0, 0.5, 0.0),
|
|
206
|
+
7: (0.5, 1.0, 0.0),
|
|
207
|
+
8: (0.0, 0.5, 0.0),
|
|
208
|
+
}
|
|
209
|
+
for node_id, xyz in coords.items():
|
|
210
|
+
model.add_node(node_id, *xyz)
|
|
211
|
+
model.add_element(1, ShellElement(1, list(range(1, 9)), "steel", thickness=0.01))
|
|
212
|
+
return model
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _run_mlbc_001(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
216
|
+
geometry = _flat_stiffened_geometry()
|
|
217
|
+
model = build_fe_model_from_generated_geometry(geometry)
|
|
218
|
+
shell_count = sum(1 for element in model.mesh.elements.values() if isinstance(element, ShellElement))
|
|
219
|
+
beam_roles = sorted(getattr(element, "structural_role", "") for element in model.mesh.elements.values() if isinstance(element, BeamElement))
|
|
220
|
+
coords = [_coords_key(node.coords()) for node in model.mesh.nodes.values()]
|
|
221
|
+
duplicate_count = len(coords) - len(set(coords))
|
|
222
|
+
stiffener_nodes = [model.mesh.get_node(node_id) for node_id in (100, 101, 102)]
|
|
223
|
+
girder_nodes = [model.mesh.get_node(node_id) for node_id in (200, 201)]
|
|
224
|
+
stiffener_y_spread = max(node.y for node in stiffener_nodes) - min(node.y for node in stiffener_nodes)
|
|
225
|
+
girder_x_spread = max(node.x for node in girder_nodes) - min(node.x for node in girder_nodes)
|
|
226
|
+
validation = validate_production_model(model, allow_free_mechanisms=True)
|
|
227
|
+
|
|
228
|
+
_assert(shell_count == 2, "flat mesh shell count changed")
|
|
229
|
+
_assert(beam_roles == ["girder", "stiffener"], "stiffener/girder roles were not preserved")
|
|
230
|
+
_assert(duplicate_count == 0, "flat generated mesh contains duplicate coordinates")
|
|
231
|
+
_assert(stiffener_y_spread < 1.0e-12 and girder_x_spread < 1.0e-12, "member lines are not aligned with shell coordinates")
|
|
232
|
+
_assert(validation.status in {"ok", "warning"}, "flat generated mesh failed production validation")
|
|
233
|
+
return _pass(
|
|
234
|
+
case,
|
|
235
|
+
measured={
|
|
236
|
+
"shell_count": shell_count,
|
|
237
|
+
"beam_roles": beam_roles,
|
|
238
|
+
"duplicate_coordinate_count": duplicate_count,
|
|
239
|
+
"stiffener_y_spread": stiffener_y_spread,
|
|
240
|
+
"girder_x_spread": girder_x_spread,
|
|
241
|
+
},
|
|
242
|
+
diagnostics={"validation_status": validation.status, "mesh_quality": validation.mesh_quality},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _run_mlbc_002(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
247
|
+
geometry = _cylinder_geometry()
|
|
248
|
+
model = build_fe_model_from_generated_geometry(geometry)
|
|
249
|
+
shells = [element for element in model.mesh.elements.values() if isinstance(element, ShellElement)]
|
|
250
|
+
beams = [element for element in model.mesh.elements.values() if isinstance(element, BeamElement)]
|
|
251
|
+
seam_shells = [shell for shell in geometry["shells"] if {1, 12} <= set(shell["node_ids"]) or {25, 36} <= set(shell["node_ids"])]
|
|
252
|
+
normal_dots = []
|
|
253
|
+
for shell in shells:
|
|
254
|
+
coords = _node_coords(model, shell.node_ids[:4])
|
|
255
|
+
centroid = np.mean(coords, axis=0)
|
|
256
|
+
radial = np.array([centroid[0], centroid[1], 0.0], dtype=float)
|
|
257
|
+
radial /= max(float(np.linalg.norm(radial)), 1.0e-15)
|
|
258
|
+
normal_dots.append(float(np.dot(_shell_normal(model, shell), radial)))
|
|
259
|
+
validation = validate_production_model(model, allow_free_mechanisms=True)
|
|
260
|
+
|
|
261
|
+
_assert(len(shells) == 24, "cylinder shell count changed")
|
|
262
|
+
_assert(len(beams) == 12, "ring-frame beam count changed")
|
|
263
|
+
_assert(len(seam_shells) >= 1, "cylinder seam is not closed by wraparound connectivity")
|
|
264
|
+
_assert(min(normal_dots) > 0.95, "one or more cylinder shell normals are not outward")
|
|
265
|
+
_assert(validation.status in {"ok", "warning"}, "cylinder mesh failed production validation")
|
|
266
|
+
return _pass(
|
|
267
|
+
case,
|
|
268
|
+
measured={"shell_count": len(shells), "ring_beam_count": len(beams), "min_outward_normal_dot": min(normal_dots)},
|
|
269
|
+
diagnostics={"validation_status": validation.status, "mesh_quality": validation.mesh_quality},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _run_mlbc_003(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
274
|
+
good = validate_production_model(_q8_model(), allow_free_mechanisms=True)
|
|
275
|
+
distorted = validate_production_model(_q8_model(midside_offset=0.3, warp=0.2), allow_free_mechanisms=True)
|
|
276
|
+
codes = {issue.code for issue in distorted.issues}
|
|
277
|
+
_assert(good.status == "ok", "well-formed Q8 element should pass mesh guardrails")
|
|
278
|
+
_assert({"MESH003", "MESH004"} <= codes, "distorted Q8 did not trigger warp and midside warnings")
|
|
279
|
+
return _pass(
|
|
280
|
+
case,
|
|
281
|
+
measured={
|
|
282
|
+
"good_status": good.status,
|
|
283
|
+
"distorted_status": distorted.status,
|
|
284
|
+
"max_q8_midside_deviation": distorted.mesh_quality["max_q8_midside_deviation"],
|
|
285
|
+
"max_warp": distorted.mesh_quality["max_warp"],
|
|
286
|
+
},
|
|
287
|
+
diagnostics={"distorted_issue_codes": sorted(codes)},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _run_mlbc_004(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
292
|
+
flat = build_fe_model_from_generated_geometry(
|
|
293
|
+
{
|
|
294
|
+
"nodes": [
|
|
295
|
+
{"id": 1, "coords": [0.0, 0.0, 0.0]},
|
|
296
|
+
{"id": 2, "coords": [2.0, 0.0, 0.0]},
|
|
297
|
+
{"id": 3, "coords": [2.0, 1.0, 0.0]},
|
|
298
|
+
{"id": 4, "coords": [0.0, 1.0, 0.0]},
|
|
299
|
+
],
|
|
300
|
+
"shells": [{"id": 1, "node_ids": [1, 2, 3, 4], "thickness": 0.02}],
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
positive = LoadCase("positive_normal")
|
|
304
|
+
positive.add_pressure_load(1, 5.0)
|
|
305
|
+
negative = LoadCase("negative_normal")
|
|
306
|
+
negative.add_pressure_load(1, -5.0)
|
|
307
|
+
pos_res = load_case_resultant(flat, positive)
|
|
308
|
+
neg_res = load_case_resultant(flat, negative)
|
|
309
|
+
|
|
310
|
+
cylinder = build_fe_model_from_generated_geometry(_cylinder_geometry())
|
|
311
|
+
external = LoadCase("external")
|
|
312
|
+
for element in cylinder.mesh.elements.values():
|
|
313
|
+
if isinstance(element, ShellElement):
|
|
314
|
+
external.add_pressure_load(int(element.element_id), -100.0)
|
|
315
|
+
external_vector = external.get_load_vector(cylinder.mesh, cylinder.mesh.dof_manager, cylinder.get_material)
|
|
316
|
+
radial_work = 0.0
|
|
317
|
+
for node in cylinder.mesh.nodes.values():
|
|
318
|
+
radius = np.array([node.x, node.y, 0.0], dtype=float)
|
|
319
|
+
norm = float(np.linalg.norm(radius))
|
|
320
|
+
if norm > 0.0:
|
|
321
|
+
radial_work += float(np.dot(external_vector[node.dofs[:3]], radius / norm))
|
|
322
|
+
cyl_res = load_vector_resultant(cylinder, external_vector)
|
|
323
|
+
|
|
324
|
+
_assert(np.allclose(pos_res.force, [0.0, 0.0, 10.0], atol=1.0e-12), "positive flat pressure resultant is wrong")
|
|
325
|
+
_assert(np.allclose(neg_res.force, [0.0, 0.0, -10.0], atol=1.0e-12), "negative flat pressure resultant is wrong")
|
|
326
|
+
_assert(radial_work < 0.0, "external cylinder pressure is not inward")
|
|
327
|
+
_assert(np.linalg.norm(cyl_res.force) < 1.0e-10, "closed-cylinder pressure resultant should self-equilibrate")
|
|
328
|
+
return _pass(
|
|
329
|
+
case,
|
|
330
|
+
measured={
|
|
331
|
+
"flat_positive_force": pos_res.force.tolist(),
|
|
332
|
+
"flat_negative_force": neg_res.force.tolist(),
|
|
333
|
+
"cylinder_external_radial_force_sum": radial_work,
|
|
334
|
+
"cylinder_resultant_force_norm": cyl_res.force_norm,
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _run_mlbc_005(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
340
|
+
model = build_fe_model_from_generated_geometry(_flat_stiffened_geometry())
|
|
341
|
+
patch = PressurePatch("one_shell", pressure_time=1.0, element_ids=[1])
|
|
342
|
+
_vector, info = assemble_pressure_patch_load_vector(model, patch, pressure=7.0)
|
|
343
|
+
resultant = np.asarray(info["resultant_force"], dtype=float)
|
|
344
|
+
_assert(info["selected_element_ids"] == [1], "explicit pressure patch selected wrong elements")
|
|
345
|
+
_assert(np.allclose(resultant, [0.0, 0.0, 7.0], atol=1.0e-12), "pressure patch resultant does not match selected area")
|
|
346
|
+
return _pass(case, measured={"selected_element_ids": info["selected_element_ids"], "resultant_force": resultant.tolist()})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class _Part:
|
|
350
|
+
span = 1.0
|
|
351
|
+
spacing = 1.0
|
|
352
|
+
t = 0.01
|
|
353
|
+
sigma_x1 = 5.0
|
|
354
|
+
sigma_x2 = 5.0
|
|
355
|
+
sigma_y1 = 3.0
|
|
356
|
+
sigma_y2 = 3.0
|
|
357
|
+
tau_xy = 1.0
|
|
358
|
+
pressure = 0.0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _Calc:
|
|
362
|
+
Plate = _Part()
|
|
363
|
+
Stiffener = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _run_mlbc_006(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
367
|
+
model = build_fe_model_from_generated_geometry(
|
|
368
|
+
{
|
|
369
|
+
"nodes": [
|
|
370
|
+
{"id": 1, "coords": [0.0, 0.0, 0.0]},
|
|
371
|
+
{"id": 2, "coords": [2.0, 0.0, 0.0]},
|
|
372
|
+
{"id": 3, "coords": [2.0, 1.0, 0.0]},
|
|
373
|
+
{"id": 4, "coords": [0.0, 1.0, 0.0]},
|
|
374
|
+
],
|
|
375
|
+
"shells": [{"id": 1, "node_ids": [1, 2, 3, 4], "thickness": 0.02}],
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
edge_load = build_symmetric_load_case(_Calc(), model, AnyStructureFEMConfig(pressure_pa=0.0, add_inplane_edge_loads=True))
|
|
379
|
+
edge_resultant = load_case_resultant(model, edge_load)
|
|
380
|
+
moment_load = LoadCase("pure_nodal_moment")
|
|
381
|
+
moment_load.add_nodal_load(1, moments=np.array([0.0, 0.0, 12.0]))
|
|
382
|
+
moment_resultant = load_case_resultant(model, moment_load)
|
|
383
|
+
_assert(edge_resultant.force_norm < 1.0e-12, "balanced edge loads produced net force")
|
|
384
|
+
_assert(edge_resultant.moment_norm < 1.0e-12, "balanced edge loads produced net moment")
|
|
385
|
+
_assert(np.allclose(moment_resultant.moment, [0.0, 0.0, 12.0], atol=1.0e-12), "nodal moment resultant is wrong")
|
|
386
|
+
return _pass(
|
|
387
|
+
case,
|
|
388
|
+
measured={
|
|
389
|
+
"edge_force_norm": edge_resultant.force_norm,
|
|
390
|
+
"edge_moment_norm": edge_resultant.moment_norm,
|
|
391
|
+
"nodal_moment_resultant": moment_resultant.moment.tolist(),
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _support_model() -> FEModel:
|
|
397
|
+
model = FEModel("support_semantics")
|
|
398
|
+
model.add_material("steel", 210.0e9, 0.3)
|
|
399
|
+
for node_id in range(1, 5):
|
|
400
|
+
model.add_node(node_id, float(node_id), 0.0, 0.0)
|
|
401
|
+
model.add_element(1, BeamElement(1, [1, 2], "steel", {"area": 0.01, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}))
|
|
402
|
+
model.add_boundary_condition(FixedSupport("fixed", [1]))
|
|
403
|
+
model.add_boundary_condition(PinnedSupport("pinned", [2]))
|
|
404
|
+
model.add_boundary_condition(RollerSupport("roller", [3], ["uy", "uz"]))
|
|
405
|
+
model.add_boundary_condition(SymmetryBC("symmetry", [4], "xy"))
|
|
406
|
+
model.apply_boundary_conditions()
|
|
407
|
+
return model
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _run_mlbc_007(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
411
|
+
model = _support_model()
|
|
412
|
+
constrained = set(model.mesh.dof_manager._constrained_dofs)
|
|
413
|
+
|
|
414
|
+
def names(node_id: int) -> List[str]:
|
|
415
|
+
return [model.mesh.dof_manager.get_dof_info(dof)[2] for dof in model.mesh.get_node(node_id).dofs if dof in constrained]
|
|
416
|
+
|
|
417
|
+
measured = {str(node_id): names(node_id) for node_id in range(1, 5)}
|
|
418
|
+
_assert(measured["1"] == ["ux", "uy", "uz", "rx", "ry", "rz"], "fixed support DOFs changed")
|
|
419
|
+
_assert(measured["2"] == ["ux", "uy", "uz"], "pinned support DOFs changed")
|
|
420
|
+
_assert(measured["3"] == ["uy", "uz"], "roller support DOFs changed")
|
|
421
|
+
_assert(measured["4"] == ["uz", "rx", "ry"], "symmetry support DOFs changed")
|
|
422
|
+
return _pass(case, measured={"constrained_dof_names_by_node": measured})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _run_mlbc_008(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
426
|
+
duplicate = FEModel("duplicate_mpc")
|
|
427
|
+
duplicate.add_material("steel", 210.0e9, 0.3)
|
|
428
|
+
duplicate.add_node(1, 0.0, 0.0, 0.0)
|
|
429
|
+
duplicate.add_node(2, 0.1, 0.0, 0.0)
|
|
430
|
+
duplicate.add_node(3, 0.2, 0.0, 0.0)
|
|
431
|
+
duplicate.add_element(1, CoupledBeamShellElement(1, beam_node_id=2, shell_node_id=1, material_name="steel"))
|
|
432
|
+
duplicate.add_element(2, CoupledBeamShellElement(2, beam_node_id=2, shell_node_id=3, material_name="steel"))
|
|
433
|
+
duplicate_report = validate_production_model(duplicate, allow_free_mechanisms=True)
|
|
434
|
+
|
|
435
|
+
fixed_slave = FEModel("fixed_slave")
|
|
436
|
+
fixed_slave.add_material("steel", 210.0e9, 0.3)
|
|
437
|
+
fixed_slave.add_node(1, 0.0, 0.0, 0.0)
|
|
438
|
+
fixed_slave.add_node(2, 0.1, 0.0, 0.0)
|
|
439
|
+
fixed_slave.add_element(1, CoupledBeamShellElement(1, beam_node_id=2, shell_node_id=1, material_name="steel"))
|
|
440
|
+
fixed_slave.add_boundary_condition(BoundaryCondition("bad_fixed_slave", [2], {"ux": 0.0}))
|
|
441
|
+
fixed_slave.apply_boundary_conditions()
|
|
442
|
+
load = np.zeros(fixed_slave.mesh.dof_manager.total_dofs)
|
|
443
|
+
fixed_slave_error = ""
|
|
444
|
+
try:
|
|
445
|
+
build_constraint_transformation(sparse.eye(fixed_slave.mesh.dof_manager.total_dofs, format="csr"), load, fixed_slave)
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
fixed_slave_error = str(exc)
|
|
448
|
+
|
|
449
|
+
_assert("MPC001" in {issue.code for issue in duplicate_report.issues}, "duplicate MPC slave was not rejected")
|
|
450
|
+
_assert("both fixed and used as an MPC slave" in fixed_slave_error, "fixed MPC slave was not rejected")
|
|
451
|
+
return _pass(
|
|
452
|
+
case,
|
|
453
|
+
measured={"duplicate_status": duplicate_report.status, "fixed_slave_error": fixed_slave_error},
|
|
454
|
+
diagnostics={"duplicate_issue_codes": sorted(issue.code for issue in duplicate_report.issues)},
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _beam_bar_model(fixed_left: bool) -> FEModel:
|
|
459
|
+
model = FEModel("free_free_bar" if not fixed_left else "fixed_bar")
|
|
460
|
+
model.add_material("steel", 100.0, 0.3)
|
|
461
|
+
model.add_node(1, 0.0, 0.0, 0.0)
|
|
462
|
+
model.add_node(2, 1.0, 0.0, 0.0)
|
|
463
|
+
model.add_element(1, BeamElement(1, [1, 2], "steel", {"area": 1.0, "Iy": 1.0e-6, "Iz": 1.0e-6, "J": 1.0e-6}))
|
|
464
|
+
if fixed_left:
|
|
465
|
+
model.add_boundary_condition(BoundaryCondition("axial_only", [1, 2], {"uy": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0, "rz": 0.0}))
|
|
466
|
+
model.add_boundary_condition(BoundaryCondition("fix_left", [1], {"ux": 0.0}))
|
|
467
|
+
return model
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _run_mlbc_009(case: MeshLoadBCCase) -> MeshLoadBCCaseResult:
|
|
471
|
+
free = _beam_bar_model(fixed_left=False)
|
|
472
|
+
free_load = LoadCase("self_equilibrated")
|
|
473
|
+
free_load.add_nodal_load(1, forces=np.array([-10.0, 0.0, 0.0]))
|
|
474
|
+
free_load.add_nodal_load(2, forces=np.array([10.0, 0.0, 0.0]))
|
|
475
|
+
free_u, free_info = solve_linear(free, free_load, constraint_mode="auto")
|
|
476
|
+
free_extension = float(free_u[free.mesh.get_node(2).dofs[0]] - free_u[free.mesh.get_node(1).dofs[0]])
|
|
477
|
+
|
|
478
|
+
fixed = _beam_bar_model(fixed_left=True)
|
|
479
|
+
fixed_load = LoadCase("tip_load")
|
|
480
|
+
fixed_load.add_nodal_load(2, forces=np.array([10.0, 0.0, 0.0]))
|
|
481
|
+
fixed_u, fixed_info = solve_linear(fixed, fixed_load, constraint_mode="auto")
|
|
482
|
+
fixed_extension = float(fixed_u[fixed.mesh.get_node(2).dofs[0]] - fixed_u[fixed.mesh.get_node(1).dofs[0]])
|
|
483
|
+
|
|
484
|
+
_assert(free_info["convergence_info"]["status"] == "converged", "free-free self-equilibrated solve did not converge")
|
|
485
|
+
_assert(free_info["constraint_method"].endswith("_nullspace"), "free-free solve did not use nullspace handling")
|
|
486
|
+
_assert(abs(free_extension - fixed_extension) < 1.0e-10, "free-free nullspace extension does not match constrained reference")
|
|
487
|
+
_assert(abs(free_extension - 0.1) < 1.0e-10, "bar extension does not match FL/EA")
|
|
488
|
+
return _pass(
|
|
489
|
+
case,
|
|
490
|
+
measured={
|
|
491
|
+
"free_extension": free_extension,
|
|
492
|
+
"fixed_extension": fixed_extension,
|
|
493
|
+
"expected_extension": 0.1,
|
|
494
|
+
"nullspace_rank": free_info["nullspace_info"]["rank"],
|
|
495
|
+
},
|
|
496
|
+
diagnostics={
|
|
497
|
+
"free_constraint_method": free_info["constraint_method"],
|
|
498
|
+
"fixed_constraint_method": fixed_info["constraint_method"],
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
RUNNERS: Mapping[str, Callable[[MeshLoadBCCase], MeshLoadBCCaseResult]] = {
|
|
504
|
+
"MLBC-001": _run_mlbc_001,
|
|
505
|
+
"MLBC-002": _run_mlbc_002,
|
|
506
|
+
"MLBC-003": _run_mlbc_003,
|
|
507
|
+
"MLBC-004": _run_mlbc_004,
|
|
508
|
+
"MLBC-005": _run_mlbc_005,
|
|
509
|
+
"MLBC-006": _run_mlbc_006,
|
|
510
|
+
"MLBC-007": _run_mlbc_007,
|
|
511
|
+
"MLBC-008": _run_mlbc_008,
|
|
512
|
+
"MLBC-009": _run_mlbc_009,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def run_mesh_load_bc_verification(selected_ids: Optional[Iterable[str]] = None) -> Dict[str, Any]:
|
|
517
|
+
selected = None if selected_ids is None else {str(case_id) for case_id in selected_ids}
|
|
518
|
+
results: List[MeshLoadBCCaseResult] = []
|
|
519
|
+
for case in mesh_load_bc_manifest_cases():
|
|
520
|
+
if selected is not None and case.case_id not in selected:
|
|
521
|
+
continue
|
|
522
|
+
runner = RUNNERS.get(case.case_id)
|
|
523
|
+
if runner is None:
|
|
524
|
+
results.append(_fail(case, "case is registered but not implemented"))
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
results.append(runner(case))
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
results.append(_fail(case, str(exc)))
|
|
530
|
+
counts: Dict[str, int] = {}
|
|
531
|
+
for result in results:
|
|
532
|
+
counts[result.status] = counts.get(result.status, 0) + 1
|
|
533
|
+
required_failures = [result.case_id for result in results if result.status != "PASS"]
|
|
534
|
+
status = "passed" if not required_failures else "failed"
|
|
535
|
+
return {
|
|
536
|
+
"schema_version": 1,
|
|
537
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
538
|
+
"commit": _git_sha(),
|
|
539
|
+
"environment": {"platform": platform.platform(), "python": sys.version.split()[0], "numpy": np.__version__},
|
|
540
|
+
"status": status,
|
|
541
|
+
"counts": counts,
|
|
542
|
+
"required_failures": required_failures,
|
|
543
|
+
"manifest_cases": [case.to_dict() for case in mesh_load_bc_manifest_cases()],
|
|
544
|
+
"results": [result.to_dict() for result in results],
|
|
545
|
+
"known_limitations": [
|
|
546
|
+
"This focused gate verifies supported ANYsolver mesh/load/boundary behavior only.",
|
|
547
|
+
"Follower pressure, contact and arbitrary CAD topology remain outside this gate.",
|
|
548
|
+
"External pressure is negative element-normal direction; internal/outward pressure is positive normal.",
|
|
549
|
+
],
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _markdown(report: Mapping[str, Any]) -> str:
|
|
554
|
+
lines = [
|
|
555
|
+
"# Mesh, Load and Boundary Verification Report",
|
|
556
|
+
"",
|
|
557
|
+
f"- Status: {report.get('status')}",
|
|
558
|
+
f"- Commit: {report.get('commit') or 'unknown'}",
|
|
559
|
+
"",
|
|
560
|
+
"## Counts",
|
|
561
|
+
"",
|
|
562
|
+
]
|
|
563
|
+
for key, value in sorted((report.get("counts") or {}).items()):
|
|
564
|
+
lines.append(f"- {key}: {value}")
|
|
565
|
+
lines.extend(["", "## Results", ""])
|
|
566
|
+
for result in report.get("results", []):
|
|
567
|
+
suffix = f" - {result.get('reason')}" if result.get("reason") and result.get("status") != "PASS" else ""
|
|
568
|
+
lines.append(f"- {result.get('case_id')} {result.get('status')}: {result.get('title')}{suffix}")
|
|
569
|
+
lines.extend(["", "## Known Limitations", ""])
|
|
570
|
+
for item in report.get("known_limitations", []):
|
|
571
|
+
lines.append(f"- {item}")
|
|
572
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def write_mesh_load_bc_verification_report(
|
|
576
|
+
path: Path | str = DEFAULT_MESH_LOAD_BC_VERIFICATION_PATH,
|
|
577
|
+
*,
|
|
578
|
+
markdown: Path | str | None = None,
|
|
579
|
+
selected_ids: Optional[Iterable[str]] = None,
|
|
580
|
+
) -> Dict[str, Any]:
|
|
581
|
+
report = run_mesh_load_bc_verification(selected_ids=selected_ids)
|
|
582
|
+
output = Path(path)
|
|
583
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
584
|
+
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
585
|
+
if markdown is not None:
|
|
586
|
+
md = Path(markdown)
|
|
587
|
+
md.parent.mkdir(parents=True, exist_ok=True)
|
|
588
|
+
md.write_text(_markdown(report), encoding="utf-8")
|
|
589
|
+
return report
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def mesh_load_bc_result_by_case(selected_ids: Optional[Iterable[str]] = None) -> Dict[str, MeshLoadBCCaseResult]:
|
|
593
|
+
"""Return case results keyed by id for reuse in other verification ledgers."""
|
|
594
|
+
|
|
595
|
+
report = run_mesh_load_bc_verification(selected_ids=selected_ids)
|
|
596
|
+
results: Dict[str, MeshLoadBCCaseResult] = {}
|
|
597
|
+
for item in report["results"]:
|
|
598
|
+
results[str(item["case_id"])] = MeshLoadBCCaseResult(
|
|
599
|
+
case_id=str(item["case_id"]),
|
|
600
|
+
status=str(item["status"]),
|
|
601
|
+
category=str(item["category"]),
|
|
602
|
+
title=str(item["title"]),
|
|
603
|
+
checks=item.get("checks") or {},
|
|
604
|
+
measured=item.get("measured") or {},
|
|
605
|
+
tolerance=item.get("tolerance") or {},
|
|
606
|
+
diagnostics=item.get("diagnostics") or {},
|
|
607
|
+
reason=item.get("reason"),
|
|
608
|
+
)
|
|
609
|
+
return results
|