From 185dd36bf33630b3bb39dc16a631b2d7235885ca Mon Sep 17 00:00:00 2001 From: lipan Date: Fri, 14 Aug 2026 00:12:53 +0800 Subject: [PATCH] feat: standardize algorithm measurement results --- alg/00_算法交付与输出规范/README.md | 259 +++++++++++++++++ .../head-car-coupler-and-deflection.result.json | 72 +++++ .../examples/invalid-legacy-result.json | 27 ++ alg/00_算法交付与输出规范/result.schema.json | 115 ++++++++ alg/00_算法交付与输出规范/validate_result.py | 136 +++++++++ .../算法工程师通知模板.md | 45 +++ client/src/components/AnalysisPanel.tsx | 71 ++++- client/src/components/GenericResultView.tsx | 45 ++- docs/algorithm-integration-spec.md | 11 + server/algorithmJobRunner.ts | 35 ++- server/fileRoutes.ts | 26 +- server/measurementCompatibility.test.ts | 134 +++++++++ server/measurementCompatibility.ts | 261 ++++++++++++++++++ server/plugins/types.ts | 12 + server/routers.ts | 43 ++- 15 files changed, 1273 insertions(+), 19 deletions(-) create mode 100644 alg/00_算法交付与输出规范/README.md create mode 100644 alg/00_算法交付与输出规范/examples/head-car-coupler-and-deflection.result.json create mode 100644 alg/00_算法交付与输出规范/examples/invalid-legacy-result.json create mode 100644 alg/00_算法交付与输出规范/result.schema.json create mode 100755 alg/00_算法交付与输出规范/validate_result.py create mode 100644 alg/00_算法交付与输出规范/算法工程师通知模板.md create mode 100644 server/measurementCompatibility.test.ts create mode 100644 server/measurementCompatibility.ts diff --git a/alg/00_算法交付与输出规范/README.md b/alg/00_算法交付与输出规范/README.md new file mode 100644 index 0000000..c810a0c --- /dev/null +++ b/alg/00_算法交付与输出规范/README.md @@ -0,0 +1,259 @@ +# 算法交付与测量结果输出规范 + +版本:1.0.0 +状态:自 2026-08-13 起,新交付算法必须遵循;存量算法逐步迁移。 +适用范围:`alg/` 下所有向宏量点云处理平台输出测量结果的算法。 + +## 1. 目的 + +本规范统一算法 `result.json` 中正式测量结果的表达,解决以下问题: + +- 任务号被写在名称字符串里,例如 `任务46_车体横断面对角线差值`; +- 同一任务的多个测量位置无法分组,例如任务 5 的左梁和右梁; +- 头车、中车使用不同任务号,但结果中没有检测对象上下文; +- `dx_y`、`dx_z` 等计算中间量混入正式测量结果; +- 前端只能解析中文名称,无法稳定排序、筛选、统计和生成报告。 + +平台进程、进度事件、产物文件等接入方式仍参考 +[`docs/algorithm-integration-spec.md`](../../docs/algorithm-integration-spec.md)。涉及测量结果字段时,**以本规范为准**。 + +## 2. 基本原则 + +1. **任务号结构化**:正式测量项必须提供 `taskNo`,不得只写进 `name`。 +2. **名称不编码**:`name` 只写项目名称,不包含 `任务XX_`、左右位置或车型前缀。 +3. **指标编码稳定**:每项结果必须有稳定的 `metricCode`,程序关联以它为准,不能依赖中文名称。 +4. **一个测量值一条记录**:同一任务存在左/右、前/后等多个结果时,保留多条记录并使用 `position` 区分,`taskNo` 相同。 +5. **正式结果与中间量隔离**:只有可交付、可判定、可进入报告的值才能进入 `measurements`;中间量放入 `extra.intermediateMetrics`。 +6. **不猜任务号**:任务号必须来自经确认的业务任务清单。无法确定时应阻止正式交付并请求确认,不能按数组序号或名称相似度推断。 +7. **值和单位分离**:`value` 必须是 JSON 数值或 `null`;禁止输出 `"0.073/700mm"` 这样的混合字符串。 +8. **JSON 必须标准合法**:禁止 `NaN`、`Infinity`;失败且无有效值时使用 `null`。 + +## 3. 职责边界 + +### 3.1 业务/产品负责人 + +- 提供并确认正式任务清单:`taskNo`、项目名称、适用检测对象、理论值、公差、单位; +- 审批任务号变更,算法工程师不得自行重新编号。 + +### 3.2 平台/集成人员 + +- 在算法输入或适配层提供 `inspectionObject`,例如 `head`(头车)、`middle`(中车); +- 将经确认的业务任务清单配置为映射表; +- 校验最终 `result.json`,不让缺少任务号的正式结果进入业务表格。 + +### 3.3 算法工程师 + +- 为算法指标定义稳定的 `metricCode` 和准确的 `name`; +- 输出 `position` 等算法能够确定的测量位置; +- 在已获得检测对象和任务映射时输出正确的 `taskNo`; +- 只把最终可交付结果放进 `measurements`,中间量放进 `extra.intermediateMetrics`; +- 交付前运行本目录的校验脚本。 + +> 最终写入平台的 `result.json` 必须满足本规范。若算法核心包不负责业务任务号,平台薄适配层必须在写出 `result.json` 前补齐,不能交给前端猜测。 + +## 4. 顶层结构 + +```json +{ + "schemaVersion": 1, + "measurementContractVersion": "1.0", + "status": "completed", + "message": "测量完成", + "context": { + "inspectionObject": "head", + "inspectionObjectName": "头车" + }, + "measurements": [], + "extra": { + "intermediateMetrics": [] + } +} +``` + +字段说明: + +| 字段 | 必填 | 说明 | +|---|---:|---| +| `schemaVersion` | 是 | 平台插件结果版本;当前平台存量插件仍使用 `1` | +| `measurementContractVersion` | 是 | 本测量结果规范版本,当前固定为 `"1.0"` | +| `status` | 是 | `completed` 或 `error` | +| `message` | 否 | 面向用户的简短说明 | +| `context` | 有对象差异时必填 | 本次测量的业务上下文 | +| `measurements` | 完成时必填 | 正式测量结果;允许空数组,不允许混入调试指标 | +| `extra` | 否 | 算法报告、质量信息、中间量等非正式结果 | + +`schemaVersion` 与 `measurementContractVersion` 含义不同,不得互相替代。 + +## 5. 正式测量项 `measurements[]` + +推荐完整结构: + +```json +{ + "taskNo": "5", + "metricCode": "underframe.longitudinal_beam.vertical_deflection", + "name": "纵向梁到直线的垂直偏差(挠度)", + "position": "左梁", + "value": 0.265, + "unit": "mm", + "tolerance": 7.0, + "toleranceLabel": "±7 mm", + "status": "pass" +} +``` + +| 字段 | 必填 | 类型/取值 | 说明 | +|---|---:|---|---| +| `taskNo` | 是 | 字符串 | 正式业务任务号;即使全是数字也必须用字符串 | +| `metricCode` | 是 | 字符串 | 稳定指标编码,建议 `<域>.<部件>.<指标>`,仅用小写字母、数字、点、下划线和短横线 | +| `name` | 是 | 字符串 | 纯项目名称,不带 `任务XX_` 和位置前缀 | +| `position` | 多位置时必填 | 字符串 | 如 `左梁`、`右梁`、`一位端`;不适用时省略 | +| `value` | 是 | 数值或 `null` | 测量值;无有效结果时为 `null` | +| `unit` | 有量纲时必填 | 字符串 | 如 `mm`、`°`、`%`;无量纲使用 `"1"` | +| `theoreticalValue` | 否 | 数值 | 理论值,与 `unit` 相同 | +| `baseline` | 斜率形式时必填 | 正数 | 基准长度,单位为 mm | +| `tolerance` | 简单对称公差时使用 | 非负数 | `±tolerance`,与 `unit` 相同;斜率形式表示分子公差 | +| `toleranceLower` | 非对称公差时使用 | 数值 | 相对理论值的下偏差 | +| `toleranceUpper` | 非对称公差时使用 | 数值 | 相对理论值的上偏差 | +| `toleranceLabel` | 复杂公差时使用 | 字符串 | 报告展示原文,例如 `+8.5/-3 mm` | +| `status` | 是 | `pass` / `warning` / `fail` | 正式结果不得使用 `info` | +| `qualityFlags` | 否 | 字符串数组 | 质量告警代码,不替代 `status` | + +约束: + +- 同一 `taskNo + metricCode + position` 在一次结果中必须唯一; +- `name` 不得匹配 `^任务\s*\d+`; +- `status=pass|warning|fail` 必须基于已确认的公差规则; +- `value=null` 时不得判为 `pass`; +- 若尚未获得正式任务号,该项不得进入最终 `measurements`。 + +## 6. 检测对象与任务号映射 + +当同一指标在不同检测对象上对应不同任务号时,必须先根据 `context.inspectionObject` 选择任务号。 + +本次已确认的映射如下: + +| 检测对象 | 指标 | `taskNo` | +|---|---|---:| +| 任意 | 纵向梁到直线的垂直偏差(左、右挠度) | 5 | +| 头车 `head` | 车钩面板到参考面的右偏转角 | 8 | +| 中车 `middle` | 车钩面板到参考面的右偏转角 | 9 | +| 头车 `head` | 车钩面板到纵向中心的右偏转角 | 10 | +| 中车 `middle` | 车钩面板到纵向中心的右偏转角 | 12 | + +任务 5 的左右梁应输出两条记录:两条的 `taskNo` 和 `metricCode` 相同,`position` 分别为 `左梁`、`右梁`。 + +如果输入中没有头车/中车信息,车钩算法不得自行默认成头车;应报契约错误,或由平台适配层在具备检测对象信息后完成映射。 + +## 7. 斜率形式与角度值 + +车钩“右偏转角”目前存在两种可能的交付口径,必须由业务任务清单明确: + +### 7.1 以角度交付 + +```json +{ + "value": 0.0063, + "unit": "°", + "tolerance": 0.08 +} +``` + +### 7.2 以偏差值/基准长度交付 + +```json +{ + "value": 0.073, + "unit": "mm", + "baseline": 700, + "tolerance": 1.0, + "toleranceLabel": "1/700 mm" +} +``` + +禁止把 `value` 写成字符串 `"0.073/700mm"`。角度值和换算后的偏差值不能共用同一个 `metricCode`;如两者都需保留,一个作为正式结果,另一个放入中间量并使用不同编码。 + +## 8. 中间数据与诊断数据 + +以下数据通常不属于正式测量项:拟合系数、法向量、点数、残差、`dx_y`、`dx_z`、`D_y`、`D_z`、ICP fitness、RMSE 等。 + +统一放入: + +```json +{ + "extra": { + "intermediateMetrics": [ + { + "metricCode": "coupler.panel.dx_y_max", + "name": "Y 方向计算用最大 X 偏差", + "value": 0.0101, + "unit": "mm" + } + ] + } +} +``` + +中间量: + +- 不需要 `taskNo`; +- 不设置业务 `status` 和公差; +- 不进入“算法结果”项数、合格率和正式报告; +- 可用于日志、排障和算法复核。 + +## 9. 禁止的旧写法 + +```json +{ + "measurements": [ + { "name": "任务46_车体横断面对角线差值", "value": 0.592 }, + { "name": "左梁 · 纵向梁到直线的垂直偏差(挠度)", "value": 0.265 }, + { "name": "最大 x 偏差 dx_y", "value": 0.010, "status": "pass" } + ] +} +``` + +问题分别是:任务号嵌入名称、位置嵌入名称、中间量冒充正式结果。 + +正确写法见 [`examples/head-car-coupler-and-deflection.result.json`](examples/head-car-coupler-and-deflection.result.json)。 + +## 10. 兼容与迁移 + +- 本规范是**目标输出契约**。当前平台前端尚未使用所有新增字段时,也必须保留这些字段,不能因此退回名称拼接方案; +- 存量 `任务46_xxx` 等结果允许历史回放,但算法下一版本应拆为 `taskNo="46"` 和纯 `name`; +- 平台适配层可以暂时从经确认的旧配置中迁移任务号,但不得长期正则解析中文名称; +- 修改任务号、单位、换算口径、公差或 `metricCode` 均属于接口变更,必须在算法版本说明中记录; +- 核心计算方法未变、只调整输出结构时,也应至少提升算法补丁版本并注明“输出契约迁移”。 + +## 11. 交付检查清单 + +算法工程师提交前逐项确认: + +- [ ] `result.json` 是 UTF-8 标准 JSON,无 `NaN`/`Infinity` +- [ ] 顶层包含 `measurementContractVersion: "1.0"` +- [ ] 每个正式测量项都有 `taskNo`、`metricCode`、纯 `name`、`value`、`status` +- [ ] 多位置结果使用相同任务号并通过 `position` 区分 +- [ ] 头车/中车任务号来自 `context.inspectionObject`,没有默认猜测 +- [ ] `measurements` 中没有 `info` 状态和算法中间量 +- [ ] 单位、公差、理论值和换算口径与正式任务清单一致 +- [ ] `extra.intermediateMetrics` 中的指标不参与合格判定 +- [ ] 已执行:`python3 validate_result.py result.json` +- [ ] 版本说明记录了输出字段变化 + +## 12. 校验方式 + +本目录提供两种校验: + +```bash +# 零第三方依赖,执行本规范的关键业务规则校验 +python3 validate_result.py /path/to/result.json + +# 如本机安装了 jsonschema,可进行完整 JSON Schema 校验 +python3 -m jsonschema -i /path/to/result.json result.schema.json +``` + +示例自检: + +```bash +python3 validate_result.py examples/head-car-coupler-and-deflection.result.json +``` diff --git a/alg/00_算法交付与输出规范/examples/head-car-coupler-and-deflection.result.json b/alg/00_算法交付与输出规范/examples/head-car-coupler-and-deflection.result.json new file mode 100644 index 0000000..7bd87a0 --- /dev/null +++ b/alg/00_算法交付与输出规范/examples/head-car-coupler-and-deflection.result.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": 1, + "measurementContractVersion": "1.0", + "status": "completed", + "message": "头车车钩偏转与左右梁挠度测量完成", + "context": { + "inspectionObject": "head", + "inspectionObjectName": "头车" + }, + "measurements": [ + { + "taskNo": "5", + "metricCode": "underframe.longitudinal_beam.vertical_deflection", + "name": "纵向梁到直线的垂直偏差(挠度)", + "position": "左梁", + "value": 0.265, + "unit": "mm", + "tolerance": 7.0, + "toleranceLabel": "±7 mm", + "status": "pass" + }, + { + "taskNo": "5", + "metricCode": "underframe.longitudinal_beam.vertical_deflection", + "name": "纵向梁到直线的垂直偏差(挠度)", + "position": "右梁", + "value": 0.270, + "unit": "mm", + "tolerance": 12.0, + "toleranceLabel": "±12 mm", + "status": "pass" + }, + { + "taskNo": "8", + "metricCode": "coupler.panel.reference_plane.right_deflection", + "name": "车钩面板到参考面的右偏转角", + "value": 0.022, + "unit": "mm", + "baseline": 375.0, + "tolerance": 0.8, + "toleranceLabel": "0.8/375 mm", + "status": "pass" + }, + { + "taskNo": "10", + "metricCode": "coupler.panel.longitudinal_center.right_deflection", + "name": "车钩面板到纵向中心的右偏转角", + "value": 0.073, + "unit": "mm", + "baseline": 700.0, + "tolerance": 1.0, + "toleranceLabel": "1/700 mm", + "status": "pass" + } + ], + "extra": { + "intermediateMetrics": [ + { + "metricCode": "coupler.panel.dx_y_max", + "name": "Y 方向计算用最大 X 偏差", + "value": 0.010, + "unit": "mm" + }, + { + "metricCode": "coupler.panel.dx_z_max", + "name": "Z 方向计算用最大 X 偏差", + "value": 0.010, + "unit": "mm" + } + ] + } +} diff --git a/alg/00_算法交付与输出规范/examples/invalid-legacy-result.json b/alg/00_算法交付与输出规范/examples/invalid-legacy-result.json new file mode 100644 index 0000000..5c3f5f8 --- /dev/null +++ b/alg/00_算法交付与输出规范/examples/invalid-legacy-result.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "measurementContractVersion": "1.0", + "status": "completed", + "measurements": [ + { + "name": "任务46_车体横断面对角线差值", + "metricCode": "vehicle.diagonal.delta", + "value": 0.592, + "unit": "mm", + "status": "info" + } + ], + "extra": { + "intermediateMetrics": [ + { + "taskNo": "debug-1", + "metricCode": "coupler.panel.dx_y_max", + "name": "最大 x 偏差 dx_y", + "value": 0.01, + "unit": "mm", + "status": "pass", + "tolerance": 1.0 + } + ] + } +} diff --git a/alg/00_算法交付与输出规范/result.schema.json b/alg/00_算法交付与输出规范/result.schema.json new file mode 100644 index 0000000..7fbcc94 --- /dev/null +++ b/alg/00_算法交付与输出规范/result.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://massive-cloud.local/schemas/algorithm-measurement-result-1.0.json", + "title": "宏量点云算法测量结果输出契约 1.0", + "type": "object", + "required": ["schemaVersion", "measurementContractVersion", "status"], + "properties": { + "schemaVersion": { "type": "integer", "minimum": 1 }, + "measurementContractVersion": { "const": "1.0" }, + "status": { "enum": ["completed", "error"] }, + "message": { "type": "string" }, + "context": { + "type": "object", + "properties": { + "inspectionObject": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "inspectionObjectName": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + }, + "measurements": { + "type": "array", + "items": { "$ref": "#/$defs/measurement" } + }, + "errorStats": { "type": "object" }, + "overlay3d": { "type": "object" }, + "images": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "files": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "derivedPointClouds": { "type": "array" }, + "extra": { + "type": "object", + "properties": { + "intermediateMetrics": { + "type": "array", + "items": { "$ref": "#/$defs/intermediateMetric" } + } + }, + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "completed" } }, + "required": ["status"] + }, + "then": { "required": ["measurements"] } + } + ], + "additionalProperties": true, + "$defs": { + "metricCode": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + }, + "measurement": { + "type": "object", + "required": ["taskNo", "metricCode", "name", "value", "status"], + "properties": { + "taskNo": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "metricCode": { "$ref": "#/$defs/metricCode" }, + "name": { + "type": "string", + "minLength": 1, + "not": { "pattern": "^任务\\s*[0-9]+" } + }, + "position": { "type": "string", "minLength": 1 }, + "value": { "type": ["number", "null"] }, + "unit": { "type": "string", "minLength": 1 }, + "theoreticalValue": { "type": "number" }, + "baseline": { "type": "number", "exclusiveMinimum": 0 }, + "tolerance": { "type": "number", "minimum": 0 }, + "toleranceLower": { "type": "number" }, + "toleranceUpper": { "type": "number" }, + "toleranceLabel": { "type": "string", "minLength": 1 }, + "status": { "enum": ["pass", "warning", "fail"] }, + "qualityFlags": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": true + }, + "intermediateMetric": { + "type": "object", + "required": ["metricCode", "name", "value"], + "properties": { + "metricCode": { "$ref": "#/$defs/metricCode" }, + "name": { "type": "string", "minLength": 1 }, + "value": { "type": ["number", "null"] }, + "unit": { "type": "string", "minLength": 1 } + }, + "not": { + "anyOf": [ + { "required": ["taskNo"] }, + { "required": ["status"] }, + { "required": ["tolerance"] } + ] + }, + "additionalProperties": true + } + } +} diff --git a/alg/00_算法交付与输出规范/validate_result.py b/alg/00_算法交付与输出规范/validate_result.py new file mode 100755 index 0000000..e3e6dfc --- /dev/null +++ b/alg/00_算法交付与输出规范/validate_result.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""算法测量结果契约 1.0 的零第三方依赖校验器。""" + +from __future__ import annotations + +import json +import math +import re +import sys +from pathlib import Path +from typing import Any + + +METRIC_CODE_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") +TASK_NO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +TASK_NAME_PREFIX_RE = re.compile(r"^任务\s*\d+") +FINAL_STATUSES = {"pass", "warning", "fail"} + + +def is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def validate(payload: Any) -> list[str]: + errors: list[str] = [] + if not isinstance(payload, dict): + return ["根节点必须是 JSON 对象"] + + if not isinstance(payload.get("schemaVersion"), int) or payload["schemaVersion"] < 1: + errors.append("schemaVersion 必须是大于等于 1 的整数") + if payload.get("measurementContractVersion") != "1.0": + errors.append('measurementContractVersion 必须为 "1.0"') + if payload.get("status") not in {"completed", "error"}: + errors.append("status 必须为 completed 或 error") + + measurements = payload.get("measurements") + if payload.get("status") == "completed" and not isinstance(measurements, list): + errors.append("completed 结果必须包含 measurements 数组") + measurements = [] + elif measurements is None: + measurements = [] + elif not isinstance(measurements, list): + errors.append("measurements 必须是数组") + measurements = [] + + unique_keys: set[tuple[str, str, str]] = set() + for index, item in enumerate(measurements): + path = f"measurements[{index}]" + if not isinstance(item, dict): + errors.append(f"{path} 必须是对象") + continue + + task_no = item.get("taskNo") + metric_code = item.get("metricCode") + name = item.get("name") + position = item.get("position", "") + value = item.get("value", ...) + status = item.get("status") + + if not isinstance(task_no, str) or not TASK_NO_RE.fullmatch(task_no): + errors.append(f"{path}.taskNo 必须是非空规范字符串,数字任务号也要加引号") + if not isinstance(metric_code, str) or not METRIC_CODE_RE.fullmatch(metric_code): + errors.append(f"{path}.metricCode 格式无效") + if not isinstance(name, str) or not name.strip(): + errors.append(f"{path}.name 必须是非空字符串") + elif TASK_NAME_PREFIX_RE.search(name): + errors.append(f"{path}.name 不得包含任务号前缀,请使用 taskNo 字段") + if position and not isinstance(position, str): + errors.append(f"{path}.position 必须是字符串") + if value is ... or (value is not None and not is_number(value)): + errors.append(f"{path}.value 必须是有限数值或 null") + if status not in FINAL_STATUSES: + errors.append(f"{path}.status 必须为 pass、warning 或 fail,正式结果不得使用 info") + if value is None and status == "pass": + errors.append(f"{path} 的 value 为 null 时不能判定为 pass") + if "baseline" in item and (not is_number(item["baseline"]) or item["baseline"] <= 0): + errors.append(f"{path}.baseline 必须是正数") + if "tolerance" in item and (not is_number(item["tolerance"]) or item["tolerance"] < 0): + errors.append(f"{path}.tolerance 必须是非负有限数值") + + if isinstance(task_no, str) and isinstance(metric_code, str) and isinstance(position, str): + key = (task_no, metric_code, position) + if key in unique_keys: + errors.append(f"{path} 与其他正式测量项重复:taskNo + metricCode + position 必须唯一") + unique_keys.add(key) + + extra = payload.get("extra", {}) + if extra is not None and not isinstance(extra, dict): + errors.append("extra 必须是对象") + extra = {} + intermediate = extra.get("intermediateMetrics", []) if isinstance(extra, dict) else [] + if not isinstance(intermediate, list): + errors.append("extra.intermediateMetrics 必须是数组") + intermediate = [] + for index, item in enumerate(intermediate): + path = f"extra.intermediateMetrics[{index}]" + if not isinstance(item, dict): + errors.append(f"{path} 必须是对象") + continue + if any(field in item for field in ("taskNo", "status", "tolerance")): + errors.append(f"{path} 不得包含 taskNo、status 或 tolerance") + code = item.get("metricCode") + if not isinstance(code, str) or not METRIC_CODE_RE.fullmatch(code): + errors.append(f"{path}.metricCode 格式无效") + if not isinstance(item.get("name"), str) or not item["name"].strip(): + errors.append(f"{path}.name 必须是非空字符串") + value = item.get("value", ...) + if value is ... or (value is not None and not is_number(value)): + errors.append(f"{path}.value 必须是有限数值或 null") + + return errors + + +def main() -> int: + if len(sys.argv) != 2: + print(f"用法: {Path(sys.argv[0]).name} /path/to/result.json", file=sys.stderr) + return 2 + path = Path(sys.argv[1]) + try: + payload = json.loads(path.read_text(encoding="utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"非法数值 {value}"))) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print(f"校验失败: 无法读取标准 JSON:{exc}", file=sys.stderr) + return 1 + + errors = validate(payload) + if errors: + print(f"校验失败,共 {len(errors)} 个问题:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print(f"校验通过:{path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/alg/00_算法交付与输出规范/算法工程师通知模板.md b/alg/00_算法交付与输出规范/算法工程师通知模板.md new file mode 100644 index 0000000..af82989 --- /dev/null +++ b/alg/00_算法交付与输出规范/算法工程师通知模板.md @@ -0,0 +1,45 @@ +# 算法输出规范执行通知(转发模板) + +各位算法同事: + +为统一平台“算法结果”中的任务号、测量位置、单位、公差和中间数据,从现在起请新交付算法以及存量算法的后续版本遵循: + +`alg/00_算法交付与输出规范/README.md` + +目录内同时提供: + +- `result.schema.json`:完整 JSON Schema; +- `validate_result.py`:零第三方依赖校验器; +- `examples/head-car-coupler-and-deflection.result.json`:正确示例; +- `examples/invalid-legacy-result.json`:禁止的旧写法反例。 + +本次最关键的变化: + +1. 正式测量结果必须单独返回 `taskNo`,不再把任务号写在 `name` 中; +2. `name` 只保留纯项目名称;同一任务的左/右等多个结果使用 `position` 区分; +3. 每个指标必须提供稳定的 `metricCode`; +4. 头车/中车必须依据输入的检测对象选择任务号,不得默认猜测; +5. `dx_y`、`dx_z`、拟合残差、法向量等中间量不得进入正式 `measurements`,统一放入 `extra.intermediateMetrics`; +6. 正式结果只允许 `pass`、`warning`、`fail`,不再用 `info` 表示可交付项; +7. 交付前请执行: + +```bash +python3 alg/00_算法交付与输出规范/validate_result.py /path/to/result.json +``` + +请各算法负责人先检查自己的输出,并回复以下信息: + +- 指标与正式业务任务号的映射是否完整; +- 哪些输出是正式测量值,哪些只是计算中间量; +- 同一指标是否会因头车/中车等检测对象对应不同任务号; +- 单位、公差、理论值和换算口径是否与正式任务清单一致; +- 预计从哪个算法版本开始按新规范交付。 + +车钩与挠度算法请重点确认: + +- 任务 5 的左梁、右梁作为同一任务的两个 `position` 输出; +- 头车车钩对应任务 8、10,中车对应任务 9、12; +- `dx_y`、`dx_z` 仅保留为中间量; +- “右偏转角”最终交付口径是角度 `°`,还是偏差值/基准长度(例如 `0.073/700 mm`)。 + +平台插件的运行、输入、进度和产物接入仍参考 `docs/algorithm-integration-spec.md`;测量结果字段发生冲突时,以新规范为准。 diff --git a/client/src/components/AnalysisPanel.tsx b/client/src/components/AnalysisPanel.tsx index 17eb807..bdac908 100644 --- a/client/src/components/AnalysisPanel.tsx +++ b/client/src/components/AnalysisPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { trpc } from '@/lib/trpc'; import { useAuth } from '@/_core/hooks/useAuth'; import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react'; -import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, type Overlay3D } from './GenericResultView'; +import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, sortMeasurements, statusClass, type Overlay3D } from './GenericResultView'; import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; @@ -66,7 +66,19 @@ interface Props { }) => void; } -type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string }; +type Measurement = { + taskNo?: string; + metricCode?: string; + name: string; + position?: string; + value: number; + unit: string; + tolerance: number; + status: string; + baseline?: number; + legacyUnmapped?: boolean; + legacyOriginalName?: string; +}; type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number }; function ErrorStatCard({ stats }: { stats: ErrorStats }) { @@ -112,10 +124,13 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) { function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) { const showDetails = displayMode === 'maximized'; + const sortedItems = sortMeasurements(items); return ( - +
+
+ {showDetails && } {showDetails && } @@ -123,9 +138,23 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa - {items.map((m, i) => ( + {sortedItems.map((m, i) => ( - + + {showDetails && } {showDetails && }
任务号 项目值公差
{m.name} + {m.legacyUnmapped || !m.taskNo ? ( + + 待确认 + + ) : m.taskNo} + + {m.name} + {m.position && ( + + {m.position} + + )} + {m.value.toFixed(3)} {m.unit}±{m.tolerance} @@ -141,6 +170,7 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa ))}
+ ); } @@ -861,6 +891,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o (result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)), { includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS }, ); + const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS + ? aggregated.tableRows + : sortMeasurements(aggregated.measurementRows.map((row) => ({ + ...row, + taskNo: row.measurement.taskNo, + position: row.measurement.position, + }))); const showMeasurementDetails = variant === 'maximized'; return ( @@ -907,6 +944,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o + {showMeasurementDetails && } {showMeasurementDetails && } @@ -914,13 +952,30 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o - {aggregated.tableRows.map((row) => row.kind === 'algorithm' ? ( + {sortedTableRows.map((row) => row.kind === 'algorithm' ? ( - + ) : ( - + + {showMeasurementDetails &&
任务号 项目值公差
{row.algorithmName}{row.algorithmName}
{row.measurement.name} + {row.measurement.legacyUnmapped || !row.measurement.taskNo ? ( + + 待确认 + + ) : row.measurement.taskNo} + + {row.measurement.name} + {row.measurement.position && ( + + {row.measurement.position} + + )} + {row.measurement.baseline != null ? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm` diff --git a/client/src/components/GenericResultView.tsx b/client/src/components/GenericResultView.tsx index 4b8d79c..fdff8b5 100644 --- a/client/src/components/GenericResultView.tsx +++ b/client/src/components/GenericResultView.tsx @@ -11,7 +11,10 @@ import { useState } from 'react'; import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react'; interface Measurement { + taskNo?: string; + metricCode?: string; name: string; + position?: string; value: number | null; unit?: string; tolerance?: number; @@ -24,6 +27,8 @@ interface Measurement { passLabel?: string; warningLabel?: string; failLabel?: string; + legacyUnmapped?: boolean; + legacyOriginalName?: string; } interface ErrorStats { @@ -113,6 +118,24 @@ export function filterVisibleMeasurements(measur return measurements.filter((measurement) => !isReferenceMeasurement(measurement)); } +export function sortMeasurements(measurements: readonly T[]): T[] { + const positionOrder = (position: unknown) => position === '左梁' ? 0 : position === '右梁' ? 1 : 2; + return measurements + .map((measurement, index) => ({ measurement, index })) + .sort((a, b) => { + const aNo = typeof a.measurement.taskNo === 'string' && /^\d+$/.test(a.measurement.taskNo) + ? Number(a.measurement.taskNo) + : Number.POSITIVE_INFINITY; + const bNo = typeof b.measurement.taskNo === 'string' && /^\d+$/.test(b.measurement.taskNo) + ? Number(b.measurement.taskNo) + : Number.POSITIVE_INFINITY; + if (aNo !== bNo) return aNo - bNo; + const positionDelta = positionOrder(a.measurement.position) - positionOrder(b.measurement.position); + return positionDelta || a.index - b.index; + }) + .map(({ measurement }) => measurement); +} + export function formatValue(v: number | null): string { if (v == null) return '—'; if (!Number.isFinite(v)) return '—'; @@ -174,7 +197,7 @@ export default function GenericResultView({ }: Props) { const [showExtra, setShowExtra] = useState(false); const errorStats = data.errorStats ?? null; - const measurements = filterVisibleMeasurements(data.measurements ?? []); + const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? [])); const showMeasurementDetails = displayMode === 'maximized'; const images = data.images ?? []; const files = (data.files ?? []).map((f) => @@ -216,9 +239,11 @@ export default function GenericResultView({ {measurements.length > 0 && (
测量值
- +
+
+ {showMeasurementDetails && } {showMeasurementDetails && } @@ -228,8 +253,23 @@ export default function GenericResultView({ {measurements.map((m, i) => ( + {showMeasurementDetails &&
任务号 项目值公差
+ {m.legacyUnmapped || !m.taskNo ? ( + + 待确认 + + ) : m.taskNo} + {m.name} + {m.position && ( + + {m.position} + + )} {m.baseline != null @@ -254,6 +294,7 @@ export default function GenericResultView({ ))}
+
)} diff --git a/docs/algorithm-integration-spec.md b/docs/algorithm-integration-spec.md index 2b87be1..2b4a6ed 100644 --- a/docs/algorithm-integration-spec.md +++ b/docs/algorithm-integration-spec.md @@ -1,5 +1,11 @@ # 算法集成规范(Route B · 通用接口接入) +> **文档分工说明(2026-08-13)**:本文继续负责插件运行、Manifest、输入、进度、 +> 产物和平台接入方式。算法 `result.json` 中的正式测量项、结构化任务号、检测对象、 +> 多位置结果以及中间数据隔离,已由 +> [`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md) +> 统一规定。若本文第 4 节旧示例与该规范冲突,**以算法交付与输出规范为准**。 + 本文是宏量点云处理平台对**多类算法批量接入**的对外规范,面向算法团队 + 验收方。一句话总结: @@ -130,6 +136,11 @@ runner 在 workDir 写出 `input.json` 后调用算法 CLI,固定路径: ## 4. 输出契约 — `result.json` +以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合 +[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md), +并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入 +`measurements`。 + 算法在 `outDir/result.json` 写最终结果: ```jsonc diff --git a/server/algorithmJobRunner.ts b/server/algorithmJobRunner.ts index 311db66..d32fb3d 100644 --- a/server/algorithmJobRunner.ts +++ b/server/algorithmJobRunner.ts @@ -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, }); diff --git a/server/fileRoutes.ts b/server/fileRoutes.ts index 500e4a1..b1296b1 100644 --- a/server/fileRoutes.ts +++ b/server/fileRoutes.ts @@ -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) ?? {}; + 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) }); diff --git a/server/measurementCompatibility.test.ts b/server/measurementCompatibility.test.ts new file mode 100644 index 0000000..f8c4910 --- /dev/null +++ b/server/measurementCompatibility.test.ts @@ -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 }); + }); +}); diff --git a/server/measurementCompatibility.ts b/server/measurementCompatibility.ts new file mode 100644 index 0000000..bb8b341 --- /dev/null +++ b/server/measurementCompatibility.ts @@ -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; + 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(); diff --git a/server/plugins/types.ts b/server/plugins/types.ts index aa98a62..5fe3b39 100644 --- a/server/plugins/types.ts +++ b/server/plugins/types.ts @@ -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 兼容) */ diff --git a/server/routers.ts b/server/routers.ts index 7a51d98..c965eb9 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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) } + : {}; + 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