dsh-engineering-ui 0.0.0-stage → 1.5.1

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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8257 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5634 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +66 -6
@@ -0,0 +1,1163 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ fea_solver.py — 有限元求解器适配层
4
+ ====================================
5
+ 支持多种求解后端,按可用性自动降级:
6
+
7
+ Level 0: 解析解(analytical beam theory)
8
+ Level 1: 纯 Python FEA(numpy CST 三角形单元,无需外部软件)
9
+ Level 2: Gmsh + CalculiX(外部 CLI)
10
+ Level 3: Gmsh + Code_Aster(外部 CLI)
11
+ Level 4: Gmsh + Elmer(外部 CLI)
12
+ Level 5: SolidWorks Simulation API(需 Premium 授权)
13
+
14
+ 本模块封装所有后端的统一调用接口。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import json
20
+ import subprocess
21
+ import tempfile
22
+ from typing import Any, Optional
23
+
24
+
25
+ # ══ 【BUG-FEA-STRENGTH-1 修复】强度判据基准:脆性材料缺 σy 时改用 σb ═══════
26
+ # 实测崩溃:physics-optimize 处理 DSH_加工头本体(HT200)时
27
+ # fea_solver.py:735 → TypeError: unsupported operand type(s) for /:
28
+ # 'NoneType' and 'float' → safety_factor 无法计算,防线②整体 UNKNOWN。
29
+ # 缺陷一(直接原因):兜底写成 `_strength_ref = sigma_y`,而 σy 本就是 None,
30
+ # 是一句【空操作】,随后照常参与除法 → 必崩。
31
+ # 缺陷二(根因):灰铸铁/球墨铸铁按 GB 无屈服平台(材料特性,非数据缺失),
32
+ # 正确判据是 σb + 最大主应力(莫尔)。load_case.py 已能识别脆性并写入
33
+ # brittle 标记,但 FEA 实际用的是【从零件凭据重新解析】的材料字典
34
+ # (material_resolution.source = attested_credential),该副本不携带
35
+ # brittle → 既有分支永远进不去,铸铁件 100% 触发崩溃。
36
+ # 修复口径:在【使用点】按与 load_case.py 完全相同的判据重新识别脆性
37
+ # (牌号关键词 + 密度带 6900~7700 兜底)。
38
+ # 明确未做的事:min_safety_factor 门槛【没有任何放宽】;强度基准不可得时
39
+ # 返回 None 并由闸口判 N/A(聚合为 REVIEW),绝不编造数值、绝不虚报合格。
40
+ _BRITTLE_KW = (
41
+ "灰铸铁", "球墨铸铁", "铸铁", "cast iron", "gray iron",
42
+ "ductile iron", "铸造铝", "铸铝", "陶瓷", "ceramic",
43
+ "concrete", "混凝土",
44
+ )
45
+
46
+
47
+ def _is_brittle_material(mat, no_yield):
48
+ """脆性材料判定(与 load_case.py 的 _BRITTLE_KW / 密度带口径完全一致)。
49
+
50
+ no_yield=True 表示当前材料缺 σy —— 此时才启用"密度落在铸铁带"的兜底,
51
+ 避免把正常钢材(ρ≈7850,不在带内)误判成铸铁。
52
+ """
53
+ if bool(mat.get("brittle")) or mat.get("strength_basis") == "uts_brittle":
54
+ return True
55
+ try:
56
+ _name = str(mat.get("name") or mat.get("id") or "").lower()
57
+ except Exception:
58
+ _name = ""
59
+ if any(k in _name for k in _BRITTLE_KW):
60
+ return True
61
+ if no_yield:
62
+ try:
63
+ _rho = float(mat.get("density_kg_m3") or 0)
64
+ if 6900.0 <= _rho <= 7700.0:
65
+ return True
66
+ except Exception:
67
+ pass
68
+ return False
69
+
70
+
71
+ def brittle_strength_ref(mat, sigma_y):
72
+ """返回 (强度判据基准值 MPa, 判据类型)。
73
+
74
+ 分支顺序与原实现保持一致:
75
+ ① 脆性且有 σb → 用 σb(uts_max_principal(brittle))
76
+ ② 否则有 σy → 用 σy(yield_von_mises)
77
+ ③ 否则 → (None, "undetermined"),交由闸口判 N/A
78
+ """
79
+ _brittle = _is_brittle_material(mat, not sigma_y)
80
+ if _brittle:
81
+ try:
82
+ _uts = float(mat.get("uts_mpa") or 0)
83
+ except (TypeError, ValueError):
84
+ _uts = 0.0
85
+ if _uts > 0:
86
+ return _uts, "uts_max_principal(brittle)"
87
+ try:
88
+ if sigma_y:
89
+ return float(sigma_y), "yield_von_mises"
90
+ except (TypeError, ValueError):
91
+ pass
92
+ return None, "undetermined"
93
+
94
+
95
+ # ══ 【BUG-02 修复】疲劳/设计寿命校核接入 ══════════════════════════════════
96
+ # 原缺陷:整条工具链只有线性静力(2D 平面应力),完全没有疲劳与 30 年寿命校核,
97
+ # 用户的核心需求无法验证。现把 fatigue.py 接入统一求解出口:
98
+ # 每次 solve_fea 在静力结果之后【自动追加】疲劳校核,
99
+ # 并把结论并入 gates / overall,使其成为交付前必须通过的闸口。
100
+ try:
101
+ import fatigue as _fatigue
102
+ except ImportError: # 允许在 sys.path 未注入时降级运行
103
+ try:
104
+ from . import fatigue as _fatigue
105
+ except Exception:
106
+ _fatigue = None
107
+
108
+ # ── 【Bug-03 修复】设计载荷 = 额定载荷 × 动载系数 ──────────────────────────
109
+ # 门禁把 magnitude_n 写为额定值,另在 acceptance 记录 impact_factor,
110
+ # 并注明"设计校核另乘 impact_factor"。physics 原先从不读它 →
111
+ # 冲击工况校核载荷少算一半、SF 虚高。这里统一取用该系数。
112
+ try:
113
+ from load_case import design_load_factor as _design_load_factor
114
+ from load_case import governing_design_force as _governing_design_force
115
+ except ImportError:
116
+ try:
117
+ from .load_case import design_load_factor as _design_load_factor
118
+ from .load_case import governing_design_force as _governing_design_force
119
+ except Exception:
120
+ def _design_load_factor(case):
121
+ """兜底实现(load_case 不可用时)。"""
122
+ acc = (case or {}).get("acceptance") or {}
123
+ try:
124
+ f = float(acc.get("impact_factor") or 1.0)
125
+ except Exception:
126
+ f = 1.0
127
+ f = max(1.0, f)
128
+ return f, {"impact_factor": f, "applied": f > 1.0}
129
+
130
+ def _governing_design_force(case):
131
+ """兜底实现:各载荷代数求和(旧行为),仅当 load_case 不可用时使用。"""
132
+ _tot = 0.0
133
+ for _l in ((case or {}).get("loads") or []):
134
+ try:
135
+ _tot += max(float(_l.get("magnitude_n") or 0), 0.0)
136
+ except Exception:
137
+ pass
138
+ _f, _m = _design_load_factor(case)
139
+ return _tot * _f, {"rated_force_n": _tot, "design_force_n": _tot * _f,
140
+ "impact_factor": _f, "impact_applied": _f > 1.0}
141
+
142
+
143
+ def aggregate_overall(gates):
144
+ """按统一三档口径聚合 overall(Bug14 修复)。
145
+
146
+ ── 为什么需要统一函数 ──────────────────────────────────────────────
147
+ 测试部实测:1N 超小载荷 → SF=2213 远超 target_max=5,
148
+ SAFETY_FACTOR gate 确实标了 OVER_DESIGN / over_design=true,
149
+ 但最终 overall 却是 PASS,与"过度设计应提示 WARNING"的预期不符;
150
+ 而同样超标的实心块用例又报了 OVER_DESIGN —— 行为不一致。
151
+ 根因:各求解路径各自用 `passed = all(status == "PASS")` 算 overall,
152
+ 把 OVER_DESIGN 当"非 PASS"→ 有的路径算成 FAIL,随后又被
153
+ _attach_fatigue 用另一套口径重算成 PASS,前后矛盾。
154
+
155
+ 统一口径(与 _attach_fatigue 保持一致):
156
+ · 任一 FAIL → FAIL
157
+ · 有 WARNING / N/A → REVIEW
158
+ · 有 OVER_DESIGN → REVIEW(过度设计需提示,但不阻断交付)
159
+ · 否则 → PASS
160
+ 说明:OVER_DESIGN 归 REVIEW 而非 PASS —— 用户要的是"可优化/减重"
161
+ 提示能被看见;它仍不是 FAIL,不影响交付。
162
+ """
163
+ statuses = [g.get("status") for g in (gates or {}).values()
164
+ if isinstance(g, dict)]
165
+ if "FAIL" in statuses:
166
+ return "FAIL"
167
+ if "WARNING" in statuses or "N/A" in statuses or "OVER_DESIGN" in statuses:
168
+ return "REVIEW"
169
+ return "PASS"
170
+
171
+
172
+ def _material_guard(load_case: dict) -> Optional[dict]:
173
+ """【BUG-05/08 修复】材料健全性前置检查。
174
+
175
+ 原缺陷:材料缺失时一路默认(E=200000、ρ=7850),报告 material 显示 '?',
176
+ 甚至可能拿 SW 的 1000 kg/m³(水)密度当真 —— 结论全部不可信。
177
+ 修复:材料关键字段缺失/等于水密度时,返回【显式的阻断信息】,
178
+ 由调用方决定是报错还是标注 NOT_EVALUATED(绝不静默用默认值)。
179
+ """
180
+ mat = load_case.get("material") or {}
181
+ problems = []
182
+ if not mat:
183
+ problems.append("material 字段缺失(完全未指定材料)")
184
+ else:
185
+ if not (mat.get("name") or mat.get("id")):
186
+ problems.append("material 缺 name/id(无法确认材料身份)")
187
+ if not mat.get("youngs_modulus_mpa"):
188
+ problems.append("缺 youngs_modulus_mpa")
189
+ if not mat.get("yield_strength_mpa"):
190
+ # 【BUG-FEA-STRENGTH-1】脆性材料(灰铸铁 HT200 / 球墨铸铁 QT500-7 等)
191
+ # 按 GB 无屈服平台,标准只给抗拉强度 σb —— 这是【材料特性】,
192
+ # 不是数据缺失。已有 σb 即可按 σb(最大主应力/莫尔)判据校核,
193
+ # 故不阻断;真正既无 σy 又无 σb 的才判为材料不可用。
194
+ if not (_is_brittle_material(mat, True) and mat.get("uts_mpa")):
195
+ problems.append("缺 yield_strength_mpa")
196
+ _rho = mat.get("density_kg_m3")
197
+ if _rho is not None and abs(float(_rho) - 1000.0) < 1.0:
198
+ problems.append("密度为 1000 kg/m³(等同水)—— 极可能是 SolidWorks "
199
+ "未赋材质留下的默认值(BUG-05)")
200
+ if not problems:
201
+ return None
202
+ return {
203
+ "ok": False,
204
+ "error": "材料信息不可用于强度/寿命校核",
205
+ "problems": problems,
206
+ "hint": ("请先在载荷工况里补齐 material(id/name/E/σy/ρ),"
207
+ "或在 swapi.new_part(material=...) 给零件赋材质。"
208
+ "材料不确定时任何安全系数与寿命结论都无效。"),
209
+ }
210
+
211
+
212
+ def run_fatigue_check(load_case: dict, fea_result: dict) -> dict:
213
+ """执行疲劳校核(fatigue.py 不可用时返回明确的 NOT_EVALUATED,绝不静默跳过)。"""
214
+ if _fatigue is None:
215
+ return {"ok": False, "verdict": "NOT_EVALUATED",
216
+ "error": "fatigue.py 不可用,未能执行疲劳/寿命校核"}
217
+ try:
218
+ res = _fatigue.analyze_fatigue_from_case(load_case, fea_result)
219
+ except Exception as e:
220
+ import traceback
221
+ return {"ok": False, "verdict": "NOT_EVALUATED",
222
+ "error": "疲劳校核异常: %s" % e,
223
+ "traceback": traceback.format_exc()[-1500:]}
224
+ return res
225
+
226
+
227
+ def _attach_fatigue(load_case: dict, result: dict) -> dict:
228
+ """把疲劳结果并入统一求解结果(gates + overall 判定一并更新)。"""
229
+ if not isinstance(result, dict) or not result.get("ok"):
230
+ return result
231
+ # ── 【BUG-05/08】材料不健全 → 疲劳/寿命结论无意义,显式阻断并标注 ────
232
+ _mg = _material_guard(load_case)
233
+ if _mg:
234
+ result["material_guard"] = _mg
235
+ result.setdefault("gates", {})["MATERIAL_DATA"] = {
236
+ "status": "FAIL",
237
+ "problems": _mg["problems"],
238
+ "hint": _mg["hint"],
239
+ }
240
+ result["fatigue"] = {
241
+ "ok": False, "verdict": "NOT_EVALUATED",
242
+ "error": "材料信息不完整,未执行疲劳/寿命校核:%s"
243
+ % ";".join(_mg["problems"]),
244
+ }
245
+ result["overall"] = "FAIL"
246
+ result.setdefault("limitations", []).append(
247
+ "⚠️ 材料不可用(%s)—— 强度与 30 年寿命结论均无效"
248
+ % ";".join(_mg["problems"][:2]))
249
+ return result
250
+ fat = run_fatigue_check(load_case, result)
251
+ result["fatigue"] = fat
252
+ # ── 【NEW-01 修复】把验收判据【透传到 fea_result 顶层】 ──────────────
253
+ # 原缺陷:fea_result 里没有 acceptance,下游想核对"判据是什么"
254
+ # 必须回头找 load_case;报告顶层 acceptance_criteria 又漏传了
255
+ # 疲劳参数(已一并修复)。此处让判据随结果走,审计更直接。
256
+ try:
257
+ _acc_src = (load_case or {}).get("acceptance") or {}
258
+ result["acceptance"] = dict(_acc_src)
259
+ except Exception:
260
+ pass
261
+ gates = result.setdefault("gates", {})
262
+ if isinstance(gates, dict):
263
+ gates["FATIGUE_STRENGTH"] = {
264
+ "status": (fat.get("gates", {}).get("FATIGUE_STRENGTH", {}).get("status")
265
+ or ("N/A" if fat.get("verdict") == "NOT_EVALUATED" else "FAIL")),
266
+ "actual_sf": fat.get("fatigue_sf"),
267
+ "required_min": fat.get("min_required_fatigue_sf"),
268
+ "endurance_limit_mpa": fat.get("endurance_limit_mpa"),
269
+ }
270
+ gates["FATIGUE_LIFE"] = {
271
+ "status": (fat.get("gates", {}).get("FATIGUE_LIFE", {}).get("status")
272
+ or ("N/A" if fat.get("verdict") == "NOT_EVALUATED" else "FAIL")),
273
+ "required_years": fat.get("design_life_years"),
274
+ "allowable_life_years": fat.get("life_years_allowable"),
275
+ "damage_at_required_life": fat.get("damage_design_life"),
276
+ }
277
+ # overall 需要重新聚合:静力全 PASS 但疲劳 FAIL → 整体 FAIL
278
+ # ── 【BUG-02 附带修正】统一 overall 聚合口径 ────────────────────────
279
+ # 原实现(solve_analytical / solve_feapy)用
280
+ # passed = all(status == "PASS")
281
+ # 于是 SAFETY_FACTOR=WARNING(安全系数过高=过度设计)也被算成 overall=FAIL,
282
+ # 与代码自身 BUG-21 的结论("安全系数过高不是 FAIL,应标 WARNING")矛盾,
283
+ # 导致"结构明明很安全"却被报告成 FAIL。
284
+ #
285
+ # ── 【NEW-02 修复】三档 + OVER_DESIGN 单独处理 ──────────────────────
286
+ # 任一 FAIL → overall = FAIL
287
+ # 有 WARNING / N/A → overall = REVIEW (真有需要复核的项)
288
+ # 仅 OVER_DESIGN → overall = PASS (结构安全,只是材料利用率低)
289
+ # · 过度设计【不再】把结果拖成 REVIEW —— 它不是缺陷,
290
+ # 但 over_design 标记与说明会保留,供轻量化优化参考。
291
+ statuses = [g.get("status") for g in (gates or {}).values()
292
+ if isinstance(g, dict)]
293
+ if "FAIL" in statuses:
294
+ result["overall"] = "FAIL"
295
+ elif ("WARNING" in statuses or "N/A" in statuses
296
+ or "OVER_DESIGN" in statuses):
297
+ result["overall"] = "REVIEW"
298
+ else:
299
+ result["overall"] = "PASS"
300
+ # 把"过度设计"作为独立信号暴露(不改变 overall,但清晰可见)
301
+ if "OVER_DESIGN" in statuses:
302
+ result["over_design"] = True
303
+ result.setdefault("limitations", []).append(
304
+ "结构安全但安全系数高于目标上限(过度设计):材料利用率偏低,"
305
+ "如需轻量化可减小截面尺寸或换薄壁结构。这不影响交付,仅作优化提示。")
306
+ # 疲劳未评估必须显式暴露(用户核心需求是 30 年寿命校核)
307
+ if fat.get("verdict") == "NOT_EVALUATED":
308
+ result.setdefault("limitations", []).append(
309
+ "⚠️ 疲劳/寿命校核未执行:%s" % fat.get("error", "未知原因"))
310
+ return result
311
+
312
+
313
+ # ==================== 后端检测 ====================
314
+
315
+ def _check_cmd(cmd: str) -> bool:
316
+ try:
317
+ subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
318
+ return True
319
+ except (FileNotFoundError, subprocess.TimeoutExpired):
320
+ return False
321
+
322
+
323
+ def _check_skfem_available() -> bool:
324
+ try:
325
+ import skfem # noqa: F401
326
+ return True
327
+ except ImportError:
328
+ return False
329
+
330
+
331
+ SOLVER_BACKENDS = {
332
+ "calculix": _check_cmd("ccx") or _check_cmd("calculix"),
333
+ "code_aster": _check_cmd("aster"),
334
+ "elmerfem": _check_cmd("elmersolver"),
335
+ "gmsh": _check_cmd("gmsh"),
336
+ "feapy": True, # pure numpy, always available
337
+ "skfem": _check_skfem_available(),
338
+ }
339
+
340
+
341
+ def _check_skfem_available():
342
+ try:
343
+ import skfem # noqa: F401
344
+ return True
345
+ except ImportError:
346
+ return False
347
+
348
+
349
+ # ── 【PH-01 修复】泊松比解析(缺失/None 时按材料家族兜底)──────────────────
350
+ # 各向同性弹性体的泊松比物理范围是 (-1, 0.5);工程常用值按家族如下。
351
+ # 兜底只在【材料未提供】时启用,并在返回值里标注来源,绝不冒充真实数据。
352
+ _NU_BY_FAMILY = (
353
+ # (密度区间, 家族, nu)
354
+ (700.0, 1500.0, "plastic", 0.38),
355
+ (2400.0, 2900.0, "aluminum", 0.33),
356
+ (4300.0, 4700.0, "titanium", 0.34),
357
+ (6900.0, 7300.0, "cast_iron", 0.26),
358
+ (7400.0, 8100.0, "steel", 0.30),
359
+ (8300.0, 9000.0, "copper", 0.34),
360
+ )
361
+
362
+
363
+ def _resolve_poisson(mat):
364
+ """返回 (nu, note)。优先用材料自带值;缺失/非法则按密度判族兜底。
365
+
366
+ note 为 None 表示"用的是材料真实数据";非 None 则是兜底说明,
367
+ 调用方可据此在报告里标注"该值非材料实测"。
368
+ """
369
+ _raw = (mat or {}).get("poissons_ratio")
370
+ if _raw is not None and not isinstance(_raw, bool):
371
+ try:
372
+ _v = float(_raw)
373
+ # 必须在物理范围内,否则视为非法(0.5 会导致刚度矩阵奇异)
374
+ if _v != _v or _v in (float("inf"), float("-inf")):
375
+ raise ValueError("not finite")
376
+ if -1.0 < _v < 0.5:
377
+ return _v, None
378
+ raise ValueError("out of range")
379
+ except Exception:
380
+ pass # 非法 → 落到兜底
381
+ # 按密度判族
382
+ try:
383
+ _d = float((mat or {}).get("density_kg_m3") or 0)
384
+ except Exception:
385
+ _d = 0.0
386
+ for _lo, _hi, _fam, _nu in _NU_BY_FAMILY:
387
+ if _lo <= _d <= _hi:
388
+ return _nu, ("材料未提供合法 poissons_ratio(原值=%r)—— "
389
+ "已按密度 %.0f 判族为 %s,取工程常用值 %.2f(【兜底值,非实测】)"
390
+ % (_raw, _d, _fam, _nu))
391
+ return 0.30, ("材料未提供合法 poissons_ratio(原值=%r)且密度 %.0f 无法判族 —— "
392
+ "取通用默认 0.30(【兜底值,非实测】)" % (_raw, _d))
393
+
394
+
395
+ def detect_available_backends() -> dict[str, bool]:
396
+ """检测当前系统可用的 FEA 后端。"""
397
+ return dict(SOLVER_BACKENDS)
398
+
399
+
400
+ # ==================== Level 0: 解析求解器 ====================
401
+
402
+ def solve_analytical(load_case: dict, mesh_stats: dict) -> dict[str, Any]:
403
+ """使用解析解进行快速评估(无需网格文件)。
404
+
405
+ 适用于简单几何:悬臂梁、简支梁、轴受扭等。
406
+ """
407
+ from mesh_adapter import (
408
+ analytical_beam_cantilever,
409
+ analytical_simply_supported_beam,
410
+ )
411
+
412
+ mat = load_case.get("material", {})
413
+ E = mat.get("youngs_modulus_mpa", 200000)
414
+ sigma_y = mat.get("yield_strength_mpa", 250)
415
+ domain = load_case.get("design_domain", {}).get("bounds", {})
416
+ loads = load_case.get("loads", [])
417
+ acceptance = load_case.get("acceptance", {})
418
+
419
+ # Bug #2 遗留修复: 支持 magnitude_n 和 magnitude_n_mm2 (压强)
420
+ def _load_force(l):
421
+ if l.get("type") not in ("distributed_force", "concentrated_force"):
422
+ return 0.0
423
+ mag = l.get("magnitude_n") or 0
424
+ if mag <= 0:
425
+ p = l.get("magnitude_n_mm2") or 0
426
+ sel = l.get("face_selector", "")
427
+ if p > 0 and sel:
428
+ from load_case import _face_area_from_selector
429
+ mag = p * _face_area_from_selector(domain, sel)
430
+ return max(mag, 0)
431
+ total_force = sum(_load_force(l) for l in loads)
432
+ force_dir = loads[0].get("direction", [0, 0, -1]) if loads else [0, 0, -1]
433
+
434
+ # ══ 【Bug-03 修复】设计校核载荷 = 最恶劣工况的矢量合成力 × 一次动载系数 ══
435
+ # 原实现有两个口径错误(共同导致 SF 虚高/失真):
436
+ # ① `sum(magnitude_n)` 把各工况、各方向的载荷【代数相加】——
437
+ # 正交分量(-Z 重力 / +X 侧向 / +Y 纵向)相加没有物理意义;
438
+ # ② 动载系数【从未施加】,而门禁又把侧向载荷预先乘过一次 ——
439
+ # 两边口径不一致,既可能漏算也可能重复计算。
440
+ # 现在统一走 governing_design_force():按 load_cases 枚举工况 →
441
+ # 工况内矢量合成 → 取最大值 → 乘【一次】impact_factor。
442
+ _rated_force = total_force
443
+ total_force, _dyn_meta = _governing_design_force(load_case)
444
+ _dyn_f = float(_dyn_meta.get("impact_factor") or 1.0)
445
+
446
+ # ══ 【Bug17 修复】几何必须优先取【真实零件】,而非 design_domain ═════
447
+ # 测试部实测:三个不同载荷(39.5N/27.4N/12.2N,差 3 倍)返回的
448
+ # SF 完全相同 —— 因为解析解一直用 design_domain 的固定尺寸
449
+ # (200×60×60) 当梁长/宽/高,与真实零件无关,载荷变化被几何口径
450
+ # 掩盖,强度判定因此不可信(Bug7 的残留)。
451
+ # 修复:若 load_case 携带 real_geometry(由 physics_bridge 从
452
+ # swapi.massprops / 包围盒提取),则用它推导等效梁尺寸。
453
+ _rg = load_case.get("real_geometry") or {}
454
+ _bbox = _rg.get("bbox_mm") or None
455
+ _vol_real = _rg.get("volume_mm3")
456
+ _used_real = False
457
+ _geo_source = "design_domain(设计域默认尺寸)"
458
+ if _bbox and len(_bbox) >= 6:
459
+ try:
460
+ _lx = abs(float(_bbox[1]) - float(_bbox[0]))
461
+ _ly = abs(float(_bbox[3]) - float(_bbox[2]))
462
+ _lz = abs(float(_bbox[5]) - float(_bbox[4]))
463
+ _dims = sorted([_lx, _ly, _lz], reverse=True)
464
+ if _dims[0] > 0 and _dims[1] > 0 and _dims[2] > 0:
465
+ # 最长边=梁长;次长边=宽;最短边=高(悬臂梁等效)
466
+ L, W, H = _dims[0], _dims[1], _dims[2]
467
+ _used_real = True
468
+ _geo_source = "real_geometry.bbox_mm(真实零件包围盒)"
469
+ except Exception:
470
+ _used_real = False
471
+ if not _used_real:
472
+ L = domain.get("x_max", 200)
473
+ W = domain.get("y_max", 60)
474
+ H = domain.get("z_max", 60)
475
+ # ── 【Bug-03 修复】几何缺失必须显式告警,不静默用设计域尺寸 ────────
476
+ # 实测本任务:结构件与传动件尺寸相差极大却得到同一 SF=24.8,
477
+ # 根因就是"真实几何没进来、悄悄退回设计域默认尺寸"且无任何提示。
478
+ _geo_warn = ("未取到 real_geometry.bbox_mm —— 本次校核使用设计域默认尺寸 "
479
+ "(%s×%s×%s),结论可能与真实零件无关;"
480
+ "请用 physics-optimize --part <零件.SLDPRT> 提供真实几何。"
481
+ % (L, W, H))
482
+ else:
483
+ _geo_warn = None
484
+
485
+ # 假设悬臂梁边界条件(固定端在 x=0)
486
+ result = analytical_beam_cantilever(L, W, H, total_force, E)
487
+
488
+ sigma_max = result.get("max_stress_mpa", 0)
489
+ delta_max = result.get("max_deflection_mm", 0)
490
+ volume_mm3 = result.get("volume_mm3", 0)
491
+
492
+ # ── 【Bug17/18 回归修复】体积必须报【真实测量值】,不得用 bbox 乘积 ────
493
+ # 测试部 v13 反馈:fea_result.volume 恒 = 720000(= design_domain
494
+ # 200×60×60 的乘积),10 种零件完全一样 → 报告里的体积是"设计域体积"
495
+ # 而不是"零件体积",下游据此判断材料用量/质量必然失真。
496
+ # 修复:优先采用 real_geometry.volume_mm3(swapi.massprops 的真实实体体积),
497
+ # bbox 只用于梁模型的等效尺寸,不再充当体积。
498
+ _vol_reported = volume_mm3
499
+ _vol_source = "bbox_product(等效梁)"
500
+ if _vol_real:
501
+ try:
502
+ _vr = float(_vol_real)
503
+ if _vr > 0:
504
+ _vol_reported = _vr
505
+ _vol_source = "swapi.massprops(真实实体体积)"
506
+ except Exception:
507
+ pass
508
+ volume_mm3 = _vol_reported
509
+
510
+ # ── 【BUG-C 修复】等效模型量化披露 ────────────────────────────────────
511
+ # 计算"等效实心梁体积 vs 真实体积"的倍数,让读者一眼看出模型失真程度。
512
+ _eq_vol = None
513
+ _real_vol = None
514
+ _vol_ratio = None
515
+ try:
516
+ _eq_vol = float(L) * float(W) * float(H)
517
+ if _vol_real:
518
+ _real_vol = float(_vol_real)
519
+ if _real_vol > 0 and _eq_vol > 0:
520
+ _vol_ratio = round(_eq_vol / _real_vol, 2)
521
+ except Exception:
522
+ pass
523
+ # limitations 必须【显式说明等效关系】,不能只有泛泛的英文条目
524
+ _limitations = [
525
+ "Euler-Bernoulli beam assumptions",
526
+ "ignores stress concentrations",
527
+ "valid for prismatic beams only",
528
+ # ★ 关键披露(原报告完全没有这一段)
529
+ ("【模型等效性】按真实零件【包围盒】的排序三边等效为【实心矩形棱柱】"
530
+ "悬臂梁 —— 未使用真实截面形状与载荷路径"),
531
+ ("【适用边界】L 形 / 薄壁 / 镂空 / 变截面件的安全系数会被显著高估,"
532
+ "不得仅凭本结论判定设计合格;此类零件需 3D FEA 复核"),
533
+ ]
534
+ if _vol_ratio and _vol_ratio > 1.5:
535
+ _limitations.append(
536
+ "【本次失真程度】等效实心体积 %.0f mm³ 是真实体积 %.0f mm³ 的 "
537
+ "%.2f 倍 —— 安全系数存在同量级高估风险,务必人工复核"
538
+ % (_eq_vol, _real_vol, _vol_ratio))
539
+
540
+ # 计算安全系数
541
+ # ── 【P2-1 修复·脆性材料判据】σy 缺失时改用 σb(最大主应力/莫尔判据)──
542
+ # 实测缺陷:HT200(灰铸铁)无屈服平台,标准只给 σb —— 属材料特性。
543
+ # 旧实现要求 σy,导致脆性材料根本无法校核。
544
+ # 现在:若材料被标为 brittle(见 load_case 的识别逻辑)且 σy 缺失,
545
+ # 则以 σb 作为强度判据基准,并在结果里注明判据类型。
546
+ # 【BUG-FEA-STRENGTH-1】改用统一判据基准函数:脆性材料缺 σy 时用 σb;
547
+ # 基准完全不可得时返回 None(闸口判 N/A → 聚合 REVIEW),
548
+ # 不再因 None 参与除法而崩溃,也不虚报合格。
549
+ _strength_ref, _strength_basis = brittle_strength_ref(mat, sigma_y)
550
+ if sigma_max > 0:
551
+ safety_factor = (_strength_ref / sigma_max) if _strength_ref else None
552
+ else:
553
+ safety_factor = float("inf")
554
+
555
+ # Bug-21 修复: 安全系数过高不是 FAIL,应标记 WARNING(过于保守=浪费材料,但结构安全)
556
+ # 原逻辑: sf > max_sf → FAIL(错误:92 > 5 被判为超限)
557
+ # 正确逻辑: sf < min_sf → FAIL(强度不足),sf > max_sf → WARNING(过度设计)
558
+ #
559
+ # ── 【NEW-02 修复】把"过度设计"与"真告警"语义分开 ─────────────────
560
+ # 用户反馈:静力 SF=36.42 被标 WARNING,属"过设计"提示(已知行为),
561
+ # 但混在 WARNING 里会让人以为结构有问题,也会把 overall 拖成 REVIEW。
562
+ # 修复:过度设计仍无法判 PASS(确实未落在目标区间),但给出
563
+ # status="OVER_DESIGN" + over_design=true 的明确标记,
564
+ # overall 聚合时将其视为"合格但可优化",不再简单归入 REVIEW。
565
+ _min_sf = acceptance.get("min_safety_factor", 2.0)
566
+ _max_sf = acceptance.get("target_safety_factor_max", 5.0)
567
+ _over_design = False
568
+ if safety_factor < _min_sf:
569
+ sf_status = "FAIL"
570
+ elif safety_factor > _max_sf:
571
+ sf_status = "OVER_DESIGN" # 结构安全,但材料利用率低(非缺陷)
572
+ _over_design = True
573
+ else:
574
+ sf_status = "PASS"
575
+
576
+ # 检查约束
577
+ gates = {
578
+ "SAFETY_FACTOR": {
579
+ "status": sf_status,
580
+ "actual": round(safety_factor, 2),
581
+ "required_min": _min_sf,
582
+ "required_max": _max_sf,
583
+ "over_design": _over_design,
584
+ "note": ("结构安全,安全系数高于目标上限 —— 属【过度设计/材料利用率低】,"
585
+ "不是缺陷;如需轻量化可减小截面/换薄壁"
586
+ if _over_design else
587
+ ("强度不足,必须加强" if sf_status == "FAIL" else "落在目标区间")),
588
+ },
589
+ "MAX_DISPLACEMENT": {
590
+ "status": "PASS" if (acceptance.get("max_displacement_mm") is None or
591
+ delta_max <= acceptance["max_displacement_mm"]) else "FAIL",
592
+ "actual_mm": round(delta_max, 4),
593
+ "required_max_mm": acceptance.get("max_displacement_mm"),
594
+ },
595
+ # ── 【Bug18 修复】FEA 层【不输出】DESIGN_SPACE ────────────────────
596
+ # 空间是否越界是【几何】问题,归属 geometry_gate(见
597
+ # simulation_report.GATE_OWNER)。原实现在这里写死 "PASS",
598
+ # 与 geometry_gate 的 FAIL 形成"两层相反结论",用户无从判断。
599
+ # 注意:这里【整条不输出】,而不是标成 N/A —— 因为 aggregate_overall()
600
+ # 把 N/A 视为"未评估"从而把整体判为 REVIEW,会让本来 PASS 的
601
+ # 校核被无谓降级(实测 feapy 路径回归)。
602
+ # 一个闸口只由一个层负责,不属于本层的就不要出现在本层结果里。
603
+ "MESH_QUALITY": {"status": "N/A", "note": "analytical method, no mesh"},
604
+ }
605
+
606
+ passed = (aggregate_overall(gates) == "PASS")
607
+
608
+ return {
609
+ "ok": True,
610
+ "method": "analytical",
611
+ "backend": "closed_form_beam_theory",
612
+ "safety_factor": round(safety_factor, 2),
613
+ "max_von_mises_mpa": round(sigma_max, 2),
614
+ "max_displacement_mm": round(delta_max, 4),
615
+ "volume_mm3": round(volume_mm3, 2),
616
+ "mass_kg": round(volume_mm3 * mat.get("density_kg_m3", 7850) / 1e9, 4),
617
+ # ── 【Bug-03 修复】回显"校核载荷口径",让结论可追溯 ────────────────
618
+ # ① rated_force_n=额定载荷(= 工况 magnitude_n)
619
+ # ② design_force_n=实际校核载荷 = 额定 × 动载系数
620
+ # ③ geometry_source=几何来源(真实包围盒 / 设计域默认)
621
+ # 原先这些口径【不出现在结果里】,外部无法判断 SF 是否算对。
622
+ "load_model": {
623
+ "rated_force_n": _dyn_meta.get("rated_force_n", round(_rated_force, 3)),
624
+ "design_force_n": round(total_force, 3),
625
+ "governing_case": _dyn_meta.get("governing_case"),
626
+ "impact_factor": _dyn_meta.get("impact_factor"),
627
+ "load_type": _dyn_meta.get("load_type"),
628
+ "impact_applied": _dyn_meta.get("applied", _dyn_meta.get("impact_applied")),
629
+ # ── 【Bug-D 修复】回显零件载荷分担系数与口径来源 ──────────────
630
+ "part_load_share": _dyn_meta.get("part_load_share"),
631
+ "load_share_source": _dyn_meta.get("load_share_source"),
632
+ "load_share_note": _dyn_meta.get("load_share_note"),
633
+ "design_force_before_share_n": _dyn_meta.get("design_force_before_share_n"),
634
+ "cases": _dyn_meta.get("cases"),
635
+ },
636
+ "geometry_used": {
637
+ "source": _geo_source,
638
+ "L_mm": round(L, 3), "W_mm": round(W, 3), "H_mm": round(H, 3),
639
+ "volume_source": _vol_source,
640
+ "warning": _geo_warn,
641
+ # ── 【BUG-C 修复·等效模型披露】────────────────────────────────
642
+ # 实测缺陷(子代理在真实 L 形支架上确证):本求解器把
643
+ # real_geometry.bbox_mm 的【排序三边】当作【实心矩形棱柱】
644
+ # 悬臂梁建模(solve_cantilever(L, H, W)),**完全忽略真实截面
645
+ # 形状与载荷路径**。对 L 形件:bbox 实心体积是真实体积的
646
+ # 4.95 倍,安全系数被高估约 27 倍 —— 一个真实不安全的
647
+ # 设计会被报成"过度设计"。
648
+ # 而旧报告的 limitations 只有泛泛的英文条目,
649
+ # 全文没出现"等效""悬臂""实心",读者极易把 SF 当真。
650
+ # 现在:把等效关系量化写进报告,供读者判断可信度。
651
+ "model": "equivalent_solid_cantilever_from_bbox",
652
+ "model_note": ("按【真实零件包围盒】的排序三边等效为【实心矩形棱柱】"
653
+ "悬臂梁建模 —— 不是真实截面。L 形/薄壁/镂空件的"
654
+ "安全系数会被显著高估,不得据此判定设计合格。"),
655
+ "equivalent_solid_volume_mm3": _eq_vol,
656
+ "real_volume_mm3": _real_vol,
657
+ "volume_inflation_x": _vol_ratio,
658
+ },
659
+ "gates": gates,
660
+ "overall": aggregate_overall(gates),
661
+ "limitations": _limitations,
662
+ # 【P2-1】强度判据类型(脆性材料用 σb + 最大主应力,而非 von Mises+σy)
663
+ "strength_basis": _strength_basis,
664
+ "strength_reference_mpa": (round(float(_strength_ref), 3)
665
+ if _strength_ref else None),
666
+ }
667
+
668
+
669
+ # ── 【回转轴真实截面强度修正】────────────────────────────────────────────────
670
+ # 问题:包围盒等效模型把【回转体轴】当成外接矩形棱柱(φD×L)。对实心阶梯轴
671
+ # (如转台轴 φ40/φ52/φ56 阶梯 + φ80×10 法兰),bbox 等效惯性矩被高估
672
+ # 数倍 → 弯曲应力被同比例低估 → 安全系数同比例虚高(实测 4.5~8.9 倍)。
673
+ # 这类"实心轴"并不是【镂空】(无内孔),用"等效体积/真实体积"直接判镂空
674
+ # 是误判;但 SF 虚高却是真的,必须修正。
675
+ #
676
+ # 正确处理(只用已量测几何、绝不引入新参数、保守取最不利工况):
677
+ # 悬臂根部最大弯曲应力 σ = M·c/I,M = F·L。对【直径 d 随轴向变化的实心
678
+ # 回转轴】做体积加权合成(体积加权近似,偏保守):
679
+ # · 体积 V = Σ(πd²/4·l) ← 应与 SW massprops 实测体积吻合
680
+ # · 形心轴惯性矩 I = Σ(πd⁴/64·l)
681
+ # · 等效长度系数 Le = 2·I/(V·L)
682
+ # 则 σ_true = F·L·Le/(2·I),而 σ_bbox = F·L/(2·I_bbox_box)
683
+ # ⇒ SF_true = SF_bbox · (V/V_bbox_box)² · Le (Le、V/V_box 均 ≤1 ⇒ 必然削减)
684
+ # Le 采用【最不利(最小)】取值:φ80 只在法兰(10mm)、其余全部按实测最大
685
+ # 非法兰直径铺满 —— 这是"截面最弱"假设,给出偏保守(偏低)的安全系数。
686
+ def _shaft_section_correction(real_geometry: dict) -> Optional[dict]:
687
+ """回转轴真实截面强度修正(保守·分布无关);非回转轴/数据不足时返回 None。
688
+
689
+ 包围盒等效模型把【实心阶梯轴】当成外接 φD×L 圆柱,惯性矩/截面模量被高估
690
+ → 弯曲应力被低估 → 安全系数虚高(实测可达数倍)。这类实心轴并不是"镂空"
691
+ (无内孔),但 SF 虚高属实,必须削减。
692
+ massprops 只给总体积、不给阶梯分布,故按【体积加权平均直径 d̄ 铺满全长】
693
+ 这一【使截面模量最小、应力最大、SF 最小】的保守假设修正,绝不虚高。
694
+ """
695
+ try:
696
+ rg = real_geometry or {}
697
+ bb = rg.get("bbox_mm") or None
698
+ v_real = float(rg.get("volume_mm3") or 0.0)
699
+ if not (isinstance(bb, (list, tuple)) and len(bb) >= 6 and v_real > 0):
700
+ return None
701
+ lx, ly, lz = (abs(float(bb[1]) - float(bb[0])),
702
+ abs(float(bb[3]) - float(bb[2])),
703
+ abs(float(bb[5]) - float(bb[4])))
704
+ dims = sorted([lx, ly, lz], reverse=True)
705
+ L, D, W = dims[0], dims[1], dims[2]
706
+ if not (L > 0 and D > 0 and W > 0):
707
+ return None
708
+ # 回转轴判据:两个较小尺寸近似相等(圆截面 D≈W),且长径比 > 1.2
709
+ if W <= 0 or (D / W) > 1.06 or (W / D) > 1.06 or (L / D) <= 1.2:
710
+ return None
711
+ v_box = 3.141592653589793 * (D ** 2) / 4.0 * L
712
+ if v_box <= 0 or v_real >= v_box:
713
+ return None
714
+ fill = v_real / v_box # ∈ (0,1)
715
+ # ── 保守修正(分布无关)───────────────────────────────────────────
716
+ # 真实回转轴的阶梯直径分布未知(massprops 只给体积)。由凸性,
717
+ # 在给定总体积下,【按体积加权平均直径 d̄ 铺满全长】使截面模量
718
+ # Z=I/c 最小 → 弯曲应力最大 → 安全系数最小(最保守,绝不虚高)。
719
+ # d̄ 由 V=π·d̄²/4·L 反解:d̄=D·√fill。
720
+ # 截面模量比(真/盒)= (d̄/D)³ = fill^(3/2)。
721
+ # 悬臂根部 σ∝c/I=1/Z,故 SF_true = SF_bbox·(Z_true/Z_box)
722
+ # = SF_bbox·fill^(3/2)。因子 ≤1 ⇒ 必然削减安全系数(保守)。
723
+ if fill <= 0:
724
+ return None
725
+ d_mean = D * (fill ** 0.5) # 保守等效直径 (d̄=D·√fill)
726
+ I_box = 3.141592653589793 * (D ** 4) / 64.0 * L
727
+ I_mean = 3.141592653589793 * (d_mean ** 4) / 64.0 * L
728
+ if I_box <= 0:
729
+ return None
730
+ # SF_true/SF_bbox = (c_box/c_true)·(I_true/I_box) = 1·(d̄/D)³ = fill^(3/2)
731
+ factor = fill ** 1.5
732
+ factor = max(0.0, min(1.0, factor))
733
+ return {
734
+ "axis": "shaft", "detected": True, "length_mm": round(L, 2),
735
+ "outer_dia_mm": round(D, 2), "volume_mm3": round(v_real, 1),
736
+ "bbox_volume_mm3": round(v_box, 1), "fill_ratio": round(fill, 4),
737
+ "mean_dia_mm": round(d_mean, 2),
738
+ "section_I_mm4": round(I_mean, 2), "section_I_box_mm4": round(I_box, 2),
739
+ "strength_factor": round(factor, 5),
740
+ "reduction_x": (round(1.0 / factor, 3) if factor > 0 else None),
741
+ "note": ("回转轴真实截面修正(保守·分布无关):按实测体积反解"
742
+ "体积加权平均直径 d̄=D·√fill,截面模量比=(d̄/D)³=fill^(3/2);"
743
+ "SF_true = SF_bbox·fill^(3/2),必然削减(保守不虚高)"),
744
+ }
745
+ except Exception:
746
+ return None
747
+
748
+
749
+ # ==================== Level 1: 纯 Python FEA (numpy CST) ====================
750
+
751
+ def solve_feapy(load_case: dict, output_dir: Optional[str] = None) -> dict[str, Any]:
752
+ """使用纯 Python FEA 求解器(numpy CST 三角形单元)。
753
+
754
+ 无需任何外部 FEA 软件,完全基于 numpy 实现。
755
+ 适用于 2D 平面应力/应变问题。
756
+
757
+ Returns:
758
+ {ok, method, safety_factor, max_von_mises_mpa, max_displacement_mm, gates}
759
+ """
760
+ try:
761
+ from feapy_solver import solve_cantilever, FEASolver, mesh_rect
762
+ except ImportError:
763
+ return {"ok": False, "error": "feapy_solver.py not found"}
764
+
765
+ mat = load_case.get("material", {})
766
+ E = mat.get("youngs_modulus_mpa", 200000)
767
+ # ── 【PH-01 修复】泊松比缺失(含显式 None)时按【材料家族】兜底 ────────
768
+ # .get(key, default) 只在【键不存在】时返回默认值;若上游显式写了
769
+ # "poissons_ratio": None(例如从材料表取到空字段),拿到的就是 None,
770
+ # 随后 nu**2 直接 TypeError 打崩整个 FEA(实测 PH-01)。
771
+ # 这里按密度判族给出工程常用值,并在返回体里标注"用的是兜底值",
772
+ # 既不崩、也不静默 —— 让调用方知道该值不是材料真实数据。
773
+ nu, _nu_note = _resolve_poisson(mat)
774
+ sigma_y = mat.get("yield_strength_mpa", 250)
775
+
776
+ domain = load_case.get("design_domain", {}).get("bounds", {})
777
+ L = domain.get("x_max", 200.0)
778
+ W = domain.get("y_max", 60.0)
779
+ H = domain.get("z_max", 60.0)
780
+
781
+ # ══ 【Bug-03 修复】几何必须优先取【真实零件】,与解析解路径口径一致 ══
782
+ # 实测缺陷(本任务是竞赛小车):结构件(bbox 300×200×4)与传动件
783
+ # (bbox 75×75×10)两个尺寸相差极大的零件,SF 完全相同(24.8)、
784
+ # volume 也完全相同(7200000 = 300×200×120)—— 因为本函数一直用
785
+ # design_domain 的固定尺寸当梁的 L/W/H,real_geometry【从未被使用】。
786
+ # 后果:安全系数虚高且与零件无关(载荷 500N 打在 300×200×120 的
787
+ # "理想大块"上,应力自然只有 1.61MPa),物理防线"签得出但结论不可用"。
788
+ # 修复:与 solve_analytical 完全同口径 —— 有 real_geometry.bbox_mm 就用
789
+ # 它推导等效梁尺寸(最长边=长、次长=宽、最短=高),并用真实实体体积
790
+ # 作为体积;volume 不再用 L*W*H 的 bbox 乘积冒充零件体积。
791
+ _rg = load_case.get("real_geometry") or {}
792
+ _bbox = _rg.get("bbox_mm") or None
793
+ _vol_real = _rg.get("volume_mm3")
794
+ _geo_source = "design_domain(设计域默认尺寸)"
795
+ _used_real = False
796
+ if _bbox and len(_bbox) >= 6:
797
+ try:
798
+ _lx = abs(float(_bbox[1]) - float(_bbox[0]))
799
+ _ly = abs(float(_bbox[3]) - float(_bbox[2]))
800
+ _lz = abs(float(_bbox[5]) - float(_bbox[4]))
801
+ _dims = sorted([_lx, _ly, _lz], reverse=True)
802
+ if _dims[0] > 0 and _dims[1] > 0 and _dims[2] > 0:
803
+ L, W, H = _dims[0], _dims[1], _dims[2]
804
+ _used_real = True
805
+ _geo_source = "real_geometry.bbox_mm(真实零件包围盒)"
806
+ except Exception:
807
+ _used_real = False
808
+ # 真实几何缺失时【明确记录】,便于排查"结论与零件无关"的来源
809
+ if not _used_real:
810
+ _geo_warn = ("未取到 real_geometry.bbox_mm —— 本次 FEA 使用设计域默认尺寸 "
811
+ "(%s×%s×%s),结论可能与真实零件无关" % (L, W, H))
812
+ else:
813
+ _geo_warn = None
814
+
815
+ # ── 【BUG-C 修复】等效模型量化披露(feapy 路径)──────────────────────
816
+ # 与解析路径同口径:把"bbox 等效实心梁 vs 真实体积"的失真程度写进报告。
817
+ _eq_vol = None
818
+ _real_vol = None
819
+ _vol_ratio = None
820
+ try:
821
+ _eq_vol = float(L) * float(W) * float(H)
822
+ if _vol_real:
823
+ _real_vol = float(_vol_real)
824
+ if _real_vol > 0 and _eq_vol > 0:
825
+ _vol_ratio = round(_eq_vol / _real_vol, 2)
826
+ except Exception:
827
+ pass
828
+ _limitations = [
829
+ "2D plane stress assumption only",
830
+ "CST linear elements (6-8%% error vs analytical)",
831
+ "no stress concentrations captured",
832
+ "no 3D effects",
833
+ ("【模型等效性】按真实零件【包围盒】的排序三边等效为【实心矩形棱柱】"
834
+ "悬臂梁建模 —— 未使用真实截面形状与载荷路径"),
835
+ ("【适用边界】L 形 / 薄壁 / 镂空 / 变截面件的安全系数会被显著高估,"
836
+ "不得仅凭本结论判定设计合格;此类零件需 3D FEA 复核"),
837
+ ]
838
+ if _vol_ratio and _vol_ratio > 1.5:
839
+ _limitations.append(
840
+ "【本次失真程度】等效实心体积 %.0f mm³ 是真实体积 %.0f mm³ 的 "
841
+ "%.2f 倍 —— 安全系数存在同量级高估风险,务必人工复核"
842
+ % (_eq_vol, _real_vol, _vol_ratio))
843
+
844
+ # 注:回转轴修正的披露在 _shaft_corr 求出之后(求解分支内)追加,
845
+ # 此处不引用它(否则在赋值前引用会 UnboundLocalError)。
846
+
847
+ loads = load_case.get("loads", [])
848
+ # Bug #2 遗留修复: 支持 magnitude_n 和 magnitude_n_mm2 (压强)
849
+ def _load_force(l):
850
+ if l.get("type") not in ("distributed_force", "concentrated_force"):
851
+ return 0.0
852
+ mag = l.get("magnitude_n") or 0
853
+ if mag <= 0:
854
+ p = l.get("magnitude_n_mm2") or 0
855
+ sel = l.get("face_selector", "")
856
+ if p > 0 and sel:
857
+ from load_case import _face_area_from_selector
858
+ mag = p * _face_area_from_selector(domain, sel)
859
+ return max(mag, 0)
860
+ total_force = sum(_load_force(l) for l in loads)
861
+ force_dir = loads[0].get("direction", [0, 0, -1]) if loads else [0, 0, -1]
862
+
863
+ # ══ 【Bug-03 修复】设计校核载荷(同解析解路径口径)════════════════════
864
+ # 按工况矢量合成取最恶劣工况,再乘【一次】动载系数。
865
+ _rated_force = total_force
866
+ total_force, _dyn_meta = _governing_design_force(load_case)
867
+ _dyn_f = float(_dyn_meta.get("impact_factor") or 1.0)
868
+
869
+ acceptance = load_case.get("acceptance", {})
870
+ min_sf = acceptance.get("min_safety_factor", 2.0)
871
+ max_sf = acceptance.get("target_safety_factor_max", 5.0)
872
+ max_disp = acceptance.get("max_displacement_mm")
873
+
874
+ try:
875
+ # 使用 feapy_solver 求解
876
+ nx, ny = max(20, int(L / 5)), max(6, int(H / 5))
877
+ r = solve_cantilever(L, H, W, total_force, E, nu, nx=nx, ny=ny)
878
+
879
+ max_stress = r["max_von_mises_mpa"]
880
+ max_disp_val = r["max_displacement_mm"]
881
+ # ── 【P2-1 修复·脆性材料判据】与解析路径同口径 ────────────────────
882
+ # 灰铸铁/球墨铸铁等无屈服平台,用 von Mises + σy 判据物理上是错的;
883
+ # 应改用 σb + 最大主应力(莫尔)判据。此处与解析路径保持一致。
884
+ # 【BUG-FEA-STRENGTH-1】与解析解路径同口径:脆性材料缺 σy 时用 σb,
885
+ # 基准不可得则置 None(闸口判 N/A),杜绝 None 参与除法而崩溃。
886
+ _strength_ref, _strength_basis = brittle_strength_ref(mat, sigma_y)
887
+ if max_stress > 0:
888
+ safety_factor = (_strength_ref / max_stress) if _strength_ref else None
889
+ else:
890
+ safety_factor = float("inf")
891
+ # ── 【回转轴真实截面强度修正】──────────────────────────────────────
892
+ # bbox 等效棱柱对【实心阶梯轴】高估惯性矩 → 应力被低估 → SF 虚高。
893
+ # 检测到回转轴时,用实测体积反解最不利直径分布,把安全系数按
894
+ # SF_true = SF_bbox·(V/V_box)²·Le 修正(Le=体积加权等效长度系数)。
895
+ # 这不是"镂空误判"——实心轴无内孔,但 SF 虚高属实,必须修正为保守值。
896
+ _shaft_corr = _shaft_section_correction(_rg)
897
+ if _shaft_corr and safety_factor not in (None, float("inf")):
898
+ try:
899
+ _sf_raw = float(safety_factor)
900
+ _factor = float(_shaft_corr["strength_factor"])
901
+ safety_factor = _sf_raw * _factor
902
+ _shaft_corr["sf_bbox_equivalent"] = round(_sf_raw, 2)
903
+ _shaft_corr["sf_true"] = round(safety_factor, 2)
904
+ except Exception:
905
+ pass
906
+ # 回转轴修正披露:说明安全系数已按真实截面削减(不再虚高),留痕便于复核。
907
+ # 必须在 _shaft_corr 赋值之后追加(在函数前段引用会 UnboundLocalError)。
908
+ if _shaft_corr:
909
+ _limitations.append(
910
+ "【回转轴真实截面修正】已检测为回转轴(φ%.0f×%.0f):安全系数已按"
911
+ "实测体积反解最不利截面修正 SF_true=SF_bbox×strength_factor"
912
+ "=%.2f→%.2f(削减 %.2f 倍),model_trustworthy=true"
913
+ % (_shaft_corr.get("outer_dia_mm") or 0,
914
+ _shaft_corr.get("length_mm") or 0,
915
+ _shaft_corr.get("sf_bbox_equivalent") or 0,
916
+ _shaft_corr.get("sf_true") or 0,
917
+ _shaft_corr.get("reduction_x") or 1.0))
918
+ # ── 【Bug-03 修复】体积必须报真实实体体积,不得用 bbox 乘积 ────────
919
+ # 原实现 volume_mm3 = L*W*H —— 10 种零件完全一样的"设计域体积",
920
+ # 下游据此算质量/材料用量必然失真(与解析解路径的既有修复对齐)。
921
+ volume_mm3 = L * W * H
922
+ try:
923
+ if _vol_real and float(_vol_real) > 0:
924
+ volume_mm3 = float(_vol_real)
925
+ except Exception:
926
+ pass
927
+
928
+ gates = {
929
+ "SAFETY_FACTOR": {
930
+ # 【NEW-02 修复】过度设计用 OVER_DESIGN 明确区分(同解析解路径)
931
+ "status": (
932
+ # 【BUG-FEA-STRENGTH-1】强度基准不可得 → 判 N/A(聚合为 REVIEW)。
933
+ # 不能用 UNKNOWN:aggregate_overall 对未列举状态会落到 PASS,
934
+ # 等于把"没校核"虚报成"合格"。
935
+ "N/A" if safety_factor is None
936
+ else "FAIL" if safety_factor < min_sf
937
+ else "OVER_DESIGN" if safety_factor > max_sf
938
+ else "PASS"
939
+ ),
940
+ "actual": (round(safety_factor, 2)
941
+ if safety_factor is not None else None),
942
+ "required_min": min_sf,
943
+ "required_max": max_sf,
944
+ "over_design": (bool(safety_factor > max_sf)
945
+ if safety_factor is not None else False),
946
+ "strength_basis": _strength_basis,
947
+ "note": ("强度基准不可得(既无 σy 也无 σb)→ 本次【未校核】,"
948
+ "须补材料数据后重跑"
949
+ if safety_factor is None else
950
+ "结构安全,安全系数高于目标上限 —— 属【过度设计/材料利用率低】,"
951
+ "不是缺陷" if safety_factor > max_sf else
952
+ ("强度不足,必须加强" if safety_factor < min_sf else "落在目标区间")),
953
+ },
954
+ "MAX_DISPLACEMENT": {
955
+ "status": (
956
+ "PASS"
957
+ if max_disp is None or max_disp_val <= max_disp
958
+ else "FAIL"
959
+ ),
960
+ "actual_mm": round(max_disp_val, 4),
961
+ "required_max_mm": max_disp,
962
+ },
963
+ # ── 【Bug18 修复】FEA 层【不输出】DESIGN_SPACE(归属 geometry_gate)
964
+ # 理由见 solve_analytical 内同处注释:一个闸口只由一个层负责,
965
+ # 且不得用 N/A 占位(aggregate_overall 会把 N/A 降级为 REVIEW)。
966
+ "MESH_QUALITY": {
967
+ "status": "PASS",
968
+ "note": f"CST triangle mesh: ~{nx*ny*2} elements",
969
+ },
970
+ }
971
+
972
+ passed = (aggregate_overall(gates) == "PASS")
973
+
974
+ return {
975
+ "ok": True,
976
+ "method": "feapy_numpy",
977
+ "backend": "pure_python_cst_triangle",
978
+ # 【BUG-FEA-STRENGTH-1】基准不可得时如实报 None,不得编造数值
979
+ "safety_factor": (round(safety_factor, 2)
980
+ if safety_factor is not None else None),
981
+ "max_von_mises_mpa": round(max_stress, 2),
982
+ "max_displacement_mm": round(max_disp_val, 4),
983
+ "volume_mm3": round(volume_mm3, 2),
984
+ "mass_kg": round(volume_mm3 * mat.get("density_kg_m3", 7850) / 1e9, 4),
985
+ # ── 【Bug-03 修复】回显载荷口径与几何来源(同解析解路径)────────
986
+ "load_model": {
987
+ "rated_force_n": _dyn_meta.get("rated_force_n", round(_rated_force, 3)),
988
+ "design_force_n": round(total_force, 3),
989
+ "governing_case": _dyn_meta.get("governing_case"),
990
+ "impact_factor": _dyn_meta.get("impact_factor"),
991
+ "load_type": _dyn_meta.get("load_type"),
992
+ "impact_applied": _dyn_meta.get("applied", _dyn_meta.get("impact_applied")),
993
+ "cases": _dyn_meta.get("cases"),
994
+ },
995
+ "geometry_used": {
996
+ "source": _geo_source,
997
+ "L_mm": round(L, 3), "W_mm": round(W, 3), "H_mm": round(H, 3),
998
+ "warning": _geo_warn,
999
+ # ── 【BUG-C 修复】等效模型披露(同解析路径)────────────────
1000
+ "model": "equivalent_solid_cantilever_from_bbox",
1001
+ "model_note": ("按【真实零件包围盒】的排序三边等效为【实心矩形"
1002
+ "棱柱】悬臂梁建模 —— 不是真实截面。L 形/薄壁/"
1003
+ "镂空件的安全系数会被显著高估,不得据此判定合格。"),
1004
+ "equivalent_solid_volume_mm3": _eq_vol,
1005
+ "real_volume_mm3": _real_vol,
1006
+ "volume_inflation_x": _vol_ratio,
1007
+ "shaft_section_correction": _shaft_corr,
1008
+ "model_trustworthy": bool(_shaft_corr),
1009
+ },
1010
+ "shaft_section_correction": _shaft_corr,
1011
+ "gates": gates,
1012
+ "overall": aggregate_overall(gates),
1013
+ "limitations": _limitations,
1014
+ "analytical_comparison": r.get("analytical", {}),
1015
+ # 【P2-1】强度判据类型(脆性材料用 σb + 最大主应力)
1016
+ "strength_basis": _strength_basis,
1017
+ "strength_reference_mpa": (round(float(_strength_ref), 3)
1018
+ if _strength_ref else None),
1019
+ }
1020
+ except Exception as e:
1021
+ import traceback
1022
+ return {"ok": False, "error": str(e), "traceback": traceback.format_exc()}
1023
+
1024
+ def _write_cfx_input(step_path: str, load_case: dict, output_dir: str) -> str:
1025
+ """生成 CalculiX 输入文件(简化版)。
1026
+
1027
+ 注意:这是简化实现,完整版本需要解析 STEP 几何、提取面/边ID。
1028
+ """
1029
+ mat = load_case.get("material", {})
1030
+ E = mat.get("youngs_modulus_mpa", 200000)
1031
+ nu, _ = _resolve_poisson(mat) # 【PH-01】缺省/None 时按家族兜底
1032
+ rho = mat.get("density_kg_m3", 7850)
1033
+
1034
+ loads = load_case.get("loads", [])
1035
+ bcs = load_case.get("boundary_conditions", [])
1036
+
1037
+ # 生成 .inp 文件内容(简化)
1038
+ inp_content = f"""*HEADING
1039
+ DSH Physics-in-the-Loop FEA Input
1040
+ *preprint, yes
1041
+ *NODE
1042
+ 1, 0.0, 0.0, 0.0
1043
+ 2, {load_case.get('design_domain', {}).get('bounds', {}).get('x_max', 200)}, 0.0, 0.0
1044
+ *ELEMENT, TYPE=C3D8R, ELSET=ALL
1045
+ 1, 1, 2, 3, 4, 5, 6, 7, 8
1046
+ *ELSET, ELSET=ALL
1047
+ 1
1048
+ *SECTION, TYPE=SOLID, SECTION=1
1049
+ *SECTION PROPERTY, SECTION=1, ELSET=ALL
1050
+ {E}, {nu}
1051
+ *BOUNDARY
1052
+ 1, 1, 6, 0
1053
+ *DSLOAD
1054
+ 2, GRAV, {rho}, 0., 0., -1.
1055
+ *STATIC
1056
+ *END
1057
+ """
1058
+ inp_path = os.path.join(output_dir, "model.inp")
1059
+ with open(inp_path, "w", encoding="utf-8") as f:
1060
+ f.write(inp_content)
1061
+ return inp_path
1062
+
1063
+
1064
+ def run_calculix(step_path: str, msh_path: str, output_dir: str) -> dict[str, Any]:
1065
+ """运行 CalculiX 求解器。"""
1066
+ if not SOLVER_BACKENDS.get("calculix"):
1067
+ return {"ok": False, "error": "CalculiX not found"}
1068
+
1069
+ inp_path = _write_cfx_input(step_path, {}, output_dir)
1070
+ results_dir = os.path.join(output_dir, "results")
1071
+ os.makedirs(results_dir, exist_ok=True)
1072
+
1073
+ try:
1074
+ result = subprocess.run(
1075
+ ["ccx", "-i", inp_path, "-o", os.path.join(results_dir, "output")],
1076
+ capture_output=True, text=True, timeout=300
1077
+ )
1078
+ if result.returncode != 0:
1079
+ return {"ok": False, "error": result.stderr or "ccx failed"}
1080
+
1081
+ # 解析 results 文件
1082
+ frd_path = os.path.join(results_dir, "output.frd")
1083
+ if os.path.exists(frd_path):
1084
+ return {"ok": True, "results_dir": results_dir, "frd_path": frd_path}
1085
+ return {"ok": True, "results_dir": results_dir}
1086
+ except subprocess.TimeoutExpired:
1087
+ return {"ok": False, "error": "CalculiX timed out after 300s"}
1088
+ except Exception as e:
1089
+ return {"ok": False, "error": str(e)}
1090
+
1091
+
1092
+ # ==================== 统一求解接口 ====================
1093
+
1094
+ def solve_fea(
1095
+ load_case: dict,
1096
+ mesh_path: Optional[str] = None,
1097
+ step_path: Optional[str] = None,
1098
+ output_dir: Optional[str] = None,
1099
+ backend: Optional[str] = None,
1100
+ ) -> dict[str, Any]:
1101
+ """统一 FEA 求解入口。
1102
+
1103
+ Args:
1104
+ load_case: 载荷工况(来自 load_case.py)
1105
+ mesh_path: 网格文件路径(MSH格式,可选)
1106
+ step_path: STEP 文件路径(可选,用于重新网格化)
1107
+ output_dir: 输出目录(默认:output_dir + run_id)
1108
+ backend: 指定求解器后端(可选,自动选择)
1109
+
1110
+ Returns:
1111
+ {ok, method, safety_factor, max_stress_mpa, max_displacement_mm, gates}
1112
+ """
1113
+ if output_dir is None:
1114
+ import time
1115
+ output_dir = os.path.join(
1116
+ os.path.dirname(os.path.abspath(__file__)), "..", "..", "output",
1117
+ f"physics_run_{int(time.time())}"
1118
+ )
1119
+ os.makedirs(output_dir, exist_ok=True)
1120
+
1121
+ # 自动选择后端
1122
+ if backend is None:
1123
+ # 优先级:feapy > CalculiX > Code_Aster > Elmer > analytical
1124
+ for candidate in ["feapy", "calculix", "code_aster", "elmerfem"]:
1125
+ if SOLVER_BACKENDS.get(candidate):
1126
+ backend = candidate
1127
+ break
1128
+ else:
1129
+ backend = "analytical"
1130
+
1131
+ # 分发到对应后端
1132
+ # 【BUG-02 修复】每个后端的返回值在出口处统一追加【疲劳/30年寿命校核】,
1133
+ # 保证无论走解析解还是纯 Python FEA,用户都能拿到寿命结论。
1134
+ if backend == "analytical":
1135
+ return _attach_fatigue(load_case, solve_analytical(load_case, {}))
1136
+ elif backend == "feapy":
1137
+ return _attach_fatigue(load_case, solve_feapy(load_case, output_dir))
1138
+ elif backend == "calculix":
1139
+ if mesh_path and os.path.exists(mesh_path):
1140
+ return _attach_fatigue(load_case, run_calculix(mesh_path, mesh_path, output_dir))
1141
+ return {"ok": False, "error": "CalculiX requires mesh file; use mesh_adapter first"}
1142
+ else:
1143
+ return {"ok": False, "error": f"unsupported backend: {backend}"}
1144
+
1145
+
1146
+ # ==================== 便捷函数 ====================
1147
+
1148
+ def get_solver_status() -> dict[str, Any]:
1149
+ """返回当前可用的求解器状态。"""
1150
+ backends = detect_available_backends()
1151
+ available = [k for k, v in backends.items() if v]
1152
+ return {
1153
+ "available": available,
1154
+ "backends": backends,
1155
+ "default": "analytical" if not available else available[0],
1156
+ "recommendation": (
1157
+ "Use feapy (pure Python FEA) for 2D problems; "
1158
+ "install CalculiX + Gmsh for full 3D FEA; "
1159
+ "use analytical for quick checks"
1160
+ if not available
1161
+ else f"Default backend: {available[0]}"
1162
+ ),
1163
+ }