feat: 按35MPa满充模型提供剩余氢量估算与百分比
This commit is contained in:
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: 实际用于比例分母的未舍入满充质量,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
|
||||
|
||||
@@ -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) }
|
||||
+148
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user