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