Files
geMoldInsight/src/moldinsight/core/occ_worker.py
T
cjw e728dcd226 批次4后续专项完成:D11清偿 + OCC方案B实施 + 部署参数 + D2诚实标注 + CI门禁
① D11 HTML 报告 RustFS 单源化(TECH_DEBT P2 清偿):可视化产物写任务临时目录后
   裸传报告键 html/reports/{filename}(文件名寻址),/html StaticFiles 挂载删除,
   新增 html_report_router 根路径代理(报告键→遗留 JSON 包装→本地卷兜底→404,
   防穿越);URL 形状 /html/{filename} 不变,持久化引用零迁移;celery 摘除
   html_data 卷,镜像不再烤入陈旧报告;顺带删除 get_stp_file_with_data 死数据块
② OCC 方案 B(D10 清偿):run_occ(op_name, payload) 契约 + 常驻工作进程池
   (occ_process_pool + occ_worker 操作注册表),超时/崩溃 terminate 换新补位、
   任务级超时 recover 整体重建,残留线程泄漏根治;TopoDS 不跨进程(generate_cavity
   分模 + 方案 STEP 持久化全在子进程内,返回 export_manifest);删除内存形状缓存链、
   CADExporter.export_mold_results、shape_loader(→ stp_materializer)
③ OCC 方案 A 部署参数:CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 进
   Dockerfile.celery + compose + .env.example
④ D2 诚实标注:铝价响应带 source: "simulated",前端按来源渲染标注(原硬编码
   "上海期货交易所"属虚假声明),死代码 getAluminumPrice 删除
⑤ CI 门禁:.gitea/workflows/ci.yml 三 job(pytest / 前端构建含 vue-tsc /
   openapi 漂移检测)

接口变更三件套随批完成(openapi 76→77 paths + gen:api + 前端构建通过;方案 B
接口面零变化)。测试基线 143 passed, 0 skipped(新增 16 项)。文档六处同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:22:01 +08:00

252 lines
9.2 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。
"""
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"),
)
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()}))