diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html index dc3224a5..1cf82f4d 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html @@ -77,13 +77,13 @@

指定时刻总里程

POST/api/v1/vehicles/total-mileage/query

按 VIN 返回不晚于指定时刻的最近一条总里程及实际采集协议。

#

请求参数

字段必填说明
vin17 位已授权 VIN
time北京时间 yyyy-MM-dd HH:mm:ss
protocolGB32960、MQTT 或 JT808;省略时按默认顺序选取

成功返回 · 200

{
"code": "SUCCESS",
"data": {
"totalMileageKm": 12345.678,
"protocol": "GB32960",
"recordTime": "2026-08-06 10:29:45",
"timeDifferenceSeconds": 15
}
}
  • 400:VIN、时间或协议不合法
  • 403:VIN 未授权
  • 无记录:返回 NO_DATA
-

车辆实时位置与状态

POST/api/v1/vehicles/realtime/query

返回位置、在线状态、速度、总里程、储氢及独立 GPS 状态。当前储氢百分比无可信分母,返回 null/UNSUPPORTED;质量按字段状态解释。

#

请求参数

字段必填说明
plateNumbers省略或 [] 时返回全部当前有效授权车辆

成功返回 · 200

{
"data": [{
"vin": "LA9GG68L2PBAF4790",
"plateNumber": "浙F06618F",
"protocol": "GB32960",
"longitude": 120.75,
"latitude": 30.74,
"speedKmh": 0,
"totalMileageKm": 12345.678,
"recordTime": "2026-08-17 17:40:12",
"timeDifferenceSeconds": 8,
"online": true,
"activeToday": true,
"motionStatus": "idle",
"locationAvailable": true,
"status": "NORMAL"
}]
}
  • 400:车牌格式错误
  • 403:指定车牌未授权
  • 无实时记录:返回 NO_DATA
+

车辆实时位置与状态

POST/api/v1/vehicles/realtime/query

返回位置、在线状态、速度、总里程、储氢及独立 GPS 状态。储氢优先终端上报质量,缺质量可按同帧温压和 VIN 容积估算;百分比采用35 MPa、15°C满充参考,来源和质量分别说明。

比例 = 剩余质量 / PressureHydrogenMassKg(35,15,VIN容积) × 100。参考温度与密度比定义见 UNECE 定义文件;35 MPa 为本次业务参数,非所有车型额定压力的自动认证。

#

请求参数

字段必填说明
plateNumbers省略或 [] 时返回全部当前有效授权车辆

成功返回 · 200

{
"data": [{
"vin": "LA9GG68L2PBAF4790",
"plateNumber": "浙F06618F",
"protocol": "GB32960",
"longitude": 120.75,
"latitude": 30.74,
"speedKmh": 0,
"totalMileageKm": 12345.678,
"recordTime": "2026-08-17 17:40:12",
"timeDifferenceSeconds": 8,
"online": true,
"activeToday": true,
"motionStatus": "idle",
"locationAvailable": true,
"status": "NORMAL"
}]
}
  • 400:车牌格式错误
  • 403:指定车牌未授权
  • 无实时记录:返回 NO_DATA

加氢车辆停留核验

POST/api/v1/vehicles/stationary/query

按站点坐标、坐标系、时间段和半径,核验速度接近 0 的车辆停留记录,并按匹配度降序返回。

#

请求参数

字段必填说明
startTime / endTime北京时间;查询区间最长 24 小时
longitude / latitude加氢站经纬度;由 coordinateSystem 说明坐标系
coordinateSystemWGS84(默认)或 GCJ02(高德);GCJ02 自动转换后核验
radiusMeters核验半径,单位 m;1–100,默认 5
plateNumbers省略时核验全部有效授权车辆
匹配规则:速度 ≤ 3 km/h,至少 2 个样本且停留 ≥ 60 秒;相邻定位点间隔超过 10 分钟时拆分停留区间。

成功返回 · 200

{
"data": [{
"plateNumber": "浙F06618F",
"stayStartTime": "2026-08-06 10:16:02",
"stayEndTime": "2026-08-06 10:42:18",
"stayDurationSeconds": 1576,
"matchScore": 93.842
}]
}
  • 400:时间、坐标、坐标系或半径不合法
  • 403:授权期未覆盖整个时间段
  • 无匹配:data 为空数组

加氢站地图点位

POST/api/v1/hydrogen-stations/query

只读查询有有效坐标的加氢站,可按行政区划和合作属性筛选。

#

请求参数

字段必填说明
province / city行政区划筛选
cooperateOnlytrue 仅合作站;false 仅外部站

成功返回 · 200

{
"data": [{
"id": "1",
"name": "示例加氢站",
"longitude": 120.752312,
"latitude": 30.746281,
"cooperative": true
}]
}
  • 400:行政区划参数过长
  • 401:appKey 无效
  • 无符合站点:data 为空数组
-

返回字段与枚举说明

所有成功响应外层固定包含 code、message、data 和 traceId;以下为 data 内的字段。无数据时按对应接口返回 null、NO_DATA 或空数组。

#

通用响应与状态

字段/枚举说明
code = SUCCESS请求已成功处理。业务数据是否存在由每条数据的 status 判断。
message成功时固定为 success。
traceId本次请求唯一追踪标识;异常排查时请完整提供。
status = NORMAL存在可用数据,相关数值、协议和时间字段有效。
status = NO_DATA在授权范围内未找到可用数据;可选数值、位置、时间字段为 null 或省略。
status = DATA_ANOMALY检测到数据异常,里程异常不返回可能错误的里程;用氢 SUSPECT 保留审计数值。原因分别见 dataQuality / qualityReason。
dataQuality = TOTAL_MILEAGE_ROLLBACK终端累计里程明显回退,已阻止其作为正常累计里程返回。

车辆单日用氢量

字段说明
vin / plateNumber / date授权车辆 VIN、车牌与查询自然日(Asia/Shanghai)。
hydrogenConsumptionKg当日用氢量,单位 kg;无有效数据时为 null。
status / qualityStatusNORMAL / OK 为可用;DATA_ANOMALY / SUSPECT 保留数值供审计,不参与正常百公里氢耗;NO_DATA 不可用。
calculationPhase / algorithmVersionPRELIMINARY 为初步监控结果,FINAL 为批量重算结果;历史结果仍可能再重算,保留实际版本。
statisticsStartTime / statisticsEndTime / updatedAt证据包络起止和统计行更新时间;PRELIMINARY 开始时间为 null,结束为数据水位。与里程无共同快照保证。

车辆单日里程与区间日里程

字段说明
vin / plateNumber / date车辆唯一 VIN、车牌和统计自然日。
dailyMileageKm当日行驶里程,单位 km。缺少当日记录且可前向补齐时为 0。
totalMileageKm所选协议的日末累计总里程,单位 km;不会用 GPS 日里程估算值替代。
sourceProtocol实际采用的协议:GB32960、MQTT 或 JT808。
dataTime实际采用的最后一条车辆源数据时间。
updatedAt该统计周期的计算时间;前向补齐时保持上一有效统计周期时间。
snapshotId / nextCursor仅区间接口返回;snapshotId 固定本次分页车辆范围,nextCursor 为下一页游标,最后一页为 null。

指定时刻总里程

字段说明
vin / queryTime查询车辆 VIN 与请求的北京时间。
totalMileageKm不晚于请求时间的最近一条累计总里程,单位 km。
protocol / protocolInputprotocol 为实际命中协议;protocolInput 为请求显式传入的协议,未传则省略。
recordTime / timeDifferenceSeconds实际命中记录时间,以及请求时间与记录时间的差值,单位秒。
mileageMeaning该协议累计里程的业务口径;JT808 为定位终端/GPS侧累计值。

车辆实时位置与状态

字段/枚举说明
protocol本条位置、速度、累计里程和记录时间实际采用的协议:GB32960、MQTT 或 JT808。
remainingHydrogenKg / remainingHydrogenPercent全车储氢 kg / %,真实零保留。当前仅 GB32960 广东扩展质量支持;百分比为 null/UNSUPPORTED。
hydrogenRecordTime / hydrogenDataStatus同帧采集时间和聚合状态 NORMAL / PARTIAL / STALE / MISSING / UNSUPPORTED / INVALID;必须同时检查两个字段级状态。
gpsFixStatus / locationRecordTime / coordinateSystem真实定位位、位置采集时间和 WGS84 / GCJ02 / UNKNOWN。离线不清除历史 FIXED,陈旧不证明无定位。
longitude / latitude无有效位置或 NO_FIX 时为 null,locationAvailable 为 false;UNKNOWN 坐标系需由调用方确认后上图。
speedKmh / totalMileageKm所选来源的瞬时速度(km/h)和累计总里程(km)。
recordTime / timeDifferenceSeconds所选来源的记录时间,以及与当前查询时间的差值,单位秒。
onlinetrue:任一协议最近 60 秒内上报;false:没有任一协议满足该实时阈值。
activeTodaytrue:任一协议在当前自然日曾上报;不等同于实时 online。
motionStatus = driving在线且所选来源速度大于 3 km/h。
motionStatus = idle在线且所选来源速度不大于 3 km/h。
motionStatus = offline当前不在线,或没有实时记录。

加氢车辆停留核验

字段说明
stayStartTime / stayEndTime满足核验条件的连续停留开始、结束时间。
stayDurationSeconds / stayDurationMinutes停留时长,分别以秒、分钟表示。
matchScore0–100;位置越接近、速度越低、停留越久、样本越充分,分数越高。
averageDistanceMeters / maxDistanceMeters定位点相对输入站点坐标的平均/最大距离,单位 m。
averageSpeedKmh / maxSpeedKmh / matchedSamples核验区间内速度统计与匹配定位样本数。
sourceProtocols本停留片段采用过的协议数组,枚举值为 GB32960、MQTT、JT808。

加氢站地图点位

字段说明
id / name / shortName站点唯一标识、标准名称和简称;id 为字符串以避免 JavaScript 精度丢失。
address / province / city / district站点地址及行政区划。
longitude / latitude站点坐标。
cooperativetrue:合作站(内部站或已导入合作名录);false:外部站。
+

返回字段与枚举说明

所有成功响应外层固定包含 code、message、data 和 traceId;以下为 data 内的字段。无数据时按对应接口返回 null、NO_DATA 或空数组。

#

通用响应与状态

字段/枚举说明
code = SUCCESS请求已成功处理。业务数据是否存在由每条数据的 status 判断。
message成功时固定为 success。
traceId本次请求唯一追踪标识;异常排查时请完整提供。
status = NORMAL存在可用数据,相关数值、协议和时间字段有效。
status = NO_DATA在授权范围内未找到可用数据;可选数值、位置、时间字段为 null 或省略。
status = DATA_ANOMALY检测到数据异常,里程异常不返回可能错误的里程;用氢 SUSPECT 保留审计数值。原因分别见 dataQuality / qualityReason。
dataQuality = TOTAL_MILEAGE_ROLLBACK终端累计里程明显回退,已阻止其作为正常累计里程返回。

车辆单日用氢量

字段说明
vin / plateNumber / date授权车辆 VIN、车牌与查询自然日(Asia/Shanghai)。
hydrogenConsumptionKg当日用氢量,单位 kg;无有效数据时为 null。
status / qualityStatusNORMAL / OK 为可用;DATA_ANOMALY / SUSPECT 保留数值供审计,不参与正常百公里氢耗;NO_DATA 不可用。
calculationPhase / algorithmVersionPRELIMINARY 为初步监控结果,FINAL 为批量重算结果;历史结果仍可能再重算,保留实际版本。
statisticsStartTime / statisticsEndTime / updatedAt证据包络起止和统计行更新时间;PRELIMINARY 开始时间为 null,结束为数据水位。与里程无共同快照保证。

车辆单日里程与区间日里程

字段说明
vin / plateNumber / date车辆唯一 VIN、车牌和统计自然日。
dailyMileageKm当日行驶里程,单位 km。缺少当日记录且可前向补齐时为 0。
totalMileageKm所选协议的日末累计总里程,单位 km;不会用 GPS 日里程估算值替代。
sourceProtocol实际采用的协议:GB32960、MQTT 或 JT808。
dataTime实际采用的最后一条车辆源数据时间。
updatedAt该统计周期的计算时间;前向补齐时保持上一有效统计周期时间。
snapshotId / nextCursor仅区间接口返回;snapshotId 固定本次分页车辆范围,nextCursor 为下一页游标,最后一页为 null。

指定时刻总里程

字段说明
vin / queryTime查询车辆 VIN 与请求的北京时间。
totalMileageKm不晚于请求时间的最近一条累计总里程,单位 km。
protocol / protocolInputprotocol 为实际命中协议;protocolInput 为请求显式传入的协议,未传则省略。
recordTime / timeDifferenceSeconds实际命中记录时间,以及请求时间与记录时间的差值,单位秒。
mileageMeaning该协议累计里程的业务口径;JT808 为定位终端/GPS侧累计值。

车辆实时位置与状态

字段/枚举说明
protocol本条位置、速度、累计里程和记录时间实际采用的协议:GB32960、MQTT 或 JT808。
remainingHydrogenKg / remainingHydrogenPercent全车储氢 kg / %,真实零保留。质量优先终端上报,缺值可用同帧温压和 VIN 容积估算;百分比按35 MPa、15°C满充质量计算,缺条件独立返回 null。
hydrogenRecordTime / hydrogenDataStatus同帧采集时间和聚合状态 NORMAL / PARTIAL / STALE / MISSING / UNSUPPORTED / INVALID;必须同时检查两个字段级状态。
gpsFixStatus / locationRecordTime / coordinateSystem真实定位位、位置采集时间和 WGS84 / GCJ02 / UNKNOWN。离线不清除历史 FIXED,陈旧不证明无定位。
longitude / latitude无有效位置或 NO_FIX 时为 null,locationAvailable 为 false;UNKNOWN 坐标系需由调用方确认后上图。
speedKmh / totalMileageKm所选来源的瞬时速度(km/h)和累计总里程(km)。
recordTime / timeDifferenceSeconds所选来源的记录时间,以及与当前查询时间的差值,单位秒。
onlinetrue:任一协议最近 60 秒内上报;false:没有任一协议满足该实时阈值。
activeTodaytrue:任一协议在当前自然日曾上报;不等同于实时 online。
motionStatus = driving在线且所选来源速度大于 3 km/h。
motionStatus = idle在线且所选来源速度不大于 3 km/h。
motionStatus = offline当前不在线,或没有实时记录。

加氢车辆停留核验

字段说明
stayStartTime / stayEndTime满足核验条件的连续停留开始、结束时间。
stayDurationSeconds / stayDurationMinutes停留时长,分别以秒、分钟表示。
matchScore0–100;位置越接近、速度越低、停留越久、样本越充分,分数越高。
averageDistanceMeters / maxDistanceMeters定位点相对输入站点坐标的平均/最大距离,单位 m。
averageSpeedKmh / maxSpeedKmh / matchedSamples核验区间内速度统计与匹配定位样本数。
sourceProtocols本停留片段采用过的协议数组,枚举值为 GB32960、MQTT、JT808。

加氢站地图点位

字段说明
id / name / shortName站点唯一标识、标准名称和简称;id 为字符串以避免 JavaScript 精度丢失。
address / province / city / district站点地址及行政区划。
longitude / latitude站点坐标。
cooperativetrue:合作站(内部站或已导入合作名录);false:外部站。

总里程协议口径

未指定 protocol 时,按 GB32960 > MQTT > JT808 选择第一个有数据的协议;不会跨协议拼接里程。

#
规范值总里程含义
GB32960车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程。
MQTT车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报。
JT808定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程。
@@ -138,11 +138,18 @@ ], "realtime": [ ["data[]", "array", "按请求车牌顺序返回;省略车牌时返回当前授权范围内全部车辆。"], ["data[].vin / plateNumber", "string", "车辆唯一 VIN / 车牌。"], - ["data[].remainingHydrogenKg / remainingHydrogenPercent", "number | null", "全车储氢质量 kg / 比例 %。当前仅 GB32960 广东扩展 0x34 直接上报质量可用,百分比缺少可信容量分母始终 null/UNSUPPORTED。真实 0 保留,不能用 SOC 替代。"], - ["data[].hydrogenRecordTime", "datetime | null", "氢量原始同帧的实际采集时间,RFC 3339 带时区;不是查询/缓存时间。仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查快照 received_at 向前 5 分钟的候选,按采集时间优先取最新;INVALID 不回退,离线回补保留真实 STALE。"], - ["data[].hydrogenDataStatus", "enum", "NORMAL 全部有效;PARTIAL 部分字段支持;STALE 陈旧;MISSING 缺记录或本次未取得可信证据(补充查询失败/3 秒超时会降级);UNSUPPORTED 不支持;INVALID 异常。当前质量有效、百分比不支持时为 PARTIAL。"], + ["data[].remainingHydrogenKg / remainingHydrogenPercent", "number | null", "全车质量 kg / 比例 %;优先 GB32960 广东扩展终端上报 kg(0–200),缺kg可由同帧最大氢压/氢温与VIN容积估算(0–500)。比例按35 MPa、15°C满充质量计算;真实0保留,不以动力电池SOC替代。"], + ["data[].hydrogenRecordTime", "datetime | null", "氢量原始同帧的实际采集时间,RFC 3339 带时区;不是查询/缓存时间。仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查快照 received_at 向前 5 分钟的候选,按采集时间优先检查最近5条;不完整温压可取次新完整帧,INVALID 不回退,离线回补保留真实 STALE。"], + ["data[].hydrogenDataStatus", "enum", "NORMAL 全部有效;PARTIAL 部分字段支持;STALE 陈旧;MISSING 缺记录或本次未取得可信证据(补充查询失败/3 秒超时会降级);UNSUPPORTED 不支持;INVALID 异常。两项有效时 NORMAL(是否估算看来源);仅一项有效时 PARTIAL。"], ["data[].remainingHydrogenKgStatus / remainingHydrogenPercentStatus", "enum", "逐字段 NORMAL / STALE / MISSING / UNSUPPORTED / INVALID;独立判断两个数值,聚合状态不代表两者都有效。"], - ["data[].hydrogenValueSource / hydrogenSourceProtocol", "string | null", "REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;储氢数据协议独立于位置 protocol。"], + ["data[].hydrogenValueSource / hydrogenSourceProtocol", "string | null", "kg 来源 REPORTED 仅确认终端上报,ESTIMATED 为平台温压/容积实气估算;储氢协议独立于位置 protocol。"], + ["data[].remainingHydrogenPercentSource", "enum | null", "ESTIMATED:比例按模型满充质量计算,与kg来源独立;计算缺条件时null。超过100比例null/INVALID,kg独立保留,不截断成100。"], + ["data[].hydrogenFullCapacityKg / hydrogenTankCapacityL", "number | null", "未舍入满充质量分母kg / 已启用VIN全车水容积L(0<V≤10000)。无默认车型容积;缺容量百分比MISSING。"], + ["data[].hydrogenFullPressureMPa / hydrogenReferenceTemperatureC", "number | null", "35 MPa / 15°C;业务满充参考,不是自动核验每车铭牌额定压力,也不是可用容量扣除残余后的分母。"], + ["data[].hydrogenEstimatePressureMPa / hydrogenEstimateTemperatureC", "number | null", "仅估算kg时采用的同帧最大氢压/氢温;压力0–70 MPa,温度−40–726.85°C,不补默认温度。"], + ["data[].hydrogenPressureTemperatureSource", "enum | null", "MAX_SENSOR_AGGREGATE:最大温压聚合值,不保证来自同一瓶,不能当逐瓶质量求和或完整性证明。"], + ["data[].hydrogenCalculationVersion / hydrogenCapacitySource", "string | null", "REAL_GAS_35MPA_15C_V1 / vehicle_hydrogen_tank_capacity。复用实气模型与VIN配置,不改变日用氢算法。"], + ["data[].hydrogenPercentReason", "enum | null", "MISSING_HYDROGEN_MEASUREMENT、INVALID_MASS_READING、INVALID_PRESSURE_TEMPERATURE、INCOMPLETE_PRESSURE_TEMPERATURE、MISSING_TANK_CAPACITY、MASS_CALCULATION_FAILED、EXCEEDS_NOMINAL_FULL_CAPACITY;超满充参考拒绝百分比,不据此判断车辆安全状态。"], ["data[].hydrogenStaleAfterSeconds / hydrogenExpectedIntervalSeconds", "integer | null", "GB32960 服务陈旧阈值 300 秒;协议期望上报周期未约定为 null,阈值不是采样周期承诺。"], ["data[].gpsFixStatus", "enum", "FIXED / NO_FIX / UNKNOWN:实际位置报文定位位,独立于在线和数据年龄;MQTT 无可信定位位为 UNKNOWN。"], ["data[].locationRecordTime / coordinateSystem", "datetime | null / enum", "位置实际采集时间。坐标系 WGS84 / GCJ02 / UNKNOWN;仅 GB2025 显式类型 1/2 确定 WGS84/GCJ02,GB2016/JT808/MQTT 无确证为 UNKNOWN。不能默认 GCJ02。"], diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index ded8c886..a529e3bb 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: 车辆数据开放平台 API - version: 1.8.0 + version: 1.9.0 license: name: Proprietary description: | @@ -211,8 +211,9 @@ paths: socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。 activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。 离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。 - 储氢使用可追溯原始帧;最新帧缺少氢量时可有界回补,当前百分比无可信容量分母为 null/UNSUPPORTED;不得用 SOC 替代。 - 仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查该快照 received_at 向前 5 分钟的含氢量字段原始帧,按采集时间优先、接收时间次序取最新;不回退覆盖 INVALID,离线回补保留真实 STALE。 + 满充容量采用 PressureHydrogenMassKg(35,15,VIN水容积);35 MPa 是本次业务基准,15°C 密度比参考来自 https://unece.org/sites/default/files/2024-07/ECE_TRANS_WP.29_2023_110E.pdf,不代表车型额定压力自动认证。 + 储氢优先终端上报质量 REPORTED;无质量时以同帧最大氢压/最大氢温与 VIN 全车水容积估算 ESTIMATED。百分比按 35 MPa、15°C 满充参考质量计算,不以 SOC 替代。 + 仅 GB 最新快照有引用且精确帧氢量 MISSING 时有界回补,按采集时间优先、接收时间次序检查最近5条候选;不完整温压可取次新完整帧,不回退覆盖 INVALID,离线回补保留真实 STALE。 GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。 补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。 operationId: queryRealtimeVehicles @@ -878,7 +879,7 @@ components: $ref: '#/components/schemas/StationaryVehicleResult' RealtimeVehicleResult: type: object - required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, gpsFixStatus, locationRecordTime, coordinateSystem] + required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, remainingHydrogenPercentSource, hydrogenFullCapacityKg, hydrogenTankCapacityL, hydrogenFullPressureMPa, hydrogenReferenceTemperatureC, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacitySource, hydrogenPercentReason, gpsFixStatus, locationRecordTime, coordinateSystem] properties: vin: { type: string } plateNumber: { type: string } @@ -886,14 +887,14 @@ components: type: number nullable: true minimum: 0 - maximum: 200 - description: GB32960 广东燃料电池扩展 0x34 全车上报氢质量,kg;真实零保留。无值、不支持或无效为 null;陈旧值保留并标 STALE,不以单瓶压力或当日消耗推算 + maximum: 500 + description: 全车氢质量 kg;优先 GB32960 广东扩展终端上报(0–200 kg),缺质量时以同帧最大氢压/氢温和 VIN 全车水容积作实气估算(0–500 kg)。真实零保留,缺失/异常为 null,陈旧值保留并标 STALE remainingHydrogenPercent: type: number nullable: true minimum: 0 maximum: 100 - description: 当前无可信额定或可用质量容量分母,固定 null,字段状态 UNSUPPORTED;不以 SOC 替代 + description: 剩余质量除以 VIN 水容积在 35 MPa、15°C 的实气模型满充质量乘100,属于 ESTIMATED;缺容量为 null/MISSING。超过100返回 null/INVALID,不截断为100,kg独立保留;不以动力电池SOC替代 hydrogenRecordTime: type: string format: date-time @@ -902,19 +903,70 @@ components: hydrogenDataStatus: type: string enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID] - description: NORMAL 保留供未来全部字段有效;当前不会输出 NORMAL;PARTIAL 部分支持(当前质量有效但百分比不支持);STALE 数据陈旧;MISSING 无记录;UNSUPPORTED 不支持;INVALID 异常。分别检查字段级状态 + description: NORMAL 两项有效(可包含估算,见来源);PARTIAL 部分字段有效;STALE 数据陈旧;MISSING 缺少数据或容量;UNSUPPORTED 不支持;INVALID 异常。数值和字段级状态独立判断 remainingHydrogenKgStatus: type: string enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] remainingHydrogenPercentStatus: type: string enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] - description: 当前固定 UNSUPPORTED + description: 独立于 kg 状态;容量缺失 MISSING,比例超100 INVALID;有值时继承 kg 的 NORMAL 或 STALE hydrogenValueSource: type: string nullable: true - enum: [REPORTED] - description: REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;本接口不作压力推算 + enum: [REPORTED, ESTIMATED] + description: kg 来源;REPORTED 仅确认终端上报,无法判断终端内部测量或估算;ESTIMATED 为平台压力/温度/容积实气模型估算 + remainingHydrogenPercentSource: + type: string + nullable: true + enum: [ESTIMATED] + description: 百分比始终为模型满充容量推算;与 kg 来源独立,计算前缺条件时为 null + hydrogenFullCapacityKg: + type: number + nullable: true + description: 实际用于比例分母的未舍入满充质量,kg;PressureHydrogenMassKg(35,15,VIN水容积),不是扣除残余后的可用容量 + hydrogenTankCapacityL: + type: number + nullable: true + description: VIN 配置的全车储氢水容积,L;必须 active 且大于0、不超过10000,不根据车型臆造默认值 + hydrogenFullPressureMPa: + type: number + nullable: true + enum: [35] + description: 本次业务确认的满充参考压力,MPa;不代表自动核验每辆车铭牌额定压力 + hydrogenReferenceTemperatureC: + type: number + nullable: true + enum: [15] + description: 满充参考温度,摄氏度 + hydrogenEstimatePressureMPa: + type: number + nullable: true + description: 仅平台估算 kg 时使用的同帧最大氢压,MPa;支持0,不补默认值 + hydrogenEstimateTemperatureC: + type: number + nullable: true + description: 仅平台估算 kg 时使用的同帧最大氢温,摄氏度;模型输入范围−40–726.85°C,不是车辆安全温度阈值;缺失不补默认温度 + hydrogenPressureTemperatureSource: + type: string + nullable: true + enum: [MAX_SENSOR_AGGREGATE] + description: 最大氢压/最大氢温聚合读数近似;两者不保证来自同一瓶,不是逐瓶质量求和或全瓶完整性证明 + hydrogenCalculationVersion: + type: string + nullable: true + enum: [REAL_GAS_35MPA_15C_V1] + description: 实时储氢模型版本;不修改日用氢算法 + hydrogenCapacitySource: + type: string + nullable: true + enum: [vehicle_hydrogen_tank_capacity] + description: 满充容量采用的 VIN 配置数据表 + hydrogenPercentReason: + type: string + nullable: true + enum: [MISSING_HYDROGEN_MEASUREMENT, INVALID_MASS_READING, INVALID_PRESSURE_TEMPERATURE, INCOMPLETE_PRESSURE_TEMPERATURE, MISSING_TANK_CAPACITY, MASS_CALCULATION_FAILED, EXCEEDS_NOMINAL_FULL_CAPACITY] + description: 缺少或拒绝百分比的原因;超出业务满充参考仅拒绝百分比,不伪装为100,也不据此判断车辆安全状态 hydrogenSourceProtocol: type: string nullable: true diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go index 578daabd..42426fce 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go @@ -90,6 +90,10 @@ func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T) "hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime", "coordinateSystem", "statisticsStartTime", "statisticsEndTime", "updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion", + "remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL", + "hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa", + "hydrogenEstimateTemperatureC", "hydrogenPressureTemperatureSource", "hydrogenCalculationVersion", + "hydrogenCapacitySource", "hydrogenPercentReason", } { if !strings.Contains(string(openAPISpec), field+":") { t.Errorf("OpenAPI missing live contract field %s", field) @@ -99,7 +103,7 @@ func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T) } } for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} { - for _, boundary := range []string{"PARTIAL", "UNSUPPORTED", "UNKNOWN", "PRELIMINARY", "FINAL", "SUSPECT", "REPORTED", "共同快照", "null"} { + for _, boundary := range []string{"PARTIAL", "UNSUPPORTED", "UNKNOWN", "PRELIMINARY", "FINAL", "SUSPECT", "REPORTED", "ESTIMATED", "MAX_SENSOR_AGGREGATE", "EXCEEDS_NOMINAL_FULL_CAPACITY", "REAL_GAS_35MPA_15C_V1", "共同快照", "null"} { if !strings.Contains(doc, boundary) { t.Errorf("%s missing availability/comparability boundary %q", name, boundary) } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate.go new file mode 100644 index 00000000..2dcd1fe0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate.go @@ -0,0 +1,154 @@ +package openplatform + +import ( + "context" + "encoding/json" + "math" + "strings" + "time" +) + +const realtimeHydrogenPressureField = "gb32960.fuel_cell.max_hydrogen_pressure_mpa" +const realtimeHydrogenTemperatureField = "gb32960.fuel_cell.max_hydrogen_temperature_c" + +// Capacity is the configured total water volume for this VIN, never a default +// inferred from a vehicle model or a partial cylinder reading. +func (r *MySQLRepository) loadRealtimeHydrogenCapacities(ctx context.Context, vins []string) (map[string]float64, error) { + result := make(map[string]float64) + if len(vins) == 0 { + return result, nil + } + args := make([]any, len(vins)) + for i, vin := range vins { + args[i] = vin + } + rows, err := r.db.QueryContext(ctx, `SELECT vin,tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND BINARY vin IN (`+strings.TrimRight(strings.Repeat("?,", len(vins)), ",")+`)`, args...) + if err != nil { + return result, err + } + defer rows.Close() + for rows.Next() { + var vin string + var capacity float64 + if err := rows.Scan(&vin, &capacity); err != nil { + return result, err + } + if capacity > 0 && capacity <= 10000 && !math.IsNaN(capacity) && !math.IsInf(capacity, 0) { + result[vin] = capacity + } + } + return result, rows.Err() +} + +func realtimeHydrogenWithCapacity(parsed string, observedAt, now time.Time, capacity float64) RealtimeHydrogenData { + result := realtimeHydrogenFromFrame(parsed, observedAt, now) + reason := func(value string) { result.HydrogenPercentReason = &value } + if result.RemainingHydrogenKgStatus == "INVALID" { + result.RemainingHydrogenPercentStatus = "INVALID" + reason("INVALID_MASS_READING") + return result + } + full, capacityOK := PressureHydrogenMassKg(35, 15, capacity) + capacityOK = capacityOK && full > 0 + if capacityOK { + pressure, temperature := 35.0, 15.0 + version, source := "REAL_GAS_35MPA_15C_V1", "vehicle_hydrogen_tank_capacity" + result.HydrogenFullCapacityKg = &full + result.HydrogenTankCapacityL = &capacity + result.HydrogenFullPressureMPa = &pressure + result.HydrogenReferenceTemperatureC = &temperature + result.HydrogenCalculationVersion = &version + result.HydrogenCapacitySource = &source + } + var fields map[string]any + if json.Unmarshal([]byte(parsed), &fields) != nil { + return result + } + var mass float64 + if result.RemainingHydrogenKg != nil { + // Use the same source measurement for numerator and kg, before rounding. + for _, key := range hydrogenMassFields { + if value, exists := fields[key]; exists { + mass, _ = numericValue(value) + break + } + } + } else { + pv, pExists := fields[realtimeHydrogenPressureField] + tv, tExists := fields[realtimeHydrogenTemperatureField] + if !pExists && !tExists { + result.RemainingHydrogenPercentStatus = "MISSING" + reason("MISSING_HYDROGEN_MEASUREMENT") + return result + } + if !observedAt.IsZero() { + at := observedAt.In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.RFC3339Nano) + result.HydrogenRecordTime = &at + } + pressure, pOK := numericValue(pv) + temperature, tOK := numericValue(tv) + // Explicit invalid telemetry blocks fallback. Missing peer fields are also + // withheld: readings from different raw frames must never be combined. + if (pExists && (!pOK || !finiteRealtime(pressure) || pressure < 0 || pressure > 70)) || (tExists && (!tOK || !finiteRealtime(temperature) || temperature < -40 || temperature > 726.85)) || observedAt.IsZero() || observedAt.After(now.Add(time.Minute)) { + result.HydrogenDataStatus = "INVALID" + result.RemainingHydrogenKgStatus = "INVALID" + result.RemainingHydrogenPercentStatus = "INVALID" + reason("INVALID_PRESSURE_TEMPERATURE") + return result + } + if !pExists || !tExists { + result.RemainingHydrogenPercentStatus = "MISSING" + reason("INCOMPLETE_PRESSURE_TEMPERATURE") + return result + } + if !capacityOK { + result.RemainingHydrogenPercentStatus = "MISSING" + reason("MISSING_TANK_CAPACITY") + return result + } + var ok bool + mass, ok = PressureHydrogenMassKg(pressure, temperature, capacity) + if !ok { + result.HydrogenDataStatus = "INVALID" + result.RemainingHydrogenKgStatus = "INVALID" + result.RemainingHydrogenPercentStatus = "INVALID" + reason("MASS_CALCULATION_FAILED") + return result + } + rounded := round3(mass) + source := "ESTIMATED" + result.RemainingHydrogenKg = &rounded + result.HydrogenValueSource = &source + sensorSource := "MAX_SENSOR_AGGREGATE" + result.HydrogenPressureTemperatureSource = &sensorSource + result.HydrogenEstimatePressureMPa = &pressure + result.HydrogenEstimateTemperatureC = &temperature + result.RemainingHydrogenKgStatus = "NORMAL" + if now.Sub(observedAt) > time.Duration(realtimeHydrogenStaleSeconds)*time.Second { + result.RemainingHydrogenKgStatus = "STALE" + } + } + if !capacityOK { + result.RemainingHydrogenPercentStatus = "MISSING" + reason("MISSING_TANK_CAPACITY") + return result + } + percent := mass / full * 100 + source := "ESTIMATED" + result.RemainingHydrogenPercentSource = &source + if percent > 100 { + result.RemainingHydrogenPercentStatus = "INVALID" + result.HydrogenDataStatus = "PARTIAL" + if result.RemainingHydrogenKgStatus == "STALE" { + result.HydrogenDataStatus = "STALE" + } + reason("EXCEEDS_NOMINAL_FULL_CAPACITY") + return result + } + percent = round3(percent) + result.RemainingHydrogenPercent = &percent + result.RemainingHydrogenPercentStatus = result.RemainingHydrogenKgStatus + result.HydrogenDataStatus = result.RemainingHydrogenKgStatus + return result +} +func finiteRealtime(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate_test.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate_test.go new file mode 100644 index 00000000..46c79746 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_hydrogen_estimate_test.go @@ -0,0 +1,148 @@ +package openplatform + +import ( + "context" + "database/sql" + "fmt" + "github.com/DATA-DOG/go-sqlmock" + "math" + "testing" + "time" +) + +func TestRealtimeHydrogenEstimation(t *testing.T) { + now := time.Now() + pt := func(p, t float64) string { return fmtRealtimePT(p, t) } + for _, tc := range []struct { + name, parsed string + cap float64 + status, source, percentStatus string + zero bool + }{ + {"full", pt(35, 15), 520, "NORMAL", "ESTIMATED", "NORMAL", false}, + {"empty", pt(0, 15), 520, "NORMAL", "ESTIMATED", "NORMAL", true}, + {"no capacity", pt(20, 15), 0, "MISSING", "", "MISSING", false}, + {"no temperature", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, 520, "MISSING", "", "MISSING", false}, + {"invalid pressure", pt(-1, 15), 520, "INVALID", "", "INVALID", false}, + {"invalid temperature", pt(20, 6553), 520, "INVALID", "", "INVALID", false}, + {"over nominal", pt(40, 15), 520, "PARTIAL", "ESTIMATED", "INVALID", false}, + {"reported wins", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0,"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, 520, "NORMAL", "REPORTED", "NORMAL", true}, + {"invalid reported blocks", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, 520, "INVALID", "", "INVALID", false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := realtimeHydrogenWithCapacity(tc.parsed, now, now, tc.cap) + if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenPercentStatus != tc.percentStatus { + t.Fatalf("%+v", got) + } + if tc.source != "" && (got.HydrogenValueSource == nil || *got.HydrogenValueSource != tc.source) { + t.Fatalf("source %+v", got) + } + if tc.zero && (got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 0 || got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 0) { + t.Fatalf("zero %+v", got) + } + if tc.name == "full" && (got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100) { + t.Fatalf("full %+v", got) + } + }) + } + got := realtimeHydrogenWithCapacity(pt(20, 30), now.Add(-301*time.Second), now, 520) + if got.HydrogenDataStatus != "STALE" || got.RemainingHydrogenPercentStatus != "STALE" { + t.Fatal(got) + } + mass, _ := PressureHydrogenMassKg(20, 30, 520) + full, _ := PressureHydrogenMassKg(35, 15, 520) + if math.Abs(*got.RemainingHydrogenPercent-round3(mass/full*100)) > 1e-6 { + t.Fatal(got) + } +} +func fmtRealtimePT(p, t float64) string { + return fmt.Sprintf(`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":%g,"gb32960.fuel_cell.max_hydrogen_temperature_c":%g}`, p, t) +} +func TestRealtimeCapacityLookupScopedAndOptional(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery(`SELECT vin,tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND BINARY vin IN`).WithArgs("V1", "V2").WillReturnRows(sqlmock.NewRows([]string{"vin", "tank_capacity_l"}).AddRow("V1", 520).AddRow("V2", 0)) + got, err := (&MySQLRepository{db: db}).loadRealtimeHydrogenCapacities(context.Background(), []string{"V1", "V2"}) + if err != nil || got["V1"] != 520 || got["V2"] != 0 { + t.Fatalf("%v %v", got, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestRealtimePressureFallbackUsesSampleCapacity(t *testing.T) { + now := time.Now() + points := map[string]RealtimeVehiclePoint{"V1": {LiveHydrogen: missingRealtimeHydrogen("GB32960")}} + frames := []realtimeRawFrame{{VIN: "V1", Protocol: "GB32960", EventMS: sql.NullInt64{Int64: now.Add(-time.Minute).UnixMilli(), Valid: true}, Parsed: sql.NullString{String: fmtRealtimePT(35, 15), Valid: true}}} + applyRealtimeHydrogenFallback(points, []realtimeFrameReference{{VIN: "V1"}}, frames, now, map[string]float64{"V1": 520}) + if got := points["V1"].LiveHydrogen; got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100 || *got.HydrogenValueSource != "ESTIMATED" { + t.Fatal(got) + } +} + +func TestRealtimeCapacityFailurePreservesReportedMeasurement(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + td, tm, _ := sqlmock.New() + defer td.Close() + now := time.Now() + vin := "V1" + mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "sample", now)) + tm.ExpectQuery("SELECT vin,protocol,event_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(vin, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":8.125}`)) + mock.ExpectQuery("SELECT vin,tank_capacity_l").WithArgs(vin).WillReturnError(fmt.Errorf("capacity store unavailable")) + points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}} + if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil { + t.Fatal(err) + } + got := points[vin].LiveHydrogen + if got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 8.125 || got.RemainingHydrogenKgStatus != "NORMAL" || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "MISSING" { + t.Fatalf("%+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + if err := tm.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestRealtimeHydrogenNoFabricatedInputsAndUnroundedDenominator(t *testing.T) { + now := time.Now() + for _, parsed := range []string{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"NaN","gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":0,"gb32960.fuel_cell.max_hydrogen_temperature_c":null}`} { + got := realtimeHydrogenWithCapacity(parsed, now, now, 520) + if got.RemainingHydrogenKg != nil || got.HydrogenDataStatus != "INVALID" { + t.Fatal(got) + } + } + got := realtimeHydrogenWithCapacity(`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":5.12345}`, now, now, 520) + full, _ := PressureHydrogenMassKg(35, 15, 520) + if got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != round3(5.12345/full*100) || *got.HydrogenFullCapacityKg != full { + t.Fatal(got) + } +} + +func TestRealtimeHydrogenFallbackSkipsIncompleteButBlocksInvalid(t *testing.T) { + now := time.Now() + refs := []realtimeFrameReference{{VIN: "V1"}} + raw := func(parsed string, age time.Duration) realtimeRawFrame { + return realtimeRawFrame{VIN: "V1", Protocol: "GB32960", EventMS: sql.NullInt64{Int64: now.Add(-age).UnixMilli(), Valid: true}, Parsed: sql.NullString{String: parsed, Valid: true}} + } + for _, tc := range []struct{ latest, status string }{ + {`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, "NORMAL"}, + {`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null}`, "INVALID"}, + } { + points := map[string]RealtimeVehiclePoint{"V1": {LiveHydrogen: missingRealtimeHydrogen("GB32960")}} + applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(fmtRealtimePT(35, 15), 2*time.Minute), raw(tc.latest, time.Minute)}, now, map[string]float64{"V1": 520}) + got := points["V1"].LiveHydrogen + if got.HydrogenDataStatus != tc.status { + t.Fatal(got) + } + if tc.status == "NORMAL" && (got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100) { + t.Fatal(got) + } + } + got := realtimeHydrogenWithCapacity(`{}`, now, now, 520) + if got.RemainingHydrogenPercentStatus != "MISSING" || got.HydrogenPercentReason == nil || *got.HydrogenPercentReason != "MISSING_HYDROGEN_MEASUREMENT" { + t.Fatal(got) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go index f80c9bce..0736b431 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go @@ -15,21 +15,31 @@ import ( ) // GB32960 live freshness is an API policy, not an assertion about the device's -// negotiated reporting interval. No supported protocol currently has a verified -// full-vehicle percentage/capacity contract. +// negotiated reporting interval. const realtimeHydrogenStaleSeconds int64 = 300 type RealtimeHydrogenData struct { - RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"` - RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"` - HydrogenRecordTime *string `json:"hydrogenRecordTime"` - HydrogenDataStatus string `json:"hydrogenDataStatus"` - RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"` - RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"` - HydrogenValueSource *string `json:"hydrogenValueSource"` - HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"` - HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"` - HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"` + HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"` + RemainingHydrogenPercentSource *string `json:"remainingHydrogenPercentSource"` + HydrogenFullCapacityKg *float64 `json:"hydrogenFullCapacityKg"` + HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"` + HydrogenFullPressureMPa *float64 `json:"hydrogenFullPressureMPa"` + HydrogenReferenceTemperatureC *float64 `json:"hydrogenReferenceTemperatureC"` + HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"` + HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"` + HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"` + HydrogenCapacitySource *string `json:"hydrogenCapacitySource"` + HydrogenPercentReason *string `json:"hydrogenPercentReason"` + RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"` + RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"` + HydrogenRecordTime *string `json:"hydrogenRecordTime"` + HydrogenDataStatus string `json:"hydrogenDataStatus"` + RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"` + RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"` + HydrogenValueSource *string `json:"hydrogenValueSource"` + HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"` + HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"` + HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"` } func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData { @@ -37,7 +47,7 @@ func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData { if protocol == "" || protocol == "GB32960" { status = "MISSING" } - result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: "UNSUPPORTED"} + result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: status} if protocol == "GB32960" { threshold := realtimeHydrogenStaleSeconds result.HydrogenStaleAfterSeconds = &threshold @@ -231,6 +241,10 @@ func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []strin // At most two references per requested VIN. Batches bound SQL size; primary // timestamp and VIN tag filters prevent unbounded history scans. frames, loadErr := r.loadRealtimeRawFrameBatches(ctx, ordered) + capacities, capacityErr := r.loadRealtimeHydrogenCapacities(ctx, vins) + if capacityErr != nil { + log.Printf("openplatform realtime hydrogen capacity unavailable: %s", realtimeEnrichmentErrorReason(capacityErr)) + } for _, frame := range frames { ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)] if !exists { @@ -245,7 +259,7 @@ func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []strin if frame.EventMS.Valid && frame.EventMS.Int64 > 0 { at = time.UnixMilli(frame.EventMS.Int64) } - point.LiveHydrogen = realtimeHydrogenFromFrame(frame.Parsed.String, at, now) + point.LiveHydrogen = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacities[frame.VIN]) } if ref.Location { point.GPSFixStatus, point.CoordinateSystem = realtimeGPSFromFrame(frame.Protocol, frame.Parsed.String) @@ -260,7 +274,7 @@ func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []strin } } fallback, err := r.loadRealtimeRawFrameBatchesWithQuery(ctx, missing, realtimeHydrogenFallbackQuery, 1) - applyRealtimeHydrogenFallback(points, missing, fallback, now) + applyRealtimeHydrogenFallback(points, missing, fallback, now, capacities) if err != nil { return err } @@ -270,7 +284,7 @@ func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []strin // Only explicitly missing hydrogen can be filled. In particular, the newest // invalid measurement must never be hidden by an older valid reading. -func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs []realtimeFrameReference, frames []realtimeRawFrame, now time.Time) { +func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs []realtimeFrameReference, frames []realtimeRawFrame, now time.Time, capacityMaps ...map[string]float64) { allowed := make(map[string]bool, len(refs)) for _, ref := range refs { allowed[ref.VIN] = true @@ -291,6 +305,9 @@ func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs at = time.UnixMilli(frame.EventMS.Int64) } data := realtimeHydrogenFromFrame(frame.Parsed.String, at, now) + if len(capacityMaps) > 0 { + data = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacityMaps[0][frame.VIN]) + } if data.HydrogenDataStatus == "MISSING" { continue } // LIKE is a prefilter, never a JSON parser. @@ -451,10 +468,10 @@ func realtimeHydrogenFallbackQuery(database string, refs []realtimeFrameReferenc vins = append(vins, quote(ref.VIN)) clauses = append(clauses, "(vin="+quote(ref.VIN)+" AND ts>="+strconv.FormatInt(start, 10)+" AND ts<="+strconv.FormatInt(end, 10)+")") } - for _, key := range hydrogenMassFields { + for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) { fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%")) } - return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 1`, nil + return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 5`, nil } var realtimeErrorQuotedText = regexp.MustCompile(`'[^']*'|"[^"]*"`) diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go index 78903aa0..0a15b1b7 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go @@ -33,7 +33,7 @@ func TestRealtimeHydrogenQualityAndIndependentNulls(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { got := realtimeHydrogenFromFrame(tc.parsed, now.Add(-tc.age), now) - if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenKgStatus != tc.kgStatus || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "UNSUPPORTED" { + if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenKgStatus != tc.kgStatus || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "MISSING" { t.Fatalf("%+v", got) } if (tc.kg == nil) != (got.RemainingHydrogenKg == nil) || tc.kg != nil && math.Abs(*got.RemainingHydrogenKg-*tc.kg) > 0.0001 { @@ -248,7 +248,7 @@ func TestRealtimeMissingHydrogenFallsBackToActualFieldSample(t *testing.T) { columns := []string{"vin", "protocol", "event_id", "event_time", "parsed_json"} tdmock.ExpectQuery("SELECT vin,protocol,event_id.*ts IN").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "new-location-only", now.UnixMilli(), `{"gb32960.vehicle.speed_kmh":20}`)) sampleAt := now.Add(-90 * time.Second) - tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE.*ORDER BY event_time DESC,ts DESC LIMIT 1").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "older-hydrogen", sampleAt.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`)) + tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE.*ORDER BY event_time DESC,ts DESC LIMIT 5").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "older-hydrogen", sampleAt.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`)) if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil { t.Fatal(err) } @@ -309,7 +309,7 @@ func TestRealtimeHydrogenFallbackQueryLimitsEachVINWindow(t *testing.T) { if err != nil { t.Fatal(err) } - for _, want := range []string{"protocol='GB32960'", "ts>=700000 AND ts<=1000000", "vin='VIN''1' AND ts>=700000 AND ts<=1000000", `parsed_json LIKE '%"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":%'`, "ORDER BY event_time DESC,ts DESC LIMIT 1"} { + for _, want := range []string{"protocol='GB32960'", "ts>=700000 AND ts<=1000000", "vin='VIN''1' AND ts>=700000 AND ts<=1000000", `parsed_json LIKE '%"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":%'`, "ORDER BY event_time DESC,ts DESC LIMIT 5"} { if !strings.Contains(query, want) { t.Fatalf("missing %s in %s", want, query) } @@ -326,7 +326,7 @@ func TestRealtimeHydrogenFallbackQueriesEachVINSeparately(t *testing.T) { now := time.Now() refs := []realtimeFrameReference{{VIN: "V1", ReceivedAt: now}, {VIN: "V2", ReceivedAt: now}, {VIN: "V3", ReceivedAt: now}} for _, ref := range refs { - mock.ExpectQuery("SELECT vin,protocol,event_id.*vin IN \\('" + ref.VIN + "'\\).*ORDER BY event_time DESC,ts DESC LIMIT 1").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(ref.VIN, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`)) + mock.ExpectQuery("SELECT vin,protocol,event_id.*vin IN \\('" + ref.VIN + "'\\).*ORDER BY event_time DESC,ts DESC LIMIT 5").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(ref.VIN, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`)) } repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"} frames, err := repository.loadRealtimeRawFrameBatchesWithQuery(context.Background(), refs, realtimeHydrogenFallbackQuery, 1) diff --git a/vehicle-data-platform/docs/hydrogen-estimate-release-20260908.md b/vehicle-data-platform/docs/hydrogen-estimate-release-20260908.md new file mode 100644 index 00000000..d2aab219 --- /dev/null +++ b/vehicle-data-platform/docs/hydrogen-estimate-release-20260908.md @@ -0,0 +1,35 @@ +# 实时氢气余量估算及百分比上线验收 + +## 发布结果 + +- 版本:`open-platform-hydrogen-estimate-202609082311`;北京时间 2026-09-08 23:10:51 确认就绪。 +- 服务:`lingniu-vehicle-open-platform`,端口20310;OpenAPI版本1.9.0。 +- 二进制 SHA-256:`ad9022068a4aaf2cdfac6c2b5ccb5a6aee7b63980e13fcd1af3548a54d74f179`,与最终候选验收文件一致。 +- 上一版本 `open-platform-live-fields-202609082146` 保留。没有数据库迁移、容积数据改写或日用氢量算法修改;原门户静态资源沿用。 + +## 计算与来源 + +用户确认满充压力35 MPa,本次固定15°C作为满充参考温度。参考依据及字段完整定义见 `oneos-vehicle-live-api-contract.md`。 + +1. kg优先使用合法终端上报;字段缺失时,以同一原始帧氢气压力、温度及该VIN已启用的全车水容积估算。无默认温度、默认容积,不把SOC当氢量。显式无效上报不会被估算掩盖。 +2. 百分比使用同一条剩余质量除以 `PressureHydrogenMassKg(35,15,volumeL)`,再乘100。使用未舍入质量与分母计算,输出kg及比例保留3位小数。 +3. 满充模型质量每1000升为23.99502872475178 kg。生产配置只读核对有1144台有效容积,范围380–2460升,最近同步时间2026-09-08 18:39:08.308+08:00;不把有容积等同于有有效遥测。 +4. kg来源 `REPORTED` / `ESTIMATED`,百分比来源 `ESTIMATED`;返回满充kg、容积、35 MPa、15°C、算法版本、输入温压及异常原因。 +5. 最大氢压/最大氢温代表全车状态属于模型近似,不是逐瓶计量;本次35MPa参数不是每辆车铭牌额定压力的自动核验。 +6. 真零有效;缺数据或容量返回null及原因;超过100%时百分比null/INVALID并保留独立kg,不能悄悄截为100。陈旧记录保留原采集时间及STALE。 +7. 仅缺失时在原始帧接收时间前5分钟内检查每VIN最近5个候选;不完整温压可跳过,显式异常不能跳过。查询仍最多4并发、共享3秒预算,容量查询故障保留原上报kg。 + +## 验证 + +- API模块 `go test ./...`、开放平台 `go test -race ./internal/openplatform` 和 `go vet ./internal/openplatform` 通过。 +- 文档专项测试、YAML解析及required字段校验、发布脚本自测、diff-check通过。 +- 单元及SQL测试覆盖满充100%、零压力与真实零、缺容积、缺温度、NaN/null、超额定满充、未舍入分母、STALE、容量查询故障、回补不跨帧拼接、不完整候选跳过及无效候选阻挡。 +- 在生产机器127.0.0.1:20311启动候选进程读取真实数据,以临时测试凭证调用正式三个HTTP接口。所有测试凭证与临时授权均在测试后删除,既有合作方密钥和授权未修改。 +- 初轮四批各100台:78–80台kg及百分比有值,包含10–11台ESTIMATED kg及真实零;接口119–133毫秒。文档收尾后的最终二进制复验四轮:84–91台kg及百分比有值,后两轮分别46/58台ESTIMATED kg;实时221–345毫秒。每轮重新抽取活跃授权样本,不能视作同一车辆群组的趋势。 +- 最终候选同时验证当日及前一日里程/用氢量,旧接口SUCCESS、VIN及白名单校验、401未认证、403未授权名单均通过;无enrichment错误日志。 +- 正式版本三轮各100台:kg和百分比非空76/76/74台(包含STALE),每轮1个真实零;其余24/24/26台UNSUPPORTED,无MISSING/INVALID。该组三轮kg来源为REPORTED,百分比为ESTIMATED;压力温度估算kg路径由上述同二进制候选真实样本验证。 +- 每条有效百分比都校验质量/满充kg的比值、35MPa和15°C参数、VIN容积与满充分母关系及ESTIMATED来源;允许显示数值舍入误差。 +- 正式耗时:实时149/128/124毫秒;里程15/15/15毫秒;用氢6/6/5毫秒。响应数量、VIN、真零、NO_FIX空坐标、鉴权及越权拒绝验证通过。 +- 公网 `/healthz`、`/open-api/openapi.yaml`、`/open-api/docs/` 均200且版本/新增字段匹配,systemd active。 + +脱敏响应、容量统计、发布SHA及脚本证据保存在工作区 `outputs/hydrogen-estimate-release-20260908/`。旧版发布记录作为历史保留;其“百分比尚不支持”限制已由本次按条件估算实现取代。Seeker仍需映射接口字段并展示来源及质量,接口发布不会自动修改客户端页面。 diff --git a/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md b/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md index 1d96be75..62884f60 100644 --- a/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md +++ b/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md @@ -1,6 +1,6 @@ # OneOS 氢能车辆实况接口交付契约 -契约修订日期:2026-09-08。对应 `/api/v1/vehicles/realtime/query`、`/mileage/query`、`/hydrogen-consumption/query` 的增量交付。实际发布版本、上线时间及脱敏联调证据由发布验收记录提供,本文不将开发完成等同于线上验收完成。 +契约版本:1.9.0;修订日期:2026-09-08。对应 `/api/v1/vehicles/realtime/query`、`/mileage/query`、`/hydrogen-consumption/query` 的增量交付。实际发布版本、上线时间及脱敏联调证据由发布验收记录提供,本文不将开发完成等同于线上验收完成。 ## 兼容性与授权 @@ -12,26 +12,58 @@ | 字段 | 类型/单位 | 本次口径 | | --- | --- | --- | -| remainingHydrogenKg | number/null,kg | GB32960 广东燃料电池扩展 0x34 终端全车氢质量直接上报;保留真实零,不汇总单瓶、不由压力推算 | -| remainingHydrogenPercent | number/null,% | 当前缺少可信质量容量分母,固定 null,字段级 UNSUPPORTED;不使用 SOC 或当日消耗替代 | +| remainingHydrogenKg | number/null,kg | 优先 GB32960 广东扩展 0x34 终端上报;缺质量时同帧最大氢压/氢温与 VIN 全车水容积作实气估算;保留真实零 | +| remainingHydrogenPercent | number/null,% | 剩余质量 / 35 MPa、15°C 满充模型质量 × 100;缺容量 null/MISSING,超过100 null/INVALID,不以SOC替代 | | hydrogenRecordTime | RFC 3339/null | 精确匹配 raw_frames 原始同帧采集时间,非合并快照更新时间 | -| hydrogenDataStatus | enum | NORMAL/PARTIAL/STALE/MISSING/UNSUPPORTED/INVALID;当前 kg 有效但百分比不支持为 PARTIAL | +| hydrogenDataStatus | enum | NORMAL/PARTIAL/STALE/MISSING/UNSUPPORTED/INVALID;两项有效为 NORMAL(估算由来源区别);仅一项有效为 PARTIAL | | remainingHydrogenKgStatus / remainingHydrogenPercentStatus | enum | 分别为 NORMAL/STALE/MISSING/UNSUPPORTED/INVALID,客户端独立判断 | -| hydrogenValueSource / hydrogenSourceProtocol | string/null | REPORTED 仅表示终端上报,无法确认终端内部采用测量还是估算;储氢协议独立于位置 protocol | +| hydrogenValueSource / hydrogenSourceProtocol | string/null | kg 来源 REPORTED 仅确认终端上报,ESTIMATED 为平台模型估算;储氢协议独立于位置 protocol | | hydrogenStaleAfterSeconds | integer/null,秒 | GB32960 服务陈旧阈值 300;并非协议规定更新频率 | | hydrogenExpectedIntervalSeconds | integer/null,秒 | 各协议未确认上报周期,当前为 null | | gpsFixStatus | FIXED/NO_FIX/UNKNOWN | 来源为实际位置报文定位位,不根据在线、运动或记录年龄推断 | | locationRecordTime | RFC 3339/null | 位置实际采集时间,可与主记录 recordTime 不同 | | coordinateSystem | WGS84/GCJ02/UNKNOWN | 仅 GB2025 显式坐标类型 1/2 对应 WGS84/GCJ02;GB2016/JT808/MQTT 暂无确证为 UNKNOWN | -平台接受 0–200 kg,超出范围、非有限值或采集时间超过请求时刻 1 分钟标为 INVALID 并返回 null。超过 300 秒的有效质量保留数值并标 STALE,客户端需提示陈旧;PARTIAL 仍需逐字段判断。聚合 NORMAL 保留供未来两字段均有效,目前不会输出。原始帧未找到返回 MISSING;补充历史查询共享 3 秒预算,查询异常或超时降级为 MISSING/UNKNOWN 并保留旧实时字段,不以合并快照伪造采集时间。因此 MISSING 既可能暂无记录,也可能本次未取得可信证据,不能据此断言设备不支持。 +终端上报接受 0–200 kg,平台估算接受 0–500 kg;非有限值、超出范围或采集时间超过请求时刻 1 分钟标 INVALID/null。估算压力需为0–70 MPa、温度为−40–726.85°C(沿用模型输入域,不是车辆安全阈值),且必须来自同一原始帧;压力0有效,缺温度不填默认值。超过 300 秒的有效质量保留数值并标 STALE,客户端需提示陈旧;PARTIAL 仍需逐字段判断。两字段有效时聚合为 NORMAL;估算与否查看来源。百分比超过100拒绝该百分比,kg仍独立可用,聚合 PARTIAL(陈旧时 STALE);EXCEEDS_NOMINAL_FULL_CAPACITY 表示超出本次模型参考,不是车辆安全判定。原始帧未找到返回 MISSING;补充历史查询共享 3 秒预算,查询异常或超时降级为 MISSING/UNKNOWN 并保留旧实时字段,不以合并快照伪造采集时间。因此 MISSING 既可能暂无记录,也可能本次未取得可信证据,不能据此断言设备不支持。 -有界回补仅针对具有 GB 最新快照原始帧引用、且精确帧氢量为 MISSING 的车辆:查询该快照 `received_at` 向前 5 分钟内含氢量键的原始帧,按 `event_time DESC, ts DESC` 取最新候选。窗口以快照接收时间为基准,不是 API 当前时间;离线车辆可返回真实的 STALE。显式 null 或异常候选不被过滤成旧正常值;已有 INVALID/PARTIAL/STALE 不回补,不能掩盖最新异常。回补所得氢量及 hydrogenRecordTime 始终来自同一原始帧,仍共享 3 秒预算;超时或无记录维持 MISSING。该策略不会扫描全部历史,也不承诺找到窗口外最近一条氢量。 +有界回补仅针对具有 GB 最新快照原始帧引用、且精确帧氢量为 MISSING 的车辆:查询该快照 `received_at` 向前 5 分钟内含氢量、氢压或氢温键的原始帧,按 `event_time DESC, ts DESC` 检查最近5条候选;最新仅有不完整温压时可取次新完整原始帧,最新显式异常仍阻止回补。窗口以快照接收时间为基准,不是 API 当前时间;离线车辆可返回真实的 STALE。显式 null 或异常候选不被过滤成旧正常值;已有 NORMAL/INVALID/PARTIAL/STALE 不回补,不能掩盖最新异常。回补所得氢量及 hydrogenRecordTime 始终来自同一原始帧,仍共享 3 秒预算;超时或无记录维持 MISSING。该策略不会扫描全部历史,也不承诺找到窗口外最近一条氢量。 -储氢覆盖以收到扩展字段的 GB32960 车辆为限,不代表所有 GB32960 车型支持;MQTT/JT808 当前不支持储氢。平台不提供凭容量未知的百分比换算,因而本次不保证 Seeker 所有车辆两列均有值。多瓶完整性由终端全车上报负责,接口没有逐瓶完整性检测能力。 +储氢覆盖 GB32960 终端质量上报,以及具备有效同帧压力/温度和已配置 VIN 全车容积的车辆;MQTT/JT808 当前不支持储氢。GB32960 或尚无协议记录的车辆,完全没有储氢字段时两字段状态为 MISSING;有容量但无读数原因 MISSING_HYDROGEN_MEASUREMENT。收到压力/温度或终端质量但缺容积时比例 MISSING。没有容积时可保留终端上报 kg,百分比 null/MISSING;没有终端质量时无法估算 kg,两个数值均可为空。最大氢压与最大氢温不保证来自同一瓶,以全车容积计算属于近似估算,不是逐瓶质量求和,也没有逐瓶完整性检测能力。 定位使用位置行的 event_id 对应原始报文:GB 定位状态 bit0=0 表示 FIXED;JT808 bit1=1 表示 FIXED;缺少可信定位位为 UNKNOWN。NO_FIX 时位置不可用、经纬度 null。历史 FIXED 可与 offline 同时存在;显示历史位置需提示位置时间,UNKNOWN 坐标系不得擅自作为 GCJ02 上图。现有 `protocol` 是实时唯一协议字段,枚举 GB32960/MQTT/JT808;`sourceProtocol` 属于里程接口。`online=false` 时 motionStatus=offline;在线且所选速度>3 km/h 为 driving,否则 idle。 +## 实时估算追溯字段 + +以下新增字段始终序列化,无法生成时为 null。`hydrogenValueSource` 描述 kg 来源,`remainingHydrogenPercentSource` 独立描述比例来源;即使 kg 是 REPORTED,比例也是 ESTIMATED。质量 NORMAL 只表示通过当前规则,不代表直接测量。 + +| 字段 | 口径 | +| --- | --- | +| remainingHydrogenPercentSource | ESTIMATED;缺计算条件为 null | +| hydrogenFullCapacityKg | 未舍入的实际满充质量分母,kg | +| hydrogenTankCapacityL | 已启用 VIN 配置全车水容积,L,0<V≤10000,无默认车型值 | +| hydrogenFullPressureMPa / hydrogenReferenceTemperatureC | 35 MPa / 15°C | +| hydrogenEstimatePressureMPa / hydrogenEstimateTemperatureC | 仅平台估算 kg 时采用的同帧原始压力/温度 | +| hydrogenPressureTemperatureSource | MAX_SENSOR_AGGREGATE:最大传感值聚合,不保证同瓶 | +| hydrogenCalculationVersion | REAL_GAS_35MPA_15C_V1 | +| hydrogenCapacitySource | vehicle_hydrogen_tank_capacity | +| hydrogenPercentReason | MISSING_HYDROGEN_MEASUREMENT、INVALID_MASS_READING、INVALID_PRESSURE_TEMPERATURE、INCOMPLETE_PRESSURE_TEMPERATURE、MISSING_TANK_CAPACITY、MASS_CALCULATION_FAILED 或 EXCEEDS_NOMINAL_FULL_CAPACITY;无原因时 null | + +显式无效质量不改用压力估算掩盖异常;有效终端上报优先。百分比使用舍入前的质量与满充分母计算,输出质量及比例保留3位小数,因此显示值复算可能出现舍入差异。两个字段独立可空,不将超过100的百分比悄悄截断为100,不用质量状态NORMAL掩盖比例异常。 + +## 实时估算参考条件 + +本次按业务确认的 **35 MPa、15°C** 作为满充参考。UNECE 文件对 NWP 的定义采用 15°C 均温满充后稳定压力,并用当前氢密度与 NWP、15°C 参考密度的比值定义储氢 SOC。35 MPa 是本次明确选用的业务参数,不表示接口自动验证了每辆车的铭牌额定压力;这也不是动力电池 SOC。[定义来源:ECE/TRANS/WP.29/2023/110](https://unece.org/sites/default/files/2024-07/ECE_TRANS_WP.29_2023_110E.pdf) + +估算复用 `PressureHydrogenMassKg` 的现有 NIST 实气模型与 VIN 储氢水容积: + +```text +满充质量 = PressureHydrogenMassKg(35 MPa, 15°C, VIN 储氢容积 L) +估算剩余质量 = PressureHydrogenMassKg(同帧氢压 MPa, 同帧氢温 °C, VIN 储氢容积 L) +储氢百分比 = 剩余质量 / 满充质量 × 100 +``` + +分母是上述参考条件下的全车氢质量,不是扣除不可用余量后的可用容量,也不是压力除以 35 MPa。模型不代表传感器实测或法规认证;逐瓶缺失及温压分布无法仅由总容积和单组遥测识别。接口保留估算来源与参数供追溯。此变化仅作用于实时储氢展示,不修改当日用氢量计算。 + ## 日统计身份与时间 两个接口均以 Asia/Shanghai 自然日请求,新增时间为 RFC 3339 带时区,不改变原 dataTime / updatedAt 的含义。