diff --git a/alg/平整度、平面度、轮廓测量v1.5/pipeline.py b/alg/平整度、平面度、轮廓测量v1.5/pipeline.py index 0fcf220..b2ead31 100644 --- a/alg/平整度、平面度、轮廓测量v1.5/pipeline.py +++ b/alg/平整度、平面度、轮廓测量v1.5/pipeline.py @@ -322,6 +322,7 @@ class MeasurementPipeline: excel_output_path: str | None = None, result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE, combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD, + save_combined_visualization: bool = True, no_visualize: bool = False, ) -> dict[str, Any]: batch_start_time = time.perf_counter() @@ -443,7 +444,7 @@ class MeasurementPipeline: combined_visualization_output = None combined_pointclouds = [] - if combined_visualizations: + if save_combined_visualization and combined_visualizations: combined_visualization_output = self._contour_combined_save_path(save_path) self._save_contour_combined_pointcloud_stream( combined_visualizations, @@ -627,6 +628,7 @@ class MeasurementPipeline: template_sheet_name, headers, template_rows = self._read_measurement_template(template_path) task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"]) result_col = self._find_header_index(headers, ["测量结果"]) + error_col = self._find_header_index(headers, ["误差结果"]) conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"]) result_by_task = { @@ -648,6 +650,7 @@ class MeasurementPipeline: continue output_row[result_col] = self._row_measurement_mean(result_row) + output_row[error_col] = result_row.get("max") output_row[conclusion_col] = self._row_measurement_conclusion(result_row) table.append(output_row) used_task_ids.add(task_id) @@ -659,6 +662,7 @@ class MeasurementPipeline: output_row = [""] * len(headers) output_row[task_col] = task_id output_row[result_col] = self._row_measurement_mean(result_row) + output_row[error_col] = result_row.get("max") output_row[conclusion_col] = self._row_measurement_conclusion(result_row) table.append(output_row) @@ -729,7 +733,9 @@ class MeasurementPipeline: def _row_measurement_conclusion(self, row: dict[str, Any]) -> str: status = str(row.get("status", "")).strip() - if status and status != "成功": + if status in {"成功", "部分成功", "失败"}: + return status + if status: return "失败" value = self._row_measurement_mean(row) diff --git a/alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py b/alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py new file mode 100644 index 0000000..1a7e47a --- /dev/null +++ b/alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py @@ -0,0 +1,62 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import Mock +import tempfile + +import numpy as np + +from pipeline import MeasurementPipeline + + +class CombinedContourRetentionTest(TestCase): + def make_pipeline(self, root: Path) -> MeasurementPipeline: + mesh = root / "mesh.ply" + pointcloud = root / "points.ply" + mesh.touch() + pointcloud.touch() + + pipeline = MeasurementPipeline.__new__(MeasurementPipeline) + pipeline._resolve_contour_task_inputs = Mock(return_value=[{ + "task_id": "1", + "sample_id": "sample", + "cad_path": None, + "mesh_path": mesh, + "pointcloud_path": pointcloud, + }]) + pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply") + pipeline.run_contour = Mock(return_value={ + "distance_stats": SimpleNamespace(max=0.5), + "distances": np.array([0.1, 0.2]), + "pointcloud": object(), + }) + pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply") + pipeline._save_contour_combined_pointcloud_stream = Mock() + pipeline._should_visualize = Mock(return_value=False) + return pipeline + + def test_skips_combined_file_when_disabled(self) -> None: + with tempfile.TemporaryDirectory() as temp: + pipeline = self.make_pipeline(Path(temp)) + result = pipeline.run_contour_batch( + all_tasks=True, + save_path=temp, + save_combined_visualization=False, + no_visualize=True, + ) + + self.assertIsNone(result["combined_visualization_output"]) + pipeline._save_contour_combined_pointcloud_stream.assert_not_called() + + def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None: + with tempfile.TemporaryDirectory() as temp: + pipeline = self.make_pipeline(Path(temp)) + result = pipeline.run_contour_batch( + all_tasks=True, + save_path=temp, + save_combined_visualization=True, + no_visualize=True, + ) + + self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply") + pipeline._save_contour_combined_pointcloud_stream.assert_called_once() diff --git a/scripts/backfill-flatness-suite-measurements.ts b/scripts/backfill-flatness-suite-measurements.ts new file mode 100644 index 0000000..ca66348 --- /dev/null +++ b/scripts/backfill-flatness-suite-measurements.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +import { execFile as execFileCallback } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; +import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise"; + +const execFile = promisify(execFileCallback); + +export type Measurement = Record; + +export interface CandidateRow extends RowDataPacket { + id: number; + taskId: number; + measurements: unknown; + resultData: Record | null; +} + +export interface BackfillPlan { + row: CandidateRow; + resultJsonPath: string; + originalResultJson: Record; + measurements: Measurement[]; +} + +export interface ExecutionSummary { + planned: number; + applied: number; + skipped: number; + failed: number; +} + +export function mergeMeasurements>( + value: T | null, + measurements: Measurement[], +): T & { measurements: Measurement[] } { + return { ...(value ?? ({} as T)), measurements }; +} + +export async function executePlans( + plans: BackfillPlan[], + apply: boolean, + persist: (plan: BackfillPlan) => Promise, +): Promise { + const summary: ExecutionSummary = { + planned: plans.length, + applied: 0, + skipped: apply ? 0 : plans.length, + failed: 0, + }; + if (!apply) return summary; + + for (const plan of plans) { + try { + if (await persist(plan)) summary.applied += 1; + else summary.skipped += 1; + } catch (error) { + summary.failed += 1; + console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error); + } + } + return summary; +} + +function argumentValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function timestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +async function parseMeasurements( + excelPath: string, + templatePath: string, + helperPath: string, +): Promise { + const python = process.env.PYTHON3_PATH || "/usr/bin/python3"; + const { stdout } = await execFile( + python, + [helperPath, "--excel", excelPath, "--template", templatePath], + { maxBuffer: 10 * 1024 * 1024 }, + ); + const parsed = JSON.parse(stdout) as unknown; + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error("解析结果为空") + } + return parsed.filter((item): item is Measurement => !!item && typeof item === "object"); +} + +async function listCandidates(pool: Pool, resultId?: number): Promise { + const filters = [ + "pluginId = 'flatness-suite'", + "status = 'completed'", + "(measurements IS NULL OR JSON_LENGTH(measurements) = 0)", + ]; + const params: number[] = []; + if (resultId !== undefined) { + filters.push("id = ?"); + params.push(resultId); + } + const [rows] = await pool.query( + `SELECT id, taskId, measurements, resultData + FROM task_analysis_results + WHERE ${filters.join(" AND ")} + ORDER BY id`, + params, + ); + return rows; +} + +async function buildPlans( + rows: CandidateRow[], + dataDir: string, + templatePath: string, + helperPath: string, +): Promise<{ plans: BackfillPlan[]; skipped: number }> { + const plans: BackfillPlan[] = []; + let skipped = 0; + for (const row of rows) { + const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id)); + const resultJsonPath = path.join(resultDir, "result.json"); + const excelPath = path.join(resultDir, "contour.xlsx"); + try { + const [resultText] = await Promise.all([ + fs.readFile(resultJsonPath, "utf8"), + fs.access(excelPath), + ]); + const resultJson = JSON.parse(resultText) as Record; + const extra = resultJson.extra as Record | undefined; + const resultDataExtra = row.resultData?.extra as Record | undefined; + if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") { + throw new Error("不是 contour_batch 批量结果") + } + const measurements = await parseMeasurements(excelPath, templatePath, helperPath); + plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements }); + console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`); + } catch (error) { + skipped += 1; + console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { plans, skipped }; +} + +async function writeJsonAtomically(filePath: string, value: unknown): Promise { + const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`; + await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await fs.rename(temporaryPath, filePath); +} + +async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise { + const backupDir = path.join(dataDir, "maintenance-backups"); + await fs.mkdir(backupDir, { recursive: true }); + const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`); + await writeJsonAtomically(backupPath, { + version: 1, + createdAt: new Date().toISOString(), + entries: plans.map((plan) => ({ + id: plan.row.id, + taskId: plan.row.taskId, + resultJsonPath: plan.resultJsonPath, + measurements: plan.row.measurements, + resultData: plan.row.resultData, + resultJson: plan.originalResultJson, + })), + }); + return backupPath; +} + +async function persistPlan(pool: Pool, plan: BackfillPlan): Promise { + const connection = await pool.getConnection(); + const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements); + const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements); + try { + await connection.beginTransaction(); + await writeJsonAtomically(plan.resultJsonPath, updatedResultJson); + const [result] = await connection.execute( + `UPDATE task_analysis_results + SET measurements = ?, resultData = ? + WHERE id = ? + AND pluginId = 'flatness-suite' + AND status = 'completed' + AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`, + [JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id], + ); + if (result.affectedRows !== 1) { + await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson); + await connection.rollback(); + return false; + } + await connection.commit(); + return true; + } catch (error) { + await connection.rollback(); + await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined); + throw error; + } finally { + connection.release(); + } +} + +interface BackupManifest { + entries: Array<{ + id: number; + resultJsonPath: string; + measurements: unknown; + resultData: unknown; + resultJson: unknown; + }>; +} + +async function restoreBackup(pool: Pool, backupPath: string): Promise { + const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest; + if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效") + for (const entry of manifest.entries) { + await writeJsonAtomically(entry.resultJsonPath, entry.resultJson); + await pool.execute( + "UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'", + [ + entry.measurements == null ? null : JSON.stringify(entry.measurements), + entry.resultData == null ? null : JSON.stringify(entry.resultData), + entry.id, + ], + ); + console.log(`[backfill] 已恢复结果 ${entry.id}`); + } +} + +async function main(): Promise { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("缺少 DATABASE_URL") + const dataDir = process.env.DATA_DIR || "/data"; + const algRoot = process.env.ALG_ROOT || "/alg"; + const resultIdRaw = argumentValue("--result-id"); + const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw); + if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数") + const apply = process.argv.includes("--apply"); + const restorePath = argumentValue("--restore"); + const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx"); + const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py"); + const pool = mysql.createPool(databaseUrl); + + try { + if (restorePath) { + await restoreBackup(pool, restorePath); + return; + } + const rows = await listCandidates(pool, resultId); + const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath); + let backupPath: string | undefined; + if (apply && plans.length > 0) { + backupPath = await writeBackup(plans, dataDir); + console.log(`[backfill] 写入前备份: ${backupPath}`); + } + const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan)); + summary.skipped += skipped; + console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`); + if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入"); + if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`); + } finally { + await pool.end(); + } +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (isMain) { + main().catch((error) => { + console.error("[backfill] 失败:", error); + process.exitCode = 1; + }); +} diff --git a/server/flatnessSuiteBackfill.test.ts b/server/flatnessSuiteBackfill.test.ts new file mode 100644 index 0000000..c06f17d --- /dev/null +++ b/server/flatnessSuiteBackfill.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executePlans, + mergeMeasurements, + type BackfillPlan, +} from "../scripts/backfill-flatness-suite-measurements"; + +function plan(id: number): BackfillPlan { + return { + row: { id, taskId: 24, measurements: [], resultData: { kind: "plugin", marker: "kept" } } as BackfillPlan["row"], + resultJsonPath: `/tmp/${id}/result.json`, + originalResultJson: { status: "completed", extra: { module: "contour_batch" } }, + measurements: [{ + name: "任务11 · 车钩面板的平整度", + value: null, + unit: "mm", + toleranceLabel: "0.5mm", + status: "fail", + passLabel: "成功", + warningLabel: "部分成功", + failLabel: "失败", + }], + }; +} + +describe("flatness-suite 历史回填", () => { + it("dry-run 不调用持久化", async () => { + const persist = vi.fn(async () => true); + const summary = await executePlans([plan(234)], false, persist); + expect(persist).not.toHaveBeenCalled(); + expect(summary).toEqual({ planned: 1, applied: 0, skipped: 1, failed: 0 }); + }); + + it("apply 由持久化层守卫实现幂等", async () => { + const applied = new Set(); + const persist = vi.fn(async (item: BackfillPlan) => { + if (applied.has(item.row.id)) return false; + applied.add(item.row.id); + return true; + }); + expect((await executePlans([plan(234)], true, persist)).applied).toBe(1); + const second = await executePlans([plan(234)], true, persist); + expect(second.applied).toBe(0); + expect(second.skipped).toBe(1); + }); + + it("异常记录不阻断其他记录", async () => { + const persist = vi.fn(async (item: BackfillPlan) => { + if (item.row.id === 1) throw new Error("bad excel"); + return true; + }); + const summary = await executePlans([plan(1), plan(2)], true, persist); + expect(summary).toEqual({ planned: 2, applied: 1, skipped: 0, failed: 1 }); + }); + + it("扩展字段完整进入 resultData 且保留已有字段", () => { + const item = plan(234); + const merged = mergeMeasurements(item.row.resultData, item.measurements); + expect(merged.marker).toBe("kept"); + expect(merged.measurements).toEqual(item.measurements); + }); +}); diff --git a/server/plugins/adapters/flatness_suite_measurements.py b/server/plugins/adapters/flatness_suite_measurements.py new file mode 100644 index 0000000..43daa6e --- /dev/null +++ b/server/plugins/adapters/flatness_suite_measurements.py @@ -0,0 +1,246 @@ +"""flatness-suite 批量测量结果的模板关联与旧报表解析。""" + +from __future__ import annotations + +import argparse +import json +import math +import posixpath +from pathlib import Path +from typing import Any +from zipfile import BadZipFile, ZipFile +from xml.etree import ElementTree as ET + + +_SHEET_NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} +_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + + +def _column_index(reference: str) -> int: + letters = "".join(character for character in reference if character.isalpha()) + if not letters: + raise ValueError(f"无效的 Excel 单元格引用: {reference}") + result = 0 + for character in letters.upper(): + result = result * 26 + ord(character) - ord("A") + 1 + return result + + +def _cell_value(cell: ET.Element, shared_strings: list[str]) -> Any: + cell_type = cell.attrib.get("t") + if cell_type == "inlineStr": + return "".join(text.text or "" for text in cell.findall(".//a:t", _SHEET_NS)) + + value_node = cell.find("a:v", _SHEET_NS) + if value_node is None or value_node.text is None: + return "" + raw = value_node.text + if cell_type == "s": + try: + return shared_strings[int(raw)] + except (IndexError, ValueError) as exc: + raise ValueError("Excel 共享字符串索引无效") from exc + if cell_type in {"str", "e"}: + return raw + if cell_type == "b": + return raw == "1" + try: + number = float(raw) + return int(number) if number.is_integer() else number + except ValueError: + return raw + + +def read_xlsx_rows(path: str | Path) -> tuple[str, list[list[Any]]]: + """只用标准库读取 xlsx 的第一个工作表。""" + workbook_path = Path(path) + if not workbook_path.is_file(): + raise FileNotFoundError(f"Excel 文件不存在: {workbook_path}") + + try: + with ZipFile(workbook_path) as xlsx: + names = set(xlsx.namelist()) + shared_strings: list[str] = [] + if "xl/sharedStrings.xml" in names: + shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml")) + shared_strings = [ + "".join(text.text or "" for text in item.findall(".//a:t", _SHEET_NS)) + for item in shared_root.findall("a:si", _SHEET_NS) + ] + + workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml")) + first_sheet = workbook_root.find("a:sheets/a:sheet", _SHEET_NS) + if first_sheet is None: + raise ValueError(f"Excel 中没有工作表: {workbook_path}") + sheet_name = first_sheet.attrib.get("name", "Sheet1") + relation_id = first_sheet.attrib.get(f"{{{_REL_NS}}}id") + if not relation_id: + raise ValueError(f"Excel 工作表关系缺失: {workbook_path}") + + rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels")) + relation_targets = { + relation.attrib["Id"]: relation.attrib["Target"] for relation in rels_root + } + target = relation_targets.get(relation_id) + if not target: + raise ValueError(f"Excel 工作表文件关系缺失: {workbook_path}") + sheet_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join("xl", target)) + if sheet_path not in names: + raise ValueError(f"Excel 工作表文件不存在: {sheet_path}") + + sheet_root = ET.fromstring(xlsx.read(sheet_path)) + sparse_rows: list[dict[int, Any]] = [] + max_column = 0 + for row in sheet_root.findall("a:sheetData/a:row", _SHEET_NS): + values: dict[int, Any] = {} + for cell in row.findall("a:c", _SHEET_NS): + column = _column_index(cell.attrib.get("r", "")) + values[column] = _cell_value(cell, shared_strings) + max_column = max(max_column, column) + if values: + sparse_rows.append(values) + except (BadZipFile, KeyError, ET.ParseError) as exc: + raise ValueError(f"无法解析 Excel 文件 {workbook_path}: {exc}") from exc + + rows = [ + [row.get(column, "") for column in range(1, max_column + 1)] + for row in sparse_rows + ] + if not rows: + raise ValueError(f"Excel 工作表为空: {workbook_path}") + return sheet_name, rows + + +def _header_index(headers: list[Any], candidates: tuple[str, ...]) -> int: + normalized = [str(header).strip() for header in headers] + for candidate in candidates: + if candidate in normalized: + return normalized.index(candidate) + raise ValueError(f"Excel 缺少列: {'/'.join(candidates)}") + + +def normalize_task_id(value: Any) -> str: + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + text = str(value).strip() + return text[:-2] if text.endswith(".0") and text[:-2].isdigit() else text + + +def _label(value: Any) -> str: + if value is None: + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def read_measurement_template(path: str | Path) -> dict[str, dict[str, str]]: + """读取任务号对应的项点名称与原始公差文本。""" + _, rows = read_xlsx_rows(path) + headers = rows[0] + task_column = _header_index(headers, ("任务号", "任务编号", "task_id")) + name_column = _header_index(headers, ("项点名称", "项目名称", "项目")) + tolerance_column = _header_index(headers, ("公差要求", "公差")) + + result: dict[str, dict[str, str]] = {} + for row in rows[1:]: + task_id = normalize_task_id(row[task_column] if task_column < len(row) else "") + if not task_id: + continue + item_name = _label(row[name_column] if name_column < len(row) else "") + if not item_name: + raise ValueError(f"模板任务 {task_id} 缺少项点名称") + result[task_id] = { + "item_name": item_name, + "tolerance_label": _label(row[tolerance_column] if tolerance_column < len(row) else ""), + } + if not result: + raise ValueError(f"模板中没有测量任务: {path}") + return result + + +def read_legacy_batch_rows(path: str | Path) -> list[dict[str, Any]]: + """读取旧版技术汇总表中的任务号、平均值和状态。""" + _, rows = read_xlsx_rows(path) + headers = rows[0] + task_column = _header_index(headers, ("任务号", "任务编号", "task_id")) + mean_column = _header_index(headers, ("平均值", "mean", "mean_dist", "average_distance")) + status_column = _header_index(headers, ("状态", "测量结论", "结论")) + + result: list[dict[str, Any]] = [] + for row in rows[1:]: + task_id = normalize_task_id(row[task_column] if task_column < len(row) else "") + if not task_id: + continue + result.append({ + "task_id": task_id, + "mean": row[mean_column] if mean_column < len(row) else None, + "status": row[status_column] if status_column < len(row) else "", + }) + if not result: + raise ValueError(f"旧版 Excel 中没有批量结果: {path}") + return result + + +def _finite_float(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def build_measurements( + rows: list[dict[str, Any]], + template_path: str | Path, +) -> list[dict[str, Any]]: + template = read_measurement_template(template_path) + status_fields = { + "成功": ("pass", "成功"), + "部分成功": ("warning", "部分成功"), + "失败": ("fail", "失败"), + } + measurements: list[dict[str, Any]] = [] + for row in rows: + task_id = normalize_task_id(row.get("task_id", "")) + if not task_id: + raise ValueError("批量结果包含空任务号") + template_row = template.get(task_id) + if template_row is None: + raise ValueError(f"模板中找不到任务 {task_id}") + raw_status = str(row.get("status", "")).strip() + if raw_status not in status_fields: + raise ValueError(f"任务 {task_id} 的状态无效: {raw_status or '<空>'}") + status, _ = status_fields[raw_status] + measurements.append({ + "name": f"任务{task_id} · {template_row['item_name']}", + "value": _finite_float(row.get("mean")), + "unit": "mm", + "toleranceLabel": template_row["tolerance_label"], + "status": status, + "passLabel": "成功", + "warningLabel": "部分成功", + "failLabel": "失败", + }) + return measurements + + +def measurements_from_batch_result(result: dict[str, Any], template_path: str | Path) -> list[dict[str, Any]]: + rows = result.get("rows") + if not isinstance(rows, list): + raise ValueError("run_contour_batch 返回值缺少 rows") + return build_measurements(rows, template_path) + + +def main() -> int: + parser = argparse.ArgumentParser(description="从旧版 flatness-suite Excel 构造通用 measurements") + parser.add_argument("--excel", required=True) + parser.add_argument("--template", required=True) + args = parser.parse_args() + measurements = build_measurements(read_legacy_batch_rows(args.excel), args.template) + print(json.dumps(measurements, ensure_ascii=False, allow_nan=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/plugins/adapters/flatness_suite_run.py b/server/plugins/adapters/flatness_suite_run.py index 289b165..3df6e64 100644 --- a/server/plugins/adapters/flatness_suite_run.py +++ b/server/plugins/adapters/flatness_suite_run.py @@ -31,6 +31,7 @@ from _plugin_io import ( write_error_result, write_result, ) +from flatness_suite_measurements import measurements_from_batch_result def _safe_float(v: object) -> float | None: @@ -40,6 +41,25 @@ def _safe_float(v: object) -> float | None: return None +def _config_bool(config: dict, key: str, default: bool = False) -> bool: + value = config.get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _cleanup_temporary_artifacts(out_dir: Path) -> None: + # 仅删除适配器自己创建、且不会写入 result.json 的固定临时文件。 + # 不使用通配符,避免误删算法结果或用户要求保留的产物。 + for name in ("_roi_cropped.ply",): + try: + (out_dir / name).unlink(missing_ok=True) + except OSError as exc: + print(f"[flatness-suite] 清理临时文件失败 {name}: {exc}", file=sys.stderr) + + def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path: """根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。 @@ -191,12 +211,21 @@ def main() -> int: if use_batch_mode and module == "contour": emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云") data_root = alg_dir / "datasets" - # 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定 - context_pc = (inputs.get("contextPointCloudPath") - or pc_path_raw - or None) + # combined_contour.ply 可能包含完整整车点云,单次可达十几 GB。 + # 平台页面只使用各面板 *_contour.ply,因此默认从源头跳过组合文件; + # 仅在用户显式开启时才生成,并作为可下载产物登记。 + keep_combined_contour = _config_bool(config, "keepCombinedContour", False) + context_pc = None + if keep_combined_contour: + context_pc = (config.get("contextPointCloudPath") + or inputs.get("contextPointCloudPath") + or pc_path_raw + or None) save_dir = str(out_dir / "contour_batch") excel_path = str(out_dir / "contour.xlsx") + result_template_path = alg_dir / "excel" / "测量结果.xlsx" + if not result_template_path.is_file(): + raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}") res = pipeline.run_contour_batch( all_tasks=True, data_root=str(data_root), @@ -205,20 +234,13 @@ def main() -> int: triangle_chunk_size=512, save_path=save_dir, excel_output_path=excel_path, + result_template_path=str(result_template_path), combined_context_pointcloud_path=context_pc, + save_combined_visualization=keep_combined_contour, no_visualize=True, ) emit_progress(0.95, "整理批量结果") - batch_stats = res.get("batch_stats") or {} - measurements: list[dict] = [] - for task_id, task_res in (res.get("results") or {}).items(): - s = task_res.get("distance_stats") or {} - if s: - measurements.append({ - "name": f"任务{task_id} 轮廓偏差均值", - "value": round(float(s.get("mean", 0)), 4), - "unit": "mm", "status": "info", - }) + measurements = measurements_from_batch_result(res, result_template_path) # 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色) panel_plys = sorted([ f"contour_batch/{p.name}" @@ -229,12 +251,19 @@ def main() -> int: overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys} else: overlay3d = {"kind": "none"} + combined_rel = "contour_batch/combined_contour.ply" + result_files = [combined_rel] if keep_combined_contour and (out_dir / combined_rel).is_file() else [] write_result(out_dir, { "status": "completed", - "message": f"批量完成 {len(res.get('results', {}))} 个面板测量", + "message": f"批量完成 {len(res.get('rows') or [])} 个面板测量", "measurements": measurements, "overlay3d": overlay3d, - "extra": {"module": "contour_batch", "excelPath": "contour.xlsx"}, + "files": result_files, + "extra": { + "module": "contour_batch", + "excelPath": "contour.xlsx", + "combinedContourKept": bool(result_files), + }, }) return 0 @@ -430,6 +459,8 @@ def main() -> int: extra={"traceback": traceback.format_exc()}, ) return 1 + finally: + _cleanup_temporary_artifacts(out_dir) if __name__ == "__main__": diff --git a/server/plugins/adapters/test_flatness_suite_retention.py b/server/plugins/adapters/test_flatness_suite_retention.py new file mode 100644 index 0000000..4c277a4 --- /dev/null +++ b/server/plugins/adapters/test_flatness_suite_retention.py @@ -0,0 +1,96 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import TestCase + +from flatness_suite_run import _cleanup_temporary_artifacts, _config_bool +from flatness_suite_measurements import ( + measurements_from_batch_result, + read_legacy_batch_rows, +) + + +TEMPLATE = ( + Path(__file__).parents[3] + / "alg" + / "平整度、平面度、轮廓测量v1.5" + / "excel" + / "测量结果.xlsx" +) + + +class FlatnessSuiteRetentionConfigTest(TestCase): + def test_combined_contour_defaults_to_disabled(self) -> None: + self.assertFalse(_config_bool({}, "keepCombinedContour", False)) + + def test_explicit_boolean_or_string_can_enable_retention(self) -> None: + self.assertTrue(_config_bool({"keepCombinedContour": True}, "keepCombinedContour")) + self.assertTrue(_config_bool({"keepCombinedContour": "true"}, "keepCombinedContour")) + self.assertFalse(_config_bool({"keepCombinedContour": "false"}, "keepCombinedContour")) + + def test_cleanup_removes_only_known_temporary_artifacts(self) -> None: + with TemporaryDirectory() as temp: + out_dir = Path(temp) + temporary = out_dir / "_roi_cropped.ply" + result = out_dir / "contour.ply" + temporary.touch() + result.touch() + + _cleanup_temporary_artifacts(out_dir) + + self.assertFalse(temporary.exists()) + self.assertTrue(result.exists()) + + +class FlatnessSuiteBatchMeasurementTest(TestCase): + def test_uses_top_level_rows_instead_of_nested_sample_results(self) -> None: + result = { + "rows": [{"task_id": "11", "mean": 1.25, "status": "成功"}], + "results": { + "11": { + "11": {"distance_stats": {"mean": 999}}, + }, + }, + } + + measurements = measurements_from_batch_result(result, TEMPLATE) + + self.assertEqual(len(measurements), 1) + self.assertEqual(measurements[0]["name"], "任务11 · 车钩面板的平整度") + self.assertEqual(measurements[0]["value"], 1.25) + self.assertEqual(measurements[0]["toleranceLabel"], "0.5mm") + + def test_maps_success_partial_success_failure_and_empty_value(self) -> None: + result = { + "rows": [ + {"task_id": 11, "mean": 0.1, "status": "成功"}, + {"task_id": 13, "mean": 0.2, "status": "部分成功"}, + {"task_id": 17, "mean": None, "status": "失败"}, + ], + "results": {}, + } + + measurements = measurements_from_batch_result(result, TEMPLATE) + + self.assertEqual([item["status"] for item in measurements], ["pass", "warning", "fail"]) + self.assertEqual(measurements[0]["passLabel"], "成功") + self.assertEqual(measurements[1]["warningLabel"], "部分成功") + self.assertEqual(measurements[2]["failLabel"], "失败") + self.assertIsNone(measurements[2]["value"]) + + def test_missing_template_has_explicit_error(self) -> None: + with self.assertRaisesRegex(FileNotFoundError, "Excel 文件不存在"): + measurements_from_batch_result( + {"rows": [{"task_id": 11, "mean": 0.1, "status": "成功"}]}, + Path("missing-template.xlsx"), + ) + + def test_new_template_is_not_misread_as_legacy_summary(self) -> None: + with self.assertRaisesRegex(ValueError, "平均值"): + read_legacy_batch_rows(TEMPLATE) + + def test_malformed_excel_has_explicit_error(self) -> None: + with TemporaryDirectory() as temp: + invalid = Path(temp) / "contour.xlsx" + invalid.write_text("not an xlsx", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "无法解析 Excel"): + read_legacy_batch_rows(invalid) diff --git a/server/plugins/manifests/flatness-suite.json b/server/plugins/manifests/flatness-suite.json index 5b0d03d..32b1c72 100644 --- a/server/plugins/manifests/flatness-suite.json +++ b/server/plugins/manifests/flatness-suite.json @@ -3,7 +3,7 @@ "name": "平整度 / 平面度 / 轮廓测量", "shortName": "flatness-suite", "description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。", - "version": "1.5.0", + "version": "1.5.1", "schemaVersion": 1, "category": "几何测量", "requiresPointCloud": false, @@ -15,6 +15,13 @@ "type": "boolean", "default": true }, + { + "name": "keepCombinedContour", + "label": "保留组合轮廓点云(可能占用十几 GB)", + "type": "boolean", + "default": false, + "description": "默认关闭:页面使用各面板热力点云,不需要组合文件。仅在需要离线下载包含整车背景的 combined_contour.ply 时开启。" + }, { "name": "module", "label": "测量模块", @@ -47,10 +54,10 @@ { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null }, { "name": "contextPointCloudPath", - "label": "整车背景点云路径(可视化合并用)", + "label": "整车背景点云路径(仅保留组合轮廓点云时使用)", "type": "string", "default": null, - "description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。" + "description": "仅开启“保留组合轮廓点云”时生效。可填写容器内完整点云路径;留空时使用本次选择的点云作为背景。" } ], "runtime": { diff --git a/server/plugins/types.ts b/server/plugins/types.ts index d689132..aa98a62 100644 --- a/server/plugins/types.ts +++ b/server/plugins/types.ts @@ -106,11 +106,17 @@ export interface PluginProgressEvent { /** 测量项(GenericResultView 渲染) */ export interface PluginMeasurement { name: string; - value: number; + /** 失败任务没有有效测量值时为 null,前端显示破折号。 */ + value: number | null; unit?: string; tolerance?: number; + /** 无法安全数值化的原始公差表达式。 */ + toleranceLabel?: string; /** pass / warning / fail / info */ status?: 'pass' | 'warning' | 'fail' | 'info'; + passLabel?: string; + warningLabel?: string; + failLabel?: string; } /** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */