feat: add selectable demo flow tasks
This commit is contained in:
@@ -201,6 +201,10 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
|
||||
'demoFlowSourcePointCloudFileId',
|
||||
'demoFlowCadFileId',
|
||||
'demoFlowCancelledAt',
|
||||
'demoFlowMode',
|
||||
'demoFlowSelectedTaskNos',
|
||||
'demoFlowSelectedTaskCount',
|
||||
'demoFlowPluginTaskNos',
|
||||
] as const;
|
||||
|
||||
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||
@@ -389,8 +393,19 @@ export async function runAlgorithmJob(
|
||||
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||
});
|
||||
logCompatibilityWarnings(resultId, normalized.warnings);
|
||||
const requestedDemoTaskNos = Array.isArray(params.demoTaskNos)
|
||||
? new Set(
|
||||
params.demoTaskNos
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
|
||||
.map(String),
|
||||
)
|
||||
: null;
|
||||
const normalizedMeasurements = Array.isArray(result?.measurements)
|
||||
? normalized.measurements
|
||||
? requestedDemoTaskNos
|
||||
? normalized.measurements.filter((measurement) => (
|
||||
typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo)
|
||||
))
|
||||
: normalized.measurements
|
||||
: undefined;
|
||||
const resultData: PluginResultData = {
|
||||
kind: 'plugin',
|
||||
|
||||
84
server/demoFlow.test.ts
Normal file
84
server/demoFlow.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from './demoFlow';
|
||||
import { listPlugins } from './plugins/registry';
|
||||
import type { PluginManifest } from './plugins/types';
|
||||
|
||||
function manifest(id: string, tasks: PluginManifest['demoTasks']): PluginManifest {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
version: '1',
|
||||
schemaVersion: 1,
|
||||
demoTasks: tasks,
|
||||
demoSelection: { kind: 'task-nos', param: 'demoTaskNos' },
|
||||
runtime: { kind: 'mock', fixture: 'unused.json' },
|
||||
};
|
||||
}
|
||||
|
||||
const manifests = [
|
||||
manifest('distance', [
|
||||
{ taskNo: 2, name: '车体长度', category: '尺寸', inspectionObjects: ['head', 'middle'] },
|
||||
]),
|
||||
manifest('contour', [
|
||||
{ taskNo: 11, name: '车钩面板的平整度', category: '轮廓', inspectionObjects: ['head', 'middle'] },
|
||||
]),
|
||||
manifest('coupler', [
|
||||
{ taskNo: 9, name: '参考面右偏转', category: '车钩', inspectionObjects: ['middle'] },
|
||||
{ taskNo: 10, name: '纵向中心右偏转', category: '车钩', inspectionObjects: ['head'] },
|
||||
]),
|
||||
];
|
||||
|
||||
describe('demo task catalog', () => {
|
||||
it('按数值排序并标记不适用于当前检测对象的任务', () => {
|
||||
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
|
||||
expect(catalog.items.map((item) => item.taskNo)).toEqual([2, 9, 10, 11]);
|
||||
expect(catalog.total).toBe(3);
|
||||
expect(catalog.items.find((item) => item.taskNo === 9)).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('跨算法选择转换为最小作业集合并去重', () => {
|
||||
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
|
||||
const plan = resolveSelectedDemoJobs(manifests, catalog, [11, 2, 10, 2]);
|
||||
expect(plan.taskNos).toEqual([2, 10, 11]);
|
||||
expect(plan.jobs.map((job) => job.pluginId)).toEqual(['distance', 'coupler', 'contour']);
|
||||
expect(plan.jobs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('拒绝当前检测对象不可运行的任务', () => {
|
||||
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
|
||||
expect(() => resolveSelectedDemoJobs(manifests, catalog, [9])).toThrow('不适用于当前头车');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actual plugin demo task catalog', () => {
|
||||
const actualManifests = listPlugins();
|
||||
|
||||
it('返回真实非连续任务号、名称与检测对象动态数量', () => {
|
||||
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
|
||||
const middle = buildDemoTaskCatalog(actualManifests, 'middle', '中车');
|
||||
expect(head.total).toBe(39);
|
||||
expect(middle.total).toBe(26);
|
||||
expect(head.items.find((item) => item.taskNo === 11)?.name).toBe('车钩面板的平整度');
|
||||
expect(head.items.some((item) => item.taskNo === 3)).toBe(false);
|
||||
expect(head.items.map((item) => item.taskNo)).toEqual(
|
||||
[...head.items.map((item) => item.taskNo)].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
|
||||
it('任务 2、11、10 生成三个插件作业,单个车钩任务只生成一次共享作业', () => {
|
||||
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
|
||||
const mixed = resolveSelectedDemoJobs(actualManifests, head, [2, 11, 10]);
|
||||
expect(mixed.jobs.map((job) => job.pluginId)).toEqual([
|
||||
'train-measurement',
|
||||
'coupler-panel',
|
||||
'flatness-suite',
|
||||
]);
|
||||
const couplerOnly = resolveSelectedDemoJobs(actualManifests, head, [8]);
|
||||
expect(couplerOnly.jobs).toEqual([{
|
||||
pluginId: 'coupler-panel',
|
||||
taskNos: [8],
|
||||
params: { demoTaskNos: [8] },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
120
server/demoFlow.ts
Normal file
120
server/demoFlow.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
DemoInspectionObject,
|
||||
PluginDemoTask,
|
||||
PluginManifest,
|
||||
} from './plugins/types.js';
|
||||
|
||||
export interface DemoTaskCatalogItem extends PluginDemoTask {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export interface DemoTaskCatalog {
|
||||
inspectionObject: DemoInspectionObject | 'unknown';
|
||||
inspectionObjectName?: string;
|
||||
total: number;
|
||||
items: DemoTaskCatalogItem[];
|
||||
}
|
||||
|
||||
export interface DemoSelectedPluginJob {
|
||||
pluginId: string;
|
||||
taskNos: number[];
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function disabledReasonFor(
|
||||
task: PluginDemoTask,
|
||||
inspectionObject: DemoInspectionObject | 'unknown',
|
||||
inspectionObjectName?: string,
|
||||
): string | undefined {
|
||||
if (!task.inspectionObjects?.length) return undefined;
|
||||
if (inspectionObject === 'unknown') {
|
||||
return inspectionObjectName
|
||||
? `检测对象“${inspectionObjectName}”尚未配置任务适用规则`
|
||||
: '当前任务尚未确认头车或中车检测对象';
|
||||
}
|
||||
if (!task.inspectionObjects.includes(inspectionObject)) {
|
||||
return `不适用于当前${inspectionObject === 'head' ? '头车' : '中车'}检测对象`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 从插件 manifest 构建服务端权威任务目录。 */
|
||||
export function buildDemoTaskCatalog(
|
||||
manifests: readonly PluginManifest[],
|
||||
inspectionObject: DemoInspectionObject | 'unknown',
|
||||
inspectionObjectName?: string,
|
||||
): DemoTaskCatalog {
|
||||
const items: DemoTaskCatalogItem[] = [];
|
||||
const claimedTaskNos = new Map<number, string>();
|
||||
|
||||
for (const manifest of manifests) {
|
||||
for (const task of manifest.demoTasks ?? []) {
|
||||
const previousPlugin = claimedTaskNos.get(task.taskNo);
|
||||
if (previousPlugin) {
|
||||
throw new Error(`演示任务 ${task.taskNo} 被插件 ${previousPlugin} 与 ${manifest.id} 重复声明`);
|
||||
}
|
||||
claimedTaskNos.set(task.taskNo, manifest.id);
|
||||
const disabledReason = disabledReasonFor(task, inspectionObject, inspectionObjectName);
|
||||
items.push({
|
||||
...task,
|
||||
pluginId: manifest.id,
|
||||
pluginName: manifest.name,
|
||||
enabled: disabledReason == null,
|
||||
...(disabledReason ? { disabledReason } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => a.taskNo - b.taskNo);
|
||||
return {
|
||||
inspectionObject,
|
||||
...(inspectionObjectName ? { inspectionObjectName } : {}),
|
||||
total: items.filter((item) => item.enabled).length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
/** 校验所选任务并转换为最小插件作业集合。 */
|
||||
export function resolveSelectedDemoJobs(
|
||||
manifests: readonly PluginManifest[],
|
||||
catalog: DemoTaskCatalog,
|
||||
requestedTaskNos: readonly number[],
|
||||
): { taskNos: number[]; jobs: DemoSelectedPluginJob[] } {
|
||||
const taskNos = Array.from(new Set(requestedTaskNos)).sort((a, b) => a - b);
|
||||
if (taskNos.length === 0) throw new Error('请至少选择一个任务');
|
||||
|
||||
const itemByTaskNo = new Map(catalog.items.map((item) => [item.taskNo, item]));
|
||||
for (const taskNo of taskNos) {
|
||||
const item = itemByTaskNo.get(taskNo);
|
||||
if (!item) throw new Error(`任务 ${taskNo} 不存在或当前算法未提供`);
|
||||
if (!item.enabled) throw new Error(`任务 ${taskNo} 当前不可执行:${item.disabledReason ?? '运行条件不满足'}`);
|
||||
}
|
||||
|
||||
const taskNosByPlugin = new Map<string, number[]>();
|
||||
for (const taskNo of taskNos) {
|
||||
const pluginId = itemByTaskNo.get(taskNo)!.pluginId;
|
||||
const pluginTaskNos = taskNosByPlugin.get(pluginId) ?? [];
|
||||
pluginTaskNos.push(taskNo);
|
||||
taskNosByPlugin.set(pluginId, pluginTaskNos);
|
||||
}
|
||||
|
||||
const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest]));
|
||||
const jobs: DemoSelectedPluginJob[] = [];
|
||||
for (const [pluginId, pluginTaskNos] of Array.from(taskNosByPlugin.entries())) {
|
||||
const manifest = manifestById.get(pluginId);
|
||||
if (!manifest) throw new Error(`未注册的算法插件:${pluginId}`);
|
||||
if (manifest.demoSelection?.kind !== 'task-nos' || !manifest.demoSelection.param) {
|
||||
throw new Error(`算法插件 ${pluginId} 未配置任务选择执行方式`);
|
||||
}
|
||||
jobs.push({
|
||||
pluginId,
|
||||
taskNos: pluginTaskNos,
|
||||
params: { [manifest.demoSelection.param]: pluginTaskNos },
|
||||
});
|
||||
}
|
||||
|
||||
return { taskNos, jobs };
|
||||
}
|
||||
@@ -155,23 +155,53 @@ def main() -> int:
|
||||
offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right))
|
||||
offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref))
|
||||
|
||||
measurements = [
|
||||
{
|
||||
inspection_object = str((payload.get("context") or {}).get("inspectionObject") or "unknown")
|
||||
longitudinal_task_no = "10" if inspection_object == "head" else "12" if inspection_object == "middle" else None
|
||||
reference_task_no = "8" if inspection_object == "head" else "9" if inspection_object == "middle" else None
|
||||
raw_selected_task_nos = config.get("demoTaskNos")
|
||||
selected_task_nos = (
|
||||
{str(int(value)) for value in raw_selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()}
|
||||
if isinstance(raw_selected_task_nos, list)
|
||||
else None
|
||||
)
|
||||
|
||||
def _selected(task_no: str | None) -> bool:
|
||||
return selected_task_nos is None or (task_no is not None and task_no in selected_task_nos)
|
||||
|
||||
measurements = []
|
||||
if _selected(longitudinal_task_no):
|
||||
longitudinal_measurement = {
|
||||
"name": "车钩面板距纵向中心的右偏转角",
|
||||
"value": round(offset_right, 6),
|
||||
"unit": "mm",
|
||||
"baseline": 700.0,
|
||||
"tolerance": 1.0,
|
||||
"status": _status_for(offset_right, 1.0),
|
||||
},
|
||||
{
|
||||
}
|
||||
if longitudinal_task_no:
|
||||
longitudinal_measurement.update({
|
||||
"taskNo": longitudinal_task_no,
|
||||
"metricCode": "coupler.longitudinal_right_offset",
|
||||
"name": "纵向中心右偏转",
|
||||
})
|
||||
measurements.append(longitudinal_measurement)
|
||||
if _selected(reference_task_no):
|
||||
reference_measurement = {
|
||||
"name": "车钩面板距参考面的右偏转角",
|
||||
"value": round(offset_ref, 6),
|
||||
"unit": "mm",
|
||||
"baseline": 375.0,
|
||||
"tolerance": 0.8,
|
||||
"status": _status_for(offset_ref, 0.8),
|
||||
},
|
||||
}
|
||||
if reference_task_no:
|
||||
reference_measurement.update({
|
||||
"taskNo": reference_task_no,
|
||||
"metricCode": "coupler.reference_right_offset",
|
||||
"name": "参考面右偏转",
|
||||
})
|
||||
measurements.append(reference_measurement)
|
||||
measurements.extend([
|
||||
{
|
||||
"name": "最大 x 偏差 dx_y",
|
||||
"value": dx_y,
|
||||
@@ -198,7 +228,7 @@ def main() -> int:
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
payload_out = {
|
||||
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
||||
|
||||
@@ -226,8 +226,17 @@ def main() -> int:
|
||||
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
||||
if not result_template_path.is_file():
|
||||
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
|
||||
selected_task_nos = config.get("demoTaskNos")
|
||||
selected_task_ids = (
|
||||
[str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()]
|
||||
if isinstance(selected_task_nos, list)
|
||||
else None
|
||||
)
|
||||
if selected_task_ids == []:
|
||||
raise RuntimeError("请至少选择一个轮廓测量任务")
|
||||
res = pipeline.run_contour_batch(
|
||||
all_tasks=True,
|
||||
task_ids=selected_task_ids,
|
||||
all_tasks=selected_task_ids is None,
|
||||
data_root=str(data_root),
|
||||
pointcloud_kind="noise",
|
||||
point_chunk_size=8192,
|
||||
|
||||
24
server/plugins/adapters/test_demo_task_selection.py
Normal file
24
server/plugins/adapters/test_demo_task_selection.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_adapter(name: str):
|
||||
path = Path(__file__).with_name(name)
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class DemoTaskSelectionTest(unittest.TestCase):
|
||||
def test_train_task_number_parsing_and_selection(self):
|
||||
adapter = _load_adapter("train_measurement_run.py")
|
||||
self.assertEqual(adapter._task_no_from_name("任务46_车体横断面对角线差值"), "46")
|
||||
self.assertEqual(adapter._selected_task_nos({"demoTaskNos": [2, 46, 2]}), {"2", "46"})
|
||||
self.assertIsNone(adapter._selected_task_nos({}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,6 +19,7 @@ v2.2 变更:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
@@ -33,6 +34,18 @@ from _plugin_io import (
|
||||
)
|
||||
|
||||
|
||||
def _task_no_from_name(name: object) -> str | None:
|
||||
match = re.match(r"^任务\s*(\d+)", str(name or ""))
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _selected_task_nos(config: dict) -> set[str] | None:
|
||||
raw = config.get("demoTaskNos")
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
return {str(int(value)) for value in raw if isinstance(value, (int, float)) and float(value).is_integer()}
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
if value is None:
|
||||
return None
|
||||
@@ -155,6 +168,13 @@ def main() -> int:
|
||||
if t.get("enabled", True)
|
||||
}
|
||||
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
|
||||
selected_task_nos = _selected_task_nos(config)
|
||||
if selected_task_nos is not None:
|
||||
available_task_nos = {_task_no_from_name(task.name) for task in tm.tasks}
|
||||
missing_task_nos = selected_task_nos - available_task_nos
|
||||
if missing_task_nos:
|
||||
raise RuntimeError(f"测距配置中找不到所选任务:{','.join(sorted(missing_task_nos, key=int))}")
|
||||
tm.tasks = [task for task in tm.tasks if _task_no_from_name(task.name) in selected_task_nos]
|
||||
|
||||
# 注入可视化输出目录
|
||||
viz_dir = out_dir / "visualizations"
|
||||
|
||||
@@ -49,6 +49,11 @@ def main() -> int:
|
||||
try:
|
||||
emit_progress(0.05, "加载挠度算法包 v1.2.1")
|
||||
|
||||
selected_task_nos = config.get("demoTaskNos")
|
||||
if isinstance(selected_task_nos, list) and 5 not in selected_task_nos:
|
||||
write_error_result(out_dir, "构架挠度插件仅支持任务 5")
|
||||
return 1
|
||||
|
||||
# ── 输入文件 ────────────────────────────────────────────────────────
|
||||
pointcloud_path = first_pointcloud(payload)
|
||||
if not pointcloud_path:
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"category": "几何测量",
|
||||
"requiresPointCloud": true,
|
||||
"requiresCad": false,
|
||||
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||
"demoTasks": [
|
||||
{ "taskNo": 8, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 9, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["middle"] },
|
||||
{ "taskNo": 10, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 12, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["middle"] }
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"name": "profile",
|
||||
|
||||
@@ -8,6 +8,29 @@
|
||||
"category": "几何测量",
|
||||
"requiresPointCloud": false,
|
||||
"requiresCad": false,
|
||||
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||
"demoTasks": [
|
||||
{ "taskNo": 11, "name": "车钩面板的平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 13, "name": "车长方向平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 17, "name": "车高方向弧形轮廓测量", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 20, "name": "门柱平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 22, "name": "从门口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 23, "name": "从窗口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 25, "name": "内风挡外圈平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 26, "name": "内风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 27, "name": "外风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 31, "name": "车头前墙区域平面度", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 34, "name": "司机室门外弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 35, "name": "前窗框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 36, "name": "前窗框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 37, "name": "顶灯框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 38, "name": "顶灯框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 39, "name": "逃生窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 40, "name": "逃生窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 41, "name": "曲面窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 42, "name": "曲面窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 45, "name": "地板平度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"name": "useBatchDatasets",
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
"category": "尺寸测量",
|
||||
"requiresPointCloud": true,
|
||||
"requiresCad": false,
|
||||
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||
"demoTasks": [
|
||||
{ "taskNo": 1, "name": "转向架距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 2, "name": "车体长度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 7, "name": "边梁下平面到参考线的距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 15, "name": "侧墙最大点到参考平面距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 16, "name": "车体最大宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 18, "name": "门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 21, "name": "侧门高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 28, "name": "端门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 29, "name": "端门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 30, "name": "参考线到司机室辅助孔距离", "category": "尺寸", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 32, "name": "司机室登车门门口宽度", "category": "尺寸", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 33, "name": "司机室登车门门口高度", "category": "尺寸", "inspectionObjects": ["head"] },
|
||||
{ "taskNo": 43, "name": "窗口下沿高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 44, "name": "车顶高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 46, "name": "车体横断面对角线差值", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
|
||||
{ "taskNo": 47, "name": "内端墙门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }
|
||||
],
|
||||
"uiPanel": "train-measurement",
|
||||
"inputs": [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"category": "几何测量",
|
||||
"requiresPointCloud": true,
|
||||
"requiresCad": false,
|
||||
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||
"demoTasks": [
|
||||
{ "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] }
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"name": "leftRoiPath",
|
||||
|
||||
@@ -68,6 +68,22 @@ export interface PluginRuntimeMock {
|
||||
|
||||
export type PluginRuntime = PluginRuntimeProcess | PluginRuntimeMock;
|
||||
|
||||
export type DemoInspectionObject = 'head' | 'middle';
|
||||
|
||||
/** 插件可在“全流程任务选择”中暴露的正式检验任务。 */
|
||||
export interface PluginDemoTask {
|
||||
taskNo: number;
|
||||
name: string;
|
||||
category: string;
|
||||
inspectionObjects?: DemoInspectionObject[];
|
||||
}
|
||||
|
||||
/** 任务选择如何转换为插件参数。 */
|
||||
export interface PluginDemoSelection {
|
||||
kind: 'task-nos';
|
||||
param: string;
|
||||
}
|
||||
|
||||
/** 插件 manifest(写在 server/plugins/manifests/*.json) */
|
||||
export interface PluginManifest {
|
||||
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
|
||||
@@ -89,6 +105,10 @@ export interface PluginManifest {
|
||||
requiresCad?: boolean;
|
||||
/** 是否需要点云(默认 true) */
|
||||
requiresPointCloud?: boolean;
|
||||
/** “选择任务运行”模式的权威任务目录。 */
|
||||
demoTasks?: PluginDemoTask[];
|
||||
/** 选中任务传给适配器的方式。 */
|
||||
demoSelection?: PluginDemoSelection;
|
||||
runtime: PluginRuntime;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
|
||||
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
||||
import { stepParseCacheDir } from "./stepParse.js";
|
||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
|
||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||
@@ -313,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户
|
||||
};
|
||||
}
|
||||
|
||||
interface DemoFlowMeasurementJob {
|
||||
pluginId: string;
|
||||
params?: Record<string, unknown>;
|
||||
taskNos?: number[];
|
||||
}
|
||||
|
||||
interface DemoFlowMetadata {
|
||||
demoFlowMode: "all" | "selected";
|
||||
demoFlowSelectedTaskNos?: number[];
|
||||
demoFlowSelectedTaskCount?: number;
|
||||
}
|
||||
|
||||
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||
taskId: number;
|
||||
sourcePointCloudFileId: number;
|
||||
cadFileId: number;
|
||||
registrationResultId: number;
|
||||
measurementPluginIds: string[];
|
||||
measurementJobs: DemoFlowMeasurementJob[];
|
||||
demoFlowRunId: string;
|
||||
metadata: DemoFlowMetadata;
|
||||
}) {
|
||||
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
||||
if (registrationResult?.status !== "completed") {
|
||||
@@ -335,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
|
||||
const pluginId = input.measurementPluginIds[idx];
|
||||
for (let idx = 0; idx < input.measurementJobs.length; idx += 1) {
|
||||
const job = input.measurementJobs[idx];
|
||||
const pluginId = job.pluginId;
|
||||
const manifest = getPlugin(pluginId);
|
||||
await createAndEnqueuePluginJob({
|
||||
taskId: input.taskId,
|
||||
pluginId,
|
||||
params: paramsForDemoPlugin(pluginId),
|
||||
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
|
||||
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
||||
? [registered.id]
|
||||
: [input.sourcePointCloudFileId],
|
||||
@@ -350,14 +365,86 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||
demoFlowRunId: input.demoFlowRunId,
|
||||
demoFlowRole: "measurement",
|
||||
demoFlowIndex: idx + 2,
|
||||
demoFlowTotal: input.measurementPluginIds.length + 1,
|
||||
demoFlowTotal: input.measurementJobs.length + 1,
|
||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
demoFlowCadFileId: input.cadFileId,
|
||||
...input.metadata,
|
||||
...(job.taskNos ? { demoFlowPluginTaskNos: job.taskNos } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startDemoFlow(input: {
|
||||
taskId: number;
|
||||
sourcePointCloudFileId: number;
|
||||
cadFileId: number;
|
||||
measurementJobs: DemoFlowMeasurementJob[];
|
||||
metadata: DemoFlowMetadata;
|
||||
}) {
|
||||
const demoFlowRunId = randomUUID();
|
||||
clearDemoFlowCancelled(input.taskId);
|
||||
for (const job of input.measurementJobs) {
|
||||
const manifest = getPlugin(job.pluginId);
|
||||
if (!manifest) throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${job.pluginId}` });
|
||||
if (manifest.category === "配准") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "全流程的测量算法列表不能包含配准算法。" });
|
||||
}
|
||||
}
|
||||
|
||||
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
|
||||
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
|
||||
if (!sourcePointcloud) throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
|
||||
if (sourcePointcloud.source === "registered") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "请选择原始点云,系统会自动找到对应配准产物。" });
|
||||
}
|
||||
const cadFiles = await db.listTaskCadFiles(input.taskId);
|
||||
const cad = cadFiles.find((row) => row.id === input.cadFileId);
|
||||
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
||||
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
|
||||
|
||||
const registration = await createAndEnqueuePluginJob({
|
||||
taskId: input.taskId,
|
||||
pluginId: "vehicle-registration",
|
||||
params: {},
|
||||
pointCloudFileIds: [input.sourcePointCloudFileId],
|
||||
cadFileIds: [input.cadFileId],
|
||||
resultData: {
|
||||
demoFlowRunId,
|
||||
demoFlowRole: "registration",
|
||||
demoFlowIndex: 1,
|
||||
demoFlowTotal: input.measurementJobs.length + 1,
|
||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
demoFlowCadFileId: input.cadFileId,
|
||||
...input.metadata,
|
||||
},
|
||||
});
|
||||
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
|
||||
void registration.promise.then(() => {
|
||||
if (isDemoFlowCancelled(input.taskId)) {
|
||||
console.log(`[demoFlow] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
|
||||
return undefined;
|
||||
}
|
||||
return enqueueDemoRegisteredMeasurementsAfterRegistration({
|
||||
taskId: input.taskId,
|
||||
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
cadFileId: input.cadFileId,
|
||||
registrationResultId: registration.id,
|
||||
measurementJobs: input.measurementJobs,
|
||||
demoFlowRunId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
}).catch((err) => console.error("[demoFlow] 配准链式提交失败:", err));
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
jobs: [] as { id: number; pluginId: string }[],
|
||||
registrationJob,
|
||||
queuedMeasurementCount: input.measurementJobs.length,
|
||||
scheduler: algorithmJobStats(),
|
||||
};
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -1325,6 +1412,19 @@ export const appRouter = router({
|
||||
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
|
||||
}),
|
||||
|
||||
/** 当前检测对象可用的正式任务目录。 */
|
||||
demoTaskCatalog: protectedProcedure
|
||||
.input(z.object({ taskId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const task = await db.getInspectionTaskById(input.taskId);
|
||||
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
|
||||
const inspectionObject = task.inspectionObjectId
|
||||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||||
: undefined;
|
||||
const context = inspectionObjectContextFromName(inspectionObject?.name);
|
||||
return buildDemoTaskCatalog(listPlugins(), context.inspectionObject, context.inspectionObjectName);
|
||||
}),
|
||||
|
||||
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
|
||||
runDemoAll: protectedProcedure
|
||||
.input(
|
||||
@@ -1337,85 +1437,50 @@ export const appRouter = router({
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
||||
const demoFlowRunId = randomUUID();
|
||||
clearDemoFlowCancelled(input.taskId);
|
||||
for (const pluginId of measurementPluginIds) {
|
||||
const manifest = getPlugin(pluginId);
|
||||
if (!manifest) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `未注册的算法插件:${pluginId}`,
|
||||
});
|
||||
}
|
||||
if (manifest.category === "配准") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "一键全流程演示的测量算法列表不能包含配准算法。",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
|
||||
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
|
||||
if (!sourcePointcloud) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
|
||||
}
|
||||
if (sourcePointcloud.source === "registered") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "请选择原始点云,系统会自动找到对应配准产物。",
|
||||
});
|
||||
}
|
||||
|
||||
const cadFiles = await db.listTaskCadFiles(input.taskId);
|
||||
const cad = cadFiles.find((row) => row.id === input.cadFileId);
|
||||
if (!cad) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
||||
}
|
||||
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
|
||||
|
||||
const registration = await createAndEnqueuePluginJob({
|
||||
return startDemoFlow({
|
||||
taskId: input.taskId,
|
||||
pluginId: "vehicle-registration",
|
||||
params: {},
|
||||
pointCloudFileIds: [input.sourcePointCloudFileId],
|
||||
cadFileIds: [input.cadFileId],
|
||||
resultData: {
|
||||
demoFlowRunId,
|
||||
demoFlowRole: "registration",
|
||||
demoFlowIndex: 1,
|
||||
demoFlowTotal: measurementPluginIds.length + 1,
|
||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
demoFlowCadFileId: input.cadFileId,
|
||||
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
cadFileId: input.cadFileId,
|
||||
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
|
||||
metadata: { demoFlowMode: "all" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
|
||||
runDemoSelected: protectedProcedure
|
||||
.input(z.object({
|
||||
taskId: z.number(),
|
||||
sourcePointCloudFileId: z.number(),
|
||||
cadFileId: z.number(),
|
||||
taskNos: z.array(z.number().int().positive()).min(1).max(64),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const task = await db.getInspectionTaskById(input.taskId);
|
||||
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
|
||||
const inspectionObject = task.inspectionObjectId
|
||||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||||
: undefined;
|
||||
const context = inspectionObjectContextFromName(inspectionObject?.name);
|
||||
const manifests = listPlugins();
|
||||
const catalog = buildDemoTaskCatalog(manifests, context.inspectionObject, context.inspectionObjectName);
|
||||
let selectedPlan: ReturnType<typeof resolveSelectedDemoJobs>;
|
||||
try {
|
||||
selectedPlan = resolveSelectedDemoJobs(manifests, catalog, input.taskNos);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "所选任务无效" });
|
||||
}
|
||||
const result = await startDemoFlow({
|
||||
taskId: input.taskId,
|
||||
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
cadFileId: input.cadFileId,
|
||||
measurementJobs: selectedPlan.jobs,
|
||||
metadata: {
|
||||
demoFlowMode: "selected",
|
||||
demoFlowSelectedTaskNos: selectedPlan.taskNos,
|
||||
demoFlowSelectedTaskCount: selectedPlan.taskNos.length,
|
||||
},
|
||||
});
|
||||
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
|
||||
void registration.promise
|
||||
.then(() => {
|
||||
if (isDemoFlowCancelled(input.taskId)) {
|
||||
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
|
||||
return undefined;
|
||||
}
|
||||
return enqueueDemoRegisteredMeasurementsAfterRegistration({
|
||||
taskId: input.taskId,
|
||||
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||
cadFileId: input.cadFileId,
|
||||
registrationResultId: registration.id,
|
||||
measurementPluginIds,
|
||||
demoFlowRunId,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[runDemoAll] 配准链式提交失败:", err);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
jobs: [] as { id: number; pluginId: string }[],
|
||||
registrationJob,
|
||||
queuedMeasurementCount: measurementPluginIds.length,
|
||||
scheduler: algorithmJobStats(),
|
||||
};
|
||||
return { ...result, selectedTaskNos: selectedPlan.taskNos };
|
||||
}),
|
||||
|
||||
cancelDemoAll: protectedProcedure
|
||||
|
||||
Reference in New Issue
Block a user