feat: standardize algorithm measurement results
This commit is contained in:
259
alg/00_算法交付与输出规范/README.md
Normal file
259
alg/00_算法交付与输出规范/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal file
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
115
alg/00_算法交付与输出规范/result.schema.json
Normal file
115
alg/00_算法交付与输出规范/result.schema.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
alg/00_算法交付与输出规范/validate_result.py
Executable file
136
alg/00_算法交付与输出规范/validate_result.py
Executable file
@@ -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())
|
||||||
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal file
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal file
@@ -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`;测量结果字段发生冲突时,以新规范为准。
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
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 { 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 { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
@@ -66,7 +66,19 @@ interface Props {
|
|||||||
}) => void;
|
}) => 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 };
|
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
|
||||||
|
|
||||||
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
||||||
@@ -112,10 +124,13 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
|||||||
|
|
||||||
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
||||||
const showDetails = displayMode === 'maximized';
|
const showDetails = displayMode === 'maximized';
|
||||||
|
const sortedItems = sortMeasurements(items);
|
||||||
return (
|
return (
|
||||||
<table className="w-full text-[10px] border-collapse">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[360px] text-[10px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
|
||||||
<th className="text-left py-1 font-normal">项目</th>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
@@ -123,9 +138,23 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((m, i) => (
|
{sortedItems.map((m, i) => (
|
||||||
<tr key={i} className="border-t border-border/40">
|
<tr key={i} className="border-t border-border/40">
|
||||||
<td className="py-1">{m.name}</td>
|
<td className="py-1 pr-2 whitespace-nowrap">
|
||||||
|
{m.legacyUnmapped || !m.taskNo ? (
|
||||||
|
<span className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help" title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}>
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : m.taskNo}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{m.name}
|
||||||
|
{m.position && (
|
||||||
|
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
|
||||||
|
{m.position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
||||||
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
@@ -141,6 +170,7 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,6 +891,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
||||||
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
|
{ 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';
|
const showMeasurementDetails = variant === 'maximized';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -907,6 +944,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<table className="w-full text-[10px] border-collapse">
|
<table className="w-full text-[10px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground border-b border-border/60">
|
<tr className="text-muted-foreground border-b border-border/60">
|
||||||
|
<th className="text-left px-2 py-1.5 font-normal whitespace-nowrap">任务号</th>
|
||||||
<th className="text-left px-2 py-1.5 font-normal">项目</th>
|
<th className="text-left px-2 py-1.5 font-normal">项目</th>
|
||||||
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
|
||||||
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
|
||||||
@@ -914,13 +952,30 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{aggregated.tableRows.map((row) => row.kind === 'algorithm' ? (
|
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
|
||||||
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
||||||
<td colSpan={showMeasurementDetails ? 4 : 2} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
<td colSpan={showMeasurementDetails ? 5 : 3} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
||||||
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>{row.measurement.name}</td>
|
<td className="px-2 py-1 whitespace-nowrap">
|
||||||
|
{row.measurement.legacyUnmapped || !row.measurement.taskNo ? (
|
||||||
|
<span
|
||||||
|
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
|
||||||
|
title={`旧算法项目尚未确认任务号:${row.measurement.legacyOriginalName ?? row.measurement.name}`}
|
||||||
|
>
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : row.measurement.taskNo}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>
|
||||||
|
{row.measurement.name}
|
||||||
|
{row.measurement.position && (
|
||||||
|
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
|
||||||
|
{row.measurement.position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
|
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
|
||||||
{row.measurement.baseline != null
|
{row.measurement.baseline != null
|
||||||
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useState } from 'react';
|
|||||||
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface Measurement {
|
interface Measurement {
|
||||||
|
taskNo?: string;
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
position?: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
@@ -24,6 +27,8 @@ interface Measurement {
|
|||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
warningLabel?: string;
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorStats {
|
interface ErrorStats {
|
||||||
@@ -113,6 +118,24 @@ export function filterVisibleMeasurements<T extends { status?: unknown }>(measur
|
|||||||
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
|
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sortMeasurements<T extends { taskNo?: unknown; position?: unknown }>(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 {
|
export function formatValue(v: number | null): string {
|
||||||
if (v == null) return '—';
|
if (v == null) return '—';
|
||||||
if (!Number.isFinite(v)) return '—';
|
if (!Number.isFinite(v)) return '—';
|
||||||
@@ -174,7 +197,7 @@ export default function GenericResultView({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
const errorStats = data.errorStats ?? null;
|
const errorStats = data.errorStats ?? null;
|
||||||
const measurements = filterVisibleMeasurements(data.measurements ?? []);
|
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
|
||||||
const showMeasurementDetails = displayMode === 'maximized';
|
const showMeasurementDetails = displayMode === 'maximized';
|
||||||
const images = data.images ?? [];
|
const images = data.images ?? [];
|
||||||
const files = (data.files ?? []).map((f) =>
|
const files = (data.files ?? []).map((f) =>
|
||||||
@@ -216,9 +239,11 @@ export default function GenericResultView({
|
|||||||
{measurements.length > 0 && (
|
{measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">测量值</div>
|
<div className="text-[10px] text-muted-foreground mb-1">测量值</div>
|
||||||
<table className="w-full text-[10px] border-collapse">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[360px] text-[10px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
|
||||||
<th className="text-left py-1 font-normal">项目</th>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
@@ -228,8 +253,23 @@ export default function GenericResultView({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{measurements.map((m, i) => (
|
{measurements.map((m, i) => (
|
||||||
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
|
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
|
||||||
|
<td className="py-1 pr-2 whitespace-nowrap">
|
||||||
|
{m.legacyUnmapped || !m.taskNo ? (
|
||||||
|
<span
|
||||||
|
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
|
||||||
|
title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}
|
||||||
|
>
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : m.taskNo}
|
||||||
|
</td>
|
||||||
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||||||
{m.name}
|
{m.name}
|
||||||
|
{m.position && (
|
||||||
|
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
|
||||||
|
{m.position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{showMeasurementDetails && <td className="text-right font-mono">
|
{showMeasurementDetails && <td className="text-right font-mono">
|
||||||
{m.baseline != null
|
{m.baseline != null
|
||||||
@@ -254,6 +294,7 @@ export default function GenericResultView({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 算法集成规范(Route B · 通用接口接入)
|
# 算法集成规范(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`
|
## 4. 输出契约 — `result.json`
|
||||||
|
|
||||||
|
以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合
|
||||||
|
[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md),
|
||||||
|
并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入
|
||||||
|
`measurements`。
|
||||||
|
|
||||||
算法在 `outDir/result.json` 写最终结果:
|
算法在 `outDir/result.json` 写最终结果:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
|
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
|
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
type InspectionObjectContext,
|
||||||
|
} from './measurementCompatibility.js';
|
||||||
import type {
|
import type {
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginProgressEvent,
|
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(
|
async function writeInputJson(
|
||||||
@@ -124,6 +135,7 @@ async function writeInputJson(
|
|||||||
cadPath: string | null,
|
cadPath: string | null,
|
||||||
inputPointCloudFileIds: number[],
|
inputPointCloudFileIds: number[],
|
||||||
inputCadFileIds: number[],
|
inputCadFileIds: number[],
|
||||||
|
inspectionContext: InspectionObjectContext,
|
||||||
) {
|
) {
|
||||||
const inputJson = {
|
const inputJson = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -133,6 +145,7 @@ async function writeInputJson(
|
|||||||
taskId,
|
taskId,
|
||||||
workDir,
|
workDir,
|
||||||
outDir: workDir,
|
outDir: workDir,
|
||||||
|
context: inspectionContext,
|
||||||
inputs: {
|
inputs: {
|
||||||
pointCloudPaths,
|
pointCloudPaths,
|
||||||
pointCloudFileIds: inputPointCloudFileIds,
|
pointCloudFileIds: inputPointCloudFileIds,
|
||||||
@@ -330,7 +343,7 @@ export async function runAlgorithmJob(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
||||||
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
|
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
|
||||||
taskId,
|
taskId,
|
||||||
manifest,
|
manifest,
|
||||||
pointCloudFileIds,
|
pointCloudFileIds,
|
||||||
@@ -349,6 +362,7 @@ export async function runAlgorithmJob(
|
|||||||
cadPath,
|
cadPath,
|
||||||
inputPointCloudFileIds,
|
inputPointCloudFileIds,
|
||||||
inputCadFileIds,
|
inputCadFileIds,
|
||||||
|
inspectionContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
let exitCode: number | null = 0;
|
let exitCode: number | null = 0;
|
||||||
@@ -367,6 +381,17 @@ export async function runAlgorithmJob(
|
|||||||
: 'error';
|
: 'error';
|
||||||
|
|
||||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
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 = {
|
const resultData: PluginResultData = {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -378,12 +403,12 @@ export async function runAlgorithmJob(
|
|||||||
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
||||||
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||||
message: result?.message,
|
message: result?.message,
|
||||||
measurements: result?.measurements,
|
measurements: normalizedMeasurements,
|
||||||
errorStats: result?.errorStats,
|
errorStats: result?.errorStats,
|
||||||
overlay3d: result?.overlay3d,
|
overlay3d: result?.overlay3d,
|
||||||
images: result?.images,
|
images: result?.images,
|
||||||
files: result?.files,
|
files: result?.files,
|
||||||
extra: result?.extra,
|
extra: normalized.extra,
|
||||||
error: finalStatus === 'error'
|
error: finalStatus === 'error'
|
||||||
? result?.message ?? `子进程退出码 ${exitCode}`
|
? result?.message ?? `子进程退出码 ${exitCode}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -393,7 +418,7 @@ export async function runAlgorithmJob(
|
|||||||
await db.updateTaskAnalysisResult(resultId, {
|
await db.updateTaskAnalysisResult(resultId, {
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
errorStats: result?.errorStats ?? null,
|
errorStats: result?.errorStats ?? null,
|
||||||
measurements: result?.measurements ?? null,
|
measurements: normalizedMeasurements ?? null,
|
||||||
resultData,
|
resultData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { samplePointcloudFiles } from './pointcloudSample.js';
|
|||||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||||
import { authenticateRequest } from './_core/auth.js';
|
import { authenticateRequest } from './_core/auth.js';
|
||||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from './measurementCompatibility.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -562,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req,
|
|||||||
try {
|
try {
|
||||||
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
|
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
|
||||||
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
|
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 = {
|
const mergedRd = {
|
||||||
...existingRd,
|
...existingRd,
|
||||||
message: result.message ?? existingRd.message,
|
message: result.message ?? existingRd.message,
|
||||||
measurements: result.measurements ?? existingRd.measurements,
|
measurements: normalized.measurements,
|
||||||
errorStats: result.errorStats ?? existingRd.errorStats,
|
errorStats: result.errorStats ?? existingRd.errorStats,
|
||||||
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
||||||
images: result.images ?? existingRd.images,
|
images: result.images ?? existingRd.images,
|
||||||
files: result.files ?? existingRd.files,
|
files: result.files ?? existingRd.files,
|
||||||
extra: result.extra ?? existingRd.extra,
|
extra: normalized.extra,
|
||||||
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
|
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 });
|
res.json({ success: true, overlay3d: result.overlay3d });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: String(e) });
|
res.status(500).json({ error: String(e) });
|
||||||
|
|||||||
134
server/measurementCompatibility.test.ts
Normal file
134
server/measurementCompatibility.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
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>();
|
||||||
@@ -105,18 +105,30 @@ export interface PluginProgressEvent {
|
|||||||
|
|
||||||
/** 测量项(GenericResultView 渲染) */
|
/** 测量项(GenericResultView 渲染) */
|
||||||
export interface PluginMeasurement {
|
export interface PluginMeasurement {
|
||||||
|
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
|
||||||
|
taskNo?: string;
|
||||||
|
/** 跨版本稳定的测量项编码。 */
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 同任务内的位置,例如左梁、右梁。 */
|
||||||
|
position?: string;
|
||||||
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
||||||
value: number | null;
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
/** 无法安全数值化的原始公差表达式。 */
|
/** 无法安全数值化的原始公差表达式。 */
|
||||||
toleranceLabel?: string;
|
toleranceLabel?: string;
|
||||||
|
/** 斜率类测量的基准长度(mm)。 */
|
||||||
|
baseline?: number;
|
||||||
/** pass / warning / fail / info */
|
/** pass / warning / fail / info */
|
||||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
warningLabel?: string;
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
|
/** 兼容层无法确定任务号时置为 true。 */
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
/** 兼容转换前的原始项目名,用于追溯。 */
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { listPlugins, getPlugin } from "./plugins/registry.js";
|
|||||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from "./measurementCompatibility.js";
|
||||||
import {
|
import {
|
||||||
clearLoginFailures,
|
clearLoginFailures,
|
||||||
createSessionToken,
|
createSessionToken,
|
||||||
@@ -963,7 +968,43 @@ export const appRouter = router({
|
|||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(z.object({ taskId: z.number() }))
|
.input(z.object({ taskId: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.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
|
runAnalysis: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user