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/validation.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Validation helpers for FE solver verification tests and benchmarks.
|
|
2
|
+
|
|
3
|
+
The functions in this module are intentionally lightweight. They do not define
|
|
4
|
+
new solver behaviour; they inspect assembled models, load vectors, solver
|
|
5
|
+
metadata and reconstructed displacement fields so tests and production
|
|
6
|
+
preflight checks can lock the supported architecture.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .boundary import LoadCase
|
|
18
|
+
from .fe_core import FEModel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class LoadResultant:
|
|
23
|
+
"""Global resultant force and moment of a load vector."""
|
|
24
|
+
|
|
25
|
+
force: np.ndarray
|
|
26
|
+
moment: np.ndarray
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def force_norm(self) -> float:
|
|
30
|
+
return float(np.linalg.norm(self.force))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def moment_norm(self) -> float:
|
|
34
|
+
return float(np.linalg.norm(self.moment))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ShellPatchSummary:
|
|
39
|
+
"""Compact shell verification summary."""
|
|
40
|
+
|
|
41
|
+
element_id: int
|
|
42
|
+
strain_energy: float
|
|
43
|
+
stiffness_symmetry_error: float
|
|
44
|
+
max_membrane_spread: float
|
|
45
|
+
max_bending_spread: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ProductionValidationIssue:
|
|
50
|
+
"""Structured production model validation diagnostic."""
|
|
51
|
+
|
|
52
|
+
code: str
|
|
53
|
+
severity: str
|
|
54
|
+
entity_type: str
|
|
55
|
+
entity_id: Optional[int]
|
|
56
|
+
message: str
|
|
57
|
+
measured: Optional[float] = None
|
|
58
|
+
limit: Optional[float] = None
|
|
59
|
+
suggestion: str = ""
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"code": self.code,
|
|
64
|
+
"severity": self.severity,
|
|
65
|
+
"entity_type": self.entity_type,
|
|
66
|
+
"entity_id": self.entity_id,
|
|
67
|
+
"message": self.message,
|
|
68
|
+
"measured": self.measured,
|
|
69
|
+
"limit": self.limit,
|
|
70
|
+
"suggestion": self.suggestion,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ProductionValidationReport:
|
|
76
|
+
"""Production guardrail report for a model before analysis."""
|
|
77
|
+
|
|
78
|
+
status: str
|
|
79
|
+
issues: Tuple[ProductionValidationIssue, ...]
|
|
80
|
+
mesh_quality: Dict[str, Any]
|
|
81
|
+
revision_signature: Dict[str, int]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def errors(self) -> Tuple[ProductionValidationIssue, ...]:
|
|
85
|
+
return tuple(issue for issue in self.issues if issue.severity == "error")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def warnings(self) -> Tuple[ProductionValidationIssue, ...]:
|
|
89
|
+
return tuple(issue for issue in self.issues if issue.severity == "warning")
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"status": self.status,
|
|
94
|
+
"issues": [issue.to_dict() for issue in self.issues],
|
|
95
|
+
"mesh_quality": self.mesh_quality,
|
|
96
|
+
"revision_signature": self.revision_signature,
|
|
97
|
+
"error_count": len(self.errors),
|
|
98
|
+
"warning_count": len(self.warnings),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dof_order_signature(model: "FEModel") -> Dict[int, List[Tuple[int, str]]]:
|
|
103
|
+
"""Return node DOF indices with local DOF names.
|
|
104
|
+
|
|
105
|
+
This is used by tests to protect the required ordering:
|
|
106
|
+
ux, uy, uz, rx, ry, rz.
|
|
107
|
+
"""
|
|
108
|
+
signature: Dict[int, List[Tuple[int, str]]] = {}
|
|
109
|
+
dof_manager = model.mesh.dof_manager
|
|
110
|
+
for node_id, node in model.mesh.nodes.items():
|
|
111
|
+
entries: List[Tuple[int, str]] = []
|
|
112
|
+
for dof in node.dofs:
|
|
113
|
+
_node_id, _local_index, name = dof_manager.get_dof_info(dof)
|
|
114
|
+
entries.append((int(dof), name))
|
|
115
|
+
signature[int(node_id)] = entries
|
|
116
|
+
return signature
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_vector_resultant(model: "FEModel", load_vector: np.ndarray) -> LoadResultant:
|
|
120
|
+
"""Compute global force and moment resultants from a full load vector.
|
|
121
|
+
|
|
122
|
+
The moment is taken about the model coordinate origin.
|
|
123
|
+
"""
|
|
124
|
+
load_vector = np.asarray(load_vector, dtype=float).reshape(-1)
|
|
125
|
+
force = np.zeros(3, dtype=float)
|
|
126
|
+
moment = np.zeros(3, dtype=float)
|
|
127
|
+
for node in model.mesh.nodes.values():
|
|
128
|
+
node_force = load_vector[node.dofs[:3]]
|
|
129
|
+
node_moment = load_vector[node.dofs[3:6]]
|
|
130
|
+
r = node.coords()
|
|
131
|
+
force += node_force
|
|
132
|
+
moment += np.cross(r, node_force) + node_moment
|
|
133
|
+
return LoadResultant(force=force, moment=moment)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_case_resultant(model: "FEModel", load_case: "LoadCase") -> LoadResultant:
|
|
137
|
+
"""Assemble a load case and return global force/moment resultants."""
|
|
138
|
+
load_vector = load_case.get_load_vector(model.mesh, model.mesh.dof_manager, model.get_material)
|
|
139
|
+
return load_vector_resultant(model, load_vector)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def mpc_constraint_residuals(model: "FEModel", displacements: np.ndarray) -> Dict[str, float]:
|
|
143
|
+
"""Return residuals for all element-provided linear MPC constraints.
|
|
144
|
+
|
|
145
|
+
A constraint is interpreted as:
|
|
146
|
+
|
|
147
|
+
u_slave = sum(coeff_i * u_master_i) + value
|
|
148
|
+
|
|
149
|
+
The residual returned is lhs - rhs. A correct reconstructed displacement
|
|
150
|
+
field should give residuals close to zero for every MPC.
|
|
151
|
+
"""
|
|
152
|
+
u = np.asarray(displacements, dtype=float).reshape(-1)
|
|
153
|
+
residuals: Dict[str, float] = {}
|
|
154
|
+
counter = 0
|
|
155
|
+
for element in model.mesh.elements.values():
|
|
156
|
+
getter = getattr(element, "get_mpc_constraints", None)
|
|
157
|
+
if getter is None:
|
|
158
|
+
continue
|
|
159
|
+
for constraint in getter(model.mesh) or []:
|
|
160
|
+
slave = int(constraint["slave"])
|
|
161
|
+
masters = constraint.get("masters", {})
|
|
162
|
+
value = float(constraint.get("value", 0.0))
|
|
163
|
+
rhs = value
|
|
164
|
+
for master, coefficient in masters.items():
|
|
165
|
+
rhs += float(coefficient) * float(u[int(master)])
|
|
166
|
+
label = str(constraint.get("label", f"mpc_{counter}"))
|
|
167
|
+
residuals[label] = float(u[slave] - rhs)
|
|
168
|
+
counter += 1
|
|
169
|
+
return residuals
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def shell_element_patch_summary(model: "FEModel", element_id: int, element_displacements: np.ndarray) -> ShellPatchSummary:
|
|
173
|
+
"""Return compact shell element diagnostics for a supplied element displacement field."""
|
|
174
|
+
element = model.mesh.get_element(element_id)
|
|
175
|
+
if element is None:
|
|
176
|
+
raise ValueError(f"Element {element_id} not found")
|
|
177
|
+
material = model.get_material(element.material_name)
|
|
178
|
+
u = np.asarray(element_displacements, dtype=float).reshape(-1)
|
|
179
|
+
if u.shape != (element.total_dofs,):
|
|
180
|
+
raise ValueError(f"Element displacement shape {u.shape} does not match {(element.total_dofs,)}")
|
|
181
|
+
|
|
182
|
+
K = element.compute_stiffness_matrix(model.mesh, material)
|
|
183
|
+
stresses = element.compute_stresses(model.mesh, u, material)
|
|
184
|
+
membrane_arrays = [
|
|
185
|
+
np.asarray(stresses[key], dtype=float)
|
|
186
|
+
for key in ("membrane_xx", "membrane_yy", "membrane_xy")
|
|
187
|
+
if key in stresses
|
|
188
|
+
]
|
|
189
|
+
bending_arrays = [
|
|
190
|
+
np.asarray(stresses[key], dtype=float)
|
|
191
|
+
for key in ("bending_xx", "bending_yy", "bending_xy")
|
|
192
|
+
if key in stresses
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
max_membrane_spread = max((float(np.max(values) - np.min(values)) for values in membrane_arrays), default=0.0)
|
|
196
|
+
max_bending_spread = max((float(np.max(values) - np.min(values)) for values in bending_arrays), default=0.0)
|
|
197
|
+
return ShellPatchSummary(
|
|
198
|
+
element_id=int(element_id),
|
|
199
|
+
strain_energy=float(u @ K @ u),
|
|
200
|
+
stiffness_symmetry_error=float(np.linalg.norm(K - K.T)),
|
|
201
|
+
max_membrane_spread=max_membrane_spread,
|
|
202
|
+
max_bending_spread=max_bending_spread,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def max_abs(values: Iterable[float]) -> float:
|
|
207
|
+
"""Return max absolute value, or 0.0 for an empty iterable."""
|
|
208
|
+
data = [abs(float(value)) for value in values]
|
|
209
|
+
return max(data) if data else 0.0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def nullspace_diagnostics(solver_info: Dict[str, Any]) -> Dict[str, Any]:
|
|
213
|
+
"""Extract nullspace-related diagnostics in a stable shape for tests."""
|
|
214
|
+
convergence = solver_info.get("convergence_info", {}) or {}
|
|
215
|
+
nullspace = solver_info.get("nullspace_info", {}) or {}
|
|
216
|
+
return {
|
|
217
|
+
"constraint_method": solver_info.get("constraint_method", ""),
|
|
218
|
+
"rank": int(nullspace.get("rank", convergence.get("nullspace_rank", 0)) or 0),
|
|
219
|
+
"relative_rigid_body_load_imbalance": float(convergence.get("relative_rigid_body_load_imbalance", 0.0) or 0.0),
|
|
220
|
+
"augmented_residual_norm": float(convergence.get("augmented_residual_norm", 0.0) or 0.0),
|
|
221
|
+
"gauge_residual_norm": float(convergence.get("gauge_residual_norm", 0.0) or 0.0),
|
|
222
|
+
"status": convergence.get("status", "unknown"),
|
|
223
|
+
"warnings": list(convergence.get("warnings", []) or []),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _issue(
|
|
228
|
+
code: str,
|
|
229
|
+
severity: str,
|
|
230
|
+
entity_type: str,
|
|
231
|
+
entity_id: Optional[int],
|
|
232
|
+
message: str,
|
|
233
|
+
*,
|
|
234
|
+
measured: Optional[float] = None,
|
|
235
|
+
limit: Optional[float] = None,
|
|
236
|
+
suggestion: str = "",
|
|
237
|
+
) -> ProductionValidationIssue:
|
|
238
|
+
return ProductionValidationIssue(
|
|
239
|
+
code=code,
|
|
240
|
+
severity=severity,
|
|
241
|
+
entity_type=entity_type,
|
|
242
|
+
entity_id=None if entity_id is None else int(entity_id),
|
|
243
|
+
message=message,
|
|
244
|
+
measured=None if measured is None else float(measured),
|
|
245
|
+
limit=None if limit is None else float(limit),
|
|
246
|
+
suggestion=suggestion,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _shell_corner_metrics(coords: np.ndarray) -> Dict[str, float]:
|
|
251
|
+
corner = np.asarray(coords[:4], dtype=float)
|
|
252
|
+
edges = [
|
|
253
|
+
corner[1] - corner[0],
|
|
254
|
+
corner[2] - corner[1],
|
|
255
|
+
corner[3] - corner[2],
|
|
256
|
+
corner[0] - corner[3],
|
|
257
|
+
]
|
|
258
|
+
lengths = [float(np.linalg.norm(edge)) for edge in edges]
|
|
259
|
+
min_edge = min(lengths) if lengths else 0.0
|
|
260
|
+
max_edge = max(lengths) if lengths else 0.0
|
|
261
|
+
aspect_ratio = max_edge / max(min_edge, 1.0e-15)
|
|
262
|
+
normal_raw = np.cross(corner[1] - corner[0], corner[2] - corner[0])
|
|
263
|
+
normal_norm = float(np.linalg.norm(normal_raw))
|
|
264
|
+
signed_area = 0.5 * normal_norm + 0.5 * float(np.linalg.norm(np.cross(corner[2] - corner[0], corner[3] - corner[0])))
|
|
265
|
+
warp = 0.0
|
|
266
|
+
if normal_norm > 1.0e-15:
|
|
267
|
+
normal = normal_raw / normal_norm
|
|
268
|
+
warp = abs(float(np.dot(corner[3] - corner[0], normal))) / max(sum(lengths) / 4.0, 1.0e-15)
|
|
269
|
+
return {
|
|
270
|
+
"min_edge": min_edge,
|
|
271
|
+
"max_edge": max_edge,
|
|
272
|
+
"aspect_ratio": aspect_ratio,
|
|
273
|
+
"warp": warp,
|
|
274
|
+
"area": signed_area,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _q8_midside_deviation(coords: np.ndarray) -> float:
|
|
279
|
+
if np.asarray(coords).shape[0] != 8:
|
|
280
|
+
return 0.0
|
|
281
|
+
pairs = ((0, 1, 4), (1, 2, 5), (2, 3, 6), (3, 0, 7))
|
|
282
|
+
deviations = []
|
|
283
|
+
for i, j, midside in pairs:
|
|
284
|
+
midpoint = 0.5 * (coords[i] + coords[j])
|
|
285
|
+
edge_length = max(float(np.linalg.norm(coords[j] - coords[i])), 1.0e-15)
|
|
286
|
+
deviations.append(float(np.linalg.norm(coords[midside] - midpoint)) / edge_length)
|
|
287
|
+
return max(deviations) if deviations else 0.0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def validate_production_model(
|
|
291
|
+
model: "FEModel",
|
|
292
|
+
load_cases: Optional[Sequence["LoadCase"]] = None,
|
|
293
|
+
*,
|
|
294
|
+
allow_free_mechanisms: bool = False,
|
|
295
|
+
aspect_ratio_limit: float = 8.0,
|
|
296
|
+
warp_limit: float = 0.08,
|
|
297
|
+
midside_deviation_limit: float = 0.20,
|
|
298
|
+
) -> ProductionValidationReport:
|
|
299
|
+
"""Validate model inputs and mesh quality before production analysis.
|
|
300
|
+
|
|
301
|
+
The function is deliberately conservative: it catches invalid or
|
|
302
|
+
unsupported configurations early and returns structured diagnostics with
|
|
303
|
+
entity IDs and corrective hints. It does not run a full analysis.
|
|
304
|
+
"""
|
|
305
|
+
from .assembly import build_constraint_transformation, build_reduced_rigid_body_modes
|
|
306
|
+
from .elements import CoupledBeamShellElement, ShellElement
|
|
307
|
+
from .matrix_assembly import assemble_stiffness_matrix
|
|
308
|
+
|
|
309
|
+
issues: List[ProductionValidationIssue] = []
|
|
310
|
+
mesh_quality: Dict[str, Any] = {
|
|
311
|
+
"shell_count": 0,
|
|
312
|
+
"beam_count": 0,
|
|
313
|
+
"max_aspect_ratio": 1.0,
|
|
314
|
+
"max_warp": 0.0,
|
|
315
|
+
"max_q8_midside_deviation": 0.0,
|
|
316
|
+
"min_edge_length": None,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for name, material in model.materials.items():
|
|
320
|
+
entity_id = None
|
|
321
|
+
if not np.isfinite(material.elastic_modulus) or material.elastic_modulus <= 0.0:
|
|
322
|
+
issues.append(_issue("MAT001", "error", "material", entity_id, f"Material {name!r} has invalid elastic modulus.", measured=material.elastic_modulus, limit=0.0, suggestion="Use a positive SI elastic modulus."))
|
|
323
|
+
if not np.isfinite(material.poisson_ratio) or not (-0.99 < material.poisson_ratio < 0.5):
|
|
324
|
+
issues.append(_issue("MAT002", "error", "material", entity_id, f"Material {name!r} has unsupported Poisson ratio.", measured=material.poisson_ratio, limit=0.5, suggestion="Use -0.99 < nu < 0.5 for isotropic elastic material."))
|
|
325
|
+
if not np.isfinite(material.density) or material.density < 0.0:
|
|
326
|
+
issues.append(_issue("MAT003", "error", "material", entity_id, f"Material {name!r} has invalid density.", measured=material.density, limit=0.0, suggestion="Use zero or positive SI density."))
|
|
327
|
+
|
|
328
|
+
slave_dof_owner: Dict[int, int] = {}
|
|
329
|
+
min_edge_values: List[float] = []
|
|
330
|
+
q8r_element_ids: List[int] = []
|
|
331
|
+
for element_id, element in model.mesh.elements.items():
|
|
332
|
+
material_name = getattr(element, "material_name", None)
|
|
333
|
+
if material_name not in model.materials:
|
|
334
|
+
issues.append(_issue("ELM001", "error", "element", int(element_id), f"Element references missing material {material_name!r}.", suggestion="Add the material before analysis or assign a valid material name."))
|
|
335
|
+
thickness = getattr(element, "thickness", None)
|
|
336
|
+
if thickness is not None and (not np.isfinite(float(thickness)) or float(thickness) <= 0.0):
|
|
337
|
+
issues.append(_issue("SHELL001", "error", "element", int(element_id), "Shell element has non-positive thickness.", measured=float(thickness), limit=0.0, suggestion="Use a positive shell thickness in metres."))
|
|
338
|
+
|
|
339
|
+
if isinstance(element, ShellElement):
|
|
340
|
+
mesh_quality["shell_count"] += 1
|
|
341
|
+
if getattr(element, "_is_8node", False) and bool(getattr(element, "reduced_integration", False)):
|
|
342
|
+
q8r_element_ids.append(int(element_id))
|
|
343
|
+
try:
|
|
344
|
+
coords = element.get_node_coordinates(model.mesh)
|
|
345
|
+
metrics = _shell_corner_metrics(coords)
|
|
346
|
+
min_edge_values.append(float(metrics["min_edge"]))
|
|
347
|
+
mesh_quality["max_aspect_ratio"] = max(float(mesh_quality["max_aspect_ratio"]), float(metrics["aspect_ratio"]))
|
|
348
|
+
mesh_quality["max_warp"] = max(float(mesh_quality["max_warp"]), float(metrics["warp"]))
|
|
349
|
+
if metrics["area"] <= 1.0e-18 or metrics["min_edge"] <= 1.0e-14:
|
|
350
|
+
issues.append(_issue("MESH001", "error", "element", int(element_id), "Shell element is degenerate or has near-zero area.", measured=metrics["area"], limit=1.0e-18, suggestion="Repair node ordering/coordinates or remesh this region."))
|
|
351
|
+
if metrics["aspect_ratio"] > aspect_ratio_limit:
|
|
352
|
+
issues.append(_issue("MESH002", "warning", "element", int(element_id), "Shell element aspect ratio exceeds production guidance.", measured=metrics["aspect_ratio"], limit=aspect_ratio_limit, suggestion="Refine or rebalance the mesh locally."))
|
|
353
|
+
if metrics["warp"] > warp_limit:
|
|
354
|
+
issues.append(_issue("MESH003", "warning", "element", int(element_id), "Shell element warpage exceeds production guidance.", measured=metrics["warp"], limit=warp_limit, suggestion="Use smaller elements or flatten the panel representation."))
|
|
355
|
+
midside = _q8_midside_deviation(coords)
|
|
356
|
+
mesh_quality["max_q8_midside_deviation"] = max(float(mesh_quality["max_q8_midside_deviation"]), midside)
|
|
357
|
+
if midside > midside_deviation_limit:
|
|
358
|
+
issues.append(_issue("MESH004", "warning", "element", int(element_id), "Q8/S8 midside node deviates strongly from edge midpoint.", measured=midside, limit=midside_deviation_limit, suggestion="Place midside nodes near geometric edge midpoints or regenerate the mesh."))
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
issues.append(_issue("MESH005", "error", "element", int(element_id), f"Shell mesh-quality evaluation failed: {exc}", suggestion="Check element connectivity and node coordinates."))
|
|
361
|
+
elif isinstance(element, CoupledBeamShellElement):
|
|
362
|
+
pass
|
|
363
|
+
else:
|
|
364
|
+
mesh_quality["beam_count"] += 1
|
|
365
|
+
|
|
366
|
+
getter = getattr(element, "get_mpc_constraints", None)
|
|
367
|
+
if getter is not None:
|
|
368
|
+
for constraint in getter(model.mesh) or []:
|
|
369
|
+
slave = int(constraint.get("slave"))
|
|
370
|
+
previous = slave_dof_owner.get(slave)
|
|
371
|
+
if previous is not None and previous != int(element_id):
|
|
372
|
+
issues.append(_issue("MPC001", "error", "mpc", int(element_id), f"MPC slave DOF {slave} is constrained by multiple MPC elements.", measured=float(slave), suggestion="Ensure each dependent DOF has only one owner."))
|
|
373
|
+
slave_dof_owner[slave] = int(element_id)
|
|
374
|
+
|
|
375
|
+
if min_edge_values:
|
|
376
|
+
mesh_quality["min_edge_length"] = float(min(min_edge_values))
|
|
377
|
+
if q8r_element_ids:
|
|
378
|
+
issues.append(
|
|
379
|
+
_issue(
|
|
380
|
+
"SHELL002",
|
|
381
|
+
"warning",
|
|
382
|
+
"element",
|
|
383
|
+
q8r_element_ids[0],
|
|
384
|
+
f"{len(q8r_element_ids)} Q8R element(s) use an experimental reduced-integration "
|
|
385
|
+
"stabilization and lumped mass formulation.",
|
|
386
|
+
suggestion="Use full-integration Q8 for qualified thin-shell bending or independently "
|
|
387
|
+
"verify hourglass energy and inertia sensitivity.",
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
for load_case in load_cases or ():
|
|
392
|
+
if bool(getattr(load_case, "follower_pressure", False)):
|
|
393
|
+
issues.append(_issue("LOAD001", "error", "load_case", None, f"Load case {load_case.name!r} requests follower pressure, which is outside the current verified production scope.", suggestion="Use prescribed fixed-direction pressure or explicitly implement/verify follower pressure first."))
|
|
394
|
+
for element_id in getattr(load_case, "pressure_loads", {}) or {}:
|
|
395
|
+
element = model.mesh.get_element(int(element_id))
|
|
396
|
+
if element is None:
|
|
397
|
+
issues.append(_issue("LOAD002", "error", "load_case", None, f"Load case {load_case.name!r} references missing pressure element {element_id}.", measured=float(element_id), suggestion="Remove stale pressure load entries or regenerate the load case."))
|
|
398
|
+
|
|
399
|
+
if not allow_free_mechanisms and not any(issue.severity == "error" for issue in issues):
|
|
400
|
+
try:
|
|
401
|
+
model.apply_boundary_conditions()
|
|
402
|
+
K, _info = assemble_stiffness_matrix(model)
|
|
403
|
+
zero = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
|
|
404
|
+
_K_red, _F_red, _T, _u0, independent, _constraint_info = build_constraint_transformation(K, zero, model)
|
|
405
|
+
Q, nullspace = build_reduced_rigid_body_modes(model, independent, int(K.shape[0]))
|
|
406
|
+
rank = int(Q.shape[1])
|
|
407
|
+
if rank > 0:
|
|
408
|
+
issues.append(_issue("MECH001", "error", "model", None, "Model has unsupported free rigid-body mechanisms for ordinary production static analysis.", measured=float(rank), limit=0.0, suggestion="Add supports, use a self-equilibrated free-free workflow, or explicitly allow free mechanisms."))
|
|
409
|
+
mesh_quality["rigid_body_nullspace_rank"] = rank
|
|
410
|
+
mesh_quality["connected_components"] = int(nullspace.get("component_count", 0) or 0)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
issues.append(_issue("MECH002", "error", "model", None, f"Mechanism check failed: {exc}", suggestion="Inspect constraints, MPC topology and element connectivity."))
|
|
413
|
+
|
|
414
|
+
status = "invalid" if any(issue.severity == "error" for issue in issues) else ("warning" if issues else "ok")
|
|
415
|
+
revision_signature = getattr(model, "revision_signature", lambda: {})()
|
|
416
|
+
return ProductionValidationReport(status, tuple(issues), mesh_quality, revision_signature)
|