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