feat: standardize algorithm measurement results
This commit is contained in:
261
server/measurementCompatibility.ts
Normal file
261
server/measurementCompatibility.ts
Normal 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>();
|
||||
Reference in New Issue
Block a user