feat: standardize algorithm measurement results

This commit is contained in:
2026-08-14 00:12:53 +08:00
parent ac1387debc
commit 185dd36bf3
15 changed files with 1273 additions and 19 deletions

View File

@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
import * as db from './db.js';
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
type InspectionObjectContext,
} from './measurementCompatibility.js';
import type {
PluginManifest,
PluginProgressEvent,
@@ -111,7 +117,12 @@ async function gatherInputs(
}
}
return { task, pointCloudRecords, pointCloudPaths, cadPath };
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const inspectionContext = inspectionObjectContextFromName(inspectionObject?.name);
return { task, pointCloudRecords, pointCloudPaths, cadPath, inspectionContext };
}
async function writeInputJson(
@@ -124,6 +135,7 @@ async function writeInputJson(
cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
inspectionContext: InspectionObjectContext,
) {
const inputJson = {
schemaVersion: 1,
@@ -133,6 +145,7 @@ async function writeInputJson(
taskId,
workDir,
outDir: workDir,
context: inspectionContext,
inputs: {
pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
@@ -330,7 +343,7 @@ export async function runAlgorithmJob(
try {
await patchProgress(resultId, pluginId, 0.02, '准备输入');
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
taskId,
manifest,
pointCloudFileIds,
@@ -349,6 +362,7 @@ export async function runAlgorithmJob(
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
inspectionContext,
);
let exitCode: number | null = 0;
@@ -367,6 +381,17 @@ export async function runAlgorithmJob(
: 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const normalized = normalizeMeasurements({
pluginId,
measurements: result?.measurements,
extra: result?.extra,
context: inspectionContext,
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const normalizedMeasurements = Array.isArray(result?.measurements)
? normalized.measurements
: undefined;
const resultData: PluginResultData = {
kind: 'plugin',
pluginId,
@@ -378,12 +403,12 @@ export async function runAlgorithmJob(
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message,
measurements: result?.measurements,
measurements: normalizedMeasurements,
errorStats: result?.errorStats,
overlay3d: result?.overlay3d,
images: result?.images,
files: result?.files,
extra: result?.extra,
extra: normalized.extra,
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
@@ -393,7 +418,7 @@ export async function runAlgorithmJob(
await db.updateTaskAnalysisResult(resultId, {
status: finalStatus,
errorStats: result?.errorStats ?? null,
measurements: result?.measurements ?? null,
measurements: normalizedMeasurements ?? null,
resultData,
});

View File

@@ -7,6 +7,11 @@ import { samplePointcloudFiles } from './pointcloudSample.js';
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
import { authenticateRequest } from './_core/auth.js';
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from './measurementCompatibility.js';
const router = Router();
@@ -562,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req,
try {
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
const task = await db.getInspectionTaskById(taskId);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const normalized = normalizeMeasurements({
pluginId: row.pluginId ?? (typeof existingRd.pluginId === 'string' ? existingRd.pluginId : undefined),
measurements: result.measurements ?? existingRd.measurements,
extra: result.extra ?? existingRd.extra,
context: inspectionObjectContextFromName(inspectionObject?.name),
algorithmProtocolVersion: result.schemaVersion ?? row.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const mergedRd = {
...existingRd,
message: result.message ?? existingRd.message,
measurements: result.measurements ?? existingRd.measurements,
measurements: normalized.measurements,
errorStats: result.errorStats ?? existingRd.errorStats,
overlay3d: result.overlay3d ?? existingRd.overlay3d,
images: result.images ?? existingRd.images,
files: result.files ?? existingRd.files,
extra: result.extra ?? existingRd.extra,
extra: normalized.extra,
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
};
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
await db.updateTaskAnalysisResult(resultId, {
measurements: normalized.measurements,
resultData: mergedRd,
});
res.json({ success: true, overlay3d: result.overlay3d });
} catch (e) {
res.status(500).json({ error: String(e) });

View File

@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
inspectionObjectContextFromName,
normalizeMeasurements,
sortNormalizedMeasurements,
} from './measurementCompatibility.js';
const valueFields = {
value: 1.25,
unit: 'mm',
tolerance: 0.8,
toleranceLabel: '+0.8/-0.2',
baseline: 375,
status: 'pass' as const,
passLabel: '通过',
};
afterEach(() => {
delete process.env.MEASUREMENT_COMPAT_ENABLED;
});
describe('normalizeMeasurements', () => {
it.each([
['任务46_车体横断面对角线差值', '46', '车体横断面对角线差值'],
['任务11 · 车钩面板的平整度', '11', '车钩面板的平整度'],
])('拆分通用旧任务名 %s', (originalName, taskNo, name) => {
const output = normalizeMeasurements({
pluginId: 'train-measurement',
measurements: [{ name: originalName, ...valueFields }],
});
expect(output.measurements[0]).toMatchObject({
taskNo,
metricCode: `legacy.train_measurement.task_${taskNo}`,
name,
legacyOriginalName: originalName,
...valueFields,
});
});
it('新协议结果原样保留且重复调用一致', () => {
const measurement = { taskNo: '8', metricCode: 'coupler.reference-offset', name: '参考面右偏转', ...valueFields, customConclusion: '合格' };
const first = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [measurement], extra: { marker: true } });
const second = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: first.measurements, extra: first.extra });
expect(first.measurements).toEqual([measurement]);
expect(second).toEqual(first);
});
it('任务 5 保留左右梁两行和位置顺序', () => {
const output = normalizeMeasurements({
pluginId: 'underframe-deflection',
measurements: [
{ name: '右梁 · 纵向梁到直线的垂直偏差(挠度)', value: 2 },
{ name: '左梁 · 纵向梁到直线的垂直偏差(挠度)', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: '5', name: '挠度', position: '左梁', value: 1 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 2 },
]);
});
it.each([
['头车', '8', '10'],
['中车', '9', '12'],
['中间车', '9', '12'],
])('%s 车钩精确映射为正确任务号', (objectName, referenceTaskNo, longitudinalTaskNo) => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName(objectName),
measurements: [
{ name: '车钩面板距纵向中心的右偏转角', value: 2 },
{ name: '车钩面板距参考面的右偏转角', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: referenceTaskNo, name: '参考面右偏转', value: 1 },
{ taskNo: longitudinalTaskNo, name: '纵向中心右偏转', value: 2 },
]);
});
it('未知检测对象不默认成头车', () => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('拖车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }],
});
expect(output.measurements[0]).toMatchObject({ legacyUnmapped: true });
expect(output.measurements[0].taskNo).toBeUndefined();
expect(output.warnings).toHaveLength(1);
});
it('车钩中间量移入 extra.intermediateMetrics 并保留字段', () => {
const intermediate = ['最大 x 偏差 dx_y', '最大 x 偏差 dx_z', '面板 y 向最大偏差 D_y', '面板 z 向最大偏差 D_z']
.map((name, index) => ({ name, value: index, unit: 'mm', status: 'info' }));
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('头车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }, ...intermediate],
extra: { marker: 'kept' },
});
expect(output.measurements).toHaveLength(1);
expect(output.extra).toEqual({ marker: 'kept', intermediateMetrics: intermediate });
});
it('未知名称不模糊猜测并生成稳定编码', () => {
const source = { name: '类似车钩面板距参考面的右偏转角但不是', ...valueFields, customConclusion: '人工通过' };
const output = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [source] });
expect(output.measurements[0]).toMatchObject({
...source,
legacyUnmapped: true,
legacyOriginalName: source.name,
});
expect(output.measurements[0].metricCode).toMatch(/^legacy\.coupler_panel\.unmapped\.[0-9a-f]{8}$/);
});
it('按数字任务号排序且未映射项最后', () => {
const sorted = sortNormalizedMeasurements([
{ taskNo: '46', name: '46' },
{ name: 'unknown' },
{ taskNo: '5', position: '右梁', name: '5R' },
{ taskNo: '11', name: '11' },
{ taskNo: '5', position: '左梁', name: '5L' },
]);
expect(sorted.map((item) => item.name)).toEqual(['5L', '5R', '11', '46', 'unknown']);
});
it('环境开关关闭时原样返回', () => {
process.env.MEASUREMENT_COMPAT_ENABLED = '0';
const measurements = [{ name: '任务46_原始', value: 1 }];
const output = normalizeMeasurements({ pluginId: 'train-measurement', measurements, extra: { raw: true } });
expect(output).toEqual({ measurements, extra: { raw: true }, warnings: [], converted: false });
});
});

View File

@@ -0,0 +1,261 @@
import type { PluginMeasurement } from './plugins/types.js';
export type InspectionObjectKind = 'head' | 'middle' | 'unknown';
export interface InspectionObjectContext {
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface CompatibilityWarning {
code: 'legacy-unmapped';
pluginId: string;
originalName: string;
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface NormalizeMeasurementsInput {
pluginId?: string | null;
measurements?: unknown;
extra?: unknown;
context?: InspectionObjectContext;
algorithmProtocolVersion?: string | number | null;
}
export interface NormalizeMeasurementsOutput {
measurements: PluginMeasurement[];
extra: Record<string, unknown>;
warnings: CompatibilityWarning[];
converted: boolean;
}
const COUPLER_INTERMEDIATE_NAMES = new Set([
'最大 x 偏差 dx_y',
'最大 x 偏差 dx_z',
'面板 y 向最大偏差 D_y',
'面板 z 向最大偏差 D_z',
]);
const DEFLECTION_NAMES = new Map<string, string>([
['左梁 · 纵向梁到直线的垂直偏差(挠度)', '左梁'],
['右梁 · 纵向梁到直线的垂直偏差(挠度)', '右梁'],
['挠度左梁', '左梁'],
['挠度右梁', '右梁'],
]);
const COUPLER_NAMES = {
longitudinal: new Set([
'车钩面板距纵向中心的右偏转角',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
]),
reference: new Set([
'车钩面板距参考面的右偏转角',
'车钩面板距参考面的右偏转角(头车一位端处车钩)',
]),
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function pluginKey(pluginId: string): string {
return (pluginId || 'unknown').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'unknown';
}
function stableNameHash(value: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function legacyMetricCode(pluginId: string, taskNo: string): string {
return `legacy.${pluginKey(pluginId)}.task_${taskNo}`;
}
function unmappedMetricCode(pluginId: string, name: string): string {
return `legacy.${pluginKey(pluginId)}.unmapped.${stableNameHash(name)}`;
}
export function inspectionObjectContextFromName(name?: string | null): InspectionObjectContext {
const normalized = name?.trim() ?? '';
return {
inspectionObject: normalized === '头车'
? 'head'
: normalized === '中车' || normalized === '中间车'
? 'middle'
: 'unknown',
inspectionObjectName: normalized || undefined,
};
}
export function measurementCompatibilityEnabled(): boolean {
return process.env.MEASUREMENT_COMPAT_ENABLED !== '0';
}
function positionOrder(position: unknown): number {
if (position === '左梁') return 0;
if (position === '右梁') return 1;
return 2;
}
export function sortNormalizedMeasurements<T extends { taskNo?: unknown; position?: unknown }>(items: readonly T[]): T[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aNo = typeof a.item.taskNo === 'string' && /^\d+$/.test(a.item.taskNo) ? Number(a.item.taskNo) : Number.POSITIVE_INFINITY;
const bNo = typeof b.item.taskNo === 'string' && /^\d+$/.test(b.item.taskNo) ? Number(b.item.taskNo) : Number.POSITIVE_INFINITY;
if (aNo !== bNo) return aNo - bNo;
const positionDelta = positionOrder(a.item.position) - positionOrder(b.item.position);
return positionDelta || a.index - b.index;
})
.map(({ item }) => item);
}
export function normalizeMeasurements(input: NormalizeMeasurementsInput): NormalizeMeasurementsOutput {
const pluginId = input.pluginId ?? '';
const context = input.context ?? { inspectionObject: 'unknown' as const };
const sourceMeasurements = Array.isArray(input.measurements)
? input.measurements.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
: [];
const sourceExtra = asRecord(input.extra);
if (!measurementCompatibilityEnabled()) {
return {
measurements: sourceMeasurements as unknown as PluginMeasurement[],
extra: sourceExtra,
warnings: [],
converted: false,
};
}
const measurements: PluginMeasurement[] = [];
const intermediateMetrics = Array.isArray(sourceExtra.intermediateMetrics)
? [...sourceExtra.intermediateMetrics]
: [];
const warnings: CompatibilityWarning[] = [];
let converted = false;
sourceMeasurements.forEach((source) => {
const item = { ...source };
const originalName = typeof item.name === 'string' ? item.name : '';
// 新协议项必须同时具备 taskNo 和 metricCode;重复规范化时保持原样。
if (typeof item.taskNo === 'string' && item.taskNo && typeof item.metricCode === 'string' && item.metricCode) {
measurements.push(item as unknown as PluginMeasurement);
return;
}
if (pluginId === 'coupler-panel' && COUPLER_INTERMEDIATE_NAMES.has(originalName)) {
intermediateMetrics.push(item);
converted = true;
return;
}
const generic = /^任务(\d+)(?:_|\s*·\s*)(.+)$/.exec(originalName);
if (generic) {
const [, taskNo, name] = generic;
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: name.trim(),
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
if (pluginId === 'underframe-deflection') {
const position = DEFLECTION_NAMES.get(originalName);
if (position) {
measurements.push({
...item,
taskNo: '5',
metricCode: legacyMetricCode(pluginId, '5'),
name: '挠度',
position,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
if (pluginId === 'coupler-panel') {
const isLongitudinal = COUPLER_NAMES.longitudinal.has(originalName);
const isReference = COUPLER_NAMES.reference.has(originalName);
if (isLongitudinal || isReference) {
const taskNo = context.inspectionObject === 'head'
? (isLongitudinal ? '10' : '8')
: context.inspectionObject === 'middle'
? (isLongitudinal ? '12' : '9')
: undefined;
if (taskNo) {
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: isLongitudinal ? '纵向中心右偏转' : '参考面右偏转',
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
}
const unmapped = {
...item,
metricCode: unmappedMetricCode(pluginId, originalName),
legacyUnmapped: true,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement;
measurements.push(unmapped);
warnings.push({
code: 'legacy-unmapped',
pluginId,
originalName,
inspectionObject: context.inspectionObject,
inspectionObjectName: context.inspectionObjectName,
});
converted = true;
});
return {
measurements: sortNormalizedMeasurements(measurements),
extra: intermediateMetrics.length > 0 ? { ...sourceExtra, intermediateMetrics } : sourceExtra,
warnings,
converted,
};
}
export function logCompatibilityWarnings(resultId: number, warnings: readonly CompatibilityWarning[]): void {
warnings.forEach((warning) => {
const signature = [
resultId,
warning.pluginId,
warning.originalName,
warning.inspectionObject,
warning.inspectionObjectName ?? '',
].join('\u0000');
if (loggedWarningSignatures.has(signature)) return;
loggedWarningSignatures.add(signature);
console.warn('[MeasurementCompatibility] legacy measurement unmapped', {
resultId,
pluginId: warning.pluginId,
originalName: warning.originalName,
inspectionObject: warning.inspectionObject,
inspectionObjectName: warning.inspectionObjectName,
});
});
}
/** 避免历史结果轮询每次重复打印同一条告警;进程重启后会重新记录一次。 */
const loggedWarningSignatures = new Set<string>();

View File

@@ -105,18 +105,30 @@ export interface PluginProgressEvent {
/** 测量项(GenericResultView 渲染) */
export interface PluginMeasurement {
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
taskNo?: string;
/** 跨版本稳定的测量项编码。 */
metricCode?: string;
name: string;
/** 同任务内的位置,例如左梁、右梁。 */
position?: string;
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
value: number | null;
unit?: string;
tolerance?: number;
/** 无法安全数值化的原始公差表达式。 */
toleranceLabel?: string;
/** 斜率类测量的基准长度(mm)。 */
baseline?: number;
/** pass / warning / fail / info */
status?: 'pass' | 'warning' | 'fail' | 'info';
passLabel?: string;
warningLabel?: string;
failLabel?: string;
/** 兼容层无法确定任务号时置为 true。 */
legacyUnmapped?: boolean;
/** 兼容转换前的原始项目名,用于追溯。 */
legacyOriginalName?: string;
}
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */

View File

@@ -16,6 +16,11 @@ import { listPlugins, getPlugin } from "./plugins/registry.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from "./measurementCompatibility.js";
import {
clearLoginFailures,
createSessionToken,
@@ -963,7 +968,43 @@ export const appRouter = router({
list: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
return db.listTaskAnalysisResults(input.taskId);
const [rows, task] = await Promise.all([
db.listTaskAnalysisResults(input.taskId),
db.getInspectionTaskById(input.taskId),
]);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return rows.map((row) => {
const resultData = row.resultData && typeof row.resultData === 'object' && !Array.isArray(row.resultData)
? { ...(row.resultData as Record<string, unknown>) }
: {};
const pluginId = row.pluginId ?? (typeof resultData.pluginId === 'string' ? resultData.pluginId : undefined);
const rawMeasurements = Array.isArray(row.measurements)
? row.measurements
: resultData.measurements;
if (!pluginId || !Array.isArray(rawMeasurements)) return row;
const normalized = normalizeMeasurements({
pluginId,
measurements: rawMeasurements,
extra: resultData.extra,
context,
algorithmProtocolVersion: row.schemaVersion ?? (resultData.schemaVersion as string | number | undefined),
});
logCompatibilityWarnings(row.id, normalized.warnings);
return {
...row,
measurements: normalized.measurements,
resultData: {
...resultData,
measurements: normalized.measurements,
extra: normalized.extra,
},
};
});
}),
runAnalysis: protectedProcedure