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; 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([ ['左梁 · 纵向梁到直线的垂直偏差(挠度)', '左梁'], ['右梁 · 纵向梁到直线的垂直偏差(挠度)', '右梁'], ['挠度左梁', '左梁'], ['挠度右梁', '右梁'], ]); const COUPLER_NAMES = { longitudinal: new Set([ '车钩面板距纵向中心的右偏转角', '车钩面板距纵向中心的右偏转角(头车一位端处车钩)', '车钩面板距纵向中心的右偏转角(头车一位端处车钩)', ]), reference: new Set([ '车钩面板距参考面的右偏转角', '车钩面板距参考面的右偏转角(头车一位端处车钩)', ]), }; function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? { ...(value as Record) } : {}; } 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(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 => 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();