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,2685 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ physics_bridge.py — Physics-in-the-Loop 命令行入口
4
+ ====================================================
5
+ DSH 工程模式物理验证子系统的统一命令入口。
6
+
7
+ 用法(直接运行):
8
+ python physics_bridge.py validate-case <load_case.json>
9
+ python physics_bridge.py optimize <load_case.json> [--max-iter N]
10
+ python physics_bridge.py demo
11
+ python physics_bridge.py status
12
+
13
+ 用法(通过 sw_bridge.py 路由):
14
+ python sw_bridge.py physics-status
15
+ python sw_bridge.py physics-validate-case <case.json>
16
+ python sw_bridge.py physics-demo
17
+ python sw_bridge.py physics-optimize <case.json> --max-iter N
18
+ python sw_bridge.py physics-report <run_id>
19
+ python sw_bridge.py physics-recommend <run_id>
20
+
21
+ 所有命令返回 dict(直接运行时会 JSON 打印),供 sw_bridge.py 路由调用。
22
+
23
+ 【兼容性】本文件不用任何 3.9+ 专有语法(如 list[str] 注解),
24
+ 保证在被机器上"随机的旧版 Python"调用时不会因注解求值而崩。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import sys
29
+ import os
30
+ import json
31
+ import argparse
32
+ import time
33
+
34
+ # Fix Windows GBK encoding
35
+ if sys.platform == "win32":
36
+ try:
37
+ sys.stdout.reconfigure(encoding="utf-8")
38
+ sys.stderr.reconfigure(encoding="utf-8")
39
+ except Exception:
40
+ pass
41
+
42
+ # 确保 physics/ 子目录可导入
43
+ _HERE = os.path.dirname(os.path.abspath(__file__))
44
+ # _BASE_DIR = 工程模式 tools 目录。
45
+ # 【修复】此前 _derive_design_params() 读 gate_load_case.json 时引用了
46
+ # _BASE_DIR,但本模块从未定义它 → 该分支每次都抛 NameError 并被
47
+ # 宽泛的 except 吞掉,导致"从工况读取设计参数"这条路径【静默失效】。
48
+ _BASE_DIR = _HERE
49
+ # ── 【P0-3 写侧修复】状态/凭据与数据文件的落点 ────────────────────────────
50
+ # STATE_DIR = 状态与凭据的权威目录(与 mode_gate/workflow_gate/defense_gate 同源)。
51
+ # 必须用它,否则从工作区跑出的 FEA 报告与防线凭据会写在工作区,
52
+ # 而宿主读安装副本 → 判定"缺少凭据"。
53
+ try:
54
+ import _store as _store_mod
55
+ STATE_DIR = _store_mod.state_dir()
56
+ except Exception:
57
+ STATE_DIR = _BASE_DIR
58
+
59
+
60
+ def _data_dirs():
61
+ """设计与工况类数据文件的候选搜索顺序(人工提供 > 权威 > 脚本目录)。"""
62
+ out = []
63
+ for d in (STATE_DIR, _BASE_DIR):
64
+ try:
65
+ a = os.path.abspath(d)
66
+ if a and a not in out and os.path.isdir(a):
67
+ out.append(a)
68
+ except Exception:
69
+ continue
70
+ return out or [_BASE_DIR]
71
+ _physics_dir = os.path.join(_HERE, "physics")
72
+ if _physics_dir not in sys.path:
73
+ sys.path.insert(0, _physics_dir)
74
+
75
+ import load_case
76
+ import material_db
77
+ import geometry_gate
78
+ import mesh_adapter
79
+ import fea_solver
80
+ import simulation_report as sim_report
81
+ import design_state
82
+ import refine_rules
83
+ try:
84
+ import fatigue as fatigue_mod
85
+ except ImportError:
86
+ fatigue_mod = None
87
+
88
+
89
+ # ═══════════════════════════════════════════════════════════════
90
+ # 命令实现 — 全部返回 dict,由调用方决定 print / 路由
91
+ # ═══════════════════════════════════════════════════════════════
92
+
93
+ def cmd_status(args=None) -> dict:
94
+ """查询求解器后端状态。"""
95
+ status = fea_solver.get_solver_status()
96
+ status["command"] = "status"
97
+ return status
98
+
99
+
100
+ def cmd_validate_case(case_path: str, relaxed: bool = False) -> dict:
101
+ """校验载荷工况 JSON。"""
102
+ result = load_case.load_from_file(case_path, strict=not relaxed)
103
+ result["command"] = "validate-case"
104
+ return result
105
+
106
+
107
+ def cmd_build(case_path: str) -> dict:
108
+ """加载工况并初始化仿真环境摘要。
109
+
110
+ ── 【BUG-05/08 修复】材料必须能解析,否则明确报错(不再返回 '?')──────
111
+ 原缺陷:material 缺失时 report 里显示 '?',调用方看不出"材料没赋",
112
+ 于是拿默认密度 1000(水)算出来的质量/强度当真。
113
+ 修复:resolve_material 失败时把可用材料 ID 一并列出,让调用方能自救。
114
+ """
115
+ lc_result = load_case.load_from_file(case_path)
116
+ if not lc_result["ok"]:
117
+ return {"ok": False, "error": "invalid load case", "details": lc_result}
118
+
119
+ mat_resolve = material_db.resolve_material(lc_result["case"])
120
+ if not mat_resolve["ok"]:
121
+ return {"ok": False, "error": "material resolution failed", "details": mat_resolve,
122
+ "hint": ("材料未指定或 ID 未知。可用 ID 示例: "
123
+ + ", ".join(sorted(material_db.ALL_MATERIALS.keys())[:12])
124
+ + " …;完整列表见 material_db.list_materials()。"
125
+ "【BUG-05/08】材质未赋会导致密度虚标为 1000(水),"
126
+ "质量与强度结论不可信,必须先解决材料问题。")}
127
+ # 把解析到的材料回写进 case,保证后续 FEA/报告口径一致
128
+ lc_result["case"]["material"] = mat_resolve["material"]
129
+
130
+ summary = load_case.load_case_to_summary(lc_result["case"])
131
+ solver_status = fea_solver.get_solver_status()
132
+
133
+ return {
134
+ "ok": True,
135
+ "command": "build",
136
+ "load_case_summary": summary,
137
+ "material": mat_resolve["material"],
138
+ "solver_status": solver_status,
139
+ "next_step": "run physics-simulate after model is built",
140
+ }
141
+
142
+
143
+ def cmd_simulate(run_dir: str) -> dict:
144
+ """在指定 run_dir 运行一轮仿真。"""
145
+ state_file = os.path.join(run_dir, "_state.json")
146
+ if not os.path.exists(state_file):
147
+ return {"ok": False, "error": f"run directory not found: {run_dir}"}
148
+
149
+ with open(state_file, "r", encoding="utf-8") as f:
150
+ state = json.load(f)
151
+
152
+ lc_path = state.get("load_case_path", "")
153
+ lc_result = load_case.load_from_file(lc_path)
154
+ if not lc_result["ok"]:
155
+ return {"ok": False, "error": "invalid load case", "details": lc_result}
156
+
157
+ run_id = f"{os.path.basename(run_dir)}_iter_{len(state.get('runs', [])) + 1}"
158
+ output_dir = os.path.join(run_dir, f"iteration_{len(state.get('runs', [])) + 1}")
159
+ os.makedirs(output_dir, exist_ok=True)
160
+
161
+ # ── 【Bug11/Bug7 修复】cmd_simulate 同样不得校验虚构几何 ────────────
162
+ # 原实现与 cmd_optimize 一样用编造拓扑(faces=6/volume=100000),
163
+ # 且 design_params={} → 壁厚阈值取不到 → WALL_THICKNESS 永远 PASS。
164
+ # 现在从 state 取真实零件路径并提取真实几何;拿不到就明确标注。
165
+ _part_path = state.get("part_path") or state.get("model_path") or ""
166
+ # 【Bug18 根因修复】把工况显式传给 _extract_real_geometry,
167
+ # 否则它内部的 design_domain 估计路径会因引用同名模块而崩(bbox 恒 None)。
168
+ _geo = _extract_real_geometry(_part_path, lc_result.get("case")) if _part_path else {
169
+ "ok": False,
170
+ "error": ("state 未记录 part_path —— 无法取得真实几何。"
171
+ "请在 physics-build 时登记零件路径。"),
172
+ }
173
+ _geo_ok = bool(_geo.get("ok"))
174
+ # ── 【Bug17 修复·顺序是关键】必须在 solve_fea【之前】注入 real_geometry ──
175
+ # 原实现把这段写在 solve_fea 之后(L177 在 L156 之后)—— 求解时
176
+ # real_geometry 还不存在,fea_solver 的 `_rg` 恒为空 →
177
+ # `_used_real=False` → 退回 design_domain(200×60×60, vol=720000),
178
+ # 于是三个不同载荷算出同一个 SF(测试部 Bug17:SF 与载荷不敏感)。
179
+ # 写晚了等于没写。现在整体前移到求解之前。
180
+ if _geo_ok:
181
+ try:
182
+ lc_result["case"]["real_geometry"] = {
183
+ "volume_mm3": _geo.get("volume_mm3"),
184
+ "surface_area_mm2": _geo.get("surface_area_mm2"),
185
+ "mass_kg": _geo.get("mass_kg"),
186
+ "density_kg_m3": _geo.get("density_kg_m3"),
187
+ "bbox_mm": _geo.get("bbox_mm"),
188
+ "source": _geo.get("source"),
189
+ }
190
+ except Exception:
191
+ pass
192
+ # ── 【新 P0 修复】同 cmd_optimize:按【零件实际材料】校核 ──────────
193
+ # 载荷文件材料只是任务级设计意图;零件本体材料才是校核依据。
194
+ try:
195
+ _mat_f, _mat_m = _resolve_part_material(_geo, lc_result.get("case"))
196
+ if _mat_f:
197
+ lc_result["case"]["material"] = _mat_f
198
+ lc_result["case"]["material_resolution"] = _mat_m
199
+ except Exception as _e_mo:
200
+ try:
201
+ lc_result["case"]["material_resolution"] = {
202
+ "source": "load_case", "error": repr(_e_mo)}
203
+ except Exception:
204
+ pass
205
+ _wall_est = (_estimate_wall_from_geometry(_geo.get("volume_mm3"),
206
+ _geo.get("surface_area_mm2"))
207
+ if _geo_ok else None)
208
+ _min_wall = float(((lc_result["case"].get("acceptance") or {})
209
+ .get("min_wall_thickness_mm") or 2.0))
210
+ # ── 【Bug⑦ 修复】与 cmd_optimize 同口径:先判薄壁/镂空,再决定求解层级 ──
211
+ _THIN_ASSESS = _thin_hollow_assessment(
212
+ _geo, lc_result["case"], _wall_est, _min_wall)
213
+ _ROOM_NAME = _resolve_defense_room("")
214
+ _ROOM_TYPE = _resolve_room_type(_ROOM_NAME or "")
215
+ _design_params = {
216
+ "thickness_mm": {
217
+ "id": "thickness_mm",
218
+ "value": (round(float(_wall_est), 3) if _wall_est else None),
219
+ "source": ("real-geometry(V/A)" if _wall_est else "unmeasured"),
220
+ },
221
+ "min_wall_thickness": {
222
+ "id": "min_wall_thickness",
223
+ "value": _min_wall,
224
+ "source": "acceptance.min_wall_thickness_mm(默认 FDM 2.0mm)",
225
+ },
226
+ }
227
+ domain = lc_result["case"].get("design_domain", {}).get("bounds", {})
228
+ # ── 【Bug18 修复】body_bbox 必须用【真实包围盒】,不能用 design_domain ──
229
+ # 原实现把 design_domain 的 x/y/z 边界直接当 body_bbox 传进去,等于
230
+ # "拿设计域自己和自己比" → DESIGN_SPACE 恒 PASS,门禁形同虚设;
231
+ # 而当真实 bbox 缺失时又会读成 design_domain 的 z_max(60) 而非真实
232
+ # 厚度(10),造成"合格件被误拒"。两者都是错的。
233
+ # 现在:有真实 bbox 就用真实值;没有就传 None,让 geometry_gate 明确
234
+ # 报"未评估",而不是编一个必然 PASS 或必然 FAIL 的假值。
235
+ _bbox = _geo.get("bbox_mm") if _geo_ok else None
236
+ if not (isinstance(_bbox, (list, tuple)) and len(_bbox) >= 6):
237
+ _bbox = None
238
+ gc_result = geometry_gate.geometry_gate_report(
239
+ bbox=_bbox,
240
+ # 真实拓扑未知时传 0(让 CONNECTIVITY 自判 NOT_EVALUATED,
241
+ # 而不是喂假拓扑骗它 PASS)
242
+ topology={"volumes": (1 if _geo_ok else 0), "bodies": (1 if _geo_ok else 0),
243
+ "faces": None, "edges": None, "vertices": None},
244
+ face_areas=[],
245
+ volume_mm3=(float(_geo.get("volume_mm3") or 0) if _geo_ok else 0.0),
246
+ surface_area_mm2=(_geo.get("surface_area_mm2") if _geo_ok else None),
247
+ design_bounds=domain, interfaces=[], design_params=_design_params,
248
+ # 显式传阈值(双保险:即使 design_params 结构有变也不会失效)
249
+ min_wall_req_mm=_min_wall,
250
+ )
251
+
252
+ # ── 【Bug17 修复】求解放在【几何注入之后】────────────────────────────
253
+ # real_geometry 已在上方写入 lc_result["case"],此时求解才能用到真实尺寸。
254
+ # ── 【Bug⑦】薄壁/镂空 → 优先升级 L1(本机无 3D 求解器,绝不冒充 3D FEA)──
255
+ if _THIN_ASSESS.get("equivalent_model_applicable") is False:
256
+ fea_result, _l1_note = _prefer_l1_backend(lc_result["case"], output_dir)
257
+ try:
258
+ fea_result.setdefault("limitations", []).append(
259
+ "【Bug⑦·求解层级】" + _l1_note)
260
+ except Exception:
261
+ pass
262
+ else:
263
+ fea_result = fea_solver.solve_fea(lc_result["case"], output_dir=output_dir)
264
+ try:
265
+ fea_result["l1_available"] = bool(
266
+ (fea_solver.detect_available_backends() or {}).get("feapy"))
267
+ except Exception:
268
+ pass
269
+ # ── 【Bug⑩】任何后端的 load_model 都必须回显本次实际用的 share ──────────
270
+ _ensure_load_share_disclosure(lc_result["case"], fea_result)
271
+ gc_result["geometry_source"] = (_geo.get("source") if _geo_ok
272
+ else "UNMEASURED")
273
+ if not _geo_ok:
274
+ gc_result["geometry_error"] = _geo.get("error")
275
+
276
+ report = sim_report.build_report(
277
+ run_id=run_id, load_case=lc_result["case"], design_params=_design_params,
278
+ geometry_check=gc_result, fea_result=fea_result,
279
+ iteration=len(state.get("runs", [])) + 1,
280
+ parent_run_id=state.get("current_run_id"),
281
+ # ── 【Bug⑦】房间类型交由报告层判定是否降级为 REVIEW ──────────────
282
+ room_type=_ROOM_TYPE,
283
+ )
284
+ try:
285
+ report["thin_hollow_assessment"] = dict(_THIN_ASSESS)
286
+ except Exception:
287
+ pass
288
+ report_path = sim_report.save_report(report, output_dir)
289
+ report["report_path"] = report_path
290
+
291
+ ds = design_state.DesignState(run_dir, lc_path)
292
+ ds.new_run(run_id)
293
+ ds.update_run(run_id, {
294
+ "fea_result": fea_result, "geometry_check": gc_result,
295
+ "report_path": report_path, "status": report["overall_status"],
296
+ })
297
+
298
+ return report
299
+
300
+
301
+ def _find_output_base() -> str:
302
+ """定位 output 目录(兼容 DSH preset 和直接运行两种场景)。"""
303
+ candidates = [
304
+ os.path.join(_HERE, "..", "..", "output"), # tools/../../output
305
+ os.path.join(_HERE, "..", "output"), # physics/../output (when run from physics/)
306
+ ]
307
+ for c in candidates:
308
+ p = os.path.abspath(c)
309
+ if os.path.isdir(p):
310
+ return p
311
+ # fallback: same dir as this file
312
+ return os.path.abspath(os.path.join(_HERE, "..", "output"))
313
+
314
+
315
+ def _find_reports(run_id: str) -> list[str]: # noqa: 3.8 兼容由 __future__ annotations 保证
316
+ """在 output 目录下搜索匹配 run_id 的报告文件。"""
317
+ out_base = _find_output_base()
318
+ candidates = []
319
+ for subdir in ("", design_state.STATE_DIR_NAME, "demo_run"):
320
+ search_dir = os.path.join(out_base, subdir) if subdir else out_base
321
+ if not os.path.isdir(search_dir):
322
+ continue
323
+ for root, _, files in os.walk(search_dir):
324
+ for fn in files:
325
+ if fn.endswith("_report.json") and run_id in fn:
326
+ candidates.append(os.path.join(root, fn))
327
+ return candidates
328
+
329
+
330
+ def cmd_report(run_id: str) -> dict:
331
+ """查找并返回指定 run_id 的仿真报告。"""
332
+ # 先检查是否是直接文件路径
333
+ if os.path.isfile(run_id):
334
+ report_file = run_id
335
+ else:
336
+ report_file = None
337
+ for path in _find_reports(run_id):
338
+ if os.path.exists(path):
339
+ report_file = path
340
+ break
341
+
342
+ if not report_file:
343
+ return {"ok": False, "error": f"report not found for: {run_id}"}
344
+
345
+ with open(report_file, "r", encoding="utf-8") as f:
346
+ report = json.load(f)
347
+ md = sim_report.report_to_markdown(report)
348
+ return {"ok": True, "command": "report", "report": report, "markdown_summary": md}
349
+
350
+
351
+ def cmd_recommend(run_id: str, max_iter: int = 3) -> dict:
352
+ """为指定 run_id 生成修正建议。"""
353
+ report_data = cmd_report(run_id)
354
+ if not report_data.get("ok"):
355
+ return report_data
356
+
357
+ report = report_data["report"]
358
+ design_params = report.get("design_params", {
359
+ "thickness_mm": {"id": "thickness_mm", "value": 5.0, "min": 2, "max": 20},
360
+ "height_mm": {"id": "height_mm", "value": 60.0, "min": 20, "max": 200},
361
+ "fillet_mm": {"id": "fillet_mm", "value": 2.0, "min": 0.5, "max": 15},
362
+ })
363
+ locked = report.get("locked_params", [])
364
+
365
+ result = refine_rules.generate_recommendations(
366
+ report=report, design_params=design_params,
367
+ locked_params=locked, max_iterations=max_iter,
368
+ )
369
+ result["command"] = "recommend"
370
+ return result
371
+
372
+
373
+ def _normalize_param_aliases(design_params):
374
+ """把设计参数的【同义键名】归一化(观察点 20 修复)。
375
+
376
+ ── 为什么需要 ──────────────────────────────────────────────────────
377
+ 同一个物理量在仿真报告与领域规则里用了不同键名,规则按自己的名字取不到值,
378
+ 于是把"有数据"误判成"无数据(CRITICAL)",room-end 被无谓拦下。实测:
379
+ · 上罩 report.design_params 里是 `thickness_mm = 2.0`;
380
+ · `housing_rules.json` 的 HS-001 却要求 `wall_thickness_mm`;
381
+ · 结果报 "HS-001 wall_thickness_mm 无数据(CRITICAL)"。
382
+
383
+ 本函数只做**键名归一**:同义组内只要任一键有有效值,就补齐其余键。
384
+ 它【不创造】任何数值 —— 没数据的参数依旧没数据,规则照旧如实报缺,
385
+ 因此不会把"不合格"洗成"合规"。
386
+
387
+ Args:
388
+ design_params: 待归一化的设计参数字典。
389
+ Returns:
390
+ 归一化后的新字典(原字典不被修改)。
391
+ """
392
+ if not isinstance(design_params, dict):
393
+ return design_params
394
+ out = dict(design_params)
395
+
396
+ def _valid(v):
397
+ """有效值判据:非 None、非空串;0 视为"未测量"(与调用方口径一致)。"""
398
+ if v is None:
399
+ return False
400
+ if isinstance(v, str):
401
+ return v.strip() != ""
402
+ if isinstance(v, (int, float)):
403
+ return v != 0
404
+ return True
405
+
406
+ # 同义组:组内任一键有值 → 补齐其余键
407
+ _ALIAS_GROUPS = [
408
+ # 壁厚 / 特征厚度
409
+ ("thickness_mm", "wall_thickness_mm", "min_wall_thickness_mm",
410
+ "wall_thickness", "feature_thickness_mm"),
411
+ # 圆角
412
+ ("fillet_mm", "fillet_radius_mm", "min_fillet_mm"),
413
+ # 轴径
414
+ ("shaft_diameter_mm", "shaft_dia_mm", "axle_diameter_mm"),
415
+ # 齿轮模数
416
+ ("gear_module", "module_mm", "gear_module_mm"),
417
+ # 加强筋
418
+ ("rib_height_mm", "rib_mm", "rib_height"),
419
+ # 型腔深度
420
+ ("cavity_depth_mm", "cavity_mm", "cavity_depth"),
421
+ # 拔模角
422
+ ("draft_angle_deg", "draft_angle", "draft_deg"),
423
+ # 表面粗糙度
424
+ ("surface_roughness_um", "roughness_um", "ra_um"),
425
+ ]
426
+ for _grp in _ALIAS_GROUPS:
427
+ _hit = None
428
+ for _k in _grp:
429
+ if _k in out and _valid(out.get(_k)):
430
+ _hit = out.get(_k)
431
+ break
432
+ if _hit is None:
433
+ continue
434
+ for _k in _grp:
435
+ # 只补空缺,绝不覆盖调用方已显式给出的值
436
+ if _k not in out or not _valid(out.get(_k)):
437
+ out[_k] = _hit
438
+ return out
439
+
440
+
441
+ def _derive_design_params(part_path="", volume_mm3=None, surface_area_mm2=None,
442
+ bbox_mm=None):
443
+ """从真实零件几何推导【领域规则所需的设计参数】(Bug12 复发修复)。
444
+
445
+ ── 为什么需要 ──────────────────────────────────────────────────────
446
+ 四个领域规则声明的 target 大多是【设计参数】,例如:
447
+ structural : thickness_mm, fillet_mm
448
+ transmission : shaft_diameter_mm, gear_module
449
+ housing : wall_thickness_mm, rib_height_mm
450
+ mold : cavity_depth_mm, draft_angle_deg, surface_roughness_um
451
+ 上轮修复只接入了物理结果(von_mises/SF/mass),design_params 仍为空,
452
+ 于是每条规则都报"未识别的验证目标" → 领域防线③仍非真合规。
453
+ 本函数从【真实几何】尽力推导这些参数(能推的推,推不出的留给规则
454
+ 显式报缺失 —— 绝不编造数值)。
455
+
456
+ Returns: (design_params: dict, sources: dict)
457
+ """
458
+ dp, src = {}, {}
459
+ # ══ 【Bug12 二次修复·优先级】人工提供 > 几何推导 ══════════════════════
460
+ # 必须先读【人工提供通道】,再用几何估计【补空缺】。
461
+ # 反过来的话,几何估计会覆盖人工输入 —— 实测:人工登记
462
+ # shaft_diameter_mm=25,却被 bbox 最小边估计值 10.0 盖掉,
463
+ # 于是规则校验的是"编造尺寸"而不是"设计尺寸",Bug12 等于没修。
464
+ # 因此:人工值先落 dp;后面所有几何推导一律 setdefault(不覆盖)。
465
+ _manual_src = {}
466
+ try:
467
+ # 人工提供通道:按 [权威状态目录, 脚本目录] 顺序找(取第一个存在者)
468
+ _dp_file = None
469
+ for _dd in _data_dirs():
470
+ _cand = os.path.join(_dd, "design_params.json")
471
+ if os.path.exists(_cand):
472
+ _dp_file = _cand
473
+ break
474
+ if _dp_file and os.path.exists(_dp_file):
475
+ _dpj = json.load(open(_dp_file, "r", encoding="utf-8"))
476
+ _src_items = (_dpj.get("design_params")
477
+ if isinstance(_dpj.get("design_params"), dict) else _dpj)
478
+ for _k0, _v0 in (_src_items or {}).items():
479
+ _val0 = _v0.get("value") if isinstance(_v0, dict) else _v0
480
+ if _val0 is not None:
481
+ dp[_k0] = _val0
482
+ _manual_src[_k0] = ("design_params.json【人工提供】"
483
+ + ((" · %s" % _v0.get("source"))
484
+ if isinstance(_v0, dict) and _v0.get("source")
485
+ else ""))
486
+ src[_k0] = _manual_src[_k0]
487
+ except Exception:
488
+ pass
489
+ # ① 包围盒 → 特征尺寸
490
+ _dims = None
491
+ if bbox_mm and len(bbox_mm) >= 6:
492
+ try:
493
+ _dims = sorted([abs(float(bbox_mm[1]) - float(bbox_mm[0])),
494
+ abs(float(bbox_mm[3]) - float(bbox_mm[2])),
495
+ abs(float(bbox_mm[5]) - float(bbox_mm[4]))], reverse=True)
496
+ except Exception:
497
+ _dims = None
498
+ # ② 特征壁厚(2V/A,与 geometry_gate 同口径)
499
+ _t = None
500
+ try:
501
+ _v = float(volume_mm3) if volume_mm3 else 0.0
502
+ _a = float(surface_area_mm2) if surface_area_mm2 else 0.0
503
+ if _v > 0 and _a > 0:
504
+ _t = 2.0 * _v / _a
505
+ except Exception:
506
+ _t = None
507
+ # ③ 填入各规则可能用到的键(只填【推得出】的)
508
+ # 全部用 setdefault:人工提供通道的值优先,几何估计只补空缺。
509
+ if _t:
510
+ dp.setdefault("thickness_mm", round(_t, 3))
511
+ src.setdefault("thickness_mm", "2V/A(真实几何)")
512
+ dp.setdefault("wall_thickness_mm", round(_t, 3))
513
+ src.setdefault("wall_thickness_mm", "2V/A(真实几何)")
514
+ dp.setdefault("cavity_depth_mm", round(_t, 3))
515
+ src.setdefault("cavity_depth_mm", "2V/A(真实几何)")
516
+ if _dims:
517
+ # 最小边常为壁厚/筋高;次小边常为轴径/模数尺度
518
+ # 全部 setdefault:来源标注同样不得覆盖【人工提供】的标记,
519
+ # 否则排障时会误以为该值是几何估计出来的。
520
+ dp.setdefault("fillet_mm", round(min(_dims[2] * 0.1, 20.0), 3))
521
+ src.setdefault("fillet_mm", "bbox 最小边×0.1(估计)")
522
+ dp.setdefault("rib_height_mm", round(_dims[1] * 0.2, 3))
523
+ src.setdefault("rib_height_mm", "bbox 次长边×0.2(估计)")
524
+ dp.setdefault("shaft_diameter_mm", round(_dims[2], 3))
525
+ src.setdefault("shaft_diameter_mm", "bbox 最小边(估计)")
526
+ # ── 【Bug12 二次修复】禁止凭空编造"工艺/设计意图"参数 ────────────
527
+ # 上一轮修复为了让规则不再报"无数据",从几何硬凑了三个值:
528
+ # draft_angle_deg = 2.0° ("工艺常规值")
529
+ # surface_roughness_um = 1.6μm ("通用加工值")
530
+ # gear_module = 次小边/(z+2),z 猜 20
531
+ # 这是把【不存在的设计事实】写进凭据,比"报无数据"更危险:
532
+ # 一个真正的拔模角 0.5°(不合格)会被 2.0° 的假值掩盖成合规。
533
+ # 测试部反馈已明确指出:"transmission/mold 的参数本质是人工设计输入,
534
+ # 不是从几何能提取的 —— bbox 估计的修复方向对它们不适用"。
535
+ # 因此:这些参数【一律不由几何推导】,只接受人工显式提供(见下方 ④)。
536
+ # 缺失时由 domain_validator 判"不适用/需人工提供",绝不假通过。
537
+ _dmin, _dmid = _dims[2], _dims[1]
538
+ # 型腔深度:仅当零件确为"薄壁腔体"形态时才可由最小边近似(可辩护)
539
+ dp.setdefault("cavity_depth_mm", round(_dmin, 3))
540
+ src.setdefault("cavity_depth_mm", "bbox 最小边(估计,可被 design_params 覆盖)")
541
+ # ④ 再从【门禁已对齐的工况】补齐剩余设计参数(最低优先级)。
542
+ # gear_module / draft_angle_deg / surface_roughness_um 等属于【设计意图】
543
+ # 或【工艺参数】,从几何反推没有意义(会编造数值)。
544
+ # 这里只读真实落盘值;读不到就留给规则报"需人工提供"。
545
+ #
546
+ # ── 【Bug12 二次修复】读取优先级(高 → 低)──────────────────────────
547
+ # ① 调用方显式传入的 design_params(见 cmd_validate_domain,最高)
548
+ # ② tools/design_params.json —— 【人工提供通道】(在本函数开头已读)
549
+ # ③ load_cases/gate_load_case.json 的 design_params(本段)
550
+ # 三者都读不到 → 该参数就是"未提供",规则层如实报缺,绝不假通过。
551
+ try:
552
+ # 门禁落盘的工况:同样按 [权威, 脚本目录] 顺序查找
553
+ _lc = None
554
+ for _dd in _data_dirs():
555
+ _cand = os.path.join(_dd, "load_cases", "gate_load_case.json")
556
+ if os.path.exists(_cand):
557
+ _lc = _cand
558
+ break
559
+ if _lc and os.path.exists(_lc):
560
+ _lcj = json.load(open(_lc, "r", encoding="utf-8"))
561
+ _dp_src = (_lcj.get("design_params")
562
+ or (_lcj.get("case") or {}).get("design_params") or {})
563
+ for _k2, _v2 in (_dp_src or {}).items():
564
+ _val2 = _v2.get("value") if isinstance(_v2, dict) else _v2
565
+ if _val2 is not None and _k2 not in dp:
566
+ dp[_k2] = _val2
567
+ src[_k2] = "gate_load_case.json"
568
+ except Exception:
569
+ pass
570
+ return dp, src
571
+
572
+
573
+ def _physics_run_roots():
574
+ """受管的物理运行根目录(output/physics_runs 的候选落点)。
575
+
576
+ ── 【Bug④】抽成独立函数,让"按房间取运行"的判定与回归测试用同一套根目录,
577
+ 避免各调用点各拼一套导致口径不一致。
578
+ """
579
+ _here = os.path.dirname(os.path.abspath(__file__))
580
+ return [
581
+ os.path.abspath(os.path.join(_here, "..", "..", "output", "physics_runs")),
582
+ os.path.abspath(os.path.join(_here, "output", "physics_runs")),
583
+ os.path.abspath(os.path.join(_here, "..", "output", "physics_runs")),
584
+ ]
585
+
586
+
587
+ def _latest_real_physics(room=""):
588
+ """取【最近一次真实仿真】的结果,供领域防线使用(Bug12 修复)。
589
+
590
+ 背景:领域防线原本被 cmd_validate_domain 用空 design_params/fea_result 调用,
591
+ 规则拿到的 von_mises/displacement/mass/SF 全是 0,而 0 恰好不在多数
592
+ 违规区间里 → violations=[] 被误读成"合规"。这是"空数据不违规",
593
+ 不是真合规(测试部 Bug12)。
594
+ 修复:从 output/physics_runs 下【最近一次】报告里取出真实物理量。
595
+
596
+ Returns: (design_params, fea_result, meta) —— 取不到时返回 ({}, {}, {...})
597
+ """
598
+ import glob as _glob
599
+ _roots = _physics_run_roots()
600
+ _reports = []
601
+ for _r in _roots:
602
+ if not os.path.isdir(_r):
603
+ continue
604
+ _reports.extend(_glob.glob(os.path.join(_r, "*", "**", "*_report.json"),
605
+ recursive=True))
606
+ if not _reports:
607
+ return {}, {}, {"ok": False, "error": "未找到任何仿真报告(output/physics_runs 为空)"}
608
+ # 取最新的一份;若指定了 room,优先取该房间的
609
+ try:
610
+ _reports.sort(key=lambda p: os.path.getmtime(p), reverse=True)
611
+ except Exception:
612
+ pass
613
+ # ── 【Bug④ 修复】指定了 room 就【只】接受属于该房间的报告 ─────────────
614
+ # 原实现在找不到本房报告时【静默回退】到"全库最新的一份",于是房间的
615
+ # domain 凭据会把【别的房间/主代理历史运行】的仿真数据当成自己零件的
616
+ # 校核结果(实测:传动与轮组房取到 opt_iter_3,报出 TR-001 轴径 1.5mm /
617
+ # TR-005 变形 45.6mm 两条虚假 CRITICAL,而本房零件轴孔 Ø5、变形远小于 0.5mm)。
618
+ # 现在:本房没有真实仿真时明确判"取不到本房真实物理数据"(fail-closed),
619
+ # 由 cmd_validate_domain 拒绝签发凭据并给出可执行原因。
620
+ _pick = None
621
+ _want_room = str(room or "").strip()
622
+ if _want_room:
623
+ for _p in _reports:
624
+ try:
625
+ _j = json.load(open(_p, "r", encoding="utf-8"))
626
+ except Exception:
627
+ continue
628
+ if str(_j.get("room") or "").strip() == _want_room:
629
+ _pick = (_p, _j)
630
+ break
631
+ if _pick is None:
632
+ return {}, {}, {
633
+ "ok": False,
634
+ "error": ("受管 output/physics_runs 下没有任何属于房间 %r 的仿真报告 —— "
635
+ "不得借用其他房间/历史运行的数据(Bug④ fail-closed)"
636
+ % _want_room),
637
+ "room": _want_room,
638
+ "reason_code": "NO_RUN_FOR_ROOM",
639
+ "report_count": len(_reports),
640
+ }
641
+ if _pick is None:
642
+ # 【Bug④ 修复】未指定房间时才允许取"全库最新"(无归属语义的兜底)
643
+ for _p in _reports:
644
+ try:
645
+ _j = json.load(open(_p, "r", encoding="utf-8"))
646
+ except Exception:
647
+ continue
648
+ _pick = (_p, _j)
649
+ break
650
+ if _pick is None:
651
+ return {}, {}, {"ok": False, "error": "仿真报告均无法解析"}
652
+ _path, _rep = _pick
653
+ _fea = _rep.get("fea_result") or {}
654
+ # ── 【国标材料修复】把材料事实并入 fea_result ─────────────────────────
655
+ # 国标材料合规校验(domain=gb_material)需要屈服/密度/抗拉/弹性模量。
656
+ # 这些值在报告里可能位于多处以不同键存放,这里统一归一化后注入,
657
+ # 使领域防线(含国标材料层)能真正拿到数据,而不是"无数据→CRITICAL"。
658
+ try:
659
+ _mat = _rep.get("material") or (_rep.get("load_case_summary") or {}).get("material") or {}
660
+ if isinstance(_mat, dict) and _mat:
661
+ _fea.setdefault("material", _mat)
662
+ # 顶层也放一份常用键,兜住只读顶层的旧规则
663
+ for _dst, _cands in (
664
+ ("material_yield_mpa", ("yield_strength_mpa", "yield_mpa")),
665
+ ("material_density_kg_m3", ("density_kg_m3", "density")),
666
+ ("material_uts_mpa", ("uts_mpa",)),
667
+ ("material_e_mpa", ("youngs_modulus_mpa", "e_mpa"))):
668
+ if _fea.get(_dst) in (None, "", 0):
669
+ for _c in _cands:
670
+ if _mat.get(_c) not in (None, "", 0):
671
+ _fea[_dst] = _mat.get(_c)
672
+ break
673
+ # 真实几何里的密度(swapi.massprops)作为密度兜底来源
674
+ _rg = _rep.get("real_geometry") or {}
675
+ if isinstance(_rg, dict) and _rg.get("density_kg_m3") \
676
+ and _fea.get("material_density_kg_m3") in (None, "", 0):
677
+ _fea["material_density_kg_m3"] = _rg.get("density_kg_m3")
678
+ except Exception:
679
+ pass
680
+ _dp_raw = _rep.get("design_params") or {}
681
+ # design_params 可能是 {"thickness_mm": {"value":..}} 或 {"thickness_mm": 值}
682
+ _dp = {}
683
+ for _k, _v in _dp_raw.items():
684
+ _dp[_k] = _v.get("value") if isinstance(_v, dict) else _v
685
+ _acc = _rep.get("acceptance_criteria") or {}
686
+ _dp.setdefault("min_wall_thickness", _acc.get("min_wall_thickness_mm", 2.0))
687
+ return _dp, _fea, {"ok": True, "report_path": _path,
688
+ "run_id": _rep.get("run_id"),
689
+ "overall": _rep.get("overall_status")}
690
+
691
+
692
+ def cmd_validate_domain(domain_id: str = "structural",
693
+ design_params: dict = None,
694
+ fea_result: dict = None,
695
+ room: str = "") -> dict:
696
+ """领域特定验证(DSVA)入口。
697
+
698
+ 调用示例:
699
+ python physics_bridge.py validate-domain structural
700
+ python sw_bridge.py physics-validate-domain transmission
701
+
702
+ 详见 engineering/tools/physics/domain_validator.py
703
+ """
704
+ try:
705
+ import domain_validator
706
+ except ImportError:
707
+ return {"ok": False, "error": "domain_validator.py not found"}
708
+
709
+ # ── 【Bug12 修复】必须用【真实物理结果】跑规则 ──────────────────────
710
+ # 原实现默认 design_params={} / fea_result={} —— 规则拿到的
711
+ # von_mises/displacement/mass/SF 全是 0,而 0 不在多数违规区间内,
712
+ # 于是 violations=[] 被误读成"合规"(空数据不违规 ≠ 真合规)。
713
+ # 现在:显式传入的优先;否则从最近一次真实仿真报告取;
714
+ # 若两者都拿不到 → 明确标 data_missing 并判【不可判定】,
715
+ # 绝不返回"无违规=合规"。
716
+ # 【Bug④】是否用了调用方【人工提供】的参数(= 没有仿真运行可绑定)
717
+ _explicit_data = bool(design_params or fea_result)
718
+ _data_meta = {"source": "explicit" if _explicit_data else None}
719
+ if design_params is None:
720
+ design_params = {}
721
+ if fea_result is None:
722
+ fea_result = {}
723
+ _room_key = _resolve_defense_room(room)
724
+ if not design_params and not fea_result:
725
+ # ── 【Bug④ 修复】物理数据必须来自【本房间】的真实仿真(fail-closed)──
726
+ # 原实现在找不到本房报告时会静默回退到"全库最新的一份"(可能是
727
+ # 主代理历史运行 opt_iter_3、也可能是别的房间的运行)→ 规则在
728
+ # 别人的数据上跑出与本房零件无关的虚假 CRITICAL,并且凭据里
729
+ # 记录的 run_id 指向那个历史 run。
730
+ # 现在:_latest_real_physics(room) 已改为"给了 room 就只认本房",
731
+ # 取不到时这里直接【拒绝签发凭据】并说明原因,不再拿别的数据凑。
732
+ _dp, _fr, _meta = _latest_real_physics(_room_key or room)
733
+ design_params, fea_result = _dp, _fr
734
+ _data_meta = dict(_meta or {})
735
+ if not (isinstance(_data_meta, dict) and _data_meta.get("ok")):
736
+ _reason = ((_data_meta or {}).get("error")
737
+ or "取不到本房间的真实仿真数据")
738
+ result_blocked = {
739
+ "ok": False,
740
+ "status": "NOT_EVALUATED",
741
+ "domain": domain_id,
742
+ "room": _room_key or room,
743
+ "error": ("领域校验拒绝执行:%s。不得借用其他房间/历史运行的"
744
+ "仿真数据(Bug④ fail-closed)。" % _reason),
745
+ "reason_code": ((_data_meta or {}).get("reason_code")
746
+ or "NO_RUN_FOR_ROOM"),
747
+ "data_source": _data_meta,
748
+ "physics_available": False,
749
+ "violations": [],
750
+ "warnings": [{
751
+ "rule_id": "NO_RUN_FOR_ROOM",
752
+ "severity": "CRITICAL",
753
+ "message": "本房间没有可用的真实仿真运行,领域校验未执行",
754
+ }],
755
+ "hint": ("先在本房间跑一次 physics-optimize(带 --part 与 --room),"
756
+ "再执行 physics-validate-domain"),
757
+ }
758
+ # 不签发任何领域凭据(fail-closed):旧凭据若存在仍会被
759
+ # defense_gate 的 room↔run_id 一致性检查拦下。
760
+ return result_blocked
761
+ _data_meta["source"] = "physics_runs(本房间最近一次真实仿真)"
762
+ # ── 【观察点 20 修复】字段别名归一化 ──────────────────────────────────
763
+ # 同一物理量在【仿真报告】与【领域规则】里叫法不同,导致规则"读不到数据"
764
+ # 而误报 CRITICAL:
765
+ # report.design_params.thickness_mm ↔ housing_rules HS-001 要 wall_thickness_mm
766
+ # 实测:上罩 report 里是 thickness_mm=2.0,HS-001 却读 wall_thickness_mm →
767
+ # "HS-001 wall_thickness_mm 无数据(CRITICAL)" → room-end 被拦。
768
+ # 这里在把设计参数交给规则【之前】做一次别名归一:任一同义键有值,
769
+ # 就补齐其余同义键(不覆盖已有值)。这是"读取时归一化",不改变任何
770
+ # 真实测量结果,只让规则能按自己的键名取到同一个物理量。
771
+ design_params = _normalize_param_aliases(design_params)
772
+ # ── 【Bug12 复发修复】补上【设计参数】(领域规则真正需要的东西)──────
773
+ # 上轮只接了物理结果,design_params 仍空 → 四个领域规则全部报
774
+ # "未识别的验证目标"(thickness_mm/shaft_diameter_mm/wall_thickness_mm…)。
775
+ # 现在从真实零件几何推导这些参数并合并进去(不覆盖显式传入的值)。
776
+ try:
777
+ _rg = {}
778
+ if isinstance(_data_meta, dict):
779
+ _rp = _data_meta.get("report_path")
780
+ if _rp and os.path.exists(_rp):
781
+ _rj = json.load(open(_rp, "r", encoding="utf-8"))
782
+ _rg = _rj.get("real_geometry") or {}
783
+ _derived, _dsrc = _derive_design_params(
784
+ volume_mm3=_rg.get("volume_mm3"),
785
+ surface_area_mm2=_rg.get("surface_area_mm2"),
786
+ bbox_mm=_rg.get("bbox_mm"))
787
+ for _k, _v in (_derived or {}).items():
788
+ if _k not in design_params or design_params.get(_k) in (None, 0):
789
+ design_params[_k] = _v
790
+ # 几何推导出的键也要归一化(例如推导出 thickness_mm 后补齐 wall_thickness_mm)
791
+ design_params = _normalize_param_aliases(design_params)
792
+ if isinstance(_data_meta, dict):
793
+ _data_meta["derived_params"] = _dsrc
794
+ except Exception as _e_dp:
795
+ if isinstance(_data_meta, dict):
796
+ _data_meta["derive_error"] = repr(_e_dp)
797
+ # ── 【Bug④】确定"本次领域校验实际使用的 run_id"(凭据必须绑死它)────
798
+ # 来源优先级:数据源 meta 里的 run_id(= 本房最近一次真实仿真)
799
+ # > 该报告文件自身的 run_id。
800
+ # 都取不到 → 留空,后续【拒绝签发领域凭据】并说明原因(fail-closed),
801
+ # 绝不退回到"随便找一个历史 run 的 id"。
802
+ _used_run = str((_data_meta or {}).get("run_id") or "").strip()
803
+ _used_report = str((_data_meta or {}).get("report_path") or "").strip()
804
+ if not _used_run and _used_report and os.path.exists(_used_report):
805
+ try:
806
+ _used_run = str(
807
+ (json.load(open(_used_report, "r", encoding="utf-8"))
808
+ or {}).get("run_id") or "").strip()
809
+ except Exception:
810
+ _used_run = ""
811
+ if isinstance(_data_meta, dict):
812
+ _data_meta["run_id"] = _used_run
813
+ _has_real = bool(
814
+ (fea_result or {}).get("safety_factor")
815
+ or (fea_result or {}).get("max_von_mises_mpa")
816
+ or (fea_result or {}).get("max_displacement_mm")
817
+ or (fea_result or {}).get("mass_kg")
818
+ )
819
+ result = domain_validator.validate_with_domain(
820
+ design_params=design_params,
821
+ fea_result=fea_result,
822
+ domain_id=domain_id,
823
+ )
824
+ # ── 【国标材料修复】附加执行国标材料合规校验并并入结论 ────────────────
825
+ # 材料合规对所有领域都成立(结构件/传动件/壳体一样),但领域规则是
826
+ # "一个领域一份";把 gb_material 作为附加层调用,既让国标材料数据真正
827
+ # 参与领域防线,又避免把材料规则抄进 4~5 份 json 造成漂移。
828
+ try:
829
+ if domain_id != "gb_material":
830
+ _has_mat_fact = bool(
831
+ (fea_result or {}).get("material")
832
+ or (design_params or {}).get("material_yield_mpa")
833
+ or (design_params or {}).get("material_density_kg_m3")
834
+ or (design_params or {}).get("yield_strength_mpa")
835
+ or (design_params or {}).get("density_kg_m3"))
836
+ if _has_mat_fact:
837
+ _mat = domain_validator.validate_materials(design_params, fea_result)
838
+ if isinstance(_mat, dict) and _mat.get("domain"):
839
+ result = domain_validator.merge_domain_results(result, _mat)
840
+ except Exception as _e_mat:
841
+ result.setdefault("warnings", []).append({
842
+ "rule_id": "GB_MATERIAL_CHECK_ERROR",
843
+ "severity": "WARNING",
844
+ "message": "国标材料合规校验执行异常(不阻断):%r" % (_e_mat,),
845
+ })
846
+ result["data_source"] = _data_meta
847
+ result["physics_available"] = bool(_has_real)
848
+ if not _has_real:
849
+ # 空数据 → 不可判定(不是合规!)
850
+ result["ok"] = False
851
+ result["status"] = "NOT_EVALUATED"
852
+ result["error"] = ("领域校验缺少真实物理数据:所有验证目标值为 0/缺失。"
853
+ "空数据【不得】判为合规(Bug12)。")
854
+ result["hint"] = ("请先跑一次 physics-optimize --part <零件> 生成真实仿真报告,"
855
+ "或直接传入 design_params/fea_result。")
856
+ result.setdefault("violations", [])
857
+ result.setdefault("warnings", []).append({
858
+ "rule_id": "DATA_MISSING",
859
+ "severity": "CRITICAL",
860
+ "message": "领域规则在空数据上运行 —— 结论不可采信",
861
+ })
862
+ _emit_domain_attestation(_resolve_defense_room(room), result, domain_id,
863
+ run_id=_used_run, allow_no_run=_explicit_data)
864
+ result["available_domains"] = [d["id"] for d in domain_validator.list_domains()]
865
+ return result
866
+ # ── 【防线③领域】DSVA 结论一算出就自动落凭据 ──
867
+ # 【Bug④】run_id 必须是【本房本次校验实际使用】的那个(来自 data_source),
868
+ # 且 _emit_domain_attestation 会再核一次 room↔run_id 归属,不通过就拒签。
869
+ try:
870
+ _used_att = _emit_domain_attestation(
871
+ _resolve_defense_room(room), result, domain_id, run_id=_used_run,
872
+ allow_no_run=_explicit_data)
873
+ if _used_att is None:
874
+ result["attestation_written"] = False
875
+ result.setdefault("warnings", []).append({
876
+ "rule_id": "CREDENTIAL_NOT_ISSUED",
877
+ "severity": "CRITICAL",
878
+ "message": ("领域凭据【未签发】:run_id=%r 无法归属到本房间本次运行"
879
+ "(Bug④ fail-closed)—— room-end 将因此被拒"
880
+ % (_used_run,)),
881
+ })
882
+ else:
883
+ result["attestation_written"] = True
884
+ except Exception as _e_emit:
885
+ result["attestation_written"] = False
886
+ result.setdefault("warnings", []).append({
887
+ "rule_id": "CREDENTIAL_ISSUE_ERROR",
888
+ "severity": "WARNING",
889
+ "message": "领域凭据签发异常:%r" % (_e_emit,),
890
+ })
891
+
892
+ result["available_domains"] = [d["id"] for d in domain_validator.list_domains()]
893
+ return result
894
+
895
+
896
+ def cmd_list_domains() -> dict:
897
+ """列出所有可用的验证领域。"""
898
+ try:
899
+ import domain_validator
900
+ except ImportError:
901
+ return {"ok": False, "error": "domain_validator.py not found"}
902
+ return {
903
+ "ok": True,
904
+ "command": "list-domains",
905
+ "domains": domain_validator.list_domains(),
906
+ }
907
+
908
+
909
+ # ══════════════════════════════════════════════════════════════════════
910
+ # 【Bug7 修复】真实几何提取 —— 物理校核不得校验"空气"
911
+ # ══════════════════════════════════════════════════════════════════════
912
+ # 测试部实测:壁厚仅 0.5mm(远低于 FDM 最小 2mm)的极薄壁件,
913
+ # physics-optimize 仍返回 SF=72.81 PASS。
914
+ # 根因:cmd_optimize 把 design_params 硬编码成 thickness=5/height=60/
915
+ # width=40,并把 topology / volume_mm3 也写成编造值(volume=100000),
916
+ # 于是 geometry_gate 校验的是一个【虚构零件】,与真实 SW 模型无关 ——
917
+ # 任何不合格设计都能通过,防线②形同虚设。
918
+ # 修复原则:
919
+ # ① 几何必须来自【真实零件】(swapi.massprops 的真实体积/表面积);
920
+ # ② 拿不到真实几何时,WALL_THICKNESS 等门禁【不得默认 PASS】,
921
+ # 必须标 NOT_EVALUATED 并让整体结论不可交付;
922
+ # ③ design_params 不再凭空编造:没有真实值就如实标注来源。
923
+
924
+
925
+ def _attested_material_of(part_path):
926
+ """【P0 修复】读零件的【材料凭据】里记录的国标名。
927
+
928
+ ── 为什么需要 ────────────────────────────────────────────────────────
929
+ 实测根因链(子代理3):SW 材质库没有 "Q235",赋材时被近似成
930
+ "1023 碳钢板 (SS)"。若只看 SW 读回的名字,材料库里查不到,
931
+ 于是兜底回载荷文件材料(PETG) —— 国标材料在物理校核【永远失效】。
932
+ 而 .material.json 凭据里保存的是【用户请求的国标名】
933
+ (requested_material / attested_material_name = "Q235"),
934
+ 这才是"设计意图",也是材料库里真实存在的牌号。
935
+ 因此:优先取凭据里的国标名,SW 近似名只作次选。
936
+
937
+ Returns: (name, density) 或 (None, None)
938
+ """
939
+ try:
940
+ _p = str(part_path or "")
941
+ if not _p:
942
+ return None, None
943
+ # 大小写不敏感地找凭据(Bug-09 场景)
944
+ import defense_gate as _dg
945
+ _att = _dg._read_json(_dg.attestation_path(_p))
946
+ if not isinstance(_att, dict):
947
+ _d, _n = os.path.split(_p)
948
+ _want = (_n + ".material.json").casefold()
949
+ try:
950
+ for _e in os.listdir(_d):
951
+ if _e.casefold() == _want:
952
+ _att = _dg._read_json(os.path.join(_d, _e))
953
+ break
954
+ except Exception:
955
+ pass
956
+ if not isinstance(_att, dict):
957
+ return None, None
958
+ _name = (str(_att.get("attested_material_name") or "").strip()
959
+ or str(_att.get("requested_material") or "").strip()
960
+ or str(_att.get("attested_material") or "").strip())
961
+ _dens = _att.get("attested_density_kg_m3") or _att.get("density_kg_m3")
962
+ return (_name or None), _dens
963
+ except Exception:
964
+ return None, None
965
+
966
+
967
+ def _resolve_part_material(geo, lc_case, explicit_material=None):
968
+ """【新 P0 修复】解析【零件实际材料】,用于覆盖载荷文件里的材料。
969
+
970
+ ── 为什么必须做 ──────────────────────────────────────────────────────
971
+ 载荷工况(gate_load_case.json)里的 material 是【任务级设计意图】
972
+ (例如整个任务按 PETG 申报),而零件本体可能已被赋成别的材料
973
+ (例如小屋按 GB 标准用了 Q235)。两者不一致时,物理校核必须按
974
+ 【零件实际材料】判定 —— 否则:
975
+ · Q235 零件被按 PETG(σy=40) 算 → SF 0.15(误判为严重不合格),
976
+ 而按 Q235(σy=235) 算真实 SF 是 1.12;
977
+ · 国标材料的强度优势在物理校核里【完全无法体现】。
978
+ 实测(子代理2):real_geometry.density=7858(Q235) 正确,而
979
+ fea_result.mass_kg 却用 PETG(1270) 算出 0.0102(应为 0.0628)。
980
+
981
+ 优先级(高 → 低):
982
+ ① explicit_material —— 调用方显式指定(--material 参数)
983
+ ② 零件本体材料(geo.part_material_name + 密度)—— 真实事实,最可信
984
+ ③ 载荷文件材料 —— 兜底(保持向后兼容)
985
+
986
+ Returns: (material_dict, meta)
987
+ material_dict 为可直接写入 lc_case["material"] 的字典;
988
+ meta 说明来源与是否发生覆盖,便于报告如实留痕。
989
+ """
990
+ _case_mat = dict((lc_case or {}).get("material") or {})
991
+ _meta = {
992
+ "source": "load_case",
993
+ "overridden": False,
994
+ "load_case_material": _case_mat.get("name") or _case_mat.get("id"),
995
+ "part_material": None,
996
+ "notes": [],
997
+ }
998
+
999
+ def _props_from_name(name, density_hint=None):
1000
+ """按材料名解析完整属性(内置表 → physics material_db → 密度反推)。
1001
+
1002
+ ── 【P0 修复】必须能解析【SolidWorks 的近似名】──────────────────────
1003
+ 实测根因链(子代理3):SW 材质库没有 "Q235" → 近似成
1004
+ "1023 碳钢板 (SS)" → _extract_real_geometry 读回这个名字 →
1005
+ material_db / is_valid_material 都查不到 → 返回 None →
1006
+ 兜底载荷文件材料(PETG) → 国标材料在物理校核【永远失效】。
1007
+ 现在按三级解析:
1008
+ ① 精确名(swapi 内置表 / material_db,含 GB 系列与别名)
1009
+ ② 【近似名模糊解析】剥掉 " (SS)"/"(SS)" 等后缀,并按关键词
1010
+ (碳钢/不锈钢/铝/铸铁/塑料…)+ 牌号数字匹配材料库
1011
+ ③ 【密度反推】用零件实测密度在库中找密度最接近的材料
1012
+ (密度是 SW 写进零件的客观事实,最可靠)
1013
+ """
1014
+ _nm = str(name or "").strip()
1015
+ _has_density = False
1016
+ try:
1017
+ _has_density = bool(density_hint) and float(density_hint) > 0
1018
+ except Exception:
1019
+ _has_density = False
1020
+ # 名字为空但给了实测密度 → 仍可用密度反推(见下方 ③)
1021
+ if not _nm and not _has_density:
1022
+ return None
1023
+
1024
+ # ── 【数值来源标记必须透传】────────────────────────────────────────
1025
+ # 实测缺陷(子代理发现):内层函数原先"只挑固定几个字段重建 dict",
1026
+ # 把 values_source / values_from_gb / values_pending / identity_source
1027
+ # / equivalent_name 全部丢掉。后果:报告里看不出 235 MPa 到底是
1028
+ # 【GB 标准数值】还是【等效兜底值】—— "不冒充国标"的设计意图
1029
+ # 在下游彻底失效,防线也拦不住。
1030
+ # 现在:用统一的白名单把这些标记一并带出来,缺省填 None 保证键存在。
1031
+ _PROVENANCE_KEYS = (
1032
+ "values_source", "values_from_gb", "values_pending",
1033
+ "identity_source", "equivalent_name", "equivalent_id",
1034
+ "equivalent_filled", "gb_source", "standard",
1035
+ # 【BUG 修复】逐字段台账必须一并透传,否则报告无法精确说明
1036
+ # "哪个数来自国标、哪个数是兜底" —— 那正是本批次的核心意图。
1037
+ "gb_sourced_fields", "field_provenance", "fallback_fields",
1038
+ "native_fields", "consistency_guard", "missing_fields",
1039
+ )
1040
+
1041
+ def _carry_provenance(dst, src):
1042
+ """把来源标记从 src 透传到 dst(键始终存在,便于下游判断)。"""
1043
+ for _k in _PROVENANCE_KEYS:
1044
+ if _k not in dst:
1045
+ dst[_k] = src.get(_k)
1046
+ return dst
1047
+
1048
+ def _from_swapi(n):
1049
+ try:
1050
+ import swapi as _sw
1051
+ _ok, _i, _ = _sw.is_valid_material(n)
1052
+ if _ok and _i:
1053
+ _d = {
1054
+ "id": _i.get("canonical") or n,
1055
+ "name": n,
1056
+ "youngs_modulus_mpa": _i.get("e_mpa") or _i.get("youngs_modulus_mpa"),
1057
+ "poissons_ratio": _i.get("poissons_ratio"),
1058
+ "yield_strength_mpa": _i.get("yield_mpa") or _i.get("yield_strength_mpa"),
1059
+ "uts_mpa": _i.get("uts_mpa"),
1060
+ "density_kg_m3": _i.get("density") or _i.get("density_kg_m3"),
1061
+ "source": "swapi.is_valid_material(零件材料)",
1062
+ }
1063
+ return _carry_provenance(_d, _i)
1064
+ except Exception:
1065
+ return None
1066
+ return None
1067
+
1068
+ def _from_mdb(n):
1069
+ try:
1070
+ _m = material_db.get_material(n)
1071
+ if _m:
1072
+ _d = {
1073
+ "id": _m.get("id") or n,
1074
+ "name": _m.get("name") or n,
1075
+ "youngs_modulus_mpa": _m.get("E_mpa"),
1076
+ "poissons_ratio": _m.get("nu"),
1077
+ "yield_strength_mpa": _m.get("yield_mpa"),
1078
+ "uts_mpa": _m.get("uts_mpa"),
1079
+ "density_kg_m3": _m.get("density_kg_m3"),
1080
+ "standard": _m.get("standard"),
1081
+ "source": "material_db.get_material(零件材料)",
1082
+ }
1083
+ return _carry_provenance(_d, _m)
1084
+ except Exception:
1085
+ return None
1086
+ return None
1087
+
1088
+ def _by_density(d):
1089
+ """按密度【定族 → 取代表牌号】,并明确标注为推断值。
1090
+
1091
+ ── 为什么不能"取密度最接近的材料" ──────────────────────────────
1092
+ 实测:2700 kg/m³ 下有 1060(σy35) / 6061-T6(σy276) 等同密度材料,
1093
+ "最接近"会选中 1060 → 屈服被低估 8 倍,SF 严重失真。
1094
+ 因此改为:密度只用来判定【材料族】,族内取【常用代表牌号】,
1095
+ 并在返回值里标 inferred=True + 建议复核,避免"安静地算错"。
1096
+ 密度无法归族时返回 None(宁可不判定,也不猜错)。
1097
+ """
1098
+ try:
1099
+ _d = float(d)
1100
+ except Exception:
1101
+ return None
1102
+ if _d <= 0:
1103
+ return None
1104
+ # 密度 → (族, 代表牌号)。区间取自常用工程材料密度范围。
1105
+ _FAMILY_REP = (
1106
+ (700.0, 1500.0, "plastic", "PC"), # 工程塑料
1107
+ (2400.0, 2900.0, "aluminum", "GB_6061"), # 铝及铝合金
1108
+ (4300.0, 4700.0, "titanium", "TI_GRADE5"), # 钛合金
1109
+ (6900.0, 7300.0, "cast_iron", "GB_HT200"), # 铸铁
1110
+ (7400.0, 8100.0, "steel", "GB_Q235"), # 碳钢/合金钢/不锈钢
1111
+ (8300.0, 9000.0, "copper", "GB_H62"), # 铜合金
1112
+ )
1113
+ for _lo, _hi, _fam, _rep in _FAMILY_REP:
1114
+ if _lo <= _d <= _hi:
1115
+ _m = _from_mdb(_rep)
1116
+ if _m:
1117
+ _m["source"] = ("密度 %.0f kg/m³ 归族为 %s → 取代表牌号 %s"
1118
+ "(【推断值,须人工复核】)"
1119
+ % (_d, _fam, _m.get("name")))
1120
+ _m["inferred"] = True
1121
+ _m["inferred_family"] = _fam
1122
+ return _m
1123
+ return None
1124
+
1125
+ # ① 精确名
1126
+ _info = _from_swapi(_nm) or _from_mdb(_nm)
1127
+ # ② 近似名模糊解析:剥后缀 → 关键词 + 数字匹配
1128
+ if not _info:
1129
+ import re as _re
1130
+ _clean = _re.sub(r"\s*[((]\s*S{1,2}\s*[))]\s*$", "", _nm, flags=_re.I)
1131
+ _clean = _re.sub(r"\s*[((][^))]*[))]\s*$", "", _clean).strip()
1132
+ if _clean and _clean != _nm:
1133
+ _info = _from_swapi(_clean) or _from_mdb(_clean)
1134
+ # 关键词 → 用材料库里同类材料兜住(并如实标注为"近似")
1135
+ if not _info:
1136
+ _kw = None
1137
+ _low = (_clean or _nm).lower()
1138
+ for _k, _canon in (
1139
+ ("碳钢", "GB_Q235"), ("carbon steel", "GB_Q235"),
1140
+ ("不锈钢", "GB_06CR19NI10"), ("stainless", "GB_06CR19NI10"),
1141
+ ("铸铁", "GB_HT200"), ("cast iron", "GB_HT200"),
1142
+ ("合金钢", "GB_40CR"), ("alloy steel", "GB_40CR"),
1143
+ ("铝", "GB_6061"), ("aluminum", "GB_6061"),
1144
+ ("钛", "TI_GRADE5"), ("titanium", "TI_GRADE5"),
1145
+ ("黄铜", "GB_H62"), ("青铜", "GB_ZCUSN10")):
1146
+ if _k in _low:
1147
+ _kw = _canon
1148
+ break
1149
+ # 牌号数字优先(如 "1023" / "1045" / "4140")
1150
+ _num = _re.search(r"\b(1023|1020|1045|4140|4340|304|316|6061|7075)\b",
1151
+ _clean or _nm)
1152
+ if _num:
1153
+ _g = _num.group(1)
1154
+ _kw = {"1023": "GB_Q235", "1020": "GB_Q235", "1045": "GB_45",
1155
+ "4140": "GB_40CR", "4340": "GB_40CR",
1156
+ "304": "GB_06CR19NI10", "316": "GB_022CR17NI12MO2",
1157
+ "6061": "GB_6061", "7075": "GB_7075"}.get(_g, _kw)
1158
+ if _kw:
1159
+ _info = _from_mdb(_kw)
1160
+ if _info:
1161
+ _info["source"] = ("SW 近似名映射(%s → %s)"
1162
+ % (_nm, _info.get("name")))
1163
+ _info["approximate_from_sw"] = _nm
1164
+ # ③ 密度反推(前两级都失败时)
1165
+ if not _info and density_hint:
1166
+ _info = _by_density(density_hint)
1167
+ if not _info:
1168
+ return None
1169
+ # 密度:零件实测密度最可信(SW 读回),优先于库值
1170
+ if density_hint:
1171
+ try:
1172
+ _d = float(density_hint)
1173
+ if _d > 0 and abs(_d - 1000.0) > 1.0: # 排除"水"默认值
1174
+ _info["density_kg_m3"] = _d
1175
+ _info["density_source"] = "零件实测(massprops)"
1176
+ except Exception:
1177
+ pass
1178
+ if not _info.get("density_kg_m3") and density_hint:
1179
+ try:
1180
+ _info["density_kg_m3"] = float(density_hint)
1181
+ except Exception:
1182
+ pass
1183
+ return _info
1184
+
1185
+ # ① 显式指定优先
1186
+ if explicit_material:
1187
+ _m = (_props_from_name(explicit_material) if isinstance(explicit_material, str)
1188
+ else dict(explicit_material))
1189
+ if _m:
1190
+ _meta.update({"source": "explicit", "part_material": _m.get("name")})
1191
+ if (_case_mat.get("name") or "") != (_m.get("name") or ""):
1192
+ _meta["overridden"] = True
1193
+ _meta["notes"].append("按调用方显式指定的材料校核")
1194
+ return _m, _meta
1195
+
1196
+ # ② 零件本体材料
1197
+ # ── 【P0 修复】优先级细化:凭据里的【国标名】 > SW 读回的近似名 ────────
1198
+ # 实测:SW 无 Q235 → 近似成 "1023 碳钢板 (SS)" → 材料库查不到 → 兜底
1199
+ # PETG → 国标材料失效。而凭据里保存的是用户请求的 "Q235"(真实牌号),
1200
+ # 因此必须【先查凭据】,再退回 SW 近似名。
1201
+ _pn = (geo or {}).get("part_material_name")
1202
+ _pd = (geo or {}).get("part_material_density_kg_m3")
1203
+ _part_path = (geo or {}).get("part_path") or (geo or {}).get("path")
1204
+ _att_name, _att_dens = _attested_material_of(_part_path) if _part_path else (None, None)
1205
+ _att_dens = _att_dens or _pd
1206
+ # ②a 凭据国标名(最可信的"设计意图")
1207
+ if _att_name:
1208
+ _m = _props_from_name(_att_name, _att_dens)
1209
+ if _m:
1210
+ _meta.update({"source": "attested_credential",
1211
+ "part_material": _m.get("name"),
1212
+ "attested_name": _att_name,
1213
+ "sw_applied_name": _pn})
1214
+ _case_nm = str(_case_mat.get("name") or "").strip()
1215
+ _new_nm = str(_m.get("name") or "").strip()
1216
+ if _case_nm and _new_nm and _case_nm.lower() != _new_nm.lower():
1217
+ _meta["overridden"] = True
1218
+ _meta["notes"].append(
1219
+ "按【零件材料凭据】的牌号 %s 校核(载荷文件为 %s)%s"
1220
+ % (_new_nm, _case_nm,
1221
+ (";SW 实际生效名为 %s" % _pn) if (_pn and _pn != _new_nm) else ""))
1222
+ elif not _case_nm:
1223
+ _meta["overridden"] = True
1224
+ _meta["notes"].append("载荷文件未给材料,已采用凭据中的零件材料")
1225
+ return _m, _meta
1226
+ # ②b SW 读回的名字(含近似名模糊解析 / 密度反推)
1227
+ if _pn:
1228
+ _m = _props_from_name(_pn, _pd)
1229
+ if _m:
1230
+ _meta.update({"source": "part", "part_material": _m.get("name")})
1231
+ _case_nm = str(_case_mat.get("name") or "").strip()
1232
+ _new_nm = str(_m.get("name") or "").strip()
1233
+ if _case_nm and _new_nm and _case_nm.lower() != _new_nm.lower():
1234
+ _meta["overridden"] = True
1235
+ _meta["notes"].append(
1236
+ "零件实际材料 %s 与载荷文件材料 %s 不一致 —— 已按【零件实际材料】校核"
1237
+ % (_new_nm, _case_nm))
1238
+ elif not _case_nm:
1239
+ _meta["overridden"] = True
1240
+ _meta["notes"].append("载荷文件未给材料,已采用零件实际材料")
1241
+ return _m, _meta
1242
+ # ②c 仅凭实测密度反推(名字完全解析不出来时)
1243
+ if _pd:
1244
+ _m = _props_from_name("", _pd)
1245
+ if _m:
1246
+ _meta.update({"source": "density_inference",
1247
+ "part_material": _m.get("name")})
1248
+ _meta["overridden"] = True
1249
+ _meta["notes"].append(
1250
+ "零件材料名无法解析(SW 名=%r),已按实测密度 %.0f kg/m³ 反推为 %s"
1251
+ % (_pn, float(_pd), _m.get("name")))
1252
+ return _m, _meta
1253
+
1254
+ # ③ 兜底:载荷文件材料
1255
+ _meta["notes"].append(
1256
+ "未能从零件读出材料(可能未赋材或 SW 未连接)—— 沿用载荷文件材料;"
1257
+ "若零件实际材料不同,校核结论会失真")
1258
+ return _case_mat, _meta
1259
+
1260
+
1261
+ def _extract_real_geometry(part_path, load_case_dict=None):
1262
+ """从真实 SolidWorks 零件提取几何事实(体积/表面积/包围盒)。
1263
+
1264
+ 仅在零件路径存在且能连上 SolidWorks 时成功;否则返回 ok=False,
1265
+ 调用方【必须】据此判定"几何不可用",绝不用编造值代替。
1266
+
1267
+ Args:
1268
+ part_path: 零件文件路径。
1269
+ load_case_dict: 当前载荷工况字典(可选)。用于在拿不到 SW 真实包围盒
1270
+ 时,按【设计域尺寸 + 真实体积】反推厚度 t=V/(dx*dy)。
1271
+
1272
+ ── 【Bug18/Bug17 根因修复】必须显式接收工况,不得引用同名模块 ──────────
1273
+ 原实现在函数体内写了 `load_case.get("design_domain")`。但本模块顶部有
1274
+ `import load_case`(physics/load_case.py),函数签名里又没有 load_case
1275
+ 参数 —— 于是 `load_case` 解析为【模块对象】,`.get` 不存在,抛
1276
+ `AttributeError: module 'load_case' has no attribute 'get'`。
1277
+ 该异常被下方宽泛的 `except Exception as _e_bb` 吞掉,后果是:
1278
+ · bbox_mm 恒为 None(估计路径整个失效)
1279
+ · geometry_gate 退回用 design_domain 当 body_bbox →
1280
+ 承载板 200×60×10 被读成 200×60×60(体积 720000 而非 120000)
1281
+ · real_geometry.bbox_mm 为 None → FEA 的 _used_real=False →
1282
+ 退化到 design_domain,SF 与载荷不敏感(Bug17)
1283
+ 现在改为显式参数传入,并把宽泛 except 收紧为可诊断的报错留痕。
1284
+ """
1285
+ out = {"ok": False, "source": None}
1286
+ if not part_path or not os.path.exists(str(part_path)):
1287
+ out["error"] = "零件路径不存在: %r" % (part_path,)
1288
+ return out
1289
+ _tools = os.path.dirname(os.path.abspath(__file__))
1290
+ if _tools not in sys.path:
1291
+ sys.path.insert(0, _tools)
1292
+ try:
1293
+ import swapi # 延迟导入:无 pywin32 的环境仍可跑纯计算命令
1294
+ except Exception as e:
1295
+ out["error"] = "swapi 不可用(无法读取真实几何): %r" % (e,)
1296
+ return out
1297
+ try:
1298
+ sw = swapi.get_sw()
1299
+ if sw is None:
1300
+ out["error"] = "无法连接 SolidWorks"
1301
+ return out
1302
+ # ── 【Bug9 修复】打开零件必须用 swapi 的正确封装 ──────────────
1303
+ # 实机(SW2025 SP5.0)诊断结论:
1304
+ # ① model.GetPathName 是【属性】不是方法 —— 加括号会抛
1305
+ # TypeError("'str' object is not callable");
1306
+ # ② OpenDoc6 必须传满 6 个参数(FileName, Type, Options,
1307
+ # Configuration, Errors, Warnings),且后两个要 VARIANT:
1308
+ # OpenDoc6(path,1,0,"",0,0) → com_error(-2147352571 类型不匹配)
1309
+ # OpenDoc6(path,1,0) → com_error(-2147352561 非选择性参数)
1310
+ # swapi.SWModel.open_part() 已封装正确签名(swapi.py L7996+),
1311
+ # 直接复用,避免在此重复踩坑。
1312
+ try:
1313
+ m = None
1314
+ _cur = ""
1315
+ try:
1316
+ _m0 = swapi.from_active(sw)
1317
+ # 注意:GetPathName 是属性(无括号)
1318
+ _cur = str(_m0.model.GetPathName or "")
1319
+ except Exception:
1320
+ _cur = ""
1321
+ if _cur and os.path.abspath(_cur) == os.path.abspath(str(part_path)):
1322
+ m = _m0 # 已是目标文档,直接复用
1323
+ else:
1324
+ _op = swapi.SWModel.open_part(sw, str(part_path), doc_type=1)
1325
+ if not isinstance(_op, dict) or not _op.get("ok"):
1326
+ out["error"] = ("打开零件失败: %r"
1327
+ % ((_op or {}).get("error") or _op,))
1328
+ out["open_detail"] = _op
1329
+ return out
1330
+ # open_part 返回 {ok, doc, title};这里把 doc 包成 SWModel
1331
+ _doc = _op.get("doc")
1332
+ if _doc is None:
1333
+ out["error"] = "open_part 未返回 doc 对象"
1334
+ out["open_detail"] = {k: v for k, v in (_op or {}).items()
1335
+ if k != "doc"}
1336
+ return out
1337
+ m = swapi.SWModel(sw, _doc)
1338
+ except Exception as e:
1339
+ out["error"] = "打开零件异常: %r" % (e,)
1340
+ return out
1341
+ mp = m.massprops(safe=True)
1342
+ if not isinstance(mp, dict) or not mp.get("ok"):
1343
+ out["error"] = "massprops 失败(无法取得真实体积): %r" % (mp,)
1344
+ return out
1345
+ _vol = mp.get("volume_mm3")
1346
+ if not _vol or float(_vol) <= 0:
1347
+ out["error"] = "真实体积为 0/缺失 —— 零件可能没有实体"
1348
+ return out
1349
+ out.update({
1350
+ "ok": True,
1351
+ # ── 【P0 修复】必须回传零件路径:材料解析要用它去找 .material.json
1352
+ # 凭据(凭据里存的是用户请求的【国标名】,比 SW 近似名可信)。
1353
+ "part_path": os.path.abspath(str(part_path)),
1354
+ "volume_mm3": float(_vol),
1355
+ "surface_area_mm2": mp.get("surface_area_mm2"),
1356
+ "mass_kg": mp.get("mass_kg"),
1357
+ "density_kg_m3": mp.get("density_kg_m3"),
1358
+ "center_of_mass_mm": mp.get("center_of_mass_mm"),
1359
+ "volume_source": mp.get("volume_source"),
1360
+ "source": "swapi.massprops(真实实体体积)",
1361
+ })
1362
+ # ── 【新 P0 修复】同时读取【零件的实际材料】─────────────────────────
1363
+ # 背景(子代理2 实测):physics 校核原先【完全跟随载荷文件】的材料
1364
+ # (gate_load_case.json 里是 PETG),而零件本体其实已赋 Q235。
1365
+ # 后果:Q235 零件(σy=235)被按 PETG(σy=40)判定 →
1366
+ # σ_peak=263MPa 时 SF 从 1.12 掉到 0.15,国标材料在物理校核里
1367
+ # 【完全没发挥作用】。
1368
+ # 修复:这里把零件的真实材料名/密度一并取出,供调用方覆盖载荷材料。
1369
+ try:
1370
+ _gm = m.get_material()
1371
+ if isinstance(_gm, dict):
1372
+ out["part_material_name"] = _gm.get("name")
1373
+ out["part_material_density_kg_m3"] = _gm.get("density_kg_m3")
1374
+ out["part_material_mass_kg"] = _gm.get("mass_kg")
1375
+ # SW 返回的可能是 IdName(如 "ST_API_1020")也可能是显示名
1376
+ _mn = str(_gm.get("name") or "").strip()
1377
+ if _mn:
1378
+ out["part_material_source"] = "swapi.get_material(零件本体)"
1379
+ except Exception as _e_mat:
1380
+ out["part_material_error"] = repr(_e_mat)
1381
+ # ── 【Bug17/18 回归修复·核心】补上【真实包围盒】────────────────────
1382
+ # 测试部 v13 反馈(原话):
1383
+ # «_extract_real_geometry 的变量遮蔽虽改了,但 bbox x/y 仍被
1384
+ # design_domain 锁定,FEA 仍用 design_domain»;实测 10 种零件
1385
+ # (100×60×10 / 100×100×10 / Ø20×100 …)读出的 bbox 全部是
1386
+ # [0,200,0,60,…] 即 design_domain 的 x/y,fea_vol 全 720000,SF 全 72.81。
1387
+ #
1388
+ # 根因:上一版在拿不到 bbox 时,用
1389
+ # _bb = [0, _dx, 0, _dy, 0, t] ← _dx/_dy 来自 design_domain
1390
+ # 形如"估计"实则【把设计域当成了零件尺寸】,等于伪造几何。
1391
+ # 而且它让 SF 对真实尺寸完全不敏感 —— 比"没有 bbox"更糟。
1392
+ #
1393
+ # 正确做法:用 SolidWorks 的真实 API `IBody2.GetBodyBox()`。
1394
+ # swapi 里已有 _bbox_extent_mm/_bbox_max_dim_mm 在用这个 API,
1395
+ # 本文件现在调用新增的 SWModel.body_box_mm() 取【6 元组真实包围盒】。
1396
+ # 拿不到真实 bbox 时:【不给 bbox】(保持 None),由 geometry_gate 判
1397
+ # NOT_EVALUATED,绝不编造 —— 宁可不判定,也不假通过。
1398
+ try:
1399
+ _bb = None
1400
+ _bb_src = None
1401
+ try:
1402
+ # ① 首选:SWModel.body_box_mm()(真实 GetBodyBox,mm,6 元组)
1403
+ _f = getattr(m, "body_box_mm", None)
1404
+ if callable(_f):
1405
+ _r = _f()
1406
+ if isinstance(_r, (list, tuple)) and len(_r) >= 6:
1407
+ _bb = [float(x) for x in _r[:6]]
1408
+ _bb_src = "swapi.body_box_mm(真实 GetBodyBox)"
1409
+ except Exception as _e1:
1410
+ out["bbox_probe_error"] = repr(_e1)
1411
+ # ② 兼容旧命名(若某些分支未同步)
1412
+ if _bb is None:
1413
+ try:
1414
+ for _bn in ("get_bbox_mm", "bounding_box_mm"):
1415
+ _f2 = getattr(m, _bn, None)
1416
+ if callable(_f2):
1417
+ _r2 = _f2()
1418
+ if isinstance(_r2, (list, tuple)) and len(_r2) >= 6:
1419
+ _bb = [float(x) for x in _r2[:6]]
1420
+ _bb_src = "swapi.%s" % _bn
1421
+ break
1422
+ except Exception:
1423
+ pass
1424
+ # ③ 仍拿不到 → 【不再编造】。只记录诊断,bbox 保持 None。
1425
+ if _bb is None:
1426
+ out["bbox_unavailable"] = (
1427
+ "无法从 SolidWorks 取得真实包围盒(GetBodyBox 不可用)。"
1428
+ "按设计原则【不编造 bbox】—— 依赖 bbox 的门禁(DESIGN_SPACE)"
1429
+ "将判 NOT_EVALUATED 而非假通过。")
1430
+ if _bb:
1431
+ out["bbox_mm"] = _bb
1432
+ out["bbox_source"] = _bb_src or "swapi.GetBodyBox"
1433
+ except Exception as _e_bb:
1434
+ out["bbox_error"] = repr(_e_bb)
1435
+ return out
1436
+ except Exception as e:
1437
+ out["error"] = "真实几何提取异常: %r" % (e,)
1438
+ return out
1439
+
1440
+
1441
+ def _estimate_wall_from_geometry(vol_mm3, area_mm2):
1442
+ """由真实体积/表面积估算特征壁厚(V/A 比)。
1443
+
1444
+ 这是【保守近似】:对薄壁件 V/A ≈ 半壁厚量级,用于识别"明显过薄"。
1445
+ 精确壁厚需 STEP 网格分析;此处只求"不合格件不得被放过"。
1446
+ """
1447
+ try:
1448
+ v = float(vol_mm3)
1449
+ a = float(area_mm2)
1450
+ if v <= 0 or a <= 0:
1451
+ return None
1452
+ return (2.0 * v) / a # 等效特征尺寸(薄板近似)
1453
+ except Exception:
1454
+ return None
1455
+
1456
+
1457
+ # ══════════════════════════════════════════════════════════════════════
1458
+ # 【Bug⑦】薄壁/镂空件的等效模型适用性判定 + L1 升级
1459
+ # ══════════════════════════════════════════════════════════════════════
1460
+ # 问题:L0(解析梁)/L1(numpy CST 2D 平面应力)都把零件【包围盒的排序
1461
+ # 三边】当成【实心矩形棱柱】来算。薄壁/镂空/变截面件的等效实心体积比
1462
+ # 真实体积大好几倍 → 截面惯性矩虚大 → 应力虚低 → 安全系数虚高
1463
+ # (实测:底盘 SF=31.55、传感器支架 SF=61.93,合理区间只有 2~5)。
1464
+ # 处理(诚实优先、不伪造能力):
1465
+ # · 用【已有】真实几何(surface_area/volume 的 2V/A 壁厚估计 + 等效体积
1466
+ # 失真倍数)判定薄壁/镂空 —— 不新增数据依赖,不做无据推断;
1467
+ # · 若本机有 L1(feapy/numpy,恒可用)而当前跑的是 L0 解析解 → 升级到 L1
1468
+ # (L1 至少是二维平面应力的数值解,比梁公式更接近真实,但仍然【不是】
1469
+ # 3D FEA —— 这点必须在 limitations 里写清楚,不得冒充);
1470
+ # · 房间类型属承力类(structural/housing/transmission 及别名)且判定为
1471
+ # 薄壁/镂空 → 结果降级为 REVIEW(不得 PASS),limitations 显式声明。
1472
+ # ⚠️ 只降级、不判 FAIL —— 不把现有房间全部判失败(现场安全)。
1473
+ _THIN_WALL_THRESHOLD_MM = 3.0 # FDM 打印薄壁判据(与 simulation_report 同源)
1474
+ _HOLLOW_INFLATION_LIMIT = 1.5 # 等效实心体积 / 真实体积 超过即判镂空
1475
+ _EQ_GUARD_ROOM_TYPES = ("structural", "housing", "transmission",
1476
+ "support", "spring", "thermal", "corrosion")
1477
+
1478
+
1479
+ def _norm_room_type(rtype):
1480
+ """房间类型归一化(别名与 defense_gate/workflow_gate 同源)。"""
1481
+ _alias = {
1482
+ "drivetrain": "transmission", "drive_train": "transmission",
1483
+ "sensor": "housing", "sensors": "housing", "electronics": "housing",
1484
+ "drawing": "drafting", "drawings": "drafting", "draft": "drafting",
1485
+ "assembly_verify": "assembly", "prime": "assembly",
1486
+ }
1487
+ _t = str(rtype or "").strip().lower()
1488
+ return _alias.get(_t, _t) if _t else ""
1489
+
1490
+
1491
+ def _resolve_room_type(room):
1492
+ """取房间类型(显式 env > workflow_state 快照 > mode_state 快照)。
1493
+
1494
+ 取不到返回 ""(调用方按"未知 → 保守按承力类处理",见 Bug⑥ 的 fail-closed
1495
+ 口径:绝不再因为"类型未知"就豁免物理校核)。
1496
+ """
1497
+ try:
1498
+ _envt = str(os.environ.get("DSH_ROOM_TYPE") or "").strip()
1499
+ if _envt:
1500
+ return _norm_room_type(_envt)
1501
+ except Exception:
1502
+ pass
1503
+ _room = str(room or "").strip()
1504
+ if not _room:
1505
+ return ""
1506
+ for _fn in ("workflow_state.json", "mode_state.json"):
1507
+ try:
1508
+ _p = os.path.join(STATE_DIR, _fn)
1509
+ if not os.path.exists(_p):
1510
+ continue
1511
+ with open(_p, "r", encoding="utf-8") as _f:
1512
+ _d = json.load(_f) or {}
1513
+ except Exception:
1514
+ continue
1515
+ try:
1516
+ _cfg = _d.get("subagent_config") or {}
1517
+ for _row in (_cfg.get("rooms") or []):
1518
+ if isinstance(_row, (list, tuple)) and len(_row) >= 2:
1519
+ if str(_row[0]) == _room:
1520
+ return _norm_room_type(_row[1])
1521
+ elif isinstance(_row, dict):
1522
+ _n = _row.get("name") or _row.get("room")
1523
+ if _n and str(_n) == _room:
1524
+ return _norm_room_type(_row.get("type") or "")
1525
+ except Exception:
1526
+ pass
1527
+ try:
1528
+ _rv = (_d.get("rooms") or {}).get(_room)
1529
+ if isinstance(_rv, dict) and _rv.get("type"):
1530
+ return _norm_room_type(_rv.get("type"))
1531
+ except Exception:
1532
+ pass
1533
+ return ""
1534
+
1535
+
1536
+ def _thin_hollow_assessment(geo, load_case_dict, wall_est_mm=None, min_wall_mm=None):
1537
+ """判定零件是否属薄壁/镂空/变截面(只用已有真实几何,不编造)。
1538
+
1539
+ Returns: dict —— 关键字段:
1540
+ thin_wall / hollow / equivalent_model_applicable / reasons[] /
1541
+ evidence{} / missing_evidence[]
1542
+ """
1543
+ geo = geo if isinstance(geo, dict) else {}
1544
+ acc = ((load_case_dict or {}).get("acceptance") or {})
1545
+ out = {
1546
+ "thin_wall": False, "hollow": False,
1547
+ "variable_section": None, # 无逐截面数据 → 未评估(不假装)
1548
+ "variable_section_evaluated": False,
1549
+ "equivalent_model_applicable": True,
1550
+ "reasons": [], "missing_evidence": [],
1551
+ "evidence": {},
1552
+ }
1553
+ try:
1554
+ _req = float(min_wall_mm if min_wall_mm is not None
1555
+ else (acc.get("min_wall_thickness_mm") or 2.0))
1556
+ except Exception:
1557
+ _req = 2.0
1558
+ _thr = max(_THIN_WALL_THRESHOLD_MM, _req)
1559
+
1560
+ _wall = wall_est_mm
1561
+ if _wall is None:
1562
+ _wall = _estimate_wall_from_geometry(geo.get("volume_mm3"),
1563
+ geo.get("surface_area_mm2"))
1564
+ out["evidence"]["estimated_min_thickness_mm"] = (
1565
+ round(float(_wall), 4) if _wall else None)
1566
+ out["evidence"]["wall_threshold_mm"] = _thr
1567
+ if _wall is None:
1568
+ out["missing_evidence"].append("surface_area/volume(2V/A 壁厚估计)")
1569
+ else:
1570
+ try:
1571
+ if float(_wall) < _thr:
1572
+ out["thin_wall"] = True
1573
+ out["reasons"].append(
1574
+ "薄壁(2V/A 估计特征厚度 %.3fmm < 判据 %.1fmm)"
1575
+ % (float(_wall), _thr))
1576
+ except Exception:
1577
+ pass
1578
+
1579
+ # 镂空:等效实心体积比真实体积(失真倍数)
1580
+ _bb = geo.get("bbox_mm")
1581
+ _vol = geo.get("volume_mm3")
1582
+ _eq_vol = None
1583
+ try:
1584
+ if isinstance(_bb, (list, tuple)) and len(_bb) >= 6:
1585
+ _dims = sorted([abs(float(_bb[1]) - float(_bb[0])),
1586
+ abs(float(_bb[3]) - float(_bb[2])),
1587
+ abs(float(_bb[5]) - float(_bb[4]))], reverse=True)
1588
+ if all(d > 0 for d in _dims):
1589
+ _eq_vol = _dims[0] * _dims[1] * _dims[2]
1590
+ except Exception:
1591
+ _eq_vol = None
1592
+ if _eq_vol and _vol:
1593
+ try:
1594
+ _ratio = _eq_vol / float(_vol)
1595
+ out["evidence"]["volume_inflation_x"] = round(_ratio, 3)
1596
+ out["evidence"]["equivalent_solid_volume_mm3"] = round(_eq_vol, 2)
1597
+ out["evidence"]["real_volume_mm3"] = round(float(_vol), 2)
1598
+ if _ratio > _HOLLOW_INFLATION_LIMIT:
1599
+ out["hollow"] = True
1600
+ out["reasons"].append(
1601
+ "镂空/变截面(等效实心体积是真实体积的 %.2f 倍 > %.1f 倍判据)"
1602
+ % (_ratio, _HOLLOW_INFLATION_LIMIT))
1603
+ except Exception:
1604
+ pass
1605
+ else:
1606
+ out["missing_evidence"].append("bbox_mm 或 volume_mm3(等效体积失真倍数)")
1607
+
1608
+ if out["thin_wall"] or out["hollow"]:
1609
+ out["equivalent_model_applicable"] = False
1610
+ return out
1611
+
1612
+
1613
+ def _ensure_load_share_disclosure(case_dict, fea_result):
1614
+ """【Bug⑩ 补口】保证【任何求解后端】的结果都回显本次实际用的 share。
1615
+
1616
+ 为什么需要:L1(feapy)路径的 load_model 只回显力/工况,不含
1617
+ part_load_share —— 而现在的默认路径对薄壁件恰好就是 L1。若不补,
1618
+ 报告层就无法把"本次实际用的 share 值"写进 limitations(Bug⑩ 要求)。
1619
+ 本函数只在缺字段时补齐,绝不改动已算出的 design_force_n(口径不变)。
1620
+ """
1621
+ if not isinstance(fea_result, dict) or not fea_result.get("ok"):
1622
+ return fea_result
1623
+ try:
1624
+ _lm = fea_result.get("load_model")
1625
+ if not isinstance(_lm, dict):
1626
+ _lm = {}
1627
+ fea_result["load_model"] = _lm
1628
+ if _lm.get("part_load_share") is not None:
1629
+ return fea_result
1630
+ _share, _meta = load_case.part_load_share(case_dict)
1631
+ _lm["part_load_share"] = _share
1632
+ _lm["load_share_source"] = _meta.get("source")
1633
+ _lm["load_share_note"] = _meta.get("note")
1634
+ _lm["part_load_share_standalone"] = bool(
1635
+ _meta.get("standalone_load_path"))
1636
+ _lm["part_load_share_review_required"] = bool(
1637
+ _meta.get("review_required"))
1638
+ _lm["part_load_share_warning"] = _meta.get("warning")
1639
+ _lm.setdefault("share_disclosure_source",
1640
+ "physics_bridge._ensure_load_share_disclosure")
1641
+ except Exception:
1642
+ pass
1643
+ return fea_result
1644
+
1645
+
1646
+ def _prefer_l1_backend(load_case_dict, output_dir, backend=None):
1647
+ """【Bug⑦】薄壁/镂空件优先用 L1(numpy CST)而不是 L0 解析解。
1648
+
1649
+ 为什么不是"直接上 3D FEA":3D 后端是否可用一律【实测】(读
1650
+ fea_solver.SOLVER_BACKENDS),不写死结论。本函数【绝不会】把 L1 说成
1651
+ 3D FEA —— 只做"在可用能力里选更合适的那个",并把层级如实记进结果。
1652
+
1653
+ Returns: (fea_result, note)
1654
+ """
1655
+ _b = dict(fea_solver.detect_available_backends() or {})
1656
+ _has_l1 = bool(_b.get("feapy"))
1657
+ _3d_keys = [k for k in ("calculix", "code_aster", "elmerfem", "gmsh") if _b.get(k)]
1658
+ _has_3d = bool(_3d_keys)
1659
+ _target = backend or ("feapy" if _has_l1 else None)
1660
+ _fea = fea_solver.solve_fea(load_case_dict, output_dir=output_dir,
1661
+ backend=_target)
1662
+ _upgraded = (_target == "feapy")
1663
+ # ── L1 求解失败时必须退回(不能因为"想升级"反而丢掉结论)──────────────
1664
+ if _upgraded and not (isinstance(_fea, dict) and _fea.get("ok")):
1665
+ _fea_l1_err = (_fea or {}).get("error")
1666
+ _fea = fea_solver.solve_fea(load_case_dict, output_dir=output_dir)
1667
+ _upgraded = False
1668
+ else:
1669
+ _fea_l1_err = None
1670
+ if isinstance(_fea, dict):
1671
+ _fea["l1_available"] = _has_l1
1672
+ _fea["solver_3d_available"] = _has_3d
1673
+ _fea["solver_backends"] = {k: bool(v) for k, v in _b.items()}
1674
+ _fea["l1_upgraded"] = bool(_upgraded)
1675
+ if _fea_l1_err:
1676
+ _fea["l1_upgrade_error"] = str(_fea_l1_err)
1677
+ # 如实标注求解层级:L0=解析梁 / L1=2D 平面应力 CST
1678
+ # ★ 本函数从不执行 3D FEA,故 is_3d_fea 恒 False(不冒充能力)
1679
+ _bk = str(_fea.get("backend") or "")
1680
+ _fea["solver_level"] = ("L1" if _bk.startswith(("pure_python", "feapy"))
1681
+ else "L0" if _bk else "UNKNOWN")
1682
+ _fea["is_3d_fea"] = False
1683
+ if _upgraded and not _has_3d:
1684
+ _note = ("已升级到 L1(numpy CST 2D 平面应力数值解)—— 仍不是 3D FEA;"
1685
+ "本机 3D 求解器不可用(Gmsh/CalculiX/Code_Aster/Elmer 均未安装),"
1686
+ "薄壁/镂空件的 3D 应力集中无法评估")
1687
+ elif _upgraded and _has_3d:
1688
+ _note = ("已升级到 L1(numpy CST 2D 平面应力数值解)—— 仍不是 3D FEA;"
1689
+ "本机虽探测到 3D 求解器(%s),但本次未执行 3D 网格/求解,"
1690
+ "不得据此宣称做过 3D FEA" % ",".join(_3d_keys))
1691
+ elif _has_3d:
1692
+ _note = ("本机探测到 3D 求解器(%s),但本次未执行 3D FEA —— "
1693
+ "薄壁/镂空件结论仍需 3D 复核" % ",".join(_3d_keys))
1694
+ else:
1695
+ _note = ("无数值求解器可用,仅 L0 解析解 —— "
1696
+ "薄壁/镂空件结论不可作为合格依据")
1697
+ return _fea, _note
1698
+
1699
+
1700
+ def cmd_optimize(case_path: str, max_iter: int = 5, room: str = "",
1701
+ part_path: str = "", explicit_material: str = "") -> dict:
1702
+ """自动迭代优化:generate → simulate → check → refine 直到通过或达到 max_iter。
1703
+
1704
+ explicit_material: 【新 P0 修复】显式指定校核材料(最高优先)。
1705
+ 默认从【零件本体】读实际材料覆盖载荷文件材料;此参数用于
1706
+ 调用方明确要求按某个材料校核(如零件尚未赋材时手工指定)。
1707
+ """
1708
+ lc_result = load_case.load_from_file(case_path)
1709
+ if not lc_result["ok"]:
1710
+ return {"ok": False, "error": "invalid load case", "details": lc_result}
1711
+
1712
+ problem_id = lc_result["case"].get("meta", {}).get("problem_id", "UNKNOWN")
1713
+ # ── 【Bug④ 修复】房间身份必须在【运行开始前】确定一次,随后全程绑定 ────
1714
+ # 原来每次签凭据都现调 _resolve_defense_room(room)(可能退到 env DSH_ROOM),
1715
+ # 而 run_id 又只由迭代序号决定 → 两者没有任何绑定关系,房间凭据于是
1716
+ # 可能引用到别的房间/历史运行的 run。
1717
+ # 现在:本次 optimize 只认一个 _DEFENSE_ROOM;拿不到房间就【不签凭据】
1718
+ # (见 _emit_physics_attestation 的 fail-closed 分支),绝不猜。
1719
+ _DEFENSE_ROOM = _resolve_defense_room(room)
1720
+ # ── 【Bug⑥/⑦】房间类型决定"薄壁/镂空是否需要降级为 REVIEW" ─────────────
1721
+ # 取不到类型时按""→_norm_room_type 回到空串,下游按 fail-closed 处理
1722
+ # (见 _eq_guard_applies:未知类型视为承力类,不再静默豁免)。
1723
+ _ROOM_TYPE = _resolve_room_type(_DEFENSE_ROOM or room)
1724
+ _RUN_STAMP = int(time.time())
1725
+ # ── 【BUG-PHYS-RPTPATH-1 修复】运行目录必须归一化 ────────────────────
1726
+ # 原实现直接 join(_HERE, "..", "..", ...),得到的 run_dir 带 ".." 片段;
1727
+ # 由它派生的 report_path 同样带 ".."。宿主签名服务(defense-sign.js)
1728
+ # 按【字符串包含关系】判定"报告是否落在受管 output/physics_runs 根内",
1729
+ # 含 ".." 的路径判否 → 以 "report path outside managed physics_runs root"
1730
+ # 拒签,防线②凭据(reports/<房间>.physics.json)永远签不出来。
1731
+ # 实测:同一份报告,把 report_path 归一化后 _sig/_kid/_nonce 立即生成。
1732
+ # os.path.abspath 只做词法归一化,落到的目录与原实现完全一致。
1733
+ run_dir = os.path.abspath(os.path.join(
1734
+ _HERE, "..", "..", "output", design_state.STATE_DIR_NAME,
1735
+ f"opt_{problem_id}_{_RUN_STAMP}",
1736
+ ))
1737
+ os.makedirs(run_dir, exist_ok=True)
1738
+
1739
+ ds = design_state.DesignState(run_dir, case_path)
1740
+ # ── 【Bug7 修复】几何必须来自【真实零件】,不得编造 ────────────────
1741
+ # 原实现把 design_params 硬编码为 thickness=5/height=60/width=40,
1742
+ # 并把 topology/volume_mm3 写成假值 → 物理校核校验的是虚构零件,
1743
+ # 任何不合格设计(如 0.5mm 极薄壁)都能通过,防线②形同虚设。
1744
+ # 现在:先从真实零件取几何;取不到就【明确拒绝出结论】,
1745
+ # 绝不用编造值凑一个 PASS。
1746
+ # 【Bug18 根因修复】显式传入工况,供无 GetBox 时反推厚度。
1747
+ _geo = _extract_real_geometry(part_path, lc_result.get("case")) if part_path else {
1748
+ "ok": False,
1749
+ "error": ("未提供 part_path —— 物理校核必须针对真实零件;"
1750
+ "请用 physics-optimize <case.json> --part <零件.SLDPRT> 指定。"),
1751
+ }
1752
+ _geo_ok = bool(_geo.get("ok"))
1753
+ # ── 【Bug17 修复·关键】必须在 solve_fea【之前】把真实几何写进工况 ──────
1754
+ # 原实现从头到尾没有任何一处把 real_geometry 写进 lc_result["case"],
1755
+ # 于是 fea_solver 的 `_rg = load_case.get("real_geometry")` 恒为空 →
1756
+ # `_used_real=False` → 解析解退回 design_domain 的固定 200×60×60
1757
+ # (体积 720000,而真实承载板只有 120000)→ 三个不同载荷算出同一个
1758
+ # SF(测试部 Bug17:SF 与载荷不敏感)。
1759
+ # 这里在求解前注入;cmd_simulate 亦同(见该函数内注释)。
1760
+ if _geo_ok:
1761
+ try:
1762
+ lc_result["case"]["real_geometry"] = {
1763
+ "volume_mm3": _geo.get("volume_mm3"),
1764
+ "surface_area_mm2": _geo.get("surface_area_mm2"),
1765
+ "mass_kg": _geo.get("mass_kg"),
1766
+ "density_kg_m3": _geo.get("density_kg_m3"),
1767
+ "bbox_mm": _geo.get("bbox_mm"),
1768
+ "source": _geo.get("source"),
1769
+ }
1770
+ except Exception:
1771
+ pass
1772
+ # ── 【新 P0 修复】用【零件实际材料】覆盖载荷文件材料 ──────────────
1773
+ # 实测缺陷(子代理2):载荷文件 material=PETG(σy40),而零件实为
1774
+ # Q235(σy235)。校核跟随载荷文件 → SF=0.15,按 Q235 应为 1.12;
1775
+ # 国标材料的强度优势在物理校核里完全无法体现。
1776
+ # 这里在 solve_fea【之前】把材料改成零件实际材料,保证:
1777
+ # · 强度判定(σy)按零件真实材料;
1778
+ # · 质量(体积×密度)按零件真实密度;
1779
+ # · 报告里如实留痕"材料来自零件而非载荷文件"。
1780
+ try:
1781
+ _mat_final, _mat_meta = _resolve_part_material(
1782
+ _geo, lc_result.get("case"), explicit_material)
1783
+ if _mat_final:
1784
+ lc_result["case"]["material"] = _mat_final
1785
+ lc_result["case"]["material_resolution"] = _mat_meta
1786
+ except Exception as _e_matov:
1787
+ try:
1788
+ lc_result["case"]["material_resolution"] = {
1789
+ "source": "load_case", "error": repr(_e_matov)}
1790
+ except Exception:
1791
+ pass
1792
+ # ── 【Bug7 修复·硬闸】拿不到真实几何 → 直接拒绝,不产出任何结论 ────
1793
+ # 这是防线的关键:只要几何不可用,就必须【拒绝】而不是"降级估算"——
1794
+ # 否则又会退化成"校验空气"(测试部实测 0.5mm 极薄壁 SF=72.81 PASS)。
1795
+ # 注意:此处同时覆盖"直接调用 cmd_optimize 未传 part_path"的路径,
1796
+ # 不依赖 sw_bridge 层的参数校验。
1797
+ if not _geo_ok:
1798
+ return {
1799
+ "ok": False,
1800
+ "command": "optimize",
1801
+ "gate": "REAL_GEOMETRY_REQUIRED",
1802
+ "error": "物理校核必须针对真实零件:无法取得真实几何。",
1803
+ "geometry_error": _geo.get("error"),
1804
+ "part_path": part_path or None,
1805
+ "hint": ("物理校核不得校验虚构几何(Bug7)。请传入真实零件:"
1806
+ "python sw_bridge.py physics-optimize <case.json> "
1807
+ "--part <零件.SLDPRT> --room <房间>。"
1808
+ "若零件已存在但仍失败,检查 SolidWorks 是否在运行、"
1809
+ "以及零件是否有实体(no_body)。"),
1810
+ }
1811
+ # 真实体积/表面积 → 特征壁厚估计(V/A 比)
1812
+ _wall_est = None
1813
+ if _geo_ok:
1814
+ _wall_est = _estimate_wall_from_geometry(
1815
+ _geo.get("volume_mm3"), _geo.get("surface_area_mm2"))
1816
+ # ── 【Bug⑦ 修复】薄壁/镂空判定必须在【求解之前】完成 ──────────────────
1817
+ # 判定结果决定用 L0 还是升级到 L1(见下方 solve_fea 分支),并随报告
1818
+ # 落盘(limitations 显式声明"该件属薄壁/镂空,L0 等效模型不适用")。
1819
+ # 只用已有真实几何,不做无据推断;证据缺失时如实记 missing_evidence。
1820
+ _THIN_ASSESS = _thin_hollow_assessment(
1821
+ _geo, lc_result["case"], _wall_est,
1822
+ ((lc_result["case"].get("acceptance") or {})
1823
+ .get("min_wall_thickness_mm") or 2.0))
1824
+ # design_params:有真实壁厚估计就用它,否则如实标注"未实测"
1825
+ design_params = {
1826
+ "thickness_mm": {
1827
+ "id": "thickness_mm",
1828
+ "value": (round(float(_wall_est), 3) if _wall_est else None),
1829
+ "source": ("real-geometry(V/A)" if _wall_est else "unmeasured"),
1830
+ },
1831
+ # ── 【Bug11 修复】min_wall_thickness 必须是【单层】结构 ──────────
1832
+ # 原实现嵌套了两层:
1833
+ # {"min_wall_thickness": {"min_wall_thickness": {"value": 2.0}}}
1834
+ # → geometry_gate 按 .get("min_wall_thickness").get("value") 只取
1835
+ # 一层,拿到的是内层 dict 而非数值 → _wall_req=None
1836
+ # → `if wall_check.ok and _wall_req` 恒为 False
1837
+ # → WALL_THICKNESS 门【永远 PASS】,0.5mm 极薄板也放行。
1838
+ # 实测(测试部):estimated_min_thickness_mm=0.495 已正确测到壁厚,
1839
+ # 但 gate 仍 PASS —— 正是阈值取不到所致。
1840
+ # 现在与 thickness_mm 保持同样的单层形状。
1841
+ "min_wall_thickness": {
1842
+ "id": "min_wall_thickness",
1843
+ # None 安全:acceptance 里没有该字段时退回 FDM 默认 2.0mm
1844
+ "value": float(((lc_result["case"].get("acceptance") or {})
1845
+ .get("min_wall_thickness_mm") or 2.0)),
1846
+ "source": "acceptance.min_wall_thickness_mm(默认 FDM 2.0mm)",
1847
+ },
1848
+ }
1849
+ locked_params = []
1850
+
1851
+ final_report = None
1852
+ for i in range(1, max_iter + 1):
1853
+ # ── 【Bug④ 修复】run_id 必须【唯一标识本次运行】────────────────────
1854
+ # 原实现run_id = f"opt_iter_{i}" —— 只是一个迭代序号,全部房间/全部
1855
+ # 历史运行共用同一个名字(实测磁盘上 opt_iter_1 有 136 份、
1856
+ # opt_iter_3 有 31 份,分属结构件/壳体机架/传动机构…)。
1857
+ # 于是凭据里的 run_id 无法区分"是谁的这次运行",防线检查只能
1858
+ # 退回"run_id 存在即算",等于给跨房间借用开了口子(Bug④)。
1859
+ # 现在:run_id = <本次运行目录名>_iter_<i>,天然含本次唯一的
1860
+ # opt_<problem>_<时间戳> 前缀;同时把房间归属写进报告(下方已做),
1861
+ # 于是"run_id ↔ 本次运行 ↔ 本房间"三者可核对。
1862
+ run_id = f"{os.path.basename(run_dir)}_iter_{i}"
1863
+ output_dir = os.path.join(run_dir, f"iteration_{i}")
1864
+ os.makedirs(output_dir, exist_ok=True)
1865
+
1866
+ # ── 【Bug⑦ 修复】薄壁/镂空件优先升级到 L1(numpy CST 数值解)──────
1867
+ # L0 梁公式对薄壁/镂空件的截面惯性矩高估最严重(实测 SF 虚高 10 倍级)。
1868
+ # 本机没有 3D 求解器时,L1 是"可用能力里更接近真实"的那一档;
1869
+ # 是否升级由 _thin_hollow_assessment 的判定决定(先判定、再求解)。
1870
+ _thin = _THIN_ASSESS if "_THIN_ASSESS" in dir() else None
1871
+ if _thin is None:
1872
+ _thin = _thin_hollow_assessment(
1873
+ _geo, lc_result["case"], _wall_est,
1874
+ ((lc_result["case"].get("acceptance") or {})
1875
+ .get("min_wall_thickness_mm") or 2.0))
1876
+ if _thin.get("equivalent_model_applicable") is False:
1877
+ fea_result, _l1_note = _prefer_l1_backend(
1878
+ lc_result["case"], output_dir)
1879
+ try:
1880
+ fea_result.setdefault("limitations", []).append(
1881
+ "【Bug⑦·求解层级】" + _l1_note)
1882
+ except Exception:
1883
+ pass
1884
+ else:
1885
+ fea_result = fea_solver.solve_fea(lc_result["case"], output_dir=output_dir)
1886
+ try:
1887
+ fea_result["l1_available"] = bool(
1888
+ (fea_solver.detect_available_backends() or {}).get("feapy"))
1889
+ except Exception:
1890
+ pass
1891
+ # ── 【Bug⑩】任何后端的 load_model 都必须回显本次实际用的 share ──────
1892
+ # L1 路径原本不回显 → 报告层无法把 share 写进 limitations。
1893
+ _ensure_load_share_disclosure(lc_result["case"], fea_result)
1894
+
1895
+ domain = lc_result["case"].get("design_domain", {}).get("bounds", {})
1896
+ # ── 【Bug18 根因修复】不得用 design_domain 回填缺失的 bbox 分量 ──────
1897
+ # 原实现逐分量 `b if b is not None else design_domain[...]`:真实 bbox
1898
+ # 一旦缺失(此前因 load_case 变量遮蔽而恒缺失),z 就取 design_domain
1899
+ # 的 z_max=60,而承载板真实厚度只有 10 → body_bbox 被读成
1900
+ # 200×60×60(体积 720000,真实 120000)→ DESIGN_SPACE 与 SF 全错。
1901
+ # 更糟的是"用设计域补设计域"会让 DESIGN_SPACE 恒 PASS,门禁失效。
1902
+ # 现在:有真实 bbox 就整条用真实值;没有就传 None,让 geometry_gate
1903
+ # 明确报"未评估",绝不编造一个必然通过或必然失败的假值。
1904
+ _bbox = _geo.get("bbox_mm") if _geo_ok else None
1905
+ if not (isinstance(_bbox, (list, tuple)) and len(_bbox) >= 6):
1906
+ _bbox = None
1907
+ gc_result = geometry_gate.geometry_gate_report(
1908
+ bbox=_bbox,
1909
+ # topology 未知时传空 dict(让 CONNECTIVITY 自己判 NOT_EVALUATED,
1910
+ # 而不是喂一个假拓扑骗它 PASS)
1911
+ topology={"volumes": (1 if _geo_ok else 0), "bodies": (1 if _geo_ok else 0),
1912
+ "faces": None, "edges": None, "vertices": None},
1913
+ face_areas=[],
1914
+ # 真实体积(0 表示未取得 → 壁厚门禁将 NOT_EVALUATED)
1915
+ volume_mm3=(float(_geo.get("volume_mm3") or 0) if _geo_ok else 0.0),
1916
+ # 真实总表面积(用于壁厚估计;无则 None → 门禁 NOT_EVALUATED)
1917
+ surface_area_mm2=(_geo.get("surface_area_mm2") if _geo_ok else None),
1918
+ design_bounds=domain, interfaces=[], design_params=design_params,
1919
+ # ── 【Bug11 修复·双保险】显式传阈值,不依赖 design_params 结构 ──
1920
+ # 原实现只靠 design_params 里嵌套取值,一旦结构写错(曾嵌套两层)
1921
+ # 阈值就变成 None → `if wall_check.ok and _wall_req` 恒 False
1922
+ # → WALL_THICKNESS 永远 PASS(0.5mm 极薄板也放行)。
1923
+ min_wall_req_mm=float(((lc_result["case"].get("acceptance") or {})
1924
+ .get("min_wall_thickness_mm") or 2.0)),
1925
+ )
1926
+ gc_result["geometry_source"] = (_geo.get("source") if _geo_ok
1927
+ else "UNMEASURED")
1928
+ if not _geo_ok:
1929
+ gc_result["geometry_error"] = _geo.get("error")
1930
+
1931
+ report = sim_report.build_report(
1932
+ run_id=run_id, load_case=lc_result["case"], design_params=design_params,
1933
+ geometry_check=gc_result, fea_result=fea_result, iteration=i,
1934
+ # 【Bug④】父运行必须是【本次运行】的上一轮,不能写成全局共享的
1935
+ # "opt_iter_{i-1}"(那是跨房间混用的旧命名)
1936
+ parent_run_id=(f"{os.path.basename(run_dir)}_iter_{i - 1}"
1937
+ if i > 1 else None),
1938
+ # ── 【Bug⑦ 修复】把房间类型交给报告层 ──────────────────────
1939
+ # 报告层据此判断"薄壁/镂空 + 承力类房间 → overall 必须降为
1940
+ # REVIEW(不得 PASS)",并把等效模型局限写进 limitations。
1941
+ room_type=_ROOM_TYPE,
1942
+ )
1943
+ # ── 【Bug⑦】判定证据随报告落盘(可审计,不依赖二次推断)─────────
1944
+ try:
1945
+ report["thin_hollow_assessment"] = dict(_THIN_ASSESS)
1946
+ except Exception:
1947
+ pass
1948
+ # [防伪造] 报告必须自带【房间归属】:否则别的房间/别人只要复制或
1949
+ # 引用这份报告路径,就能给任意房间背书(对抗测试 C1c/C1d)。
1950
+ try:
1951
+ # 【Bug④】房间归属在运行开始前已确定(_DEFENSE_ROOM),此处不再现算,
1952
+ # 避免中途 env/参数变化导致同一 run 内报告归属漂移。
1953
+ if _DEFENSE_ROOM:
1954
+ report["room"] = _DEFENSE_ROOM
1955
+ except Exception:
1956
+ pass
1957
+ report_path = os.path.abspath(sim_report.save_report(report, output_dir))
1958
+ # 【BUG-PHYS-RPTPATH-1】双保险:无论 output_dir 是否已归一化,
1959
+ # 落进凭据的 report_path 一律是无 ".." 的绝对路径,避免宿主拒签。
1960
+ report["report_path"] = report_path
1961
+
1962
+ ds.new_run(run_id)
1963
+ ds.update_run(run_id, {
1964
+ "fea_result": fea_result, "geometry_check": gc_result,
1965
+ "design_params": design_params, "status": report["overall_status"],
1966
+ })
1967
+
1968
+ final_report = report
1969
+ print(f" [iter {i}] status={report['overall_status']} "
1970
+ f"SF={fea_result.get('safety_factor', 'N/A')} "
1971
+ f"stress={fea_result.get('max_von_mises_mpa', 'N/A')}MPa",
1972
+ file=sys.stderr, flush=True)
1973
+
1974
+ if report["overall_status"] == "PASS":
1975
+ # ── 【防线②物理】结论一算出就自动落凭据 ──
1976
+ try:
1977
+ _emit_physics_attestation(_DEFENSE_ROOM, report,
1978
+ case_path=case_path, run_dir=run_dir,
1979
+ run_id=run_id)
1980
+ except Exception:
1981
+ pass
1982
+ return {
1983
+ "ok": True, "command": "optimize",
1984
+ "converged": True, "iterations": i,
1985
+ "final_report": report, "run_dir": run_dir,
1986
+ }
1987
+
1988
+ rec = refine_rules.generate_recommendations(
1989
+ report=report, design_params=design_params,
1990
+ locked_params=locked_params, max_iterations=max_iter - i,
1991
+ )
1992
+ if rec["ok"] and rec["actions"]:
1993
+ design_params = rec["next_params"]
1994
+ else:
1995
+ # ── 【防线②物理】收敛失败也要落凭据(结论已产生,不能被漏掉)──
1996
+ try:
1997
+ _emit_physics_attestation(_DEFENSE_ROOM, report,
1998
+ case_path=case_path, run_dir=run_dir,
1999
+ run_id=run_id)
2000
+ except Exception:
2001
+ pass
2002
+
2003
+ return {
2004
+ "ok": True, "command": "optimize",
2005
+ "converged": False, "iterations": i,
2006
+ "reason": "no valid refinement actions; design space exhausted",
2007
+ "final_report": report, "run_dir": run_dir,
2008
+ }
2009
+
2010
+ # ── 【防线②物理】达到迭代上限:结论同样要落凭据 ──
2011
+ try:
2012
+ _emit_physics_attestation(_DEFENSE_ROOM, final_report,
2013
+ case_path=case_path, run_dir=run_dir,
2014
+ run_id=(final_report or {}).get("run_id") or "")
2015
+ except Exception:
2016
+ pass
2017
+
2018
+ return {
2019
+ "ok": True, "command": "optimize",
2020
+ "converged": False, "iterations": max_iter,
2021
+ "reason": f"reached max iterations ({max_iter})",
2022
+ "final_report": final_report, "run_dir": run_dir,
2023
+ }
2024
+
2025
+ # ══════════════════════════════════════════════════════════════════════
2026
+ # 【三大防线 · 防线②物理 / 防线③领域】凭据落盘
2027
+ # ══════════════════════════════════════════════════════════════════════
2028
+ # 设计要点:凭据必须【自动】产生,而不是等小屋记得多调一条命令。
2029
+ # 因此挂在 physics 结论已经算出来的位置(optimize / validate-domain),
2030
+ # 只要跑过一次物理链路,防线凭据就必然存在。
2031
+ def _emit_physics_attestation(room, report, case_path="", run_dir="",
2032
+ run_id=""):
2033
+ """把仿真报告折算成防线②凭据,写入 reports/<room>.physics.json。
2034
+
2035
+ room 为空时【不写】—— 凭据必须归属到具体房间才能被 room-end 校验。
2036
+
2037
+ ── 【Bug④ 修复】run_id 必须绑死【本房间本次运行】─────────────────────
2038
+ 原实现直接取 report["run_id"],而 run_id 曾只由迭代序号决定
2039
+ (opt_iter_3),跨房间复用。现在:
2040
+ ① 优先用调用方显式传入的 run_id(= 本次 optimize 实际产生的那一个);
2041
+ ② 否则用报告自己的 run_id,但必须与报告的 room【互相印证】:
2042
+ 报告 room 必须等于入参 room,report_path 必须落在【本次 run_dir】下;
2043
+ ③ 任一不满足 → 拒绝签发(fail-closed)并写明原因,绝不用历史遗留 run。
2044
+ """
2045
+ if not room or not isinstance(report, dict):
2046
+ return None
2047
+ try:
2048
+ import defense_gate as _dg
2049
+ except ImportError:
2050
+ return None
2051
+ # ── ① room ↔ report 归属一致性(Bug④ fail-closed 闸口)──────────────
2052
+ _rep_room = str(report.get("room") or "").strip()
2053
+ if _rep_room and _rep_room != str(room).strip():
2054
+ _dg.note_unsigned_attempt(
2055
+ room, "physics",
2056
+ "拒绝签发:报告房间归属 %r 与凭据房间 %r 不一致(Bug④)"
2057
+ % (_rep_room, str(room)))
2058
+ return None
2059
+ _explicit_run = str(run_id or "").strip()
2060
+ _rep_run = str(report.get("run_id") or "").strip()
2061
+ if _explicit_run and _rep_run and _explicit_run != _rep_run:
2062
+ _dg.note_unsigned_attempt(
2063
+ room, "physics",
2064
+ "拒绝签发:显式 run_id=%r 与报告 run_id=%r 不一致(Bug④)"
2065
+ % (_explicit_run, _rep_run))
2066
+ return None
2067
+ _final_run = _explicit_run or _rep_run
2068
+ if not _final_run:
2069
+ _dg.note_unsigned_attempt(
2070
+ room, "physics",
2071
+ "拒绝签发:报告没有 run_id,无法绑定到本房间本次运行(Bug④)")
2072
+ return None
2073
+ # ② report_path 必须落在【本次运行目录】下(不能是历史遗留 report)
2074
+ _rp = str(report.get("report_path") or "").strip()
2075
+ _rd = str(run_dir or "").strip()
2076
+ if _rd and _rp:
2077
+ try:
2078
+ _ap, _ad = os.path.abspath(_rp), os.path.abspath(_rd)
2079
+ if os.path.commonpath([_ap, _ad]).lower() != _ad.lower():
2080
+ _dg.note_unsigned_attempt(
2081
+ room, "physics",
2082
+ "拒绝签发:report_path 不在本次 run_dir 下(疑似引用历史运行,"
2083
+ "Bug④):%s ∉ %s" % (_ap, _ad))
2084
+ return None
2085
+ except Exception:
2086
+ pass
2087
+ _fea = report.get("fea_result") or {}
2088
+ _fat = _fea.get("fatigue") or {}
2089
+ payload = {
2090
+ "overall": report.get("overall_status") or _fea.get("overall"),
2091
+ "safety_factor": _fea.get("safety_factor"),
2092
+ "max_von_mises_mpa": _fea.get("max_von_mises_mpa"),
2093
+ "max_displacement_mm": _fea.get("max_displacement_mm"),
2094
+ "fatigue_verdict": _fat.get("verdict"),
2095
+ "fatigue_sf": _fat.get("fatigue_sf"),
2096
+ "failed_gates": report.get("failed_gates") or [],
2097
+ "passed_gates": report.get("passed_gates") or [],
2098
+ "not_evaluated": report.get("not_evaluated") or [],
2099
+ "release_status": (report.get("release_readiness") or {}).get("status"),
2100
+ # ── 【Bug④】run_id 已绑死本次运行(见函数头闸口),绝不用历史遗留 ──
2101
+ "run_id": _final_run,
2102
+ # [防伪造] run_id 在磁盘上【不唯一】(opt_iter_1 有 7 份),必须同时
2103
+ # 记录【唯一路径】:report_path(具体报告文件)+ run_dir(运行目录),
2104
+ # 让防线校验能精确定位到"就是这一次运行",而不是"借"一个同名 run_id。
2105
+ "report_path": report.get("report_path") or "",
2106
+ "case_path": case_path,
2107
+ "run_dir": run_dir,
2108
+ # 【Bug④】凭据显式记录房间,供验签端做 room ↔ run_id 一致性核对
2109
+ "room": str(room),
2110
+ "source": "physics_bridge",
2111
+ }
2112
+ # ══ 【A-68 修复】凭据必须绑定【它实际校核的那件零件】════════════════
2113
+ # 原凭据只记 room + run_id + report_path,【没有零件维度】——
2114
+ # 实测(2026-11-01):某房"通过"的物理凭据,run_id 是另一个进程
2115
+ # 06:37:29 写进去的,part_path=null / material=null,
2116
+ # 而本房真正想校核的是另一件。等于"你过的是别人的成绩单"。
2117
+ # 同房 2 小时内结论还能 2.39x → 2.82x → 1.45x,每次都"恰好更好"。
2118
+ # 现在把零件路径 + sha256 写进凭据(宿主会纳入待签体),校验端据此
2119
+ # 确认"这份结论确实是为当前这件零件算的"。取不到零件时如实标 null
2120
+ # (校验端会判"未绑定",不静默放行)。
2121
+ try:
2122
+ _pp_att = str((report or {}).get("part_path") or "").strip()
2123
+ if not _pp_att:
2124
+ _pp_att = str(((_fea or {}).get("real_geometry") or {}).get("part_path")
2125
+ or "").strip()
2126
+ if _pp_att and os.path.isfile(_pp_att):
2127
+ payload["part_path"] = os.path.abspath(_pp_att)
2128
+ payload["part_name"] = os.path.basename(_pp_att)
2129
+ try:
2130
+ import hashlib as _hl_att
2131
+ _h_att = _hl_att.sha256()
2132
+ with open(_pp_att, "rb") as _pf_att:
2133
+ for _ch_att in iter(lambda: _pf_att.read(1 << 20), b""):
2134
+ _h_att.update(_ch_att)
2135
+ payload["part_sha256"] = _h_att.hexdigest()
2136
+ payload["part_size_bytes"] = int(os.path.getsize(_pp_att))
2137
+ except Exception:
2138
+ pass
2139
+ else:
2140
+ payload["part_path"] = None
2141
+ payload["part_name"] = None
2142
+ payload["part_sha256"] = None
2143
+ except Exception:
2144
+ pass
2145
+ # [主防线] 凭据必须由【宿主签发】—— 本地只提交磁盘事实,宿主自己读报告后签名。
2146
+ # 这样 AI 即使手写一份格式完美的 JSON,也拿不到合法签名。
2147
+ _signed = _dg.request_signed_credential({
2148
+ "kind": "physics",
2149
+ "room": room,
2150
+ "report_path": payload.get("report_path") or "",
2151
+ })
2152
+ if _signed and _signed.get("ok") and isinstance(_signed.get("credential"), dict):
2153
+ # ── 【签后补字段 BUG 修复】宿主已签名 → 【原样落盘】,一个字段都不加 ──
2154
+ # 原先这里做了 _cred.setdefault("run_dir", ...):在签名之后改凭据体,
2155
+ # HMAC 必然失配,防线②永远验签失败。run_dir 现在由宿主在签发时
2156
+ # 从已签名的 report_path 推导并纳入待签体(见 defense-sign.js)。
2157
+ return _dg.write_physics_attestation(room, _signed["credential"])
2158
+ # 宿主不可用 → fail-closed:写一份【无签名】凭据,让后续校验必然失败并留痕,
2159
+ # 而不是静默产出一份"看起来合法"的凭据。
2160
+ _dg.note_unsigned_attempt(room, "physics", (_signed or {}).get("error"))
2161
+ _fallback = dict(payload)
2162
+ _fallback["_unsigned_reason"] = (_signed or {}).get("error") or "宿主签名服务不可用"
2163
+ return _dg.write_physics_attestation(room, _fallback)
2164
+
2165
+
2166
+ def _emit_domain_attestation(room, dom_result, domain_id="", run_id="",
2167
+ allow_no_run=False):
2168
+ """把 DSVA 结论折算成防线③凭据,写入 reports/<room>.domain.json。
2169
+
2170
+ ── 【Bug④ 修复】领域凭据的 run_id 必须绑死【本房间本次运行】───────────
2171
+ 原实现从 dom_result["run_id"] 或 data_source["run_id"] 取;而在"本房没有
2172
+ 报告"时 _latest_real_physics 会回退到全库最新的一份(很可能是主代理
2173
+ 历史运行 opt_iter_3 / 别的房间的运行),于是领域凭据带着别人的 run_id
2174
+ 落盘,规则也在别人的真实物理量上跑出与本房零件无关的结论。
2175
+ 现在(fail-closed):
2176
+ ① run_id 优先取调用方显式传入的(本房本次校验实际使用的那个);
2177
+ ② 用 defense_gate.run_attribution_for_room() 核对
2178
+ "run_id ↔ 本房间 ↔ 报告房间归属"三重一致;
2179
+ ③ 核对不通过 → 【拒绝签发】,写留痕并返回 None(绝不落一份带
2180
+ 别人 run_id 的凭据)。
2181
+ """
2182
+ if not room or not isinstance(dom_result, dict):
2183
+ return None
2184
+ try:
2185
+ import defense_gate as _dg
2186
+ except ImportError:
2187
+ return None
2188
+ _room_s = str(room).strip()
2189
+ _ds = dom_result.get("data_source") if isinstance(dom_result.get("data_source"), dict) else {}
2190
+ _final_run = (str(run_id or "").strip()
2191
+ or str(dom_result.get("run_id") or "").strip()
2192
+ or str((_ds or {}).get("run_id") or "").strip())
2193
+ if not _final_run:
2194
+ # ── 没有仿真运行可绑定时的两条合法路径(都必须可追溯,不得静默)──
2195
+ # ① allow_no_run=True:本次校验用的是调用方【人工提供】的
2196
+ # design_params/fea_result(physics-validate-domain --param),
2197
+ # 本来就没有、也不需要仿真运行。此时以【人工参数 + 校验时间】作为
2198
+ # 可追溯标识落盘,并在留痕里写明"无仿真运行背书",
2199
+ # 验签端会把它判为"无真实运行背书"→ 物理防线仍 fail-closed。
2200
+ # ② 否则(默认):拒绝签发 —— 没有 run_id 就无法证明数据属于本房本次运行。
2201
+ if not allow_no_run:
2202
+ _dg.note_unsigned_attempt(
2203
+ room, "domain",
2204
+ "拒绝签发:领域校验没有 run_id,无法绑定到本房间本次运行(Bug④)")
2205
+ return None
2206
+ _dg.note_unsigned_attempt(
2207
+ room, "domain",
2208
+ "领域凭据基于【人工提供参数】签发(无仿真运行可绑定,故 run_id 为空,"
2209
+ "仅以 checked_at 追溯;物理防线不会因此放行)")
2210
+ # 房间 ↔ run_id 归属核对(含报告里的 room 字段)
2211
+ if _final_run:
2212
+ try:
2213
+ _ok_run, _why_run, _ = _dg.run_attribution_for_room(
2214
+ _final_run, _room_s, (_ds or {}).get("report_path"))
2215
+ except Exception as _e_attr:
2216
+ _ok_run, _why_run = False, "run_id 归属核对异常: %r" % (_e_attr,)
2217
+ if not _ok_run:
2218
+ _dg.note_unsigned_attempt(
2219
+ room, "domain",
2220
+ "拒绝签发:领域凭据的 run_id=%r 无法归属到本房间本次运行(%s)(Bug④)"
2221
+ % (_final_run, _why_run))
2222
+ return None
2223
+ payload = {
2224
+ "domain": dom_result.get("domain") or domain_id,
2225
+ "score": dom_result.get("score"),
2226
+ "violations": dom_result.get("violations") or [],
2227
+ "warnings": dom_result.get("warnings") or [],
2228
+ "passed": dom_result.get("passed") or [],
2229
+ "source": "physics_bridge",
2230
+ # ── 【新 Bug 修复】领域凭据必须带【可追溯标识】────────────────────
2231
+ # 校验端(defense_gate.check_domain_attestation)要求 run_id 或
2232
+ # checked_at 至少有一个,否则判"缺少可追溯标识(疑似伪造)"→
2233
+ # CRITICAL 拦截。原实现两个字段都不写,导致【每份领域凭据
2234
+ # 一落盘就自带一条防伪造违规】,实测被 room-end 拦下
2235
+ # (报告 §5「领域凭据缺 run_id 可追溯标识」)。
2236
+ # 现在:run_id 优先取本次领域校验携带的运行标识;没有则用
2237
+ # 校验时间戳 generated_at 兜底,保证凭据始终可追溯。
2238
+ # 【Bug④】已核对过归属的 run_id(绝不取历史遗留/别的房间的运行)
2239
+ "run_id": _final_run,
2240
+ "checked_at": (str(dom_result.get("checked_at") or "")
2241
+ or time.strftime("%Y-%m-%d %H:%M:%S")),
2242
+ # 【Bug④】凭据显式记录房间,供验签端做 room ↔ run_id 一致性核对
2243
+ "room": _room_s,
2244
+ # 领域校验的依据来源(本房间最近一次真实仿真报告),便于审计追溯
2245
+ "data_source": (_ds or {}),
2246
+ }
2247
+ # [主防线] 领域凭据同样必须宿主签发。
2248
+ _dom_rules = payload.get("domain") or domain_id
2249
+ _signed_d = _dg.request_signed_credential({
2250
+ "kind": "domain",
2251
+ "room": room,
2252
+ "domain": _dom_rules,
2253
+ "violations": payload.get("violations") or [],
2254
+ "score": payload.get("score"),
2255
+ "warnings": payload.get("warnings") or [],
2256
+ "passed": payload.get("passed") or [],
2257
+ "checked_items": payload.get("checked_items") or [],
2258
+ # ── 【新 Bug 修复】把可追溯标识一并提交签发 ────────────────────────
2259
+ # 校验端要求 run_id / checked_at 至少一个存在。必须在【申请签发时】
2260
+ # 提交(由宿主纳入待签体);若签完再补字段会 HMAC 失配。
2261
+ "run_id": payload.get("run_id") or "",
2262
+ "checked_at": payload.get("checked_at") or "",
2263
+ })
2264
+ if _signed_d and _signed_d.get("ok") and isinstance(_signed_d.get("credential"), dict):
2265
+ # 【签后补字段 BUG 修复】宿主已签名 → 原样落盘(不得再补任何字段)
2266
+ return _dg.write_domain_attestation(room, _signed_d["credential"])
2267
+ _dg.note_unsigned_attempt(room, "domain", (_signed_d or {}).get("error"))
2268
+ _fb = dict(payload)
2269
+ _fb["_unsigned_reason"] = (_signed_d or {}).get("error") or "宿主签名服务不可用"
2270
+ return _dg.write_domain_attestation(room, _fb)
2271
+
2272
+
2273
+ def _resolve_defense_room(room=""):
2274
+ """防线凭据归属房间:显式参数 > 环境变量 DSH_ROOM。"""
2275
+ _r = str(room or "").strip() or str(os.environ.get("DSH_ROOM") or "").strip()
2276
+ return _r or None
2277
+
2278
+
2279
+
2280
+
2281
+ def cmd_fatigue(case_path: str = "", stress_mpa: float = None,
2282
+ run_id: str = "", part_path: str = "",
2283
+ allow_design_domain: bool = False) -> dict:
2284
+ """【BUG-02 修复】疲劳 / 设计寿命(默认 30 年)独立校核入口。
2285
+
2286
+ 用法:
2287
+ python physics_bridge.py fatigue <case.json> --part <零件.SLDPRT> # 静力+FEA+疲劳
2288
+ python physics_bridge.py fatigue <case.json> --stress 45 # 指定应力直接校核
2289
+ python physics_bridge.py fatigue --report <run_id> # 用已有报告的应力
2290
+
2291
+ ── 【BUG 修复·虚假 PASS】必须绑定真实几何 ──────────────────────────────
2292
+ 实测缺陷(子代理发现,已实验确证):本函数原先不注入 real_geometry,
2293
+ 于是 solve_fea 退回 design_domain 的默认几何(300×200×120)。
2294
+ 承载截面被高估 → 应力被低估约 95 倍 → 得出【虚假 PASS】:
2295
+ 同一 Q235、同一工况:
2296
+ fatigue CLI → verdict=PASS, sf=94.16, peak=1.93 MPa
2297
+ optimize(真实零件) → verdict=FAIL, sf=0.996, peak=182.49 MPa
2298
+ 这是最危险的一类缺陷:用户单独跑 fatigue 会拿到与真实结论相反的 PASS。
2299
+ 现在:与 cmd_optimize 同口径 —— 拿不到真实几何就【硬闸拒绝】,
2300
+ 并给出可执行的修复指引;只有显式传入 allow_design_domain=True
2301
+ (CLI 的 --allow-design-domain)才允许退回设计域估算,
2302
+ 且结果里明确标注该结论【不可用于设计定型】。
2303
+ """
2304
+ if fatigue_mod is None:
2305
+ return {"ok": False, "error": "fatigue.py 未找到(无法做疲劳校核)",
2306
+ "verdict": "NOT_EVALUATED"}
2307
+
2308
+ def _flatten(fat: dict, static_part: dict = None, source: str = "") -> dict:
2309
+ """把疲劳结果摊平到顶层,保证任意路径都有同一个读取口径。"""
2310
+ out = dict(fat or {})
2311
+ if static_part:
2312
+ out["static"] = static_part
2313
+ if source:
2314
+ out["source"] = source
2315
+ out["command"] = "fatigue"
2316
+ # ok 语义:以"疲劳是否通过"为准(NOT_EVALUATED 时 ok=False)
2317
+ out["ok"] = (out.get("verdict") not in ("NOT_EVALUATED", None))
2318
+ return out
2319
+
2320
+ # 路径甲:用已有报告的静力应力
2321
+ if stress_mpa is None and run_id:
2322
+ rep = cmd_report(run_id)
2323
+ if not rep.get("ok"):
2324
+ return {"ok": False, "error": "找不到报告: %s" % run_id,
2325
+ "verdict": "NOT_EVALUATED"}
2326
+ report = rep["report"]
2327
+ fea = report.get("fea_result", {})
2328
+ fat = report.get("fatigue") or fatigue_mod.analyze_fatigue_from_case(
2329
+ report.get("load_case_summary") or {}, fea)
2330
+ return _flatten(fat, {"max_von_mises_mpa": fea.get("max_von_mises_mpa"),
2331
+ "safety_factor": fea.get("safety_factor")},
2332
+ source="report:%s" % run_id)
2333
+
2334
+ if not case_path:
2335
+ return {"ok": False, "error": "需要 case_path 或 --report <run_id>",
2336
+ "verdict": "NOT_EVALUATED"}
2337
+
2338
+ lc_result = load_case.load_from_file(case_path)
2339
+ if not lc_result["ok"]:
2340
+ return {"ok": False, "error": "invalid load case", "details": lc_result,
2341
+ "verdict": "NOT_EVALUATED"}
2342
+ case = lc_result["case"]
2343
+
2344
+ # ── 未显式给应力 → 需要跑 FEA;此时【必须】有真实几何 ──────────────
2345
+ if stress_mpa is None:
2346
+ _geo_ok = False
2347
+ _geo = {}
2348
+ _geo_note = ""
2349
+ if part_path:
2350
+ try:
2351
+ _geo = _extract_real_geometry(part_path)
2352
+ _geo_ok = bool(_geo.get("ok"))
2353
+ except Exception as _e:
2354
+ _geo = {"ok": False, "error": repr(_e)}
2355
+ else:
2356
+ _geo = {"ok": False,
2357
+ "error": "未提供 --part,无法取得真实零件几何"}
2358
+ if _geo_ok:
2359
+ # 把真实几何注入工况(与 cmd_optimize 同口径)
2360
+ try:
2361
+ case["real_geometry"] = _geo
2362
+ case["geometry_used"] = {
2363
+ "source": "real_geometry.bbox_mm(真实零件包围盒)",
2364
+ "L": (_geo.get("bbox_mm") or [None, None, None, None, None, None])[1],
2365
+ "W": None, "H": None,
2366
+ }
2367
+ except Exception:
2368
+ pass
2369
+ _geo_note = "real_geometry"
2370
+ elif not allow_design_domain:
2371
+ # ── 硬闸:与 cmd_optimize 的 REAL_GEOMETRY_REQUIRED 完全一致 ──
2372
+ return {
2373
+ "ok": False,
2374
+ "command": "fatigue",
2375
+ "gate": "REAL_GEOMETRY_REQUIRED",
2376
+ "verdict": "NOT_EVALUATED",
2377
+ "error": ("疲劳校核必须针对真实零件:无法取得真实几何。"
2378
+ "退回设计域默认几何会把承载截面高估、"
2379
+ "应力低估(实测可达 95 倍),从而给出虚假 PASS。"),
2380
+ "geometry_error": _geo.get("error"),
2381
+ "part_path": part_path or None,
2382
+ "hint": ("请传入真实零件:python physics_bridge.py fatigue "
2383
+ "<case.json> --part <零件.SLDPRT>。"
2384
+ "若零件已存在但仍失败,检查 SolidWorks 是否在运行、"
2385
+ "以及零件是否有实体(no_body)。"
2386
+ "仅在明确知道后果时,才用 --allow-design-domain "
2387
+ "做粗估(该结论不得用于设计定型)。"),
2388
+ }
2389
+ else:
2390
+ _geo_note = "design_domain(粗估,不可用于设计定型)"
2391
+
2392
+ fea_result = fea_solver.solve_fea(case)
2393
+ stress_mpa = (fea_result.get("max_von_mises_mpa")
2394
+ or fea_result.get("max_stress_mpa") or 0.0)
2395
+ static_part = {"safety_factor": fea_result.get("safety_factor"),
2396
+ "max_von_mises_mpa": stress_mpa,
2397
+ "geometry_source": _geo_note}
2398
+ # solve_fea 已附带疲劳 → 直接复用,避免重复计算
2399
+ if fea_result.get("fatigue"):
2400
+ out = _flatten(fea_result["fatigue"], static_part, source="fea")
2401
+ out["stress_used_mpa"] = float(stress_mpa)
2402
+ out["geometry_source"] = _geo_note
2403
+ out["real_geometry_bound"] = bool(_geo_ok)
2404
+ if not _geo_ok:
2405
+ out["design_use_warning"] = (
2406
+ "本次疲劳结论基于【设计域默认几何】粗估,"
2407
+ "承载截面可能被严重高估 —— 不得用于设计定型。")
2408
+ return out
2409
+
2410
+ fat = fatigue_mod.analyze_fatigue_from_case(
2411
+ case, {"max_von_mises_mpa": float(stress_mpa)})
2412
+ out = _flatten(fat, {"max_von_mises_mpa": float(stress_mpa)}, source="given_stress")
2413
+ out["stress_used_mpa"] = float(stress_mpa)
2414
+ # 显式给应力时,几何不参与计算 —— 如实标注,避免误以为做了几何校核
2415
+ out["geometry_source"] = ("given_stress(未做几何校核,"
2416
+ "应力由调用方直接指定)")
2417
+ out["real_geometry_bound"] = False
2418
+ return out
2419
+
2420
+
2421
+ def cmd_demo(args=None) -> dict:
2422
+ """运行悬臂梁演示:自动生成工况 → 解析解 → 报告。"""
2423
+ lc = load_case.default_load_case("DEMO_CANTILEVER", "演示悬臂梁")
2424
+ lc_result = load_case.validate_load_case(lc)
2425
+ if not lc_result["ok"]:
2426
+ return {"ok": False, "errors": lc_result["errors"]}
2427
+
2428
+ run_dir = os.path.join(_HERE, "..", "..", "output", "demo_run")
2429
+ os.makedirs(run_dir, exist_ok=True)
2430
+ fea_result = fea_solver.solve_fea(lc, output_dir=run_dir)
2431
+
2432
+ domain = lc.get("design_domain", {}).get("bounds", {})
2433
+ gc = geometry_gate.geometry_gate_report(
2434
+ bbox=[domain.get("x_min", 0), domain.get("x_max", 200),
2435
+ domain.get("y_min", 0), domain.get("y_max", 60),
2436
+ domain.get("z_min", 0), domain.get("z_max", 60)],
2437
+ topology={"volumes": 1, "bodies": 1, "faces": 6, "edges": 12, "vertices": 8},
2438
+ face_areas=[], volume_mm3=200 * 60 * 60,
2439
+ design_bounds=domain, interfaces=[], design_params={},
2440
+ )
2441
+
2442
+ report = sim_report.build_report(
2443
+ run_id="demo_001", load_case=lc, design_params={},
2444
+ geometry_check=gc, fea_result=fea_result, iteration=1,
2445
+ )
2446
+ report_path = sim_report.save_report(report, run_dir)
2447
+ report["report_path"] = report_path
2448
+
2449
+ md = sim_report.report_to_markdown(report)
2450
+ return {
2451
+ "ok": True, "command": "demo",
2452
+ "report": report, "markdown_summary": md,
2453
+ "report_path": report_path,
2454
+ "notes": (
2455
+ "这是解析解(Euler-Bernoulli 梁理论),仅用于快速估算。\n"
2456
+ "实际零件请使用 physics-optimize 配合完整载荷工况文件。"
2457
+ ),
2458
+ }
2459
+
2460
+
2461
+ # ═══════════════════════════════════════════════════════════════
2462
+ # CLI 入口(直接运行 physics_bridge.py 时使用)
2463
+ # ═══════════════════════════════════════════════════════════════
2464
+
2465
+ def _parse_param_args(pairs):
2466
+ """把 --param k=v 解析成 dict(值自动尝试转数值;null/none 表示清除)。"""
2467
+ out = {}
2468
+ for item in pairs or []:
2469
+ if "=" not in str(item):
2470
+ continue
2471
+ k, v = str(item).split("=", 1)
2472
+ k, v = k.strip(), v.strip()
2473
+ if not k:
2474
+ continue
2475
+ if v.lower() in ("null", "none", ""):
2476
+ out[k] = None
2477
+ continue
2478
+ try:
2479
+ out[k] = float(v) if ("." in v or "e" in v.lower()) else int(v)
2480
+ except Exception:
2481
+ out[k] = v
2482
+ return out
2483
+
2484
+
2485
+ def _design_params_path():
2486
+ """人工设计参数的落盘路径。
2487
+
2488
+ 【P0-3 修复】写入【权威状态目录】(若已存在的旧文件在脚本目录,则沿用
2489
+ 那个位置,避免用户登记过的参数"突然读不到")。
2490
+ """
2491
+ for _dd in _data_dirs():
2492
+ _cand = os.path.join(_dd, "design_params.json")
2493
+ if os.path.exists(_cand):
2494
+ return _cand
2495
+ return os.path.join(STATE_DIR, "design_params.json")
2496
+
2497
+
2498
+ def cmd_set_design_params(pairs=None, src_file="", show_only=False):
2499
+ """【Bug12 二次修复】人工提供通道:把设计意图/工艺参数登记为权威输入。
2500
+
2501
+ 为什么必须有这条通道(测试部原话):
2502
+ "shaft_diameter/gear_module/cavity_depth/draft_angle 等本质是人工设计
2503
+ 输入(由扭矩、模数、注塑工艺决定),无法从零件 bbox 提取。"
2504
+ 因此这些参数的正确来源是【人】或【上游流程】,而不是几何反推。
2505
+ 本命令把它们落盘到 tools/design_params.json,供 _derive_design_params
2506
+ 以【人工提供】优先级读取(见该函数的 ④ 段)。
2507
+
2508
+ Returns: {ok, path, design_params, cleared:[...]}
2509
+ """
2510
+ p = _design_params_path()
2511
+ cur = {}
2512
+ if os.path.exists(p):
2513
+ try:
2514
+ j = json.load(open(p, "r", encoding="utf-8"))
2515
+ cur = j.get("design_params") if isinstance(j.get("design_params"), dict) else (j or {})
2516
+ except Exception:
2517
+ cur = {}
2518
+ if show_only:
2519
+ return {"ok": True, "path": p, "design_params": cur,
2520
+ "note": "当前已登记的人工设计参数(领域规则的数据源)"}
2521
+ incoming = {}
2522
+ if src_file:
2523
+ try:
2524
+ jf = json.load(open(src_file, "r", encoding="utf-8"))
2525
+ incoming = jf.get("design_params") if isinstance(jf.get("design_params"), dict) else jf
2526
+ except Exception as e:
2527
+ return {"ok": False, "error": "无法读取 --file: %r" % (e,)}
2528
+ incoming.update(_parse_param_args(pairs))
2529
+ cleared = []
2530
+ for k, v in (incoming or {}).items():
2531
+ if v is None:
2532
+ if k in cur:
2533
+ del cur[k]
2534
+ cleared.append(k)
2535
+ else:
2536
+ cur[k] = v
2537
+ try:
2538
+ os.makedirs(os.path.dirname(p), exist_ok=True)
2539
+ with open(p, "w", encoding="utf-8") as f:
2540
+ json.dump({"_comment": ("人工提供的设计参数(Bug12 通道)。"
2541
+ "领域规则中无法由几何推导的参数在此登记,"
2542
+ "如 shaft_diameter_mm / gear_module / "
2543
+ "draft_angle_deg / surface_roughness_um / "
2544
+ "rib_height_mm / min_wall_thickness。"),
2545
+ "design_params": cur}, f, ensure_ascii=False, indent=2)
2546
+ except Exception as e:
2547
+ return {"ok": False, "error": "写入失败: %r" % (e,)}
2548
+ return {"ok": True, "path": p, "design_params": cur, "cleared": cleared,
2549
+ "note": "已登记。这些值将在 physics-validate-domain 时以『人工提供』优先采用。"}
2550
+
2551
+
2552
+ def cmd_validate_domain_cli(domain_id="structural", room="", pairs=None, params_file=""):
2553
+ """validate-domain 的 CLI 封装:支持 --param 人工提供设计参数。"""
2554
+ dp = {}
2555
+ # ① --params-file 显式文件
2556
+ if params_file and os.path.exists(params_file):
2557
+ try:
2558
+ jf = json.load(open(params_file, "r", encoding="utf-8"))
2559
+ dp.update(jf.get("design_params") if isinstance(jf.get("design_params"), dict) else jf)
2560
+ except Exception:
2561
+ pass
2562
+ # ② --param k=v(最高优先级)
2563
+ dp.update(_parse_param_args(pairs))
2564
+ return cmd_validate_domain(domain_id, design_params=dp or None, room=room)
2565
+
2566
+
2567
+ def main():
2568
+ parser = argparse.ArgumentParser(
2569
+ description="Physics-in-the-Loop CAD Engineering Tool",
2570
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2571
+ epilog="""
2572
+ Examples:
2573
+ python physics_bridge.py demo
2574
+ python physics_bridge.py validate-case case.json
2575
+ python physics_bridge.py optimize case.json --max-iter 5
2576
+ python physics_bridge.py report opt_iter_3
2577
+ python physics_bridge.py recommend opt_iter_3
2578
+ python physics_bridge.py status
2579
+ """,
2580
+ )
2581
+ sub = parser.add_subparsers(dest="cmd", required=True)
2582
+
2583
+ sub.add_parser("demo", help="运行悬臂梁解析解演示")
2584
+ sub.add_parser("status", help="显示求解器后端状态")
2585
+
2586
+ p = sub.add_parser("validate-case", help="校验载荷工况 JSON")
2587
+ p.add_argument("case_path", help="载荷工况 JSON 文件路径")
2588
+ p.add_argument("--relaxed", action="store_true", help="宽松校验(仅 warning 不报错)")
2589
+
2590
+ p = sub.add_parser("build", help="加载工况并初始化仿真环境")
2591
+ p.add_argument("case_path", help="载荷工况 JSON 文件路径")
2592
+
2593
+ p = sub.add_parser("simulate", help="运行仿真(需先有 run_dir)")
2594
+ p.add_argument("run_dir", help="运行目录路径")
2595
+
2596
+ p = sub.add_parser("report", help="查看仿真报告")
2597
+ p.add_argument("run_id", help="运行 ID 或报告文件路径")
2598
+
2599
+ p = sub.add_parser("recommend", help="生成修正建议")
2600
+ p.add_argument("run_id", help="运行 ID")
2601
+ p.add_argument("--max-iter", type=int, default=3, help="剩余最大迭代次数")
2602
+
2603
+ p = sub.add_parser("optimize", help="自动迭代优化(generate→simulate→refine)")
2604
+ p.add_argument("case_path", help="载荷工况 JSON 文件路径")
2605
+ p.add_argument("--max-iter", type=int, default=5, help="最大迭代次数")
2606
+ # ── 【新 P0 修复】零件路径与材料覆盖 ─────────────────────────────────
2607
+ # 物理校核必须按【零件实际材料】判定,否则国标材料(Q235 σy235)会被
2608
+ # 载荷文件材料(PETG σy40)顶掉,SF 从 1.12 掉到 0.15。
2609
+ p.add_argument("--part", default="", help="零件 .SLDPRT 路径(物理校核必须针对真实零件)")
2610
+ p.add_argument("--room", default="", help="房间名(用于写防线②凭据)")
2611
+ p.add_argument("--material", default="",
2612
+ help="显式指定校核材料(如 Q235/40Cr/6061-T6);"
2613
+ "缺省时自动读取【零件本体材料】,再退回载荷文件材料")
2614
+
2615
+ # ── 【BUG-02 修复】疲劳 / 设计寿命校核 ────────────────────────────────
2616
+ p = sub.add_parser("fatigue", help="疲劳强度与设计寿命(默认30年)校核")
2617
+ p.add_argument("case_path", nargs="?", default="", help="载荷工况 JSON 文件路径")
2618
+ p.add_argument("--stress", type=float, default=None,
2619
+ help="已知最大 von Mises 应力 (MPa),给定则跳过静力求解")
2620
+ p.add_argument("--report", dest="run_id", default="",
2621
+ help="用已有报告的应力做校核(传 run_id)")
2622
+ # ── 【BUG 修复·虚假 PASS】疲劳必须绑定真实几何 ────────────────────────
2623
+ # 不加 --part 时,旧实现会静默退回设计域默认几何 → 应力低估约 95 倍
2624
+ # → 给出虚假 PASS。现在改为硬闸拒绝(与 optimize 同口径)。
2625
+ p.add_argument("--part", dest="part_path", default="",
2626
+ help="零件 .SLDPRT 路径(疲劳校核必须绑定真实零件几何)")
2627
+ p.add_argument("--allow-design-domain", dest="allow_design_domain",
2628
+ action="store_true",
2629
+ help="允许退回设计域默认几何做粗估(结果不得用于设计定型)")
2630
+
2631
+ # ── 【Bug12 二次修复】人工提供设计参数 / 领域校验 ─────────────────────
2632
+ p = sub.add_parser("validate-domain",
2633
+ help="领域规则(GB/T)校验;设计参数缺失时明确报『需人工提供』")
2634
+ p.add_argument("domain", nargs="?", default="structural",
2635
+ help="领域: structural|transmission|housing|mold")
2636
+ p.add_argument("--room", default="", help="房间名(用于写防线③凭据)")
2637
+ p.add_argument("--param", action="append", default=[],
2638
+ help="人工提供设计参数,形如 --param shaft_diameter_mm=25 "
2639
+ "(可多次;这是【设计意图/工艺参数】的唯一正确来源)")
2640
+ p.add_argument("--params-file", default="",
2641
+ help="设计参数 JSON 文件路径(默认读 tools/design_params.json)")
2642
+
2643
+ p = sub.add_parser("set-design-params",
2644
+ help="把人工设计参数写入 tools/design_params.json(领域规则的数据源)")
2645
+ p.add_argument("--param", action="append", default=[],
2646
+ help="形如 --param draft_angle_deg=1.5 (可多次;value=null 表示清除)")
2647
+ p.add_argument("--file", default="", help="改为从该 JSON 文件批量导入")
2648
+ p.add_argument("--show", action="store_true", help="只显示当前已登记的设计参数")
2649
+
2650
+ args = parser.parse_args()
2651
+ cmd_map = {
2652
+ "demo": cmd_demo,
2653
+ "status": cmd_status,
2654
+ "validate-case": lambda a: cmd_validate_case(a.case_path, getattr(a, "relaxed", False)),
2655
+ "build": lambda a: cmd_build(a.case_path),
2656
+ "simulate": lambda a: cmd_simulate(a.run_dir),
2657
+ "report": lambda a: cmd_report(a.run_id),
2658
+ "recommend": lambda a: cmd_recommend(a.run_id, getattr(a, "max_iter", 3)),
2659
+ "optimize": lambda a: cmd_optimize(a.case_path, getattr(a, "max_iter", 5),
2660
+ getattr(a, "room", ""),
2661
+ getattr(a, "part", ""),
2662
+ getattr(a, "material", "")),
2663
+ "fatigue": lambda a: cmd_fatigue(getattr(a, "case_path", ""),
2664
+ getattr(a, "stress", None),
2665
+ getattr(a, "run_id", ""),
2666
+ getattr(a, "part_path", ""),
2667
+ getattr(a, "allow_design_domain", False)),
2668
+ # ── 【Bug12 二次修复】人工提供设计参数 / 领域校验 ──────────────
2669
+ "validate-domain": lambda a: cmd_validate_domain_cli(
2670
+ getattr(a, "domain", "structural"),
2671
+ getattr(a, "room", ""),
2672
+ getattr(a, "param", []),
2673
+ getattr(a, "params_file", "")),
2674
+ "set-design-params": lambda a: cmd_set_design_params(
2675
+ getattr(a, "param", []),
2676
+ getattr(a, "file", ""),
2677
+ getattr(a, "show", False)),
2678
+ }
2679
+ result = cmd_map[args.cmd](args)
2680
+ if result is not None:
2681
+ print(json.dumps(result, ensure_ascii=False, indent=2))
2682
+
2683
+
2684
+ if __name__ == "__main__":
2685
+ main()