feat: 按35MPa满充模型提供剩余氢量估算与百分比

This commit is contained in:
lingniu
2026-09-08 23:12:57 +08:00
parent a6e31effc2
commit 4ab654e1c1
9 changed files with 497 additions and 48 deletions
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: 车辆数据开放平台 API title: 车辆数据开放平台 API
version: 1.8.0 version: 1.9.0
license: license:
name: Proprietary name: Proprietary
description: | description: |
@@ -211,8 +211,9 @@ paths:
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。 socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。 activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。 离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。
储氢使用可追溯原始帧;最新帧缺少氢量时可有界回补,当前百分比无可信容量分母为 null/UNSUPPORTED;不得用 SOC 替代 满充容量采用 PressureHydrogenMassKg(35,15,VIN水容积)35 MPa 是本次业务基准,15°C 密度比参考来自 https://unece.org/sites/default/files/2024-07/ECE_TRANS_WP.29_2023_110E.pdf,不代表车型额定压力自动认证
仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查该快照 received_at 向前 5 分钟的含氢量字段原始帧,按采集时间优先、接收时间次序取最新;不回退覆盖 INVALID,离线回补保留真实 STALE 储氢优先终端上报质量 REPORTED;无质量时以同帧最大氢压/最大氢温与 VIN 全车水容积估算 ESTIMATED。百分比按 35 MPa、15°C 满充参考质量计算,不以 SOC 替代
仅 GB 最新快照有引用且精确帧氢量 MISSING 时有界回补,按采集时间优先、接收时间次序检查最近5条候选;不完整温压可取次新完整帧,不回退覆盖 INVALID,离线回补保留真实 STALE。
GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。 GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。
补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。 补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。
operationId: queryRealtimeVehicles operationId: queryRealtimeVehicles
@@ -878,7 +879,7 @@ components:
$ref: '#/components/schemas/StationaryVehicleResult' $ref: '#/components/schemas/StationaryVehicleResult'
RealtimeVehicleResult: RealtimeVehicleResult:
type: object 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: properties:
vin: { type: string } vin: { type: string }
plateNumber: { type: string } plateNumber: { type: string }
@@ -886,14 +887,14 @@ components:
type: number type: number
nullable: true nullable: true
minimum: 0 minimum: 0
maximum: 200 maximum: 500
description: GB32960 广东燃料电池扩展 0x34 全车上报氢质量,kg;真实零保留。无值、不支持或无效为 null陈旧值保留并标 STALE,不以单瓶压力或当日消耗推算 description: 全车氢质量 kg;优先 GB32960 广东扩展终端上报(0–200 kg),缺质量时以同帧最大氢压/氢温和 VIN 全车水容积作实气估算(0–500 kg)。真实零保留,缺失/异常为 null陈旧值保留并标 STALE
remainingHydrogenPercent: remainingHydrogenPercent:
type: number type: number
nullable: true nullable: true
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: 当前无可信额定或可用质量容量分母,固定 null,字段状态 UNSUPPORTED;不以 SOC 替代 description: 剩余质量除以 VIN 水容积在 35 MPa、15°C 的实气模型满充质量乘100,属于 ESTIMATED;缺容量为 null/MISSING。超过100返回 null/INVALID,不截断为100,kg独立保留;不以动力电池SOC替代
hydrogenRecordTime: hydrogenRecordTime:
type: string type: string
format: date-time format: date-time
@@ -902,19 +903,70 @@ components:
hydrogenDataStatus: hydrogenDataStatus:
type: string type: string
enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID] enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID]
description: NORMAL 保留供未来全部字段有效;当前不会输出 NORMAL;PARTIAL 部分支持(当前质量有效但百分比不支持)STALE 数据陈旧;MISSING 无记录UNSUPPORTED 不支持;INVALID 异常。分别检查字段级状态 description: NORMAL 两项有效(可包含估算,见来源);PARTIAL 部分字段有效STALE 数据陈旧;MISSING 缺少数据或容量UNSUPPORTED 不支持;INVALID 异常。数值和字段级状态独立判断
remainingHydrogenKgStatus: remainingHydrogenKgStatus:
type: string type: string
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
remainingHydrogenPercentStatus: remainingHydrogenPercentStatus:
type: string type: string
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
description: 当前固定 UNSUPPORTED description: 独立于 kg 状态;容量缺失 MISSING,比例超100 INVALID;有值时继承 kg 的 NORMAL 或 STALE
hydrogenValueSource: hydrogenValueSource:
type: string type: string
nullable: true nullable: true
enum: [REPORTED] enum: [REPORTED, ESTIMATED]
description: REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;本接口不作压力推 description: kg 来源;REPORTED 仅确认终端上报,无法判断终端内部测量或估算;ESTIMATED 为平台压力/温度/容积实气模型估
remainingHydrogenPercentSource:
type: string
nullable: true
enum: [ESTIMATED]
description: 百分比始终为模型满充容量推算;与 kg 来源独立,计算前缺条件时为 null
hydrogenFullCapacityKg:
type: number
nullable: true
description: 实际用于比例分母的未舍入满充质量,kgPressureHydrogenMassKg(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: hydrogenSourceProtocol:
type: string type: string
nullable: true nullable: true
@@ -90,6 +90,10 @@ func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T)
"hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime", "hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime",
"coordinateSystem", "statisticsStartTime", "statisticsEndTime", "coordinateSystem", "statisticsStartTime", "statisticsEndTime",
"updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion", "updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion",
"remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL",
"hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa",
"hydrogenEstimateTemperatureC", "hydrogenPressureTemperatureSource", "hydrogenCalculationVersion",
"hydrogenCapacitySource", "hydrogenPercentReason",
} { } {
if !strings.Contains(string(openAPISpec), field+":") { if !strings.Contains(string(openAPISpec), field+":") {
t.Errorf("OpenAPI missing live contract field %s", 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 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) { if !strings.Contains(doc, boundary) {
t.Errorf("%s missing availability/comparability boundary %q", name, boundary) t.Errorf("%s missing availability/comparability boundary %q", name, boundary)
} }
@@ -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) }
@@ -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)
}
}
@@ -15,21 +15,31 @@ import (
) )
// GB32960 live freshness is an API policy, not an assertion about the device's // GB32960 live freshness is an API policy, not an assertion about the device's
// negotiated reporting interval. No supported protocol currently has a verified // negotiated reporting interval.
// full-vehicle percentage/capacity contract.
const realtimeHydrogenStaleSeconds int64 = 300 const realtimeHydrogenStaleSeconds int64 = 300
type RealtimeHydrogenData struct { type RealtimeHydrogenData struct {
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"` HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"` RemainingHydrogenPercentSource *string `json:"remainingHydrogenPercentSource"`
HydrogenRecordTime *string `json:"hydrogenRecordTime"` HydrogenFullCapacityKg *float64 `json:"hydrogenFullCapacityKg"`
HydrogenDataStatus string `json:"hydrogenDataStatus"` HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"` HydrogenFullPressureMPa *float64 `json:"hydrogenFullPressureMPa"`
RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"` HydrogenReferenceTemperatureC *float64 `json:"hydrogenReferenceTemperatureC"`
HydrogenValueSource *string `json:"hydrogenValueSource"` HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"` HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"` HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"` 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 { func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData {
@@ -37,7 +47,7 @@ func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData {
if protocol == "" || protocol == "GB32960" { if protocol == "" || protocol == "GB32960" {
status = "MISSING" status = "MISSING"
} }
result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: "UNSUPPORTED"} result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: status}
if protocol == "GB32960" { if protocol == "GB32960" {
threshold := realtimeHydrogenStaleSeconds threshold := realtimeHydrogenStaleSeconds
result.HydrogenStaleAfterSeconds = &threshold 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 // At most two references per requested VIN. Batches bound SQL size; primary
// timestamp and VIN tag filters prevent unbounded history scans. // timestamp and VIN tag filters prevent unbounded history scans.
frames, loadErr := r.loadRealtimeRawFrameBatches(ctx, ordered) 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 { for _, frame := range frames {
ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)] ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)]
if !exists { if !exists {
@@ -245,7 +259,7 @@ func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []strin
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 { if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
at = time.UnixMilli(frame.EventMS.Int64) 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 { if ref.Location {
point.GPSFixStatus, point.CoordinateSystem = realtimeGPSFromFrame(frame.Protocol, frame.Parsed.String) 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) fallback, err := r.loadRealtimeRawFrameBatchesWithQuery(ctx, missing, realtimeHydrogenFallbackQuery, 1)
applyRealtimeHydrogenFallback(points, missing, fallback, now) applyRealtimeHydrogenFallback(points, missing, fallback, now, capacities)
if err != nil { if err != nil {
return err 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 // Only explicitly missing hydrogen can be filled. In particular, the newest
// invalid measurement must never be hidden by an older valid reading. // 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)) allowed := make(map[string]bool, len(refs))
for _, ref := range refs { for _, ref := range refs {
allowed[ref.VIN] = true allowed[ref.VIN] = true
@@ -291,6 +305,9 @@ func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs
at = time.UnixMilli(frame.EventMS.Int64) at = time.UnixMilli(frame.EventMS.Int64)
} }
data := realtimeHydrogenFromFrame(frame.Parsed.String, at, now) 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" { if data.HydrogenDataStatus == "MISSING" {
continue continue
} // LIKE is a prefilter, never a JSON parser. } // LIKE is a prefilter, never a JSON parser.
@@ -451,10 +468,10 @@ func realtimeHydrogenFallbackQuery(database string, refs []realtimeFrameReferenc
vins = append(vins, quote(ref.VIN)) 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)+")") 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+"\":%")) 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(`'[^']*'|"[^"]*"`) var realtimeErrorQuotedText = regexp.MustCompile(`'[^']*'|"[^"]*"`)
@@ -33,7 +33,7 @@ func TestRealtimeHydrogenQualityAndIndependentNulls(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
got := realtimeHydrogenFromFrame(tc.parsed, now.Add(-tc.age), now) 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) t.Fatalf("%+v", got)
} }
if (tc.kg == nil) != (got.RemainingHydrogenKg == nil) || tc.kg != nil && math.Abs(*got.RemainingHydrogenKg-*tc.kg) > 0.0001 { 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"} 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}`)) 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) 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 { if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -309,7 +309,7 @@ func TestRealtimeHydrogenFallbackQueryLimitsEachVINWindow(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) { if !strings.Contains(query, want) {
t.Fatalf("missing %s in %s", want, query) t.Fatalf("missing %s in %s", want, query)
} }
@@ -326,7 +326,7 @@ func TestRealtimeHydrogenFallbackQueriesEachVINSeparately(t *testing.T) {
now := time.Now() now := time.Now()
refs := []realtimeFrameReference{{VIN: "V1", ReceivedAt: now}, {VIN: "V2", ReceivedAt: now}, {VIN: "V3", ReceivedAt: now}} refs := []realtimeFrameReference{{VIN: "V1", ReceivedAt: now}, {VIN: "V2", ReceivedAt: now}, {VIN: "V3", ReceivedAt: now}}
for _, ref := range refs { 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"} repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
frames, err := repository.loadRealtimeRawFrameBatchesWithQuery(context.Background(), refs, realtimeHydrogenFallbackQuery, 1) frames, err := repository.loadRealtimeRawFrameBatchesWithQuery(context.Background(), refs, realtimeHydrogenFallbackQuery, 1)
@@ -0,0 +1,35 @@
# 实时氢气余量估算及百分比上线验收
## 发布结果
- 版本:`open-platform-hydrogen-estimate-202609082311`;北京时间 2026-09-08 23:10:51 确认就绪。
- 服务:`lingniu-vehicle-open-platform`,端口20310OpenAPI版本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及百分比有值,包含1011台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仍需映射接口字段并展示来源及质量,接口发布不会自动修改客户端页面。
@@ -1,6 +1,6 @@
# OneOS 氢能车辆实况接口交付契约 # 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/nullkg | GB32960 广东燃料电池扩展 0x34 终端全车氢质量直接上报;保留真实零,不汇总单瓶、不由压力推算 | | remainingHydrogenKg | number/nullkg | 优先 GB32960 广东扩展 0x34 终端上报;缺质量时同帧最大氢压/氢温与 VIN 全车水容积作实气估算;保留真实零 |
| remainingHydrogenPercent | number/null% | 当前缺少可信质量容量分母,固定 null,字段级 UNSUPPORTED;不使用 SOC 或当日消耗替代 | | remainingHydrogenPercent | number/null% | 剩余质量 / 35 MPa、15°C 满充模型质量 × 100;缺容量 null/MISSING,超过100 null/INVALID,不以SOC替代 |
| hydrogenRecordTime | RFC 3339/null | 精确匹配 raw_frames 原始同帧采集时间,非合并快照更新时间 | | 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,客户端独立判断 | | 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;并非协议规定更新频率 | | hydrogenStaleAfterSeconds | integer/null,秒 | GB32960 服务陈旧阈值 300;并非协议规定更新频率 |
| hydrogenExpectedIntervalSeconds | integer/null,秒 | 各协议未确认上报周期,当前为 null | | hydrogenExpectedIntervalSeconds | integer/null,秒 | 各协议未确认上报周期,当前为 null |
| gpsFixStatus | FIXED/NO_FIX/UNKNOWN | 来源为实际位置报文定位位,不根据在线、运动或记录年龄推断 | | gpsFixStatus | FIXED/NO_FIX/UNKNOWN | 来源为实际位置报文定位位,不根据在线、运动或记录年龄推断 |
| locationRecordTime | RFC 3339/null | 位置实际采集时间,可与主记录 recordTime 不同 | | locationRecordTime | RFC 3339/null | 位置实际采集时间,可与主记录 recordTime 不同 |
| coordinateSystem | WGS84/GCJ02/UNKNOWN | 仅 GB2025 显式坐标类型 1/2 对应 WGS84/GCJ02GB2016/JT808/MQTT 暂无确证为 UNKNOWN | | coordinateSystem | WGS84/GCJ02/UNKNOWN | 仅 GB2025 显式坐标类型 1/2 对应 WGS84/GCJ02GB2016/JT808/MQTT 暂无确证为 UNKNOWN |
平台接受 0200 kg,超出范围、非有限值或采集时间超过请求时刻 1 分钟标 INVALID 并返回 null。超过 300 秒的有效质量保留数值并标 STALE,客户端需提示陈旧;PARTIAL 仍需逐字段判断。聚合 NORMAL 保留供未来两字段均有效,目前不会输出。原始帧未找到返回 MISSING;补充历史查询共享 3 秒预算,查询异常或超时降级为 MISSING/UNKNOWN 并保留旧实时字段,不以合并快照伪造采集时间。因此 MISSING 既可能暂无记录,也可能本次未取得可信证据,不能据此断言设备不支持。 终端上报接受 0200 kg平台估算接受 0–500 kg;非有限值、超出范围或采集时间超过请求时刻 1 分钟标 INVALID/null。估算压力需为070 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 表示 FIXEDJT808 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。 定位使用位置行的 event_id 对应原始报文:GB 定位状态 bit0=0 表示 FIXEDJT808 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 的含义。 两个接口均以 Asia/Shanghai 自然日请求,新增时间为 RFC 3339 带时区,不改变原 dataTime / updatedAt 的含义。