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

@@ -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>();