Files
geMoldInsight/src/moldinsight/core/occ_worker.py
T
cjw 505f3591ab D17 批 2:算法接缝(OCC payload 通道 + hints 透传到分模评分)—— 闭环通
让批 1 沉淀的老师傅经验 hints 真接入分模方案生成:
processing_service 拉同指纹 hints 装进 OCC worker payload,
planner 透传到 candidate_generator(axis 优先级加成)和
scheme_scorer(score_breakdown 新字段 + total_score 加成),
写入即消费闭环通。

变更内容:
- src/moldinsight/core/parting_candidate_generator.py
  generate_candidates(..., hints=None):_build_axis_metrics 末尾按 hints
  加成(priority_score += weight × 20 上限;sample_count ≥ 2 + weight ≥ 0.5
  → method 标签升级 "human_experience_primary")
- src/moldinsight/core/parting_scheme_scorer.py
  score_schemes(schemes, *, hints=None) keyword-only:_score_scheme 新增
  human_hint_bonus 字段(weight × 12 上限;sample_count < 2 时 ×0.5 折半
  防信号不足过度影响);_compute_human_hint_bonus 静态方法解析 axis
  (parting.axis → axis → Z);bonus 纳入 total_score
- src/moldinsight/core/multi_scheme_planner.py
  generate_plan(..., hints=None):透传 hints 到 candidate_generator 与
  scheme_scorer;global_summary.applied_hints 注入返回供前端 ResultView
  渲染经验角标
- src/moldinsight/services/processing_service.py
  _step_generate_cavity 加 db_session 形参;调用
  experience_feedback_service.resolve_for_process_params 拿同指纹 hints,
  装进 run_occ payload 顶层 experience_hints;解析失败回退空 list
  不阻塞主流程(旧任务不因 receives 闭包退化)
- src/moldinsight/core/occ_worker.py
  _op_generate_cavity:payload.get("experience_hints") or {} 透传给
  planner.generate_plan(..., hints=...);普通 dict 跨进程 pickle 安全
  (满足 occ_worker.py:7-8 硬规则)
- tests/test_experience_feedback_algorithm.py(new)11 例:
  - candidate_generator 3 例(无 hints 默认 / hints 加成 / sample_count < 2 不升级)
  - scheme_scorer 4 例(无 hints 无 bonus / bonus 加成 / sample_count 折半 /
    weight=0 不加成)
  - multi_scheme_planner 2 例 OCC-gated(透传 / applied_hints 默认空)
  - processing_service 2 例 OCC-gated(payload 含 experience_hints /
    解析失败回退空 list)

设计取舍:
- keyword-only hints:避免与位置参数混淆
- weight 仅正向上有效:max(0, (adopted-rejected)/total),老师傅拒绝
  的不扣分老算法,只让采纳的加分
- signal-noise 控制:sample_count < 2 时 bonus ×0.5,但 priority_score
  仍加成(候选方向仍偏向,避免完全无信号)
- graceful degradation:hints 解析失败回退空 list,主流程继续

- docs/STATUS.md 顶部加 2026-09-23 批 2 日志条目
- docs/TECH_DEBT.md D17 追加批 2 已完成描述 + 缩减剩余工作(仅剩批 3 / 4)

测试基线:192 passed, 13 skipped(净增 7 通过 + 4 OCC-gated skip)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:21:33 +08:00

256 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# core/occ_worker.py
"""OCC 常驻工作进程入口(方案 B,见 docs/topics/performance/OCC_THROUGHPUT.md)。
子进程经 multiprocessing spawn 拉起后运行 worker_main 消息循环:父进程经管道
下发 (op_name, payload),本进程从 _OPS 注册表取 handler 执行并回传结果。
所有操作输入输出均为文件路径 + 可 pickle 的普通字典,**杜绝 pickle OCC 对象**
(TopoDS_Shape 为 C++ 原生内存对象,跨进程传输的唯一干净方式是经文件中转)。
OCC 模块在 handler 内惰性导入:本模块在无 OCC 的父进程(pip 环境)导入不报错,
子进程(conda 环境)首次执行某操作时才加载对应模块——保持 processing_service
可无 OCC 导入(OCC 契约测试在无 OCC 环境 skip)。
"""
import os
import traceback
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict
# ─── 操作注册表 ─────────────────────────────────────────────────
# 新增 OCC 操作必须在此注册:op_name -> 单 payload 字典的 handler。
# handler 内惰性 import OCC 相关核心模块。
_OPS: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
def _op(name: str):
"""注册操作到 _OPS。"""
def deco(fn):
_OPS[name] = fn
return fn
return deco
# 进程内惰性单例缓存(OCC 实例化成本高,子进程常驻期间复用)
_OCC_CACHE: Dict[str, Any] = {}
def _cached(key: str, factory: Callable[[], Any]) -> Any:
if key not in _OCC_CACHE:
_OCC_CACHE[key] = factory()
return _OCC_CACHE[key]
def _get_parser():
from moldinsight.core.stp_parser import STPParser
return STPParser()
def _get_planner():
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
return MultiSchemeMoldPlanner()
def _get_geometry_analyzer():
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
return GeometryAnalyzer()
def _get_mesh_generator():
from moldinsight.core.mesh_generator import MeshGenerator
return MeshGenerator(quality="medium")
def _get_side_action_designer():
from moldinsight.core.side_action_designer import SideActionDesigner
return SideActionDesigner()
def _get_cad_exporter(output_dir: str):
from moldinsight.core.cad_exporter import CADExporter
return CADExporter(output_dir=output_dir)
# ─── 操作实现 ───────────────────────────────────────────────────
@_op("ping")
def _op_ping(payload):
return {"pong": True}
@_op("sleep")
def _op_sleep(payload):
"""诊断/测试用:在子进程内挂起指定秒数。"""
import time
time.sleep(float(payload.get("seconds", 0)))
return {"slept": True}
@_op("warmup")
def _op_warmup(payload):
"""预热:子进程启动后触发,把 OCC 模块加载成本放到池启动而非首个分析任务。"""
_cached("parser", _get_parser)
_cached("planner", _get_planner)
_cached("geometry_analyzer", _get_geometry_analyzer)
_cached("mesh_generator", _get_mesh_generator)
return {"warmed": True}
@_op("parse_stp")
def _op_parse_stp(payload):
"""STP 解析 + 几何分析(原主进程 load_step_file → analyze_geometry 两步合一)。
形状在子进程内创建并即刻消费,不跨进程传输。
"""
parser = _cached("parser", _get_parser)
shape = parser.load_step_file(Path(payload["stp_path"]))
return parser.analyze_geometry(shape)
@_op("generate_mesh")
def _op_generate_mesh(payload):
parser = _cached("parser", _get_parser)
mesh_gen = _cached("mesh_generator", _get_mesh_generator)
shape = parser.load_step_file(Path(payload["stp_path"]))
return mesh_gen.generate_multi_lod_mesh(shape)
@_op("generate_cavity")
def _op_generate_cavity(payload):
"""多方案分模 + 方案形状 STEP 导出,全部在子进程内完成。
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
产物结构一致,主进程原样存入 export_artifacts。
D17 Human-in-Loop:payload 顶层 experience_hints 透传给 planner.generate_plan
让同指纹历史老师傅反馈影响本次分模评分。payload 普通 dict 透传,pickle 安全。
"""
parser = _cached("parser", _get_parser)
planner = _cached("planner", _get_planner)
shape = parser.load_step_file(Path(payload["stp_path"]))
plan_result = planner.generate_plan(
shape=shape,
material=payload["material"],
is_foam_material=payload.get("is_foam_material", False),
process_params=payload.get("process_params"),
hints=payload.get("experience_hints") or {},
)
export_shapes = plan_result.pop("_export_shapes", {}) or {}
export_manifest = _persist_export_shapes(payload, export_shapes)
return {"plan_result": plan_result, "export_manifest": export_manifest}
def _persist_export_shapes(payload: Dict[str, Any], export_shapes: Dict[str, Any]) -> Any:
"""在子进程内把各方案的 TopoDS 形状导出为持久化 STEP,返回 manifest。"""
if not export_shapes:
return None
task_id = payload["task_id"]
stp_path = payload["stp_path"]
export_out_dir = payload.get("export_out_dir")
if not export_out_dir:
raise ValueError("generate_cavity 缺少 export_out_dir")
# 每次按任务目录新建 exporter(output_dir 依任务固定;模块 import 由 importlib 缓存)
exporter = _get_cad_exporter(export_out_dir)
base_filename = Path(stp_path).stem or f"mold_{task_id}"
components = ["cavity", "core", "parting_surface", "product", "a_plate", "b_plate"]
manifest = {
"version": 1,
"task_id": task_id,
"generated_at": datetime.now().isoformat(),
"schemes": {},
}
for scheme_id, cavity_data in export_shapes.items():
try:
result = exporter.export_persisted_steps(
cavity_data=cavity_data,
base_filename=base_filename,
components=components,
task_id=task_id,
scheme_id=scheme_id,
)
manifest["schemes"][scheme_id] = {
"base_filename": result.get("base_filename"),
"generated_at": datetime.now().isoformat(),
"files": result.get("files", []),
"errors": result.get("errors", []),
"total_files": result.get("total_files", 0),
"total_errors": result.get("total_errors", 0),
}
except Exception as exc:
logger = _get_logger()
logger.warning("持久化 STEP 导出失败: task=%s scheme=%s error=%s", task_id, scheme_id, exc)
manifest["schemes"][scheme_id] = {
"base_filename": base_filename,
"generated_at": datetime.now().isoformat(),
"files": [],
"errors": [str(exc)],
"total_files": 0,
"total_errors": 1,
}
return manifest
def _get_logger():
from shared.utils.logger import get_logger
return get_logger(__name__)
@_op("analyze_mold_design")
def _op_analyze_mold_design(payload):
parser = _cached("parser", _get_parser)
analyzer = _cached("geometry_analyzer", _get_geometry_analyzer)
shape = parser.load_step_file(Path(payload["stp_path"]))
return analyzer.analyze_mold_design(
payload["geometry_data"],
product_material=payload.get("product_material", "ABS"),
shape=shape,
)
@_op("detect_undercuts")
def _op_detect_undercuts(payload):
parser = _cached("parser", _get_parser)
designer = _cached("side_action_designer", _get_side_action_designer)
shape = parser.load_step_file(Path(payload["stp_path"]))
return designer.analyze_and_design(
shape,
payload["parting_direction"],
payload["mold_size"],
)
@_op("convert_component_step")
def _op_convert_component_step(payload):
exporter = _get_cad_exporter(os.path.dirname(payload["step_path"]))
return exporter.convert_component_step(
payload["step_path"], payload["out_path"], payload["fmt"]
)
# ─── 子进程主循环 ───────────────────────────────────────────────
def worker_main(conn) -> None:
"""OCC 子进程消息循环。conn 为 multiprocessing.Connection(双工)。
收到 None 或管道断开即退出;单条操作异常回传 error 不杀循环
(进程级隔离意味着一个操作 segfault 才会杀死本进程,由父进程补位)。
"""
while True:
try:
msg = conn.recv()
except (EOFError, KeyboardInterrupt, ConnectionResetError, OSError):
break
if msg is None:
break
op_name, payload = msg
handler = _OPS.get(op_name)
if handler is None:
conn.send(("error", {"error": f"未知 OCC 操作: {op_name}"}))
continue
try:
result = handler(payload)
conn.send(("ok", result))
except BaseException as exc: # noqa: BLE001 子进程内兜底,保证循环存活
conn.send(("error", {"error": f"{type(exc).__name__}: {exc}", "traceback": traceback.format_exc()}))