feat: preserve flatness suite measurement outputs
This commit is contained in:
@@ -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)
|
||||
|
||||
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
@@ -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()
|
||||
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
export interface CandidateRow extends RowDataPacket {
|
||||
id: number;
|
||||
taskId: number;
|
||||
measurements: unknown;
|
||||
resultData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BackfillPlan {
|
||||
row: CandidateRow;
|
||||
resultJsonPath: string;
|
||||
originalResultJson: Record<string, unknown>;
|
||||
measurements: Measurement[];
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
planned: number;
|
||||
applied: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export function mergeMeasurements<T extends Record<string, unknown>>(
|
||||
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<boolean>,
|
||||
): Promise<ExecutionSummary> {
|
||||
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<Measurement[]> {
|
||||
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<CandidateRow[]> {
|
||||
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<CandidateRow[]>(
|
||||
`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<string, unknown>;
|
||||
const extra = resultJson.extra as Record<string, unknown> | undefined;
|
||||
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | 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<void> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<ResultSetHeader>(
|
||||
`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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
62
server/flatnessSuiteBackfill.test.ts
Normal file
62
server/flatnessSuiteBackfill.test.ts
Normal file
@@ -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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
@@ -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())
|
||||
@@ -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__":
|
||||
|
||||
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
@@ -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)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 兼容) */
|
||||
|
||||
Reference in New Issue
Block a user