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
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: 实际用于比例分母的未舍入满充质量,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:
type: string
nullable: true
@@ -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)
}
@@ -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
// 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(`'[^']*'|"[^"]*"`)
@@ -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)