From c6875fbb9863b6238aa80c3c11b5819cb0d17e49 Mon Sep 17 00:00:00 2001 From: lingniu Date: Wed, 2 Sep 2026 14:05:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=B9=B3=E5=8F=B0=E6=B0=A2=E8=80=97=E6=BA=AF?= =?UTF-8?q?=E6=BA=90=E4=B8=8E=E5=90=88=E4=BD=9C=E7=AB=99=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/api/cmd/open-platform-stat/main.go | 196 +++- .../internal/openplatform/assets/docs.html | 367 +++---- .../internal/openplatform/assets/openapi.yaml | 165 ++- .../apps/api/internal/openplatform/docs.go | 2 +- .../api/internal/openplatform/docs_test.go | 5 + .../apps/api/internal/openplatform/handler.go | 41 +- .../openplatform/hydrogen_calculator.go | 616 +++++++++++ .../openplatform/hydrogen_calculator_test.go | 953 ++++++++++++++++++ .../openplatform/hydrogen_calculator_v3.go | 759 ++++++++++++++ .../apps/api/internal/openplatform/model.go | 212 +++- .../apps/api/internal/openplatform/mysql.go | 185 +++- .../api/internal/openplatform/mysql_test.go | 88 +- .../apps/api/internal/openplatform/service.go | 260 ++++- .../api/internal/openplatform/service_test.go | 160 ++- .../internal/openplatform/sinopec_roster.go | 84 ++ .../api/internal/openplatform/statistics.go | 821 +++++++++++++-- .../internal/openplatform/statistics_test.go | 379 ++++++- .../internal/platform/gb32960_reference.go | 2 +- .../apps/api/internal/platform/handler.go | 6 + .../platform/hydrogen_evidence_test.go | 40 + .../apps/api/internal/platform/model.go | 73 ++ .../api/internal/platform/mysql_queries.go | 21 +- .../api/internal/platform/production_store.go | 162 ++- .../platform/production_store_test.go | 59 +- .../internal/platform/query_builders_test.go | 8 +- .../apps/api/internal/platform/service.go | 189 +++- .../api/internal/platform/service_test.go | 110 +- .../apps/open-portal/src/Documentation.tsx | 23 +- .../apps/open-portal/src/catalog.ts | 21 + vehicle-data-platform/docs/deployment.md | 9 +- .../docs/hydrogen-calculation-v2.md | 51 + .../docs/open-platform-api.md | 36 +- .../scripts/replay-all-hydrogen-history.sh | 64 ++ 33 files changed, 5692 insertions(+), 475 deletions(-) create mode 100644 vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator.go create mode 100644 vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_test.go create mode 100644 vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_v3.go create mode 100644 vehicle-data-platform/apps/api/internal/openplatform/sinopec_roster.go create mode 100644 vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go create mode 100644 vehicle-data-platform/docs/hydrogen-calculation-v2.md create mode 100755 vehicle-data-platform/scripts/replay-all-hydrogen-history.sh diff --git a/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go index 22a29bbd..f0d42dfb 100644 --- a/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go +++ b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go @@ -7,7 +7,10 @@ import ( "fmt" "log" "os" + "sort" "strconv" + "strings" + "sync" "time" _ "github.com/go-sql-driver/mysql" @@ -22,6 +25,11 @@ func main() { lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date") noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter") maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples") + vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild") + vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day") + allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin") + dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL") + seedStream := flag.Bool("seed-stream-state", false, "atomically seed current-day segment stream watermarks during a controlled writer handoff") flag.Parse() cfg := config.Load() @@ -50,24 +58,190 @@ func main() { if *lookback < 1 || *lookback > 31 { log.Fatal("-lookback-days must be between 1 and 31") } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + if *vinWorkers < 1 || *vinWorkers > 16 { + log.Fatal("-vin-workers must be between 1 and 16") + } + normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin)) + if *seedStream && normalizedVIN != "" { + log.Fatal("-seed-stream-state requires an all-vehicle rebuild") + } + if *seedStream && *dryRun { + log.Fatal("-seed-stream-state cannot be combined with -dry-run") + } + tdengineDB.SetMaxOpenConns(*vinWorkers) + timeout := 30 * time.Minute + if *allDates { + timeout = 6 * time.Hour + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB) if err != nil { log.Fatalf("load hydrogen tank capacities: %v", err) } - for offset := *lookback - 1; offset >= 0; offset-- { - start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset) - observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities) - if err != nil { - log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err) + if normalizedVIN != "" { + if _, ok := capacities[normalizedVIN]; !ok { + log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN) } - stats := openplatform.BuildHydrogenDailyStats(observations, start.Format("2006-01-02"), *noise, *maxDrop) - if err := openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, start.Format("2006-01-02"), stats); err != nil { - log.Fatalf("persist hydrogen statistics for %s: %v", start.Format("2006-01-02"), err) - } - fmt.Printf("date=%s observations=%d vehicles=%d\n", start.Format("2006-01-02"), len(observations), len(stats)) } + startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1)) + if *allDates { + var first, last time.Time + var found bool + var rangeErr error + if normalizedVIN == "" { + first, last, found, rangeErr = openplatform.HydrogenObservationDateRangeForAll(ctx, tdengineDB, cfg.TDengineDatabase) + } else { + first, last, found, rangeErr = openplatform.HydrogenObservationDateRange(ctx, tdengineDB, cfg.TDengineDatabase, normalizedVIN) + } + if rangeErr != nil { + log.Fatalf("load hydrogen date range: %v", rangeErr) + } + if !found { + log.Fatal("no GB32960 history found") + } + first = first.In(location) + last = last.In(location) + startDate = time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, location) + endDate = time.Date(last.Year(), last.Month(), last.Day(), 0, 0, 0, 0, location) + now := time.Now().In(location) + lastCompletedDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -1) + if endDate.After(lastCompletedDate) { + endDate = lastCompletedDate + } + scope := "all-vehicles" + if normalizedVIN != "" { + scope = normalizedVIN + } + fmt.Printf("scope=%s date_from=%s date_to=%s mode=all-dates\n", scope, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + } + for start := startDate; !start.After(endDate); start = start.AddDate(0, 0, 1) { + date := start.Format("2006-01-02") + energyParameters, parameterErr := openplatform.LoadHydrogenCalculationParameters(ctx, mysqlDB, start) + if parameterErr != nil { + log.Fatalf("load hydrogen energy parameters for %s: %v", date, parameterErr) + } + var stats []openplatform.HydrogenDailyStat + observationCount := 0 + if normalizedVIN == "" { + var vins []string + vins, err = openplatform.LoadHydrogenObservationVINs(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities) + if err == nil { + stats, observationCount, err = buildHydrogenDailyStatsByVIN(ctx, tdengineDB, cfg.TDengineDatabase, capacities, energyParameters, vins, start, *noise, *maxDrop, *vinWorkers) + } + } else { + var observations []openplatform.HydrogenObservation + observations, err = openplatform.LoadHydrogenObservationsForVIN(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities, normalizedVIN) + observationCount = len(observations) + stats = openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, date, *noise, *maxDrop, energyParameters) + } + if err != nil { + log.Fatalf("load hydrogen observations for %s: %v", date, err) + } + if !*dryRun { + if *seedStream { + err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats) + } else if normalizedVIN == "" { + err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats) + } else { + err = openplatform.ReplaceHydrogenDailyStatsForVIN(ctx, mysqlDB, date, normalizedVIN, stats) + } + if err != nil { + log.Fatalf("persist hydrogen statistics for %s: %v", date, err) + } + } + mode := "write" + if *dryRun { + mode = "dry-run" + } + if normalizedVIN != "" && len(stats) == 1 { + fmt.Printf("date=%s vin=%s observations=%d consumption_kg=%.3f refuels=%d quality=%s mode=%s\n", date, normalizedVIN, observationCount, stats[0].ConsumptionKg, stats[0].RefuelCount, stats[0].QualityStatus, mode) + } else { + fmt.Printf("date=%s observations=%d vehicles=%d mode=%s\n", date, observationCount, len(stats), mode) + } + } +} + +type hydrogenVINResult struct { + VIN string + ObservationCount int + Stats []openplatform.HydrogenDailyStat + Err error +} + +func buildHydrogenDailyStatsByVIN( + ctx context.Context, + tdengineDB *sql.DB, + database string, + capacities map[string]float64, + energyParameters map[string]openplatform.HydrogenCalculationParameters, + vins []string, + start time.Time, + noiseKg float64, + maxDropKg float64, + workerCount int, +) ([]openplatform.HydrogenDailyStat, int, error) { + if len(vins) == 0 { + return nil, 0, nil + } + if workerCount > len(vins) { + workerCount = len(vins) + } + workerCtx, cancel := context.WithCancel(ctx) + defer cancel() + jobs := make(chan string) + results := make(chan hydrogenVINResult, len(vins)) + var workers sync.WaitGroup + for worker := 0; worker < workerCount; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for vin := range jobs { + observations, err := openplatform.LoadHydrogenObservationsForVIN(workerCtx, tdengineDB, database, start, start.AddDate(0, 0, 1), capacities, vin) + if err != nil { + results <- hydrogenVINResult{VIN: vin, Err: err} + continue + } + results <- hydrogenVINResult{ + VIN: vin, ObservationCount: len(observations), + Stats: openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, start.Format("2006-01-02"), noiseKg, maxDropKg, energyParameters), + } + } + }() + } + go func() { + defer close(jobs) + for _, vin := range vins { + select { + case jobs <- vin: + case <-workerCtx.Done(): + return + } + } + }() + go func() { + workers.Wait() + close(results) + }() + stats := make([]openplatform.HydrogenDailyStat, 0, len(vins)) + observationCount := 0 + var firstErr error + for result := range results { + if result.Err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("VIN %s: %w", result.VIN, result.Err) + cancel() + } + continue + } + observationCount += result.ObservationCount + stats = append(stats, result.Stats...) + } + if firstErr != nil { + return nil, 0, firstErr + } + sort.Slice(stats, func(i, j int) bool { return stats[i].VIN < stats[j].VIN }) + return stats, observationCount, nil } func envInt(name string, fallback int) int { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html index 5a15285c..b451d30d 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html @@ -3,197 +3,212 @@ - 车辆数据开放平台 + 羚牛 · 车辆数据开放平台 -
-
-

车辆数据开放平台

-

面向合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。接口使用独立的 32 位 appKey 认证。

- -
+
+ +
+
+

接口文档

+

面向合作方开放车辆用氢量、里程、实时状态及加氢车辆停留核验数据。每个接口独立说明请求参数、成功返回与异常情况。

+ Base URL https://open.d.lnoneos.com +
-

认证

-
Authorization: Bearer <32位appKey>
-Content-Type: application/json
-

appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。

+

认证与授权

所有接口均使用平台管理员签发的 32 位 appKey。appKey 有效期及车辆授权期必须完整覆盖查询时间。

#
请求头Authorization: Bearer <YOUR_APP_KEY>
Content-Type: application/json
授权边界:不传车牌时,仅返回该 appKey 在查询日期或时间段内有效授权的全部车辆;显式传入的车牌未授权时,接口返回 403。所有接口响应均以两空格缩进的 JSON 返回,便于直接阅读和留存。
-

开放接口

-

POST/api/v1/vehicles/hydrogen-consumption/query

-

查询指定车辆的单日用氢量,单位 kg。

-

POST/api/v1/vehicles/mileage/query

-

查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。

-

以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。

-

POST/api/v1/vehicles/mileage/range/query

-

按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。

-

两个里程接口均可传 protocolPriority,唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议;未列出的协议完全禁用。省略字段时保持现有默认选源行为。

-

查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。

-

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

-

按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。

-

POST/api/v1/vehicles/realtime/query

-

查询当前有效授权车辆的最新位置、在线及运动状态、速度、累计里程、实际协议和记录时间。请求体传 {} 返回全部授权车辆。

-

POST/api/v1/hydrogen-stations/query

-

只读查询资产管理库中有有效坐标的加氢站;可按 provincecitycooperateOnly 筛选。

+

请求体样例

所有接口使用 Content-Type: application/json。以下 JSON 可直接复制;未列出的可选字段可省略。

#

车辆单日用氢量

/vehicles/hydrogen-consumption/query
{
+  "date": "2026-08-06",
+  "plateNumbers": ["浙F06618F"]
+}

车辆单日里程

/vehicles/mileage/query
{
+  "date": "2026-08-06",
+  "plateNumbers": ["浙F06618F"],
+  "protocolPriority": ["GB32960", "MQTT", "JT808"]
+}

车辆区间日里程

/vehicles/mileage/range/query
{
+  "startDate": "2026-08-01",
+  "endDate": "2026-08-06",
+  "pageSize": 100
+}

指定时刻总里程

/vehicles/total-mileage/query
{
+  "vin": "LA9GG68L2PBAF4790",
+  "time": "2026-08-06 10:30:00",
+  "protocol": "GB32960"
+}

实时位置与状态

/vehicles/realtime/query
{
+  "plateNumbers": ["浙F06618F"]
+}

加氢车辆停留核验

/vehicles/stationary/query
{
+  "startTime": "2026-08-06 10:00:00",
+  "endTime": "2026-08-06 12:00:00",
+  "longitude": 120.752312,
+  "latitude": 30.746281,
+  "coordinateSystem": "GCJ02",
+  "radiusMeters": 8
+}

加氢站地图点位

/hydrogen-stations/query
{
+  "province": "浙江省",
+  "city": "嘉兴市",
+  "cooperateOnly": true
+}
-

总里程协议口径

- - - - - - - -
protocol 唯一规范值总里程含义
GB32960车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程
YUTONG_MQTT车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报
JT808定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程
-

protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。

+

车辆单日用氢量

POST/api/v1/vehicles/hydrogen-consumption/query

查询车辆单日用氢量,单位 kg。

#

请求参数

字段必填说明
date日期,格式 yyyy-MM-dd
plateNumbers车牌数组;省略或 [] 时查询全部授权车辆

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"plateNumber": "浙F06618F",
"hydrogenConsumptionKg": 12.315,
"status": "NORMAL"
}]
}
  • 400:日期或车牌格式不正确
  • 401:appKey 无效、停用或过期
  • 403:指定车辆未授权
-

请求示例

-
curl -X POST 'https://your-host/api/v1/vehicles/hydrogen-consumption/query' \
-  -H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-  -H 'Content-Type: application/json' \
-  -d '{
-    "plateNumbers": ["粤A12345", "粤B67890"],
-    "date": "2026-07-01"
-  }'
+

车辆单日里程

POST/api/v1/vehicles/mileage/query

返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。

#

请求参数

字段必填说明
date日期,格式 yyyy-MM-dd
plateNumbers省略时查询全部授权车辆
protocolPriority协议选源顺序,例如 ["GB32960","MQTT","JT808"]
缺数规则:若当日没有有效里程,日里程为 0;累计总里程沿用上一个有效统计周期,计算时间也显示该周期时间。

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679,
"sourceProtocol": "GB32960",
"status": "NORMAL"
}]
}
  • 400:日期、车牌或协议参数错误
  • 403:授权期未覆盖查询日
  • 无统计:单车以 NO_DATA 返回
-

查询全部授权车辆的单日数据

-
{
-  "date": "2026-07-01"
-}
+

车辆区间日里程

POST/api/v1/vehicles/mileage/range/query

按车辆、日期分页返回区间日里程,最长查询区间为 366 天。

#

请求参数

字段必填说明
startDate / endDate日期区间,yyyy-MM-dd
plateNumbers车牌数组,最多 5000 辆
protocolPriority协议选源顺序
pageSize / cursor分页大小及下一页游标

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"date": "2026-08-06",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679
}],
"nextCursor": null
}
  • 400:区间超限或游标与原参数不一致
  • 403:授权未完整覆盖查询区间
-

按自定义协议优先级查询单日里程

-
{
-  "plateNumbers": ["粤A12345"],
-  "date": "2026-07-01",
-  "protocolPriority": ["JT808", "GB32960", "MQTT"]
-}
+

指定时刻总里程

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

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

#

请求参数

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

成功返回 · 200

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

指定时刻总里程

-
curl -X POST 'https://your-host/api/v1/vehicles/total-mileage/query' \
-  -H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-  -H 'Content-Type: application/json' \
-  -d '{
-    "vin": "LA9GG68L2PBAF4790",
-    "time": "2026-07-21 09:30:00",
-    "protocol": "GB32960"
-  }'
+

车辆实时位置与状态

POST/api/v1/vehicles/realtime/query

返回最新位置、在线状态、速度、总里程、实际协议和记录时间。

#

请求参数

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

成功返回 · 200

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

车辆区间日里程

-
curl -X POST 'https://your-host/api/v1/vehicles/mileage/range/query' \
-  -H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-  -H 'Content-Type: application/json' \
-  -d '{
-    "startDate": "2026-07-01",
-    "endDate": "2026-07-23",
-    "protocolPriority": ["GB32960", "MQTT"],
-    "pageSize": 5000
-  }'
-

下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。

+

加氢车辆停留核验

POST/api/v1/vehicles/stationary/query

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

#

请求参数

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

成功返回 · 200

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

实时车辆与全部加氢站

-
curl -X POST 'https://your-host/api/v1/vehicles/realtime/query' \
-  -H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-  -H 'Content-Type: application/json' -d '{}'
+      

加氢站地图点位

POST/api/v1/hydrogen-stations/query

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

#

请求参数

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

成功返回 · 200

{
"data": [{
"id": "1",
"name": "示例加氢站",
"longitude": 120.752312,
"latitude": 30.746281,
"cooperative": true
}]
}
  • 400:行政区划参数过长
  • 401:appKey 无效
  • 无符合站点:data 为空数组
-curl -X POST 'https://your-host/api/v1/hydrogen-stations/query' \ - -H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \ - -H 'Content-Type: application/json' -d '{}'
+

返回字段与枚举说明

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

#

通用响应与状态

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

车辆单日用氢量

字段说明
plateNumber / date车牌与查询自然日(Asia/Shanghai)。
hydrogenConsumptionKg当日用氢量,单位 kg;无有效数据时为 null。
statusNORMAL 表示已计算;NO_DATA 表示无可用统计。

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

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

指定时刻总里程

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

车辆实时位置与状态

字段/枚举说明
sourceProtocol本条位置、速度、累计里程和记录时间实际采用的协议:GB32960、MQTT 或 JT808。
longitude / latitude最新有效经纬度;无有效位置时为 null,locationAvailable 为 false。
speedKmh / totalMileageKm所选来源的瞬时速度(km/h)和累计总里程(km)。
recordTime / timeDifferenceSeconds所选来源的记录时间,以及与当前查询时间的差值,单位秒。
onlinetrue:任一协议最近 60 秒内上报;false:没有任一协议满足该实时阈值。
activeTodaytrue:任一协议在当前自然日曾上报;不等同于实时 online。
motionStatus = driving在线且所选来源速度大于 3 km/h。
motionStatus = idle在线且所选来源速度不大于 3 km/h。
motionStatus = offline当前不在线,或没有实时记录。
protocol旧版兼容字段;新接入请使用 sourceProtocol。

加氢车辆停留核验

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

加氢站地图点位

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

响应示例

-

车辆单日里程

-
{
-  "code": "SUCCESS",
-  "message": "success",
-  "data": [{
-    "vin": "LNB00000000000001",
-    "plateNumber": "粤A12345",
-    "date": "2026-07-01",
-    "dailyMileageKm": 182.437,
-    "totalMileageKm": 12345.679,
-    "dataTime": "2026-07-01T23:58:45+08:00",
-    "updatedAt": "2026-07-02T05:10:00+08:00",
-    "sourceProtocol": "GB32960",
-    "status": "NORMAL"
-  }],
-  "traceId": "b7ff5582ab1a4e13bfb4f10943685599"
-}
-

里程状态为 NORMAL 时,dailyMileageKm、totalMileageKm、dataTime 与 updatedAt 均有值;NO_DATA 时相关数据字段为 null,真实零里程仍为 NORMAL。

-

车辆区间日里程响应

-
{
-  "code": "SUCCESS",
-  "message": "success",
-  "data": [{
-    "vin": "LNB00000000000001",
-    "plateNumber": "粤A12345",
-    "date": "2026-07-01",
-    "dailyMileageKm": 182.437,
-    "dataTime": "2026-07-01T23:58:45+08:00",
-    "updatedAt": "2026-07-02T05:10:00+08:00",
-    "status": "NORMAL"
-  }],
-  "snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
-  "nextCursor": null,
-  "traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
-}
-

车辆单日用氢量

-
{
-  "code": "SUCCESS",
-  "message": "success",
-  "data": [{
-    "plateNumber": "粤A12345",
-    "date": "2026-07-01",
-    "hydrogenConsumptionKg": 12.315,
-    "status": "NORMAL"
-  }],
-  "traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
-}
+

总里程协议口径

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

#
规范值总里程含义
GB32960车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程。
MQTT车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报。
JT808定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程。
-

指定时刻总里程响应

-
{
-  "code": "SUCCESS",
-  "message": "success",
-  "data": {
-    "vin": "LA9GG68L2PBAF4790",
-    "queryTime": "2026-07-21 09:30:00",
-    "totalMileageKm": 12345.678,
-    "protocol": "GB32960",
-    "protocolInput": "GB32960",
-    "mileageMeaning": "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)",
-    "recordTime": "2026-07-21 09:29:45",
-    "timeDifferenceSeconds": 15,
-    "selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
-    "status": "NORMAL"
-  },
-  "traceId": "95bddca78133474fa2bf56ecdf758e22"
-}
- -

状态与错误码

- - - - - - - - - - - -
HTTPcode说明
200SUCCESS查询成功;无统计数据的车辆以 NO_DATA 返回
400INVALID_REQUEST请求格式、车牌或数量不正确
400INVALID_DATE_FORMAT日期不是 yyyy-MM-dd
400INVALID_DATETIME_FORMAT时间不是 yyyy-MM-dd HH:mm:ss
401UNAUTHORIZEDappKey 不存在、停用或过期
403FORBIDDENKey 或车辆授权未覆盖查询自然日
500INTERNAL_ERROR服务内部异常
-
+

异常与状态码

所有响应均包含 codemessagetraceId;请在工单中提供 traceId 以便排查。

#
HTTPcode说明
200SUCCESS查询成功;无统计数据时,数据行的 status 为 NO_DATA。
400INVALID_REQUEST请求格式、数量、日期、时间、坐标或协议不正确。
401UNAUTHORIZEDappKey 不存在、停用或已过期。
403FORBIDDEN车辆或 appKey 授权期未覆盖请求时间。
500INTERNAL_ERROR服务内部异常;请提供 traceId。
+
+ + diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index 52413691..f2439627 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -5,7 +5,7 @@ info: license: name: Proprietary description: | - 向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。 + 向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位。 appKey 和逐车授权必须完整覆盖查询自然日。 servers: - url: / @@ -54,7 +54,7 @@ paths: description: | plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。 - NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。 + NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。 当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。 operationId: queryDailyMileage security: @@ -92,7 +92,7 @@ paths: 首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。 快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。 protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。 - 某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。 + 某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。 operationId: queryDailyMileageRange security: - AppKeyAuth: [] @@ -160,6 +160,46 @@ paths: $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/InternalError' + /api/v1/vehicles/stationary/query: + post: + tags: [合作方数据接口] + summary: 加氢站车辆停留核验 + description: | + 用于加氢车牌核验。传入加氢站经度、纬度、坐标系、半径和北京时间区间,返回范围内速度不大于 3 km/h 的授权车辆停留区间。 + coordinateSystem 支持 WGS84(默认)和 GCJ02(高德坐标);服务端会将 GCJ02 转换为 WGS84 后进行核验。 + 同一车辆相邻静止点间隔超过 10 分钟会拆分为不同停留;仅返回至少 2 个定位样本且持续不少于 60 秒的停留。 + 最大查询区间为 24 小时。结果按 matchScore 从高到低排序;分数综合平均距离、最高速度、停留时长和采样数量,供人工核验使用,不等同于加氢交易凭证。 + operationId: queryStationaryVehicles + security: + - AppKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StationaryVehicleQuery' + example: + startTime: '2026-08-06 10:00:00' + endTime: '2026-08-06 12:00:00' + longitude: 120.752312 + latitude: 30.746281 + coordinateSystem: GCJ02 + radiusMeters: 5 + responses: + '200': + description: 查询成功;无匹配车辆时 data 为空数组 + content: + application/json: + schema: + $ref: '#/components/schemas/StationaryVehicleQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' /api/v1/vehicles/realtime/query: post: tags: [合作方数据接口] @@ -167,7 +207,9 @@ paths: description: | plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。 实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。 - 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。 + 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。 + socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。 + activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。 在线且所选来源速度大于3km/h为行驶中,否则为静止中。 operationId: queryRealtimeVehicles security: @@ -492,6 +534,52 @@ components: type: string enum: [GB32960, YUTONG_MQTT, JT808] description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808 + StationaryVehicleQuery: + type: object + additionalProperties: false + required: [startTime, endTime, longitude, latitude] + properties: + startTime: + type: string + pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$' + description: 开始时间,北京时间,yyyy-MM-dd HH:mm:ss + endTime: + type: string + pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$' + description: 结束时间,北京时间;须晚于开始时间,最长 24 小时 + longitude: + type: number + format: double + minimum: -180 + maximum: 180 + description: 加氢站经度;坐标系由 coordinateSystem 指定 + latitude: + type: number + format: double + minimum: -90 + maximum: 90 + description: 加氢站纬度;坐标系由 coordinateSystem 指定 + coordinateSystem: + type: string + enum: [WGS84, GCJ02] + default: WGS84 + description: 坐标系;WGS84 为默认值,GCJ02 为高德地图坐标。GCJ02 会在服务端转换为 WGS84 后核验 + radiusMeters: + type: number + format: double + minimum: 1 + maximum: 100 + default: 5 + description: 核验半径,单位米;省略时为 5 米 + plateNumbers: + type: array + maxItems: 2000 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 32 + description: 可选;省略或传空数组时核验整个时间区间均有效授权的全部车辆 RealtimeVehicleQuery: type: object additionalProperties: false @@ -554,7 +642,7 @@ components: type: number format: double nullable: true - description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null + description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null dataTime: type: string format: date-time @@ -570,6 +658,11 @@ components: enum: [GB32960, MQTT, JT808] nullable: true description: 本行实际选中的来源协议;status=NO_DATA 时为 null + dataQuality: + type: string + nullable: true + enum: [TOTAL_MILEAGE_ROLLBACK] + description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK status: $ref: '#/components/schemas/DataStatus' MileageRangeResult: @@ -594,7 +687,7 @@ components: format: double nullable: true minimum: 0 - description: 当日所选协议累计总里程;缺日时沿用此前最近有效值 + description: 当日所选协议终端累计总里程;GPS 日里程估算不会作为累计总里程,缺日时沿用此前最近有效值 dataTime: type: string format: date-time @@ -609,11 +702,17 @@ components: enum: [GB32960, MQTT, JT808] nullable: true description: 本行实际选中的来源协议;status=NO_DATA 时为 null + dataQuality: + type: string + nullable: true + enum: [TOTAL_MILEAGE_ROLLBACK] + description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK status: $ref: '#/components/schemas/DataStatus' DataStatus: type: string - enum: [NORMAL, NO_DATA] + enum: [NORMAL, NO_DATA, DATA_ANOMALY] + description: NORMAL=存在可用数据;NO_DATA=授权范围内无可用数据;DATA_ANOMALY=检测到数据异常,详情见 dataQuality HydrogenQueryResponse: allOf: - $ref: '#/components/schemas/SuccessEnvelope' @@ -694,6 +793,25 @@ components: example: GB32960 > YUTONG_MQTT > JT808 status: $ref: '#/components/schemas/DataStatus' + StationaryVehicleResult: + type: object + required: [vin, plateNumber, stayStartTime, stayEndTime, stayDurationSeconds, stayDurationMinutes, matchScore, averageDistanceMeters, maxDistanceMeters, averageSpeedKmh, maxSpeedKmh, matchedSamples, sourceProtocols] + properties: + vin: { type: string, description: 车辆唯一 VIN } + plateNumber: { type: string, description: 车辆车牌号 } + stayStartTime: { type: string, description: 停留开始定位时间,北京时间 } + stayEndTime: { type: string, description: 停留结束定位时间,北京时间 } + stayDurationSeconds: { type: integer, description: 停留长度,秒 } + stayDurationMinutes: { type: number, format: double, description: 停留长度,分钟 } + matchScore: { type: number, format: double, minimum: 0, maximum: 100, description: 匹配度,越高表示位置更接近、速度更低、停留更久且样本更充分 } + averageDistanceMeters: { type: number, format: double } + maxDistanceMeters: { type: number, format: double } + averageSpeedKmh: { type: number, format: double } + maxSpeedKmh: { type: number, format: double, maximum: 3 } + matchedSamples: { type: integer, minimum: 2 } + sourceProtocols: + type: array + items: { type: string, enum: [GB32960, MQTT, JT808] } TotalMileageQueryResponse: allOf: - $ref: '#/components/schemas/SuccessEnvelope' @@ -702,22 +820,37 @@ components: properties: data: $ref: '#/components/schemas/TotalMileageResult' + StationaryVehicleQueryResponse: + allOf: + - $ref: '#/components/schemas/SuccessEnvelope' + - type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/StationaryVehicleResult' RealtimeVehicleResult: type: object required: [vin, plateNumber, online, motionStatus, locationAvailable, status] properties: vin: { type: string } plateNumber: { type: string } - protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] } - longitude: { type: number, format: double, nullable: true } - latitude: { type: number, format: double, nullable: true } - speedKmh: { type: number, format: double, nullable: true } - totalMileageKm: { type: number, format: double, nullable: true } - recordTime: { type: string } - timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 } + protocol: + type: string + enum: [GB32960, MQTT, JT808] + description: 本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源 + longitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效经度;无位置时为 null } + latitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效纬度;无位置时为 null } + speedKmh: { type: number, format: double, nullable: true, description: 所选来源瞬时速度,单位 km/h } + socPercent: { type: number, format: double, minimum: 0, maximum: 100, description: 所选来源的动力电池荷电状态,单位 %;无有效采集值时字段省略 } + totalMileageKm: { type: number, format: double, nullable: true, description: 所选来源累计总里程,单位 km } + recordTime: { type: string, description: 所选来源实际记录时间,北京时间 } + timeDifferenceSeconds: { type: integer, format: int64, minimum: 0, description: 当前查询时间减 recordTime,单位秒 } online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 } - motionStatus: { type: string, enum: [driving, idle, offline] } - locationAvailable: { type: boolean } + activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 } + motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/h;idle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 } + locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 } status: { $ref: '#/components/schemas/DataStatus' } RealtimeVehicleQueryResponse: allOf: diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs.go b/vehicle-data-platform/apps/api/internal/openplatform/docs.go index 764b00c2..2eaa12f9 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/docs.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs.go @@ -46,7 +46,7 @@ func WithDocs(next http.Handler) http.Handler { serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "") case SimpleDocsPath: serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML, - "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'") + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'") default: next.ServeHTTP(w, r) } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go index 3a9dcb9d..52b16c1d 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go @@ -35,6 +35,9 @@ func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) { if recorder.Header().Get("X-Content-Type-Options") != "nosniff" { t.Fatal("documentation asset must set nosniff") } + if test.path == SimpleDocsPath && !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'") { + t.Fatal("simple documentation must allow its bundled interactive field renderer") + } }) } } @@ -61,12 +64,14 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) { HydrogenQueryPath + ":", MileageQueryPath + ":", MileageRangeQueryPath + ":", + StationaryVehicleQueryPath + ":", "/api/v2/open-platform/apps:", "AppKeyAuth:", "AdminBearer:", "省略或传空数组时", "protocolPriority:", "sourceProtocol:", + StationaryVehicleQueryPath, "enum: [GB32960, MQTT, JT808]", } { if !strings.Contains(spec, want) { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/handler.go b/vehicle-data-platform/apps/api/internal/openplatform/handler.go index a8e48eb1..291e5662 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/handler.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/handler.go @@ -17,12 +17,13 @@ import ( ) const ( - HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query" - MileageQueryPath = "/api/v1/vehicles/mileage/query" - MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query" - TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query" - RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query" - HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query" + HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query" + MileageQueryPath = "/api/v1/vehicles/mileage/query" + MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query" + TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query" + StationaryVehicleQueryPath = "/api/v1/vehicles/stationary/query" + RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query" + HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query" ) type Handler struct { @@ -61,6 +62,7 @@ func (h *Handler) registerExternalDataRoutes() { h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage) h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange) h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage) + h.mux.HandleFunc("POST "+StationaryVehicleQueryPath, h.stationaryVehicles) h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles) h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations) } @@ -110,6 +112,7 @@ func NewDataHandler(service *Service) *Handler { handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage) handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange) handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage) + handler.mux.HandleFunc("POST "+StationaryVehicleQueryPath, handler.stationaryVehicles) handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles) handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations) return handler @@ -120,7 +123,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func IsPublicPath(path string) bool { - return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath + return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath +} + +func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) { + traceID := externalTraceID(r) + var request StationaryVehicleQueryRequest + if !decodeExternalBody(w, r, traceID, &request) { + return + } + data, err := h.service.QueryStationaryVehicles(r.Context(), externalBearer(r), traceID, request) + if err != nil { + writeExternalError(w, traceID, err) + return + } + writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID}) } func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) { @@ -684,7 +701,9 @@ func writeExternal(w http.ResponseWriter, status int, response any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(response) + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(response) } func externalBearer(r *http.Request) string { @@ -759,6 +778,12 @@ func dataProducts() []DataProduct { Version: "v1", Status: "available", Method: http.MethodPost, Path: TotalMileageQueryPath, Unit: "km", }, + { + Code: "stationary_vehicle_verification", Name: "加氢车辆停留核验", + Description: "按加氢站坐标与时间区间,核验授权车辆是否在 5 米内低速停留,并按匹配度排序。", + Version: "v1", Status: "available", Method: http.MethodPost, + Path: StationaryVehicleQueryPath, Unit: "辆", + }, { Code: "realtime_vehicle", Name: "车辆实时位置与状态", Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。", diff --git a/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator.go b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator.go new file mode 100644 index 00000000..07d498b4 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator.go @@ -0,0 +1,616 @@ +package openplatform + +import ( + "math" + "sort" + "time" +) + +const ( + hydrogenRefuelObservationWindow = 10 * time.Minute + hydrogenRefuelThermalGapWindow = 30 * time.Minute + hydrogenRefuelConfirmSamples = 3 + hydrogenRefuelMileageEpsilonKm = 0.2 + hydrogenThermalMileageEpsilonKm = 5.0 + hydrogenRefuelTemperatureRiseC = 3.0 +) + +const trustedHydrogenAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5" + +func defaultHydrogenCalculationParameters() HydrogenCalculationParameters { + return HydrogenCalculationParameters{ + HydrogenEnergyKWhKg: 16, + PowerOnDelaySeconds: 0, + PowerOffLeadSeconds: 0, + RefuelRiseMPa: 3, + RefuelSustainSeconds: 60, + PureElectricDropMPA: 5, + PureElectricWindowSeconds: 1800, + AlgorithmVersion: trustedHydrogenAlgorithmVersion, + } +} + +func normalizeHydrogenCalculationParameters(input HydrogenCalculationParameters) HydrogenCalculationParameters { + result := defaultHydrogenCalculationParameters() + if input.BatteryCapacityKWh > 0 { + result.BatteryCapacityKWh = input.BatteryCapacityKWh + } + if input.HydrogenEnergyKWhKg > 0 { + result.HydrogenEnergyKWhKg = input.HydrogenEnergyKWhKg + } + if input.PowerOnDelaySeconds > 0 { + result.PowerOnDelaySeconds = input.PowerOnDelaySeconds + } + if input.PowerOffLeadSeconds > 0 { + result.PowerOffLeadSeconds = input.PowerOffLeadSeconds + } + if input.RefuelRiseMPa > 0 { + result.RefuelRiseMPa = input.RefuelRiseMPa + } + if input.RefuelSustainSeconds > 0 { + result.RefuelSustainSeconds = input.RefuelSustainSeconds + } + if input.PureElectricDropMPA > 0 { + result.PureElectricDropMPA = input.PureElectricDropMPA + } + if input.PureElectricWindowSeconds > 0 { + result.PureElectricWindowSeconds = input.PureElectricWindowSeconds + } + result.InitialChargeCycleKnown = input.InitialChargeCycleKnown + result.InitialMixedLocked = input.InitialMixedLocked + if input.AlgorithmVersion != "" { + result.AlgorithmVersion = input.AlgorithmVersion + } + return result +} + +// BuildHydrogenDailyStatsWithParameters calculates auditable daily hydrogen +// results. Pressure/temperature mass remains the physical source of truth; +// battery SOC only produces a separate energy-balanced derivative. +func BuildHydrogenDailyStatsWithParameters( + observations []HydrogenObservation, + date string, + noiseKg float64, + maxDropKg float64, + parameters map[string]HydrogenCalculationParameters, +) []HydrogenDailyStat { + return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, false, parameters) +} + +func BuildHydrogenDailyStatsOrderedWithParameters( + observations []HydrogenObservation, + date string, + noiseKg float64, + maxDropKg float64, + parameters map[string]HydrogenCalculationParameters, +) []HydrogenDailyStat { + return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, true, parameters) +} + +func buildTrustedHydrogenDailyStat( + vin string, + source string, + date string, + values []HydrogenObservation, + noiseKg float64, + maxDropKg float64, + input HydrogenCalculationParameters, +) HydrogenDailyStat { + return buildHydrogenDailyStatV3(vin, source, date, values, noiseKg, maxDropKg, input) +} + +func prepareHydrogenObservations(values []HydrogenObservation, params HydrogenCalculationParameters) ([]HydrogenObservation, int) { + prepared := append([]HydrogenObservation(nil), values...) + for index := range prepared { + prepared[index].BoundaryEligible = true + } + var powerOnAt time.Time + previousVehicleKnown := false + previousVehicleState := 0 + charging := false + chargeCount := 0 + for index := range prepared { + value := &prepared[index] + if value.VehicleStateKnown && value.VehicleState == 1 && (!previousVehicleKnown || previousVehicleState != 1) { + powerOnAt = value.ObservedAt + } + if !powerOnAt.IsZero() && value.ObservedAt.Before(powerOnAt.Add(time.Duration(params.PowerOnDelaySeconds)*time.Second)) { + value.BoundaryEligible = false + value.BoundaryReason = "上电稳定等待窗口" + } + isCharging := value.ChargeStateKnown && value.ChargeState == 1 + if isCharging && !charging { + chargeCount++ + } + charging = isCharging + if isCharging { + value.BoundaryEligible = false + value.BoundaryReason = "停车充电区间" + } + if value.VehicleStateKnown { + previousVehicleKnown = true + previousVehicleState = value.VehicleState + } + } + for index := 1; index < len(prepared); index++ { + value := prepared[index] + previous := prepared[index-1] + if !value.VehicleStateKnown || value.VehicleState != 2 || !previous.VehicleStateKnown || previous.VehicleState == 2 { + continue + } + cutoff := value.ObservedAt.Add(-time.Duration(params.PowerOffLeadSeconds) * time.Second) + for cursor := index; cursor >= 0 && prepared[cursor].ObservedAt.After(cutoff); cursor-- { + prepared[cursor].BoundaryEligible = false + prepared[cursor].BoundaryReason = "下电前稳定窗口" + } + } + markPersistentRefuels(prepared, params) + markPureElectricPressureInvalid(prepared, params) + return prepared, chargeCount +} + +func markPersistentRefuels(values []HydrogenObservation, params HydrogenCalculationParameters) { + if len(values) < 2 { + return + } + minimumPressure := values[0].PressureMPa + minimumMassKg := values[0].MassKg + minimumObservedAt := values[0].ObservedAt + minimumFloorIndex := 0 + candidateIndex := -1 + candidateBaseline := 0.0 + candidateBaselineMassKg := 0.0 + candidateHighSamples := 0 + for index := 1; index < len(values); index++ { + value := values[index] + previous := values[index-1] + // Zero/negative pressure is an invalid sensor placeholder and must not + // become the low-pressure baseline of a refuel candidate. + if value.PressureMPa <= 0 { + continue + } + if minimumPressure <= 0 { + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + minimumFloorIndex = index + continue + } + if candidateIndex >= 0 { + candidateAge := value.ObservedAt.Sub(values[candidateIndex].ObservedAt) + switch { + case candidateAge > hydrogenRefuelObservationWindow: + // A fast fill must be confirmed within the observation window. Do + // not let an old pressure rise split a much later driving segment. + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + minimumFloorIndex = index + candidateIndex = -1 + candidateHighSamples = 0 + case value.PressureMPa-candidateBaseline < params.RefuelRiseMPa: + if value.PressureMPa < minimumPressure { + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + } + candidateIndex = -1 + candidateHighSamples = 0 + default: + candidateHighSamples++ + if candidateAge >= time.Duration(params.RefuelSustainSeconds)*time.Second || candidateHighSamples >= hydrogenRefuelConfirmSamples { + values[candidateIndex].RefuelBoundary = true + values[candidateIndex].RefuelAmountKg = math.Max(0, value.MassKg-candidateBaselineMassKg) + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + minimumFloorIndex = index + candidateIndex = -1 + candidateHighSamples = 0 + } + // Keep a pending fast-refuel candidate across an immediate + // fuel-cell restart. The post-fill high pressure is confirmation, + // not ordinary bottle-valve pressure recovery. + continue + } + } + if previous.FuelCellStateKnown && !previous.FuelCellActive && value.FuelCellStateKnown && value.FuelCellActive { + // Compare the recovered pressure/mass with the highest stable value + // immediately before or during the inactive period. A recovery that does + // not exceed that baseline by at least 1 kg is pipe pressure restoration, + // not refuelling. A larger increase still needs thermal refill evidence. + reference, referenceOK := hydrogenValveRecoveryReference(values, index) + thresholdKg := math.Max(1, value.RefuelThresholdKg) + if referenceOK && value.MassKg-reference.MassKg >= thresholdKg && + hydrogenRefuelThermalJump(previous, value, params) { + candidateIndex = index + candidateBaseline = previous.PressureMPa + candidateBaselineMassKg = reference.MassKg + candidateHighSamples = 1 + continue + } + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + minimumFloorIndex = index + candidateIndex = -1 + candidateHighSamples = 0 + continue + } + if candidateIndex < 0 && hydrogenRefuelThermalJump(previous, value, params) { + // A short fill can be hidden behind invalid temperature frames or a + // reporting gap. A near-stationary pressure jump accompanied by hydrogen + // temperature rise is refill evidence even when the ordinary 10-minute + // pressure window would miss it. + candidateIndex = index + candidateBaseline = previous.PressureMPa + candidateBaselineMassKg = previous.MassKg + candidateHighSamples = 1 + continue + } + if candidateIndex < 0 { + if value.ObservedAt.Sub(minimumObservedAt) > hydrogenRefuelObservationWindow { + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + for cursor := index - 1; cursor >= minimumFloorIndex; cursor-- { + candidate := values[cursor] + if value.ObservedAt.Sub(candidate.ObservedAt) > hydrogenRefuelObservationWindow { + break + } + if candidate.PressureMPa < minimumPressure { + minimumPressure = candidate.PressureMPa + minimumMassKg = candidate.MassKg + minimumObservedAt = candidate.ObservedAt + } + } + } + if value.PressureMPa < minimumPressure { + minimumPressure = value.PressureMPa + minimumMassKg = value.MassKg + minimumObservedAt = value.ObservedAt + } + if value.PressureMPa-minimumPressure >= params.RefuelRiseMPa && hydrogenRefuelCandidateContext(previous, value) { + candidateIndex = index + candidateBaseline = minimumPressure + candidateBaselineMassKg = minimumMassKg + candidateHighSamples = 1 + } + continue + } + } +} + +func hydrogenRefuelThermalJump(previous, value HydrogenObservation, params HydrogenCalculationParameters) bool { + gap := value.ObservedAt.Sub(previous.ObservedAt) + if gap <= 0 || gap > hydrogenRefuelThermalGapWindow { + return false + } + if previous.PressureMPa <= 0 || value.PressureMPa-previous.PressureMPa < params.RefuelRiseMPa { + return false + } + if value.TemperatureC-previous.TemperatureC < hydrogenRefuelTemperatureRiseC { + return false + } + if !previous.MileageKnown || !value.MileageKnown { + return false + } + return math.Abs(value.MileageKm-previous.MileageKm) <= hydrogenThermalMileageEpsilonKm +} + +func hydrogenValveRecoveryReference(values []HydrogenObservation, restartIndex int) (HydrogenObservation, bool) { + if restartIndex <= 0 || restartIndex >= len(values) { + return HydrogenObservation{}, false + } + restartTime := values[restartIndex].ObservedAt + var reference HydrogenObservation + found := false + for index := restartIndex - 1; index >= 0; index-- { + value := values[index] + if restartTime.Sub(value.ObservedAt) > hydrogenRefuelThermalGapWindow { + break + } + if value.PressureMPa > 0 && value.MassKg > 0 && (!found || value.MassKg > reference.MassKg) { + reference = value + found = true + } + if value.FuelCellStateKnown && value.FuelCellActive { + break + } + if !value.FuelCellStateKnown { + break + } + } + return reference, found +} + +func markNetHydrogenIncreaseRefuelFallback(values, detectionRuns []HydrogenObservation) { + if len(values) < hydrogenRefuelConfirmSamples || len(detectionRuns) < hydrogenRefuelConfirmSamples { + return + } + firstRun := detectionRuns[0] + lastRun := detectionRuns[len(detectionRuns)-1] + if firstRun.PressureMPa <= 0 || lastRun.PressureMPa <= 0 { + return + } + thresholdKg := math.Max(1, firstRun.RefuelThresholdKg) + if lastRun.MassKg-firstRun.MassKg < thresholdKg { + return + } + + bestRunIndex := -1 + bestRiseKg := 0.0 + minimumMassKg := firstRun.MassKg + for index := 1; index < len(detectionRuns); index++ { + value := detectionRuns[index] + if value.MassKg < minimumMassKg { + minimumMassKg = value.MassKg + continue + } + riseKg := value.MassKg - minimumMassKg + if riseKg < thresholdKg { + continue + } + // Split at the first high point after the low-mass baseline. The daily + // start-to-end net increase already confirms that the rise persisted. + // The largest mass rise can occur much later and would otherwise place the + // boundary at day end, where it cannot separate pre/post-refuel usage. + if bestRunIndex < 0 { + bestRunIndex = index + } + bestRiseKg = math.Max(bestRiseKg, riseKg) + } + if bestRunIndex < 0 { + return + } + boundary := detectionRuns[bestRunIndex] + for index := range values { + if values[index].EventID == boundary.EventID || values[index].ObservedAt.Equal(boundary.ObservedAt) { + values[index].RefuelBoundary = true + values[index].RefuelAmountKg = bestRiseKg + return + } + } +} + +func hydrogenRefuelCandidateContext(previous, value HydrogenObservation) bool { + if previous.MileageKnown && value.MileageKnown { + // Mileage is the primary discriminator because vehicle/fuel-cell states + // can remain stale for the first frame after a short fill. + return math.Abs(value.MileageKm-previous.MileageKm) <= hydrogenRefuelMileageEpsilonKm + } + if value.VehicleStateKnown && value.VehicleState == 2 { + return true + } + if value.FuelCellStateKnown && !value.FuelCellActive { + return true + } + // Preserve pressure-only compatibility when neither operating-state signal + // is present; downstream persistence and valve-reopen checks still apply. + return !value.VehicleStateKnown && !value.FuelCellStateKnown +} + +func markPureElectricPressureInvalid(values []HydrogenObservation, params HydrogenCalculationParameters) { + var peak HydrogenObservation + hasPeak := false + window := time.Duration(params.PureElectricWindowSeconds) * time.Second + for index := range values { + value := &values[index] + pureElectric := value.RunningModeKnown && value.RunningMode == 1 + if !pureElectric { + hasPeak = false + continue + } + if !hasPeak || value.ObservedAt.Sub(peak.ObservedAt) > window { + peak, hasPeak = *value, true + continue + } + if peak.PressureMPa-value.PressureMPa > params.PureElectricDropMPA { + value.PressureInvalid = true + value.BoundaryEligible = false + value.BoundaryReason = "纯电状态30分钟内氢压下降超过阈值" + } + if value.PressureMPa > peak.PressureMPa { + peak = *value + } + } +} + +type trustedHydrogenAccumulator struct { + noiseKg float64 + maxDropKg float64 + params HydrogenCalculationParameters + previous HydrogenObservation + hasPrevious bool + segmentType string + segment []HydrogenObservation + intervals []HydrogenIntervalEvidence + minimumMassKg float64 + refuelCount int + abnormalDrops int + invalidSegments int + eligibleIntervals int + mixedSegments int +} + +func newTrustedHydrogenAccumulator(noiseKg, maxDropKg float64, params HydrogenCalculationParameters) *trustedHydrogenAccumulator { + if noiseKg <= 0 { + noiseKg = 0.05 + } + if maxDropKg <= noiseKg { + maxDropKg = 20 + } + return &trustedHydrogenAccumulator{noiseKg: noiseKg, maxDropKg: maxDropKg, params: params, minimumMassKg: math.Inf(1)} +} + +func (accumulator *trustedHydrogenAccumulator) Add(value HydrogenObservation) { + segmentType := hydrogenObservationSegmentType(value) + eligible := value.BoundaryEligible && !value.PressureInvalid && segmentType != "" + if accumulator.hasPrevious { + gap := value.ObservedAt.Sub(accumulator.previous.ObservedAt) + massDrop := accumulator.previous.MassKg - value.MassKg + massRise := value.MassKg - accumulator.previous.MassKg + switch { + case value.RefuelBoundary: + accumulator.flush("加氢前结束区间") + accumulator.refuelCount++ + eligible = false + case massRise > hydrogenObservationRefuelThreshold(value, accumulator.previous): + accumulator.flush("加氢前结束区间") + if !rapidHydrogenPressureRecovery(accumulator.previous, value) { + accumulator.refuelCount++ + } + case gap > hydrogenSegmentMaxGap: + accumulator.flush("数据中断超过5分钟") + case massDrop > accumulator.maxDropKg: + accumulator.flush("压力质量异常下降") + accumulator.abnormalDrops++ + accumulator.invalidSegments++ + eligible = false + case segmentType != accumulator.segmentType: + accumulator.flush("运行模式切换") + } + } + if value.MassKg < accumulator.minimumMassKg { + accumulator.minimumMassKg = value.MassKg + } + if !eligible { + accumulator.flush(value.BoundaryReason) + accumulator.previous, accumulator.hasPrevious = value, true + return + } + accumulator.eligibleIntervals++ + if len(accumulator.segment) == 0 { + accumulator.segmentType = segmentType + } + accumulator.segment = append(accumulator.segment, value) + accumulator.previous, accumulator.hasPrevious = value, true +} + +func (accumulator *trustedHydrogenAccumulator) Finalize() []HydrogenIntervalEvidence { + accumulator.flush("") + return accumulator.intervals +} + +func (accumulator *trustedHydrogenAccumulator) flush(reason string) { + if len(accumulator.segment) < hydrogenSegmentEndpointWindow*2 { + accumulator.segment = nil + accumulator.segmentType = "" + return + } + start := hydrogenMedianObservation(accumulator.segment[:hydrogenSegmentEndpointWindow]) + end := hydrogenMedianObservation(accumulator.segment[len(accumulator.segment)-hydrogenSegmentEndpointWindow:]) + interval := HydrogenIntervalEvidence{ + Index: len(accumulator.intervals) + 1, Type: accumulator.segmentType, + StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano), + StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source), + StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa), + StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC), + StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg), SampleCount: len(accumulator.segment), + QualityStatus: "OK", QualityReason: reason, + } + if accumulator.segmentType == "MIXED" { + drop := start.MassKg - end.MassKg + segmentNoise := math.Max(accumulator.noiseKg, math.Max(start.NoiseKg, end.NoiseKg)) + if drop > segmentNoise { + interval.RawHydrogenConsumptionKg = round3(drop) + } + accumulator.mixedSegments++ + } + if start.SOCKnown && end.SOCKnown { + startSOC, endSOC := round3(start.SOCPercent), round3(end.SOCPercent) + interval.StartSOCPercent, interval.EndSOCPercent = &startSOC, &endSOC + if accumulator.params.BatteryCapacityKWh > 0 { + discharge := accumulator.params.BatteryCapacityKWh * (start.SOCPercent - end.SOCPercent) / 100 + equivalent := discharge / accumulator.params.HydrogenEnergyKWhKg + discharge, equivalent = round3(discharge), round3(equivalent) + interval.BatteryDischargeKWh, interval.BatteryEquivalentKg = &discharge, &equivalent + if accumulator.segmentType == "MIXED" { + balanced := round3(interval.RawHydrogenConsumptionKg + equivalent) + interval.SOCBalancedConsumptionKg = &balanced + if balanced < 0 { + interval.QualityStatus = "SUSPECT" + interval.QualityReason = "SOC修正后结果为负,需复核SOC或车型参数" + } + } + } + } + if start.MileageKnown && end.MileageKnown { + startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm) + interval.StartMileageKm, interval.EndMileageKm = &startMileage, &endMileage + delta := end.MileageKm - start.MileageKm + if delta >= 0 && delta <= 1500 { + delta = round3(delta) + interval.MileageKm = &delta + if accumulator.segmentType == "MIXED" && delta > 0 { + consumption := interval.RawHydrogenConsumptionKg + if interval.SOCBalancedConsumptionKg != nil { + consumption = *interval.SOCBalancedConsumptionKg + } + rate := round3(consumption * 100 / delta) + interval.ConsumptionKgPer100Km = &rate + } + } else { + interval.QualityStatus = "SUSPECT" + interval.QualityReason = "区间仪表里程倒退或异常跳变" + } + } + if interval.QualityStatus != "OK" { + accumulator.invalidSegments++ + } + accumulator.intervals = append(accumulator.intervals, interval) + accumulator.segment = nil + accumulator.segmentType = "" +} + +func hydrogenObservationSegmentType(value HydrogenObservation) string { + if value.ChargeStateKnown && value.ChargeState == 1 { + return "" + } + if value.VehicleStateKnown && value.VehicleState != 1 { + return "" + } + if value.RunningModeKnown { + switch value.RunningMode { + case 1: + if value.FuelCellStateKnown && value.FuelCellActive { + return "" + } + return "PURE_ELECTRIC" + case 2: + if value.FuelCellStateKnown && !value.FuelCellActive { + return "" + } + return "MIXED" + default: + return "" + } + } + if value.FuelCellStateKnown { + if value.FuelCellActive { + return "MIXED" + } + return "" + } + return "MIXED" +} + +func hydrogenMedianObservation(values []HydrogenObservation) HydrogenObservation { + ordered := append([]HydrogenObservation(nil), values...) + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].MassKg != ordered[j].MassKg { + return ordered[i].MassKg < ordered[j].MassKg + } + return ordered[i].ObservedAt.Before(ordered[j].ObservedAt) + }) + return ordered[len(ordered)/2] +} + +func firstNonEmptyHydrogen(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_test.go b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_test.go new file mode 100644 index 00000000..24f12974 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_test.go @@ -0,0 +1,953 @@ +package openplatform + +import ( + "fmt" + "math" + "strings" + "testing" + "time" +) + +func TestPressureHydrogenMassNISTValidationGridHasAtLeast100Samples(t *testing.T) { + pressures := []float64{1, 3, 5, 8, 10, 12, 15, 18, 21, 25, 30, 35} + temperatures := []float64{-20, -10, 0, 10, 20, 30, 40, 50, 60, 70} + const volumeLiter = 520.0 + validated := 0 + for _, pressure := range pressures { + for _, temperature := range temperatures { + got, ok := PressureHydrogenMassKg(pressure, temperature, volumeLiter) + if !ok { + t.Fatalf("pressure=%vMPa temperature=%vC unexpectedly rejected", pressure, temperature) + } + want := independentNISTHydrogenMass(pressure, temperature, volumeLiter) + if math.Abs(got-want) > 1e-10 { + t.Fatalf("sample=%d pressure=%v temperature=%v got=%.12f want=%.12f", validated, pressure, temperature, got, want) + } + validated++ + } + } + if validated < 100 { + t.Fatalf("validated only %d pressure/temperature samples", validated) + } + t.Logf("validated %d independent NIST pressure/temperature/volume samples", validated) +} + +func independentNISTHydrogenMass(pressureMPa, temperatureC, volumeLiter float64) float64 { + a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403} + b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0} + c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5} + temperatureK := temperatureC + 273.15 + z := 1.0 + for index := range a { + z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index]) + } + return pressureMPa * 1000 * 0.00201588 * volumeLiter / (8.314472 * temperatureK * z) +} + +func TestTrustedHydrogenCalculatorProducesTraceableSOCBalancedResultFrom120Samples(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + values := make([]HydrogenObservation, 0, 120) + for index := 0; index < 120; index++ { + pressure := 30 - float64(index)*0.05 + temperature := 30 + math.Sin(float64(index)/10) + mass, ok := PressureHydrogenMassKg(pressure, temperature, 520) + if !ok { + t.Fatalf("sample %d mass conversion failed", index) + } + vehicleState := 1 + if index == 119 { + vehicleState = 2 + } + values = append(values, HydrogenObservation{ + VIN: vin, Source: "factory-a", EventID: fmt.Sprintf("event-%03d", index), + ObservedAt: base.Add(time.Duration(index) * 10 * time.Second), + MassKg: mass, TankCapacityLiter: 520, PressureMPa: pressure, TemperatureC: temperature, + NoiseKg: 0.05, RefuelThresholdKg: 1, + FuelCellActive: true, FuelCellStateKnown: true, + SOCPercent: 80 - float64(index)*0.02, SOCKnown: true, + MileageKm: 1000 + float64(index)*0.1, MileageKnown: true, + VehicleState: vehicleState, VehicleStateKnown: true, + ChargeState: 3, ChargeStateKnown: true, + RunningMode: 2, RunningModeKnown: true, + }) + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.SampleCount != 120 || len(stat.Intervals) != 1 || stat.QualityStatus != "OK" { + t.Fatalf("stat=%#v", stat) + } + interval := stat.Intervals[0] + if interval.StartEventID == "" || interval.EndEventID == "" || interval.SampleCount < 100 { + t.Fatalf("trace evidence incomplete: %#v", interval) + } + if interval.StartSOCPercent == nil || interval.EndSOCPercent == nil || interval.BatteryEquivalentKg == nil || interval.SOCBalancedConsumptionKg == nil { + t.Fatalf("SOC evidence incomplete: %#v", interval) + } + wantEnergyChange := 21.04 * (*interval.EndSOCPercent - *interval.StartSOCPercent) / 100 + wantEquivalent := wantEnergyChange / 16 + wantBalanced := interval.RawHydrogenConsumptionKg - wantEquivalent + if math.Abs(*interval.BatteryDischargeKWh-round3(wantEnergyChange)) > 0.001 || + math.Abs(*interval.BatteryEquivalentKg-round3(wantEquivalent)) > 0.001 || + math.Abs(*interval.SOCBalancedConsumptionKg-round3(wantBalanced)) > 0.001 { + t.Fatalf("energy balance mismatch: interval=%#v", interval) + } + if stat.SOCBalancedConsumptionKg == nil || stat.SOCBalancedKgPer100Km == nil || stat.MixedMileageKm <= 0 { + t.Fatalf("daily SOC-balanced result incomplete: %#v", stat) + } + t.Logf("validated traceable daily calculation with %d raw samples and %d interval samples", stat.SampleCount, interval.SampleCount) +} + +func TestSOCBalanceUsesEnergyConservationForProvided45TExample(t *testing.T) { + rawHydrogenKg := 8.33 + // SOC下降10个百分点:SOC变化和电等效氢均为负; + // 修正耗氢=用氢量-电等效氢,因此等价于补回电池放出的能量。 + batteryEnergyChangeKWh := 21.04 * (-10) / 100 + batteryEquivalentKg := batteryEnergyChangeKWh / 16 + balanced := rawHydrogenKg - batteryEquivalentKg + if math.Abs(batteryEquivalentKg-(-0.1315)) > 1e-9 || math.Abs(balanced-8.4615) > 1e-9 { + t.Fatalf("battery equivalent=%.6f balanced=%.6f", batteryEquivalentKg, balanced) + } + if math.Abs(balanced/300*100-2.8205) > 1e-9 { + t.Fatalf("balanced rate=%.6f", balanced/300*100) + } +} + +func TestTrustedHydrogenCalculatorExcludesExternalChargingAndRebaselines(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := make([]HydrogenObservation, 0, 48) + for index := 0; index < 48; index++ { + chargeState := 3 + if index >= 12 && index < 24 { + chargeState = 1 + } else if index == 24 { + chargeState = 4 + } + values = append(values, HydrogenObservation{ + VIN: vin, ObservedAt: base.Add(time.Duration(index) * time.Minute), EventID: fmt.Sprintf("charge-%02d", index), + MassKg: 10 - float64(index)*0.02, PressureMPa: 25 - float64(index)*0.05, TemperatureC: 30, + NoiseKg: 0.01, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true, + SOCPercent: 60 + float64(index), SOCKnown: true, MileageKm: 100 + float64(index), MileageKnown: true, + VehicleState: func() int { + if chargeState == 1 { + return 2 + } + return 1 + }(), VehicleStateKnown: true, ChargeState: chargeState, ChargeStateKnown: true, + RunningMode: 2, RunningModeKnown: true, + }) + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.01, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 || stats[0].ChargeCount != 1 || len(stats[0].Intervals) != 2 { + t.Fatalf("charging was not isolated: %#v", stats) + } + for _, interval := range stats[0].Intervals { + if interval.StartTime <= values[12].ObservedAt.Format(time.RFC3339Nano) && interval.EndTime >= values[23].ObservedAt.Format(time.RFC3339Nano) { + t.Fatalf("external charging leaked into calculation interval: %#v", interval) + } + } +} + +func TestHydrogenV3CountsOnlyInitialPurePrefixAndLocksAfterMixed(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10.0, 80, 1), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 5, 9.9, 70, 1), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 10, 9.8, 60, 2), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 11, 9.7, 58, 2), + hydrogenV3TestFrame(vin, base.Add(40*time.Second), 12, 9.6, 56, 2), + // Returning to pure mode does not reopen pure mileage before another charge. + hydrogenV3TestFrame(vin, base.Add(50*time.Second), 15, 9.5, 53, 1), + hydrogenV3TestFrame(vin, base.Add(60*time.Second), 20, 9.3, 50, 1), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.PureElectricMileageKm != 10 || stat.MixedMileageKm != 10 { + t.Fatalf("pure=%.3f mixed=%.3f intervals=%#v", stat.PureElectricMileageKm, stat.MixedMileageKm, stat.Intervals) + } + if stat.BatterySOCDeltaPct == nil || *stat.BatterySOCDeltaPct != -10 || stat.ElectricEquivalentKg == nil || math.Abs(*stat.ElectricEquivalentKg-(-0.132)) > 0.0011 { + t.Fatalf("SOC delta=%v equivalent=%v stat=%#v", stat.BatterySOCDeltaPct, stat.ElectricEquivalentKg, stat) + } + if stat.ConsumptionKg != 0.2 || stat.CorrectedConsumptionKg == nil || math.Abs(*stat.CorrectedConsumptionKg-0.332) > 0.0001 { + if stat.CorrectedConsumptionKg == nil { + t.Fatal("corrected consumption is nil") + } + t.Fatalf("corrected consumption=%.6f", *stat.CorrectedConsumptionKg) + } +} + +func TestHydrogenV3UsesVehicleRunningModeWhenFuelCellExtensionDisagrees(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 24, 14, 57, 46, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 35, 1), + hydrogenV3TestFrame(vin, base.Add(50*time.Second), 101, 9.9, 34, 2), + hydrogenV3TestFrame(vin, base.Add(80*time.Second), 102, 9.8, 34, 2), + hydrogenV3TestFrame(vin, base.Add(90*time.Second), 103, 9.7, 34, 2), + // The extension reports state 1, but the authoritative vehicle running + // mode remains 2 and must therefore start and lock the mixed interval. + hydrogenV3TestFrame(vin, base.Add(100*time.Second), 104, 9.6, 33, 1), + hydrogenV3TestFrame(vin, base.Add(110*time.Second), 110, 9.5, 30, 1), + } + for index := 1; index <= 3; index++ { + values[index].FuelCellActive = false + values[index].FuelCellStateKnown = true + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-24", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.PureElectricMileageKm != 1 || stat.MixedMileageKm != 9 || len(stat.Intervals) != 2 || stat.Intervals[1].Type != "MIXED" { + t.Fatalf("running mode was not authoritative: %#v", stat) + } +} + +func TestHydrogenV3IgnoresSingleMixedModeJitter(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10, 80, 1), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 5, 9.9, 75, 1), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 6, 9.8, 74, 2), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 10, 9.7, 70, 1), + hydrogenV3TestFrame(vin, base.Add(40*time.Second), 20, 9.6, 65, 1), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 || stats[0].QualityStatus != "NO_DATA" || stats[0].PureElectricMileageKm != 20 || stats[0].MixedMileageKm != 0 { + t.Fatalf("single-frame mixed jitter must not lock cycle: %#v", stats) + } +} + +func TestHydrogenV3CarriesMixedLockAcrossDayBoundary(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 0, 0, 10, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10, 60, 1), + hydrogenV3TestFrame(vin, base.Add(time.Minute), 120, 9, 50, 1), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: { + BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, + InitialChargeCycleKnown: true, InitialMixedLocked: true, + }, + }) + if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 20 { + t.Fatalf("mixed lock was reset at midnight: %#v", stats) + } +} + +func TestHydrogenV3LargeBatteryTreatsNoMixedDayAsAllElectricWithoutSameDayCharge(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10, 95, 1), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 160, 9.5, 40, 1), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.PureElectricMileageKm != 60 || stat.MixedMileageKm != 0 { + t.Fatalf("large-battery no-mixed day must be entirely pure electric: %#v", stat) + } + if stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil { + t.Fatalf("all-electric day must not expose a mixed-mode hydrogen rate: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "全部里程计入纯电") { + t.Fatalf("all-electric classification reason missing: %#v", stat) + } +} + +func TestHydrogenV3SmallBatteryKeepsExistingNoChargeClassification(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10, 95, 1), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 160, 9.5, 40, 1), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 60 { + t.Fatalf("small-battery vehicle must retain the existing classification: %#v", stats) + } +} + +func TestHydrogenV3LargeBatteryWithConfirmedMixedKeepsExistingChargeBranch(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 95, 1), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 105, 9.9, 90, 2), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 110, 9.8, 85, 2), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 120, 9.5, 80, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 20 { + t.Fatalf("confirmed mixed day must retain the existing charge-cycle branch: %#v", stats) + } +} + +func TestHydrogenV3DoesNotPublishZeroRateWhenPhysicalHydrogenIsNegative(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 80, 2), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 120, 10.1, 70, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.ConsumptionKg >= 0 || stat.CorrectedConsumptionKg == nil || *stat.CorrectedConsumptionKg != 0 || stat.SOCBalancedKgPer100Km != nil || stat.QualityStatus != "NO_DATA" { + t.Fatalf("negative physical hydrogen must not publish a zero rate: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "物理用氢量为负") { + t.Fatalf("negative physical hydrogen reason missing: %#v", stat) + } +} + +func TestHydrogenV3DoesNotPublishZeroRateWhenSOCAdjustedHydrogenIsNegative(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 50, 2), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 120, 9.9, 80, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.ConsumptionKg <= 0 || stat.CorrectedConsumptionKg == nil || *stat.CorrectedConsumptionKg != 0 || stat.SOCBalancedKgPer100Km != nil || stat.QualityStatus != "NO_DATA" { + t.Fatalf("negative SOC-adjusted hydrogen must not publish a zero rate: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "SOC修正后用氢量为负") { + t.Fatalf("negative corrected hydrogen reason missing: %#v", stat) + } +} + +func TestHydrogenV3ExcludesDaysBelowOneKilometre(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 60, 2), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 100.9, 9.9, 60, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil { + t.Fatalf("day below one kilometre must not participate in rate calculation: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "总里程不足1km") { + t.Fatalf("sub-kilometre exclusion reason missing: %#v", stat) + } +} + +func TestHydrogenV3DoesNotCalculateRateBelowTenMixedKilometres(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 60, 2), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 109.9, 9.5, 58, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.MixedMileageKm != 9.9 || stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil { + t.Fatalf("mixed mileage below 10km must not expose a daily rate: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "混动里程不足10km") { + t.Fatalf("short mixed mileage reason missing: %#v", stat) + } +} + +func TestHydrogenV3ExcludesInactiveFuelCellPipePressureLoss(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10.0, 70, 2), + hydrogenV3TestFrame(vin, base.Add(4*time.Minute), 120, 9.5, 70, 2), + hydrogenV3TestFrame(vin, base.Add(8*time.Minute), 130, 9.5, 70, 2), + hydrogenV3TestFrame(vin, base.Add(12*time.Minute), 140, 9.0, 70, 2), + hydrogenV3TestFrame(vin, base.Add(16*time.Minute), 150, 8.5, 70, 2), + } + for index := 2; index < len(values); index++ { + values[index].FuelCellActive = false + values[index].FuelCellStateKnown = true + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.ConsumptionKg != 0.5 { + t.Fatalf("inactive pipe pressure loss must not be counted as driving hydrogen: %#v", stat) + } + if stat.SuspectedLeakCount != 1 || stat.SuspectedLeakMaxPressureDropMPa != 1 { + t.Fatalf("inactive pressure loss evidence mismatch: %#v", stat) + } + if stat.SOCBalancedKgPer100Km == nil || *stat.SOCBalancedKgPer100Km != 1 { + t.Fatalf("rate must use active-stack hydrogen boundary over 50km mixed mileage: %#v", stat) + } + if !strings.Contains(stat.QualityReason, "疑似管路泄压/漏氢") || stat.QualityStatus != "SUSPECT" { + t.Fatalf("suspected leak must be explicit and require review: %#v", stat) + } +} + +func TestHydrogenV3ResetsSOCAndPurePrefixAtEachExternalCharge(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10.0, 50, 2), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 20, 9.5, 55, 2), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 20, 9.5, 60, 2), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 20, 9.5, 90, 2), + hydrogenV3TestFrame(vin, base.Add(40*time.Second), 20, 9.5, 90, 1), + hydrogenV3TestFrame(vin, base.Add(50*time.Second), 30, 9.3, 70, 2), + hydrogenV3TestFrame(vin, base.Add(60*time.Second), 35, 9.1, 68, 2), + hydrogenV3TestFrame(vin, base.Add(70*time.Second), 40, 8.9, 66, 2), + hydrogenV3TestFrame(vin, base.Add(80*time.Second), 60, 8.5, 60, 1), + } + // Joint charging condition: both charging frames are stopped + chargeState=1. + for index := 2; index <= 3; index++ { + values[index].VehicleState = 2 + values[index].ChargeState = 1 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.ChargeCount != 1 || stat.PureElectricMileageKm != 10 || stat.MixedMileageKm != 50 { + t.Fatalf("charge-cycle mileage mismatch: %#v", stat) + } + if math.Abs(stat.ChargeEnergyKWh-6.312) > 0.0011 { + t.Fatalf("charge energy mismatch: %#v", stat) + } + // First mixed cycle SOC +5; second mixed cycle SOC -10. Charging SOC +35 is excluded. + if stat.BatterySOCDeltaPct == nil || *stat.BatterySOCDeltaPct != -5 || stat.ElectricEquivalentKg == nil || math.Abs(*stat.ElectricEquivalentKg-(-0.066)) > 0.0011 { + t.Fatalf("charge SOC leaked into correction: %#v", stat) + } + if stat.ConsumptionKg != 1.1 || stat.CorrectedConsumptionKg == nil || math.Abs(*stat.CorrectedConsumptionKg-1.166) > 0.0011 { + t.Fatalf("hydrogen correction mismatch: %#v", stat) + } +} + +func TestHydrogenV3SplitsHydrogenAtPersistentRefuelButNotCharge(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + times := []time.Duration{0, time.Minute, 2 * time.Minute, 2*time.Minute + 10*time.Second, 2*time.Minute + 20*time.Second, 3 * time.Minute} + masses := []float64{10, 9, 13, 13, 13, 12} + mileages := []float64{0, 10, 10, 10, 10, 20} + values := make([]HydrogenObservation, 0, len(times)) + for index := range times { + values = append(values, hydrogenV3TestFrame(vin, base.Add(times[index]), mileages[index], masses[index], 50, 2)) + values[index].PressureMPa = masses[index] + } + // The fill itself is short and stationary. The third high-pressure sample is + // already a restarted driving frame and must still confirm the refuel. + for _, index := range []int{2, 3} { + values[index].VehicleState = 2 + values[index].FuelCellActive = false + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 || stats[0].RefuelCount != 1 || stats[0].RefuelAmountKg != 4 || stats[0].ConsumptionKg != 2 || len(stats[0].HydrogenIntervals) != 2 { + t.Fatalf("refuel split mismatch: %#v", stats) + } + if stats[0].HydrogenIntervals[0].RawHydrogenConsumptionKg != 1 || stats[0].HydrogenIntervals[1].RawHydrogenConsumptionKg != 1 { + t.Fatalf("refuel evidence mismatch: %#v", stats[0].HydrogenIntervals) + } +} + +func TestHydrogenV3DetectsThermalRefuelAcrossInvalidFrameGapAndFuelCellRestart(t *testing.T) { + base := time.Date(2026, 8, 26, 15, 33, 7, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 45241.9, 4.2, 62, 1), + hydrogenV3TestFrame("vin", base.Add(4*time.Minute+24*time.Second), 45242.0, 10.8, 63, 2), + hydrogenV3TestFrame("vin", base.Add(4*time.Minute+34*time.Second), 45242.0, 10.7, 64, 2), + hydrogenV3TestFrame("vin", base.Add(4*time.Minute+44*time.Second), 45242.0, 10.6, 64, 2), + } + values[0].PressureMPa, values[0].TemperatureC = 12.5, 34 + values[1].PressureMPa, values[1].TemperatureC = 32.9, 55 + values[2].PressureMPa, values[2].TemperatureC = 32.8, 54 + values[3].PressureMPa, values[3].TemperatureC = 32.7, 54 + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 0 { + t.Fatalf("thermal refill after invalid frames/restart was not detected: %#v", values) + } +} + +func TestHydrogenV3DetectsThermalRefuelAcrossReportingGap(t *testing.T) { + base := time.Date(2026, 8, 28, 10, 26, 38, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 57402.2, 3.9, 77, 1), + hydrogenV3TestFrame("vin", base.Add(21*time.Minute+50*time.Second), 57402.2, 11.1, 79, 1), + hydrogenV3TestFrame("vin", base.Add(22*time.Minute), 57402.2, 11.0, 79, 1), + hydrogenV3TestFrame("vin", base.Add(22*time.Minute+10*time.Second), 57402.2, 10.9, 79, 1), + } + values[0].PressureMPa, values[0].TemperatureC = 9.8, 28 + values[1].PressureMPa, values[1].TemperatureC = 34.6, 52 + values[2].PressureMPa, values[2].TemperatureC = 34.5, 52 + values[3].PressureMPa, values[3].TemperatureC = 34.4, 51 + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 0 { + t.Fatalf("thermal refill across reporting gap was not detected: %#v", values) + } +} + +func TestHydrogenV3DetectsThermalRefuelAfterShortReposition(t *testing.T) { + base := time.Date(2026, 8, 24, 18, 31, 44, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 55648.4, 4.267, 72, 1), + hydrogenV3TestFrame("vin", base.Add(12*time.Minute+6*time.Second), 55648.7, 10.728, 76, 1), + hydrogenV3TestFrame("vin", base.Add(12*time.Minute+16*time.Second), 55648.7, 10.755, 76, 1), + hydrogenV3TestFrame("vin", base.Add(12*time.Minute+26*time.Second), 55648.7, 10.72, 76, 1), + } + values[0].PressureMPa, values[0].TemperatureC = 11.0, 32 + values[1].PressureMPa, values[1].TemperatureC = 33.1, 53 + values[2].PressureMPa, values[2].TemperatureC = 33.2, 53 + values[3].PressureMPa, values[3].TemperatureC = 33.1, 52 + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 6 { + t.Fatalf("thermal refill after 0.3km reposition was not detected: %#v", values) + } +} + +func TestHydrogenV3TreatsLeakedPipeRecoveryAsValveReopen(t *testing.T) { + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 10, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Minute), 100, 5, 70, 1), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100, 9.8, 70, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute+10*time.Second), 100, 9.8, 70, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute+20*time.Second), 100, 9.8, 70, 2), + } + values[0].PressureMPa, values[0].TemperatureC = 30, 30 + values[1].PressureMPa, values[1].TemperatureC = 10, 25 + values[2].PressureMPa, values[2].TemperatureC = 29, 35 + values[3].PressureMPa, values[3].TemperatureC = 29, 35 + values[4].PressureMPa, values[4].TemperatureC = 29, 35 + for index := range values { + values[index].RefuelThresholdKg = 1 + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + for _, value := range values { + if value.RefuelBoundary { + t.Fatalf("pipe pressure restoration must not be marked as refuel: %#v", values) + } + } +} + +func TestHydrogenV3AllowsRefuelAbovePreShutdownBaseline(t *testing.T) { + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 10, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Minute), 100, 5, 70, 1), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100, 12, 70, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute+10*time.Second), 100, 12, 70, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute+20*time.Second), 100, 12, 70, 2), + } + values[0].PressureMPa, values[0].TemperatureC = 30, 30 + values[1].PressureMPa, values[1].TemperatureC = 10, 25 + values[2].PressureMPa, values[2].TemperatureC = 36, 50 + values[3].PressureMPa, values[3].TemperatureC = 36, 50 + values[4].PressureMPa, values[4].TemperatureC = 36, 50 + for index := range values { + values[index].RefuelThresholdKg = 1 + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + if !values[2].RefuelBoundary || math.Abs(values[2].RefuelAmountKg-2) > 0.001 { + t.Fatalf("refuel above pre-shutdown baseline was not detected correctly: %#v", values) + } +} + +func TestHydrogenV3InfersPersistentRefuelWhenDailyActiveMassEndsHigher(t *testing.T) { + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 4.0, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 2.5, 65, 2), + hydrogenV3TestFrame("vin", base.Add(3*time.Hour), 155, 9.5, 70, 2), + hydrogenV3TestFrame("vin", base.Add(3*time.Hour+10*time.Second), 155, 9.4, 70, 2), + hydrogenV3TestFrame("vin", base.Add(3*time.Hour+20*time.Second), 155, 9.3, 70, 2), + hydrogenV3TestFrame("vin", base.Add(8*time.Hour), 300, 7.0, 60, 2), + } + for index := range values { + values[index].PressureMPa = values[index].MassKg * 3 + values[index].TemperatureC = 30 + values[index].BoundaryEligible = true + values[index].RefuelThresholdKg = 1 + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + markNetHydrogenIncreaseRefuelFallback(values, values) + if !values[2].RefuelBoundary || values[2].RefuelAmountKg != 7 { + t.Fatalf("daily net-mass refill fallback was not detected: %#v", values) + } +} + +func TestHydrogenV3DailyCalculationUsesNetMassRefuelFallback(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 4.0, 70, 2), + hydrogenV3TestFrame(vin, base.Add(time.Hour), 150, 2.5, 65, 2), + hydrogenV3TestFrame(vin, base.Add(3*time.Hour), 155, 9.5, 70, 2), + hydrogenV3TestFrame(vin, base.Add(3*time.Hour+10*time.Second), 155, 9.4, 70, 2), + hydrogenV3TestFrame(vin, base.Add(3*time.Hour+20*time.Second), 155, 9.3, 70, 2), + hydrogenV3TestFrame(vin, base.Add(8*time.Hour), 300, 7.0, 60, 2), + } + for index := range values { + values[index].PressureMPa = values[index].MassKg * 3 + values[index].TemperatureC = 30 + values[index].RefuelThresholdKg = 1 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-28", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16}, + }) + if len(stats) != 1 || stats[0].RefuelCount != 1 || stats[0].ConsumptionKg <= 0 || stats[0].SOCBalancedKgPer100Km == nil || *stats[0].SOCBalancedKgPer100Km <= 0 { + t.Fatalf("V3.5 daily calculation did not apply net-mass refill fallback: %#v", stats) + } +} + +func TestHydrogenV3DoesNotInferRefuelForValveRecoveryWithoutDailyMassIncrease(t *testing.T) { + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 10, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 5, 65, 1), + hydrogenV3TestFrame("vin", base.Add(time.Hour+10*time.Second), 150, 10, 65, 2), + hydrogenV3TestFrame("vin", base.Add(time.Hour+20*time.Second), 150, 10, 65, 2), + hydrogenV3TestFrame("vin", base.Add(time.Hour+30*time.Second), 150, 10, 65, 2), + hydrogenV3TestFrame("vin", base.Add(8*time.Hour), 300, 9, 60, 2), + } + for index := range values { + values[index].PressureMPa = values[index].MassKg * 3 + values[index].TemperatureC = 30 + values[index].BoundaryEligible = true + values[index].RefuelThresholdKg = 1 + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + markNetHydrogenIncreaseRefuelFallback(values, values) + for _, value := range values { + if value.RefuelBoundary { + t.Fatalf("valve recovery without daily net mass increase must not be inferred as refuel: %#v", values) + } + } +} + +func TestHydrogenV3CountsRefuelAtLastEffectiveHydrogenBoundary(t *testing.T) { + base := time.Date(2026, 8, 22, 8, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 10, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 5, 65, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Hour), 150, 15, 65, 2), + } + values[2].RefuelBoundary = true + values[2].RefuelAmountKg = 10 + consumption, refuelCount, refuelAmount, _, intervals := calculateHydrogenV3Consumption(values, values) + if refuelCount != 1 || refuelAmount != 10 || consumption != 5 { + t.Fatalf("last-boundary refuel was not included: consumption=%v count=%d amount=%v intervals=%#v", consumption, refuelCount, refuelAmount, intervals) + } +} + +func TestHydrogenV3IgnoresSubNoiseMassRiseBeforeRefuel(t *testing.T) { + base := time.Date(2026, 8, 28, 10, 0, 0, 0, time.Local) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 100, 3.876, 70, 2), + hydrogenV3TestFrame("vin", base.Add(time.Minute), 100.2, 3.889, 70, 2), + hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100.2, 11.135, 70, 2), + } + for index := range values { + values[index].NoiseKg = 0.05 + } + values[2].RefuelBoundary = true + values[2].RefuelAmountKg = 7.259 + consumption, refuelCount, _, reason, intervals := calculateHydrogenV3Consumption(values, values) + if refuelCount != 1 || consumption != 0 || reason != "" || len(intervals) != 2 || intervals[0].QualityStatus != "OK" { + t.Fatalf("sub-noise mass rise should be treated as zero: consumption=%v count=%d reason=%q intervals=%#v", consumption, refuelCount, reason, intervals) + } +} + +func TestHydrogenV3RejectsPressureRiseWhileVehicleIsMovingAsRefuel(t *testing.T) { + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame("vin", base, 0, 10, 50, 2), + hydrogenV3TestFrame("vin", base.Add(10*time.Second), 1, 14, 50, 2), + hydrogenV3TestFrame("vin", base.Add(20*time.Second), 2, 14, 50, 2), + hydrogenV3TestFrame("vin", base.Add(30*time.Second), 3, 14, 50, 2), + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + for _, value := range values { + if value.RefuelBoundary { + t.Fatalf("moving pressure rise must not be marked as refuel: %#v", values) + } + } +} + +func TestHydrogenV3AllowsDailyMileageAbove1500Km(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 1000, 20, 70, 2), + hydrogenV3TestFrame(vin, base.Add(23*time.Hour), 3000, 10, 60, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 || stats[0].QualityStatus == "NO_DATA" || stats[0].MixedMileageKm != 2000 { + t.Fatalf("daily mileage above 1500km must remain calculable: %#v", stats) + } +} + +func TestHydrogenV3ToleratesFloatingPointPureMileageEquality(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10, 80, 1), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 0.1, 9.9, 79, 1), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 0.1, 9.9, 90, 1), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 0.1, 9.9, 95, 1), + hydrogenV3TestFrame(vin, base.Add(40*time.Second), 0.1, 9.9, 95, 1), + hydrogenV3TestFrame(vin, base.Add(50*time.Second), 0.3, 9.8, 90, 1), + } + for index := 2; index <= 3; index++ { + values[index].VehicleState = 2 + values[index].ChargeState = 1 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.QualityReason == "纯电里程超过当日总里程" || stat.PureElectricMileageKm != 0.3 || stat.MixedMileageKm != 0 { + t.Fatalf("floating-point equality must not be treated as pure mileage overage: %#v", stat) + } +} + +func TestHydrogenV3ClearsRatesWhenNoMixedCycleExists(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10, 80, 1), + hydrogenV3TestFrame(vin, base.Add(10*time.Second), 10, 9.9, 70, 1), + hydrogenV3TestFrame(vin, base.Add(20*time.Second), 10, 9.9, 90, 1), + hydrogenV3TestFrame(vin, base.Add(30*time.Second), 10, 9.9, 95, 1), + hydrogenV3TestFrame(vin, base.Add(40*time.Second), 10.1, 9.9, 95, 1), + hydrogenV3TestFrame(vin, base.Add(50*time.Second), 20, 9.8, 90, 1), + } + for index := 2; index <= 3; index++ { + values[index].VehicleState = 2 + values[index].ChargeState = 1 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 { + t.Fatalf("stats=%#v", stats) + } + stat := stats[0] + if stat.QualityStatus != "NO_DATA" || stat.MixedMileageKm != 0.1 || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil { + t.Fatalf("NO_DATA without a mixed cycle must not retain rate fields: %#v", stat) + } +} + +func TestHydrogenV3ExplainsMissingRunBoundaries(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + parked := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 0, 10, 80, 1), + hydrogenV3TestFrame(vin, base.Add(time.Minute), 0, 10, 80, 1), + } + for index := range parked { + parked[index].VehicleState = 2 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(parked, "2026-08-26", 0.05, 20, nil) + if len(stats) != 1 || stats[0].QualityReason != "当日无车辆启动运行帧,无法形成起止区间" { + t.Fatalf("parked vehicle reason is not explicit: %#v", stats) + } + oneRun := []HydrogenObservation{hydrogenV3TestFrame(vin, base, 0, 10, 80, 2)} + stats = BuildHydrogenDailyStatsOrderedWithParameters(oneRun, "2026-08-26", 0.05, 20, nil) + if len(stats) != 1 || stats[0].QualityReason != "有效运行分界点仅1条,无法形成起止区间" { + t.Fatalf("single run reason is not explicit: %#v", stats) + } +} + +func TestHydrogenV3DetectsRefuelAcrossPowerOffTBOXGapWithReposition(t *testing.T) { + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + {ObservedAt: base, PressureMPa: 10, TemperatureC: 30, MassKg: 10, MileageKm: 100, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1}, + {ObservedAt: base.Add(10 * time.Minute), PressureMPa: 5, TemperatureC: 25, MassKg: 5, MileageKm: 101, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: false, RefuelThresholdKg: 1}, + {ObservedAt: base.Add(25 * time.Minute), PressureMPa: 12, TemperatureC: 40, MassKg: 12, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1}, + {ObservedAt: base.Add(25*time.Minute + 10*time.Second), PressureMPa: 12.1, TemperatureC: 40, MassKg: 12.1, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1}, + {ObservedAt: base.Add(25*time.Minute + 20*time.Second), PressureMPa: 12.1, TemperatureC: 40, MassKg: 12.1, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1}, + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + if !values[2].RefuelBoundary || values[2].RefuelAmountKg < 2 { + t.Fatalf("power-off refuel with TBOX gap and reposition must be detected: %#v", values) + } +} + +func TestHydrogenV3RecognizesChargeStateOneWhileVehicleIsOn(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10, 20, 2), + hydrogenV3TestFrame(vin, base.Add(time.Minute), 100, 10, 20, 1), + hydrogenV3TestFrame(vin, base.Add(2*time.Minute), 100, 10, 80, 1), + hydrogenV3TestFrame(vin, base.Add(3*time.Minute), 100, 10, 80, 2), + hydrogenV3TestFrame(vin, base.Add(4*time.Minute), 120, 9.5, 70, 2), + } + for index := 1; index <= 2; index++ { + values[index].ChargeState = 1 + values[index].VehicleState = 1 + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 || stats[0].ChargeCount != 1 || stats[0].BatterySOCDeltaPct == nil || *stats[0].BatterySOCDeltaPct != -10 { + t.Fatalf("charge-state=1 must reset the SOC baseline even while vehicle status is on: %#v", stats) + } +} + +func TestHydrogenV3DoesNotApplySOCChangeAcrossTBOXGap(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + hydrogenV3TestFrame(vin, base, 100, 10, 20, 2), + hydrogenV3TestFrame(vin, base.Add(10*time.Minute), 110, 9.8, 90, 2), + hydrogenV3TestFrame(vin, base.Add(10*time.Minute+10*time.Second), 120, 9.5, 80, 2), + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 || stats[0].BatterySOCDeltaPct == nil || *stats[0].BatterySOCDeltaPct != -10 { + t.Fatalf("SOC change across a TBOX gap must not enter energy correction: %#v", stats) + } +} + +func TestHydrogenV3UsesFuelCellEnergyWhenPressureBoundaryWouldPublishZero(t *testing.T) { + const vin = "LTEST32960VIN0001" + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := make([]HydrogenObservation, 0, 61) + for index := 0; index <= 60; index++ { + frame := hydrogenV3TestFrame(vin, base.Add(time.Duration(index)*10*time.Second), 100+float64(index)/3, 10+float64(index)/600, 70, 2) + frame.FuelCellVoltageV = 300 + frame.FuelCellCurrentA = 100 + frame.FuelCellPowerKnown = true + values = append(values, frame) + } + stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{ + vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true}, + }) + if len(stats) != 1 || stats[0].SOCBalancedKgPer100Km == nil || *stats[0].SOCBalancedKgPer100Km <= 0 || !strings.Contains(stats[0].QualityReason, "电压电流积分") { + t.Fatalf("fuel-cell energy must provide a positive auditable fallback: %#v", stats) + } +} + +func hydrogenV3TestFrame(vin string, observedAt time.Time, mileage, mass, soc float64, mode int) HydrogenObservation { + active := mode == 2 + return HydrogenObservation{ + VIN: vin, EventID: fmt.Sprintf("frame-%d", observedAt.UnixNano()), ObservedAt: observedAt, + MassKg: mass, PressureMPa: mass, TemperatureC: 30, + FuelCellActive: active, FuelCellStateKnown: true, + SOCPercent: soc, SOCKnown: true, MileageKm: mileage, MileageKnown: true, + VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true, + RunningMode: mode, RunningModeKnown: true, + } +} + +func TestTrustedHydrogenCalculatorDetectsGradualPersistentRefuelRise(t *testing.T) { + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := make([]HydrogenObservation, 0, 12) + pressures := []float64{10, 10.5, 11.2, 12, 13.2, 13.5, 13.6, 13.7, 13.8, 13.9, 14, 14.1} + for index, pressure := range pressures { + values = append(values, HydrogenObservation{ObservedAt: base.Add(time.Duration(index) * time.Minute), PressureMPa: pressure}) + } + prepared, _ := prepareHydrogenObservations(values, defaultHydrogenCalculationParameters()) + detected := 0 + for _, value := range prepared { + if value.RefuelBoundary { + detected++ + } + } + if detected != 1 { + t.Fatalf("persistent gradual pressure rise detections=%d prepared=%#v", detected, prepared) + } +} + +func TestHydrogenV3DoesNotTreatValveReopenPressureRecoveryAsRefuel(t *testing.T) { + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + pressures := []float64{10, 8, 6, 20, 20.1, 20.1, 20.0, 20.1, 20.0, 20.1} + values := make([]HydrogenObservation, 0, len(pressures)) + for index, pressure := range pressures { + active := index >= 3 + values = append(values, HydrogenObservation{ + ObservedAt: base.Add(time.Duration(index) * time.Minute), + PressureMPa: pressure, MassKg: pressure, TemperatureC: 30, + FuelCellStateKnown: true, FuelCellActive: active, + }) + } + markPersistentRefuels(values, defaultHydrogenCalculationParameters()) + for _, value := range values { + if value.RefuelBoundary { + t.Fatalf("valve reopen pressure recovery must not be marked as refuel: %#v", values) + } + } +} + +func TestTrustedHydrogenCalculatorRejectsPureElectricPressureFall(t *testing.T) { + base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) + values := []HydrogenObservation{ + {ObservedAt: base, PressureMPa: 28, RunningMode: 1, RunningModeKnown: true}, + {ObservedAt: base.Add(20 * time.Minute), PressureMPa: 22.5, RunningMode: 1, RunningModeKnown: true}, + } + prepared, _ := prepareHydrogenObservations(values, defaultHydrogenCalculationParameters()) + if !prepared[1].PressureInvalid || prepared[1].BoundaryEligible || prepared[1].BoundaryReason == "" { + t.Fatalf("pure-electric pressure anomaly not rejected: %#v", prepared) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_v3.go b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_v3.go new file mode 100644 index 00000000..611e3843 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/hydrogen_calculator_v3.go @@ -0,0 +1,759 @@ +package openplatform + +import ( + "fmt" + "math" + "sort" + "strings" + "time" +) + +const ( + hydrogenV3MixedConfirmSamples = 3 + hydrogenV3MixedConfirmWindow = 30 * time.Second + hydrogenV3MinimumMixedKm = 10.0 + hydrogenV3MileageEpsilonKm = 0.001 + hydrogenV3LargeBatteryKWh = 30.0 + hydrogenV3LeakMinimumDuration = 5 * time.Minute + hydrogenV3LeakMaximumGap = 5 * time.Minute + hydrogenV3LeakMinimumPressure = 0.5 + hydrogenV3LeakMinimumMassKg = 0.05 + hydrogenV3SOCContinuityGap = 5 * time.Minute + hydrogenV3PowerIntegrationGap = 2 * time.Minute +) + +type hydrogenV3LeakSummary struct { + count int + maxPressureDropMPa float64 +} + +type hydrogenV3Cycle struct { + frames []HydrogenObservation + initialStateKnown bool + initialMixedLocked bool +} + +func buildHydrogenDailyStatV3( + vin string, + source string, + date string, + values []HydrogenObservation, + noiseKg float64, + maxDropKg float64, + input HydrogenCalculationParameters, +) HydrogenDailyStat { + params := normalizeHydrogenCalculationParameters(input) + params.PowerOnDelaySeconds = 0 + params.PowerOffLeadSeconds = 0 + params.AlgorithmVersion = trustedHydrogenAlgorithmVersion + + stat := HydrogenDailyStat{ + VIN: vin, Source: source, Date: date, SampleCount: len(values), + CalculationParameters: params, QualityStatus: "OK", + } + if len(values) == 0 { + stat.QualityStatus, stat.QualityReason = "NO_DATA", "无有效压力温度样本" + return stat + } + + ordered := append([]HydrogenObservation(nil), values...) + sort.SliceStable(ordered, func(i, j int) bool { + if !ordered[i].ObservedAt.Equal(ordered[j].ObservedAt) { + return ordered[i].ObservedAt.Before(ordered[j].ObservedAt) + } + return ordered[i].EventID < ordered[j].EventID + }) + + prepared := append([]HydrogenObservation(nil), ordered...) + markPureElectricPressureInvalid(prepared, params) + for index := range prepared { + annotateHydrogenV3Eligibility(&prepared[index]) + value := prepared[index] + if value.PressureInvalid { + stat.InvalidSegmentCount++ + } + } + cycles, runs, chargeCount, chargeEnergyKWh := buildHydrogenV3Cycles(prepared, params) + stat.ChargeCount = chargeCount + stat.ChargeEnergyKWh = hydrogenV3Round3(chargeEnergyKWh) + stat.EligibleIntervalCount = len(runs) + if len(runs) < 2 { + stat.QualityStatus = "NO_DATA" + if len(runs) == 1 { + stat.QualityReason = "有效运行分界点仅1条,无法形成起止区间" + } else if countHydrogenV3StartedFrames(prepared) == 0 { + stat.QualityReason = "当日无车辆启动运行帧,无法形成起止区间" + } else { + stat.QualityReason = "当日无满足计算条件的有效运行分界点" + } + return stat + } + + firstRun, lastRun := runs[0], runs[len(runs)-1] + stat.FirstObservation, stat.LastObservation = firstRun, lastRun + stat.FirstMassKg, stat.LastMassKg = hydrogenV3Round3(firstRun.MassKg), hydrogenV3Round3(lastRun.MassKg) + stat.CycleMinimumMassKg = hydrogenV3Round3(minimumHydrogenV3Mass(runs)) + + rawTotalMileage := lastRun.MileageKm - firstRun.MileageKm + if !firstRun.MileageKnown || !lastRun.MileageKnown || rawTotalMileage < -hydrogenV3MileageEpsilonKm { + stat.QualityStatus, stat.QualityReason = "NO_DATA", "当日有效仪表里程倒退或异常跳变" + return stat + } + totalMileage := hydrogenV3Round3(math.Max(0, rawTotalMileage)) + largeBatteryPureDay := params.BatteryCapacityKWh > hydrogenV3LargeBatteryKWh && !hydrogenV3HasConfirmedMixed(cycles) + + intervals := make([]HydrogenIntervalEvidence, 0, len(cycles)*2) + var pureMileage, socDeltaPct, batteryEnergyKWh, electricEquivalentKg float64 + mixedCycleCount := 0 + initialStateUnknown := false + for _, cycle := range cycles { + if largeBatteryPureDay { + // For large-battery vehicles, a day with no confirmed mixed-mode + // operation is an all-electric day even when the external charge + // happened on an earlier date and is absent from today's frames. + cycle.initialStateKnown = true + cycle.initialMixedLocked = false + } + if !cycle.initialStateKnown && len(cycle.frames) > 0 && isHydrogenV3Pure(cycle.frames[0]) { + initialStateUnknown = true + } + cycleIntervals, cyclePureKm, cycleSOCDelta, cycleEnergyKWh, cycleEquivalentKg, hasMixed := buildHydrogenV3CycleIntervals(cycle, len(intervals)+1, params) + intervals = append(intervals, cycleIntervals...) + pureMileage += cyclePureKm + if hasMixed { + mixedCycleCount++ + socDeltaPct += cycleSOCDelta + batteryEnergyKWh += cycleEnergyKWh + electricEquivalentKg += cycleEquivalentKg + } + } + fuelCellEnergyHydrogenKg := calculateHydrogenV3FuelCellEnergyHydrogen(cycles, largeBatteryPureDay, params) + if largeBatteryPureDay { + // Charging/parking gaps can leave small mileage residuals outside the + // individual run intervals. The business rule classifies the complete + // first-to-last valid mileage delta as pure electric on such days. + pureMileage = totalMileage + } + + pureMileage = hydrogenV3Round3(math.Max(0, pureMileage)) + mixedMileage := hydrogenV3Round3(totalMileage - pureMileage) + if mixedMileage < -hydrogenV3MileageEpsilonKm { + stat.QualityStatus, stat.QualityReason = "NO_DATA", "纯电里程超过当日总里程" + return stat + } + if mixedMileage < 0 { + // Telemetry mileage is reported at finite precision. When separately + // rounded charge-cycle spans add up a few ulps above the whole-day + // boundary delta, treat the sub-metre difference as zero instead of + // rejecting a physically valid pure-electric day. + mixedMileage = 0 + pureMileage = totalMileage + } + + markPersistentRefuels(prepared, params) + // Use all valid running boundaries for the day-level net-increase fallback. + // A short fill may occur while the fuel-cell state is inactive; limiting the + // fallback to active-stack frames would miss exactly that refill event. + hydrogenRuns := hydrogenV3FuelCellConsumptionRuns(runs) + _, preliminaryRefuelCount, _, _, _ := calculateHydrogenV3Consumption(hydrogenRuns, prepared) + if preliminaryRefuelCount == 0 { + markNetHydrogenIncreaseRefuelFallback(prepared, runs) + } + stat.AbnormalDropCount = countHydrogenV3AbnormalDrops(prepared, maxDropKg) + leakSummary := detectHydrogenV3InactivePressureDrops(prepared) + stat.SuspectedLeakCount = leakSummary.count + stat.SuspectedLeakMaxPressureDropMPa = hydrogenV3Round3(leakSummary.maxPressureDropMPa) + consumptionKg, refuelCount, refuelAmountKg, hydrogenReason, hydrogenIntervals := calculateHydrogenV3Consumption(hydrogenRuns, prepared) + stat.RefuelCount = refuelCount + stat.RefuelAmountKg = hydrogenV3Round3(refuelAmountKg) + stat.ConsumptionKg = hydrogenV3Round3(consumptionKg) + stat.HydrogenIntervals = hydrogenIntervals + stat.PureElectricMileageKm = hydrogenV3Round3(pureMileage) + stat.MixedMileageKm = hydrogenV3Round3(mixedMileage) + stat.Intervals = intervals + stat.QualifiedSegmentCount = len(intervals) + + rawElectricEquivalentKg := electricEquivalentKg + usedEnergyFallback := false + if mixedMileage >= hydrogenV3MinimumMixedKm && fuelCellEnergyHydrogenKg > 0 && + (consumptionKg <= 0 || consumptionKg-rawElectricEquivalentKg <= 0) { + consumptionKg = math.Max(consumptionKg, fuelCellEnergyHydrogenKg) + stat.ConsumptionKg = hydrogenV3Round3(consumptionKg) + usedEnergyFallback = true + } + socDeltaPct = hydrogenV3Round3(socDeltaPct) + batteryEnergyKWh = hydrogenV3Round3(batteryEnergyKWh) + electricEquivalentKg = hydrogenV3Round3(electricEquivalentKg) + unclampedCorrectedKg := consumptionKg - rawElectricEquivalentKg + correctedRawKg := unclampedCorrectedKg + correctedClampedToZero := consumptionKg < 0 || correctedRawKg < 0 + if correctedClampedToZero { + correctedRawKg = 0 + } + correctedKg := hydrogenV3Round3(correctedRawKg) + stat.BatterySOCDeltaPct = hydrogenV3FloatPointer(socDeltaPct) + stat.BatteryEnergyChangeKWh = hydrogenV3FloatPointer(batteryEnergyKWh) + stat.ElectricEquivalentKg = hydrogenV3FloatPointer(electricEquivalentKg) + stat.CorrectedConsumptionKg = hydrogenV3FloatPointer(correctedKg) + // Compatibility fields retain their storage locations but now follow the + // business-facing sign: SOC rise is positive and is subtracted from hydrogen. + stat.BatteryDischargeKWh = hydrogenV3FloatPointer(batteryEnergyKWh) + stat.BatteryEquivalentKg = hydrogenV3FloatPointer(electricEquivalentKg) + stat.SOCBalancedConsumptionKg = hydrogenV3FloatPointer(correctedKg) + + reasons := make([]string, 0, 4) + if hydrogenReason != "" { + reasons = append(reasons, hydrogenReason) + } + if usedEnergyFallback { + reasons = append(reasons, "压力质量首末边界无法形成有效正耗氢,采用燃料电池电压电流积分折算氢量") + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + if largeBatteryPureDay { + reasons = append(reasons, "电池容量大于30kWh且全天未出现确认混动,全部里程计入纯电") + } + if correctedClampedToZero { + if consumptionKg < 0 { + reasons = append(reasons, "当日物理用氢量为负,修正用氢量已按0处理") + } else { + reasons = append(reasons, "SOC修正后用氢量为负,修正用氢量已按0处理") + } + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + if initialStateUnknown { + reasons = append(reasons, "日初充电周期状态缺失,按已进入混动处理,未累计日初纯电里程") + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + if stat.InvalidSegmentCount > 0 { + reasons = append(reasons, fmt.Sprintf("已排除%d条纯电状态氢压异常记录", stat.InvalidSegmentCount)) + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + if stat.AbnormalDropCount > 0 { + reasons = append(reasons, fmt.Sprintf("检测到%d次氢量异常大幅下降", stat.AbnormalDropCount)) + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + if stat.SuspectedLeakCount > 0 { + reasons = append(reasons, fmt.Sprintf("检测到%d段燃料电池未工作期间持续压降,疑似管路泄压/漏氢,最大压降%.3fMPa;停机段不作为行驶耗氢边界", stat.SuspectedLeakCount, stat.SuspectedLeakMaxPressureDropMPa)) + if stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + } + switch { + case totalMileage < 1: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, "当日有效总里程不足1km,不参与氢耗计算") + case mixedCycleCount == 0: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, "当日未形成用氢混动周期") + case mixedMileage == 0: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, "当日用氢混动里程为0") + case len(hydrogenIntervals) == 0: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, "燃料电池有效工作氢量边界不足,无法计算当日耗氢") + case params.BatteryCapacityKWh <= 0: + stat.QualityStatus = "SUSPECT" + reasons = append(reasons, "车型动力电池容量未确认") + case mixedMileage < hydrogenV3MinimumMixedKm: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, fmt.Sprintf("混动里程不足%.0fkm,不计算当日百公里氢耗", hydrogenV3MinimumMixedKm)) + case correctedRawKg <= 0: + stat.QualityStatus = "NO_DATA" + reasons = append(reasons, "存在混动里程及燃料电池工作记录,但现有报文不足以形成可信的正耗氢结果,不输出0值") + } + if hydrogenReason != "" && stat.QualityStatus == "OK" { + stat.QualityStatus = "SUSPECT" + } + if stat.QualityStatus != "NO_DATA" && mixedCycleCount > 0 && mixedMileage > 0 { + physicalRate := hydrogenV3Round3(consumptionKg * 100 / mixedMileage) + correctedRate := hydrogenV3Round3(correctedRawKg * 100 / mixedMileage) + stat.ConsumptionKgPer100Km = &physicalRate + stat.SOCBalancedKgPer100Km = &correctedRate + } + stat.QualityReason = strings.Join(reasons, ";") + return stat +} + +func countHydrogenV3StartedFrames(values []HydrogenObservation) int { + count := 0 + for _, value := range values { + if value.VehicleStateKnown && value.VehicleState == 1 { + count++ + } + } + return count +} + +func buildHydrogenV3Cycles(values []HydrogenObservation, params HydrogenCalculationParameters) ([]hydrogenV3Cycle, []HydrogenObservation, int, float64) { + cycles := make([]hydrogenV3Cycle, 0, 4) + runs := make([]HydrogenObservation, 0, len(values)) + var current *hydrogenV3Cycle + charging := false + chargeCount := 0 + chargeStartSOC := 0.0 + chargeLastSOC := 0.0 + chargeSOCKnown := false + chargeEnergyKWh := 0.0 + initialStateApplied := false + finishCharge := func() { + if chargeSOCKnown && params.BatteryCapacityKWh > 0 && chargeLastSOC > chargeStartSOC { + chargeEnergyKWh += params.BatteryCapacityKWh * (chargeLastSOC - chargeStartSOC) / 100 + } + chargeSOCKnown = false + } + for _, value := range values { + if current != nil && len(current.frames) > 0 && value.ObservedAt.Sub(current.frames[len(current.frames)-1].ObservedAt) > hydrogenV3SOCContinuityGap { + cycles = append(cycles, *current) + current = nil + } + externalCharging := isHydrogenV3ExternalCharging(value) + if externalCharging { + if !charging { + chargeCount++ + if value.SOCKnown { + chargeStartSOC = value.SOCPercent + chargeLastSOC = value.SOCPercent + chargeSOCKnown = true + } + if current != nil && len(current.frames) > 0 { + cycles = append(cycles, *current) + } + current = nil + } + if value.SOCKnown { + if !chargeSOCKnown { + chargeStartSOC = value.SOCPercent + chargeSOCKnown = true + } + chargeLastSOC = value.SOCPercent + } + charging = true + continue + } + if charging { + finishCharge() + charging = false + current = nil + initialStateApplied = true + } + if !isHydrogenV3EffectiveRun(value) { + continue + } + if current == nil { + known := true + locked := false + if !initialStateApplied && params.InitialChargeCycleKnown { + locked = params.InitialMixedLocked + } else if !initialStateApplied { + // Without persisted state, do not assume that a day beginning in + // pure mode follows an external charge. This prevents midnight from + // incorrectly resetting the charge cycle. + known = false + locked = true + } + current = &hydrogenV3Cycle{initialStateKnown: known, initialMixedLocked: locked} + initialStateApplied = true + } + current.frames = append(current.frames, value) + runs = append(runs, value) + } + if current != nil && len(current.frames) > 0 { + cycles = append(cycles, *current) + } + if charging { + finishCharge() + } + return cycles, runs, chargeCount, chargeEnergyKWh +} + +func buildHydrogenV3CycleIntervals(cycle hydrogenV3Cycle, firstIndex int, params HydrogenCalculationParameters) ([]HydrogenIntervalEvidence, float64, float64, float64, float64, bool) { + frames := cycle.frames + if len(frames) == 0 { + return nil, 0, 0, 0, 0, false + } + first, last := frames[0], frames[len(frames)-1] + startsPure := isHydrogenV3Pure(first) && !cycle.initialMixedLocked + mixedStart := -1 + if cycle.initialMixedLocked || !startsPure { + mixedStart = 0 + } else { + mixedStart = findHydrogenV3ConfirmedMixed(frames) + } + + intervals := make([]HydrogenIntervalEvidence, 0, 2) + pureMileage := 0.0 + if startsPure { + pureEndIndex := len(frames) - 1 + if mixedStart >= 0 { + // The first confirmed mixed frame is the state boundary. Its mileage is + // shared by the end of the pure prefix and the start of the mixed span, + // so no mileage is lost between two adjacent telemetry frames. + pureEndIndex = mixedStart + } + if pureEndIndex >= 0 { + pureEnd := frames[pureEndIndex] + pureMileage = math.Max(0, pureEnd.MileageKm-first.MileageKm) + intervals = append(intervals, newHydrogenV3Interval(firstIndex+len(intervals), "PURE_ELECTRIC", first, pureEnd, pureEndIndex+1, params, "充电后首次连续纯电区间(终点为首次确认混动分界点)")) + } + } + if mixedStart < 0 { + return intervals, round3(pureMileage), 0, 0, 0, false + } + + mixedStartFrame := frames[mixedStart] + mixedInterval := newHydrogenV3Interval(firstIndex+len(intervals), "MIXED", mixedStartFrame, last, len(frames)-mixedStart, params, "首次进入混动后锁定至下一次充电") + intervals = append(intervals, mixedInterval) + socDelta := last.SOCPercent - mixedStartFrame.SOCPercent + energyKWh := 0.0 + equivalentKg := 0.0 + if params.BatteryCapacityKWh > 0 && params.HydrogenEnergyKWhKg > 0 { + energyKWh = params.BatteryCapacityKWh * socDelta / 100 + equivalentKg = energyKWh / params.HydrogenEnergyKWhKg + } + return intervals, pureMileage, socDelta, energyKWh, equivalentKg, true +} + +func newHydrogenV3Interval(index int, intervalType string, start, end HydrogenObservation, sampleCount int, params HydrogenCalculationParameters, reason string) HydrogenIntervalEvidence { + startSOC, endSOC := round3(start.SOCPercent), round3(end.SOCPercent) + socDelta := round3(end.SOCPercent - start.SOCPercent) + energyKWh := round3(params.BatteryCapacityKWh * (end.SOCPercent - start.SOCPercent) / 100) + equivalentKg := 0.0 + if params.HydrogenEnergyKWhKg > 0 { + equivalentKg = round3(energyKWh / params.HydrogenEnergyKWhKg) + } + rawHydrogenKg := round3(start.MassKg - end.MassKg) + correctedKg := round3(rawHydrogenKg - equivalentKg) + startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm) + mileage := round3(math.Max(0, end.MileageKm-start.MileageKm)) + interval := HydrogenIntervalEvidence{ + Index: index, Type: intervalType, + StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano), + StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source), + StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa), + StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC), + StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg), + StartSOCPercent: &startSOC, EndSOCPercent: &endSOC, BatterySOCDeltaPct: &socDelta, + BatteryEnergyChangeKWh: &energyKWh, ElectricEquivalentKg: &equivalentKg, CorrectedConsumptionKg: &correctedKg, + BatteryDischargeKWh: &energyKWh, BatteryEquivalentKg: &equivalentKg, + StartMileageKm: &startMileage, EndMileageKm: &endMileage, MileageKm: &mileage, + SampleCount: sampleCount, QualityStatus: "OK", QualityReason: reason, + } + if intervalType == "MIXED" { + interval.RawHydrogenConsumptionKg = rawHydrogenKg + interval.SOCBalancedConsumptionKg = &correctedKg + if mileage > 0 { + rate := round3(correctedKg * 100 / mileage) + interval.ConsumptionKgPer100Km = &rate + } + } + return interval +} + +// hydrogenV3FuelCellConsumptionRuns limits pressure/temperature mass boundaries +// to moments when the fuel-cell stack is actually working. When the stack is +// explicitly inactive, the bottle valve may be closed while residual pressure +// in the downstream pipe slowly decays; treating that pressure as tank mass +// would incorrectly inflate driving hydrogen consumption. Unknown stack state +// remains eligible for backward compatibility, but is still subject to the +// existing quality rules. +func hydrogenV3FuelCellConsumptionRuns(runs []HydrogenObservation) []HydrogenObservation { + filtered := make([]HydrogenObservation, 0, len(runs)) + for _, value := range runs { + if value.FuelCellStateKnown && !value.FuelCellActive { + continue + } + filtered = append(filtered, value) + } + return filtered +} + +// detectHydrogenV3InactivePressureDrops identifies sustained pressure loss while +// the fuel cell is explicitly inactive. Requiring at least three samples, five +// minutes of continuity, and simultaneous pressure and temperature-corrected +// mass loss avoids classifying isolated sensor noise or simple gas cooling as a +// leak. The estimated loss is audit evidence only and is not driving hydrogen. +func detectHydrogenV3InactivePressureDrops(values []HydrogenObservation) hydrogenV3LeakSummary { + var result hydrogenV3LeakSummary + segment := make([]HydrogenObservation, 0, 16) + flush := func() { + if len(segment) < 3 { + segment = segment[:0] + return + } + start, end := segment[0], segment[len(segment)-1] + if end.ObservedAt.Sub(start.ObservedAt) < hydrogenV3LeakMinimumDuration { + segment = segment[:0] + return + } + pressureDrop := start.PressureMPa - end.PressureMPa + massDrop := start.MassKg - end.MassKg + massThreshold := math.Max(hydrogenV3LeakMinimumMassKg, math.Max(start.NoiseKg, end.NoiseKg)) + if pressureDrop >= hydrogenV3LeakMinimumPressure && massDrop >= massThreshold { + result.count++ + result.maxPressureDropMPa = math.Max(result.maxPressureDropMPa, pressureDrop) + } + segment = segment[:0] + } + for _, value := range values { + eligible := value.FuelCellStateKnown && !value.FuelCellActive && + !value.RefuelBoundary && validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) && value.MassKg > 0 + if !eligible { + flush() + continue + } + if len(segment) > 0 && value.ObservedAt.Sub(segment[len(segment)-1].ObservedAt) > hydrogenV3LeakMaximumGap { + flush() + } + segment = append(segment, value) + } + flush() + return result +} + +func calculateHydrogenV3Consumption(runs, prepared []HydrogenObservation) (float64, int, float64, string, []HydrogenIntervalEvidence) { + if len(runs) < 2 { + return 0, 0, 0, "燃料电池有效工作氢量边界不足", nil + } + boundaries := make([]time.Time, 0, 2) + refuelAmountKg := 0.0 + for _, value := range prepared { + if value.RefuelBoundary && value.ObservedAt.After(runs[0].ObservedAt) && !value.ObservedAt.After(runs[len(runs)-1].ObservedAt) { + boundaries = append(boundaries, value.ObservedAt) + refuelAmountKg += math.Max(0, value.RefuelAmountKg) + } + } + if len(boundaries) == 0 { + _, drop, _ := hydrogenV3MassDropForCalculation(runs[0], runs[len(runs)-1]) + interval := newHydrogenV3HydrogenInterval(1, runs[0], runs[len(runs)-1], len(runs), "当日无加氢,取首末有效运行分界点") + if drop < 0 { + return drop, 0, 0, "无加氢但终点剩余氢量高于起点", []HydrogenIntervalEvidence{interval} + } + return drop, 0, 0, "", []HydrogenIntervalEvidence{interval} + } + + startIndex := 0 + consumption := 0.0 + usedBoundaries := 0 + negativeSegment := false + intervals := make([]HydrogenIntervalEvidence, 0, len(boundaries)+1) + for _, boundary := range boundaries { + endIndex := -1 + for index := startIndex; index < len(runs) && runs[index].ObservedAt.Before(boundary); index++ { + endIndex = index + } + if endIndex >= startIndex { + _, drop, _ := hydrogenV3MassDropForCalculation(runs[startIndex], runs[endIndex]) + consumption += drop + negativeSegment = negativeSegment || drop < 0 + intervals = append(intervals, newHydrogenV3HydrogenInterval(len(intervals)+1, runs[startIndex], runs[endIndex], endIndex-startIndex+1, "加氢前氢量消耗分段")) + } + nextStart := endIndex + 1 + for nextStart < len(runs) && runs[nextStart].ObservedAt.Before(boundary) { + nextStart++ + } + if nextStart < len(runs) { + startIndex = nextStart + usedBoundaries++ + } + } + if startIndex < len(runs) { + _, drop, _ := hydrogenV3MassDropForCalculation(runs[startIndex], runs[len(runs)-1]) + consumption += drop + negativeSegment = negativeSegment || drop < 0 + intervals = append(intervals, newHydrogenV3HydrogenInterval(len(intervals)+1, runs[startIndex], runs[len(runs)-1], len(runs)-startIndex, "加氢后至下一加氢或日终分段")) + } + if negativeSegment { + return consumption, usedBoundaries, refuelAmountKg, "加氢分段存在终点氢量高于起点", intervals + } + return consumption, usedBoundaries, refuelAmountKg, "", intervals +} + +func newHydrogenV3HydrogenInterval(index int, start, end HydrogenObservation, sampleCount int, reason string) HydrogenIntervalEvidence { + startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm) + mileage := round3(math.Max(0, end.MileageKm-start.MileageKm)) + rawDrop, adjustedDrop, withinNoise := hydrogenV3MassDropForCalculation(start, end) + drop := round3(rawDrop) + quality := "OK" + if adjustedDrop < 0 { + quality = "SUSPECT" + } else if withinNoise { + reason += ";氢量上升在噪声阈值内,按0计" + } + return HydrogenIntervalEvidence{ + Index: index, Type: "HYDROGEN", + StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano), + StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source), + StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa), + StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC), + StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg), RawHydrogenConsumptionKg: drop, + StartMileageKm: &startMileage, EndMileageKm: &endMileage, MileageKm: &mileage, + SampleCount: sampleCount, QualityStatus: quality, QualityReason: reason, + } +} + +func hydrogenV3MassDropForCalculation(start, end HydrogenObservation) (raw, adjusted float64, withinNoise bool) { + raw = start.MassKg - end.MassKg + adjusted = raw + noise := math.Max(0.05, math.Max(start.NoiseKg, end.NoiseKg)) + if raw < 0 && math.Abs(raw) <= noise { + adjusted = 0 + withinNoise = true + } + return raw, adjusted, withinNoise +} + +func findHydrogenV3ConfirmedMixed(frames []HydrogenObservation) int { + runStart, count := -1, 0 + var previous time.Time + for index, value := range frames { + if !isHydrogenV3Mixed(value) || (count > 0 && value.ObservedAt.Sub(previous) > hydrogenV3MixedConfirmWindow) { + runStart, count = -1, 0 + } + if isHydrogenV3Mixed(value) { + if count == 0 { + runStart = index + } + count++ + previous = value.ObservedAt + if count >= hydrogenV3MixedConfirmSamples { + return runStart + } + } + } + return -1 +} + +func hydrogenV3HasConfirmedMixed(cycles []hydrogenV3Cycle) bool { + for _, cycle := range cycles { + if findHydrogenV3ConfirmedMixed(cycle.frames) >= 0 { + return true + } + } + return false +} + +func isHydrogenV3ExternalCharging(value HydrogenObservation) bool { + return value.ChargeStateKnown && value.ChargeState == 1 && value.VehicleStateKnown && + (value.VehicleState == 1 || value.VehicleState == 2) +} + +func calculateHydrogenV3FuelCellEnergyHydrogen(cycles []hydrogenV3Cycle, allElectric bool, params HydrogenCalculationParameters) float64 { + if allElectric || params.HydrogenEnergyKWhKg <= 0 { + return 0 + } + energyKWh := 0.0 + for _, cycle := range cycles { + frames := cycle.frames + if len(frames) < 2 { + continue + } + mixedStart := 0 + if isHydrogenV3Pure(frames[0]) && !cycle.initialMixedLocked { + mixedStart = findHydrogenV3ConfirmedMixed(frames) + if mixedStart < 0 { + continue + } + } + for index := mixedStart + 1; index < len(frames); index++ { + previous, current := frames[index-1], frames[index] + gap := current.ObservedAt.Sub(previous.ObservedAt) + if gap <= 0 || gap > hydrogenV3PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown { + continue + } + previousKW := math.Max(0, previous.FuelCellVoltageV*previous.FuelCellCurrentA/1000) + currentKW := math.Max(0, current.FuelCellVoltageV*current.FuelCellCurrentA/1000) + energyKWh += (previousKW + currentKW) / 2 * gap.Hours() + } + } + return energyKWh / params.HydrogenEnergyKWhKg +} + +func isHydrogenV3EffectiveRun(value HydrogenObservation) bool { + return value.VehicleStateKnown && value.VehicleState == 1 && + !isHydrogenV3ExternalCharging(value) && !value.PressureInvalid && + validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) && value.MassKg > 0 && + value.MileageKnown && value.SOCKnown && value.RunningModeKnown +} + +func annotateHydrogenV3Eligibility(value *HydrogenObservation) { + value.BoundaryEligible = false + if value.PressureInvalid { + return + } + switch { + case isHydrogenV3ExternalCharging(*value): + value.BoundaryReason = "外部充电区间(充电状态=1且车辆状态为启动或熄火)" + case !value.VehicleStateKnown || value.VehicleState != 1: + value.BoundaryReason = "非车辆启动状态" + case !validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) || value.MassKg <= 0: + value.BoundaryReason = "压力、温度或换算氢量无效" + case !value.MileageKnown || !value.SOCKnown || !value.RunningModeKnown: + value.BoundaryReason = "SOC、仪表里程或运行模式缺失" + default: + value.BoundaryEligible = true + value.BoundaryReason = "有效运行候选点" + } +} + +func countHydrogenV3AbnormalDrops(values []HydrogenObservation, maxDropKg float64) int { + if maxDropKg <= 0 { + return 0 + } + count := 0 + for index := 1; index < len(values); index++ { + previous, current := values[index-1], values[index] + if !previous.BoundaryEligible || !current.BoundaryEligible || current.RefuelBoundary { + continue + } + if previous.MassKg-current.MassKg > maxDropKg { + count++ + } + } + return count +} + +func isHydrogenV3Pure(value HydrogenObservation) bool { + // GB/T 32960整车运行模式是纯电/混动里程划分的主判断字段。 + // 燃料电池扩展状态仅作为核验信号,不能覆盖整车运行模式, + // 否则会把“运行模式=2、发动机工作状态=1”的真实混动区间误判为纯电。 + return value.RunningModeKnown && value.RunningMode == 1 +} + +func isHydrogenV3Mixed(value HydrogenObservation) bool { + return value.RunningModeKnown && value.RunningMode == 2 +} + +func minimumHydrogenV3Mass(values []HydrogenObservation) float64 { + minimum := math.Inf(1) + for _, value := range values { + if value.MassKg < minimum { + minimum = value.MassKg + } + } + if math.IsInf(minimum, 1) { + return 0 + } + return minimum +} + +func hydrogenV3FloatPointer(value float64) *float64 { + return &value +} + +func hydrogenV3Round3(value float64) float64 { + // Telemetry arithmetic often lands a few ulps below an exact x.xxx5 tie. + // Apply a scale-relative epsilon before half-away-from-zero rounding so the + // displayed three-decimal result is stable and reproducible in Excel. + scaled := value * 1000 + return math.Round(scaled+math.Copysign(1e-9, scaled)) / 1000 +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 98848d9a..3f5f434b 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -6,8 +6,9 @@ import ( ) const ( - StatusNormal = "NORMAL" - StatusNoData = "NO_DATA" + StatusNormal = "NORMAL" + StatusNoData = "NO_DATA" + StatusDataAnomaly = "DATA_ANOMALY" ) type QueryRequest struct { @@ -60,6 +61,7 @@ type MileageResult struct { DataTime *string `json:"dataTime"` UpdatedAt *string `json:"updatedAt"` SourceProtocol *string `json:"sourceProtocol"` + DataQuality *string `json:"dataQuality,omitempty"` Status string `json:"status"` } @@ -72,6 +74,7 @@ type MileageRangeResult struct { DataTime *string `json:"dataTime"` UpdatedAt *string `json:"updatedAt"` SourceProtocol *string `json:"sourceProtocol"` + DataQuality *string `json:"dataQuality,omitempty"` Status string `json:"status"` } @@ -101,6 +104,44 @@ type TotalMileagePoint struct { TotalMileageKm float64 } +// StationaryVehicleQueryRequest verifies whether an authorized vehicle stayed +// at a refuelling-site coordinate during a supplied time window. Coordinates +// may be supplied in WGS84 or GCJ02 (Amap) longitude/latitude. +type StationaryVehicleQueryRequest struct { + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + CoordinateSystem string `json:"coordinateSystem,omitempty"` + RadiusMeters *float64 `json:"radiusMeters,omitempty"` + PlateNumbers []string `json:"plateNumbers,omitempty"` +} + +type StationaryVehicleResult struct { + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + StayStartTime string `json:"stayStartTime"` + StayEndTime string `json:"stayEndTime"` + StayDurationSeconds int64 `json:"stayDurationSeconds"` + StayDurationMinutes float64 `json:"stayDurationMinutes"` + MatchScore float64 `json:"matchScore"` + AverageDistanceM float64 `json:"averageDistanceMeters"` + MaxDistanceM float64 `json:"maxDistanceMeters"` + AverageSpeedKmh float64 `json:"averageSpeedKmh"` + MaxSpeedKmh float64 `json:"maxSpeedKmh"` + MatchedSamples int `json:"matchedSamples"` + SourceProtocols []string `json:"sourceProtocols"` +} + +type StationaryLocationPoint struct { + VIN string + Protocol string + ObservedAt time.Time + Longitude float64 + Latitude float64 + SpeedKmh float64 +} + type RealtimeVehicleRequest struct { PlateNumbers []string `json:"plateNumbers,omitempty"` } @@ -111,22 +152,28 @@ type RealtimeVehiclePoint struct { Longitude float64 Latitude float64 SpeedKmh float64 + SOCPercent *float64 TotalMileageKm float64 ObservedAt time.Time Online bool + ActiveToday bool } type RealtimeVehicleResult struct { - VIN string `json:"vin"` - PlateNumber string `json:"plateNumber"` + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + // Protocol is the single, canonical source protocol for this realtime record. + // It is normalized to GB32960, MQTT, or JT808. Protocol string `json:"protocol,omitempty"` Longitude *float64 `json:"longitude"` Latitude *float64 `json:"latitude"` SpeedKmh *float64 `json:"speedKmh"` + SOCPercent *float64 `json:"socPercent,omitempty"` TotalMileageKm *float64 `json:"totalMileageKm"` RecordTime string `json:"recordTime,omitempty"` TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"` Online bool `json:"online"` + ActiveToday bool `json:"activeToday"` MotionStatus string `json:"motionStatus"` LocationAvailable bool `json:"locationAvailable"` Status string `json:"status"` @@ -139,16 +186,21 @@ type HydrogenStationRequest struct { } type HydrogenStation struct { - ID string `json:"id"` - Name string `json:"name"` - ShortName string `json:"shortName,omitempty"` - Address string `json:"address,omitempty"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - Province string `json:"province,omitempty"` - City string `json:"city,omitempty"` - District string `json:"district,omitempty"` - Cooperative bool `json:"cooperative"` + ID string `json:"id"` + Name string `json:"name"` + ShortName string `json:"shortName,omitempty"` + Address string `json:"address,omitempty"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Province string `json:"province,omitempty"` + City string `json:"city,omitempty"` + District string `json:"district,omitempty"` + Cooperative bool `json:"cooperative"` + ContactPerson string `json:"contactPerson,omitempty"` + ContactPhone string `json:"contactPhone,omitempty"` + UnitPrice float64 `json:"unitPrice,omitempty"` + MonthlyHydrogenKg float64 `json:"monthlyHydrogenKg"` + TotalHydrogenKg float64 `json:"totalHydrogenKg"` } type ExternalResponse struct { @@ -258,15 +310,83 @@ type MileageSnapshot struct { } type HydrogenObservation struct { - VIN string - Source string - ObservedAt time.Time - MassKg float64 - TankCapacityLiter float64 - PressureMPa float64 - TemperatureC float64 - NoiseKg float64 - RefuelThresholdKg float64 + VIN string + Source string + EventID string + ObservedAt time.Time + MassKg float64 + TankCapacityLiter float64 + PressureMPa float64 + TemperatureC float64 + NoiseKg float64 + RefuelThresholdKg float64 + FuelCellActive bool + FuelCellStateKnown bool + FuelCellVoltageV float64 + FuelCellCurrentA float64 + FuelCellPowerKnown bool + SOCPercent float64 + SOCKnown bool + MileageKm float64 + MileageKnown bool + VehicleState int + VehicleStateKnown bool + ChargeState int + ChargeStateKnown bool + RunningMode int + RunningModeKnown bool + BoundaryEligible bool + BoundaryReason string + RefuelBoundary bool + RefuelAmountKg float64 + PressureInvalid bool +} + +type HydrogenCalculationParameters struct { + BatteryCapacityKWh float64 `json:"batteryCapacityKWh"` + HydrogenEnergyKWhKg float64 `json:"hydrogenEnergyKWhPerKg"` + PowerOnDelaySeconds int `json:"powerOnDelaySeconds"` + PowerOffLeadSeconds int `json:"powerOffLeadSeconds"` + RefuelRiseMPa float64 `json:"refuelRiseMpa"` + RefuelSustainSeconds int `json:"refuelSustainSeconds"` + PureElectricDropMPA float64 `json:"pureElectricDropMpa"` + PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"` + InitialChargeCycleKnown bool `json:"initialChargeCycleKnown,omitempty"` + InitialMixedLocked bool `json:"initialMixedLocked,omitempty"` + AlgorithmVersion string `json:"algorithmVersion"` +} + +type HydrogenIntervalEvidence struct { + Index int `json:"index"` + Type string `json:"type"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + StartEventID string `json:"startEventId"` + EndEventID string `json:"endEventId"` + Source string `json:"source"` + StartPressureMPa float64 `json:"startPressureMpa"` + EndPressureMPa float64 `json:"endPressureMpa"` + StartTemperatureC float64 `json:"startTemperatureC"` + EndTemperatureC float64 `json:"endTemperatureC"` + StartMassKg float64 `json:"startMassKg"` + EndMassKg float64 `json:"endMassKg"` + RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"` + StartSOCPercent *float64 `json:"startSocPercent,omitempty"` + EndSOCPercent *float64 `json:"endSocPercent,omitempty"` + BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"` + BatteryEnergyChangeKWh *float64 `json:"batteryEnergyChangeKWh,omitempty"` + ElectricEquivalentKg *float64 `json:"electricEquivalentHydrogenKg,omitempty"` + CorrectedConsumptionKg *float64 `json:"correctedHydrogenConsumptionKg,omitempty"` + BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"` + BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"` + SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"` + StartMileageKm *float64 `json:"startMileageKm,omitempty"` + EndMileageKm *float64 `json:"endMileageKm,omitempty"` + MileageKm *float64 `json:"mileageKm,omitempty"` + ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"` + SampleCount int `json:"sampleCount"` + QualityStatus string `json:"qualityStatus"` + QualityReason string `json:"qualityReason"` } type HydrogenRateObservation struct { @@ -288,16 +408,42 @@ type HydrogenRateDailyStat struct { } type HydrogenDailyStat struct { - VIN string - Source string - Date string - ConsumptionKg float64 - FirstMassKg float64 - LastMassKg float64 - SampleCount int - RefuelCount int - QualityStatus string - QualityReason string + VIN string + Source string + Date string + ConsumptionKg float64 + FirstMassKg float64 + LastMassKg float64 + CycleMinimumMassKg float64 + SampleCount int + RefuelCount int + RefuelAmountKg float64 + AbnormalDropCount int + SuspectedLeakCount int + SuspectedLeakMaxPressureDropMPa float64 + EligibleIntervalCount int + QualifiedSegmentCount int + ChargeCount int + ChargeEnergyKWh float64 + InvalidSegmentCount int + BatterySOCDeltaPct *float64 + BatteryEnergyChangeKWh *float64 + ElectricEquivalentKg *float64 + CorrectedConsumptionKg *float64 + BatteryDischargeKWh *float64 + BatteryEquivalentKg *float64 + SOCBalancedConsumptionKg *float64 + MixedMileageKm float64 + PureElectricMileageKm float64 + ConsumptionKgPer100Km *float64 + SOCBalancedKgPer100Km *float64 + CalculationParameters HydrogenCalculationParameters + Intervals []HydrogenIntervalEvidence + HydrogenIntervals []HydrogenIntervalEvidence + FirstObservation HydrogenObservation + LastObservation HydrogenObservation + QualityStatus string + QualityReason string } type PortalUser struct { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index 6bbe9cfb..7d5186a7 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strconv" "strings" "time" @@ -137,20 +138,65 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time. return nil, nil } +func (r *MySQLRepository) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) { + if r.tdengine == nil || r.tdDatabase == "" { + return nil, errors.New("TDengine is not configured for stationary vehicle query") + } + if len(vins) == 0 { + return []StationaryLocationPoint{}, nil + } + // Use a small bounding box as TDengine's prefilter, then apply the exact + // 5-metre great-circle calculation in the service before returning a match. + latitudeDelta := radiusMeters / 111320.0 + longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180)) + quotedVINs := make([]string, 0, len(vins)) + for _, vin := range vins { + quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'") + } + startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''") + endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''") + query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` + + ` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` + + ` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` + + ` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) + + ` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) + + ` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) + + ` ORDER BY vin,ts ASC,protocol ASC` + rows, err := r.tdengine.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + points := make([]StationaryLocationPoint, 0) + for rows.Next() { + var point StationaryLocationPoint + var timestampMS int64 + if err := rows.Scan(×tampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil { + return nil, err + } + point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60)) + points = append(points, point) + } + return points, rows.Err() +} + func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) { out := make(map[string]RealtimeVehiclePoint, len(vins)) if len(vins) == 0 { return out, nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",") - args := make([]any, 0, len(vins)+1) + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + args := make([]any, 0, len(vins)+2) + args = append(args, dayStart) for _, vin := range vins { args = append(args, vin) } args = append(args, now.Add(-10*time.Minute)) query := ` SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0), - COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at + COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at, + MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today FROM vehicle_realtime_location l WHERE BINARY l.vin IN (` + placeholders + `) ORDER BY l.vin, @@ -165,9 +211,14 @@ ORDER BY l.vin, onlineThreshold := now.Add(-time.Minute) for rows.Next() { var point RealtimeVehiclePoint - if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil { + var soc sql.NullFloat64 + if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday); err != nil { return nil, err } + if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 { + value := round3(soc.Float64) + point.SOCPercent = &value + } point.Online = !point.ObservedAt.Before(onlineThreshold) if selected, exists := out[point.VIN]; !exists { out[point.VIN] = point @@ -183,35 +234,57 @@ ORDER BY l.vin, func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) { where := []string{ - "s.longitude BETWEEN -180 AND 180", - "s.latitude BETWEEN -90 AND 90", - "NOT (s.longitude=0 AND s.latitude=0)", + "n.del_flag='0'", + "n.longitude BETWEEN -180 AND 180", + "n.latitude BETWEEN -90 AND 90", + "NOT (n.longitude=0 AND n.latitude=0)", } - args := make([]any, 0, 3) + args := make([]any, 0, 4) if request.Province != "" { - where = append(where, "s.province=?") - args = append(args, request.Province) + where = append(where, "(n.province=? OR province_region.NAME=?)") + args = append(args, request.Province, request.Province) } if request.City != "" { - where = append(where, "s.city=?") - args = append(args, request.City) - } - if request.CooperateOnly != nil { - if *request.CooperateOnly { - where = append(where, "s.inner_site_id IS NOT NULL") - } else { - where = append(where, "s.inner_site_id IS NULL") - } + where = append(where, "(n.city=? OR city_region.NAME=?)") + args = append(args, request.City, request.City) } rows, err := r.db.QueryContext(ctx, ` -SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''), - COALESCE(h.station_short_name,''),COALESCE(s.station_address,''), - s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''), - CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END -FROM ln_asset_management.tab_outside_hydrogen_site s -LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0' +SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''), + n.longitude,n.latitude, + COALESCE(NULLIF(province_region.NAME,''),n.province,''), + COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''), + CASE WHEN s.inner_site_id IS NOT NULL OR ( + n.cooperation_start_date IS NOT NULL + AND n.cooperation_start_date<=CURDATE() + AND (n.cooperation_end_date IS NULL OR n.cooperation_end_date>=CURDATE()) + ) THEN 1 ELSE 0 END, + COALESCE(n.contact_person,''),COALESCE(NULLIF(n.mobile_phone,''),n.fixed_phone,''),COALESCE(n.cost_price,0), + COALESCE(l.monthly_hydrogen_kg,0),COALESCE(l.total_hydrogen_kg,n.fill_weight,0) +FROM ln_asset_management.new_hydrogen_site n +LEFT JOIN ln_asset_management.common_district province_region + ON province_region.CODE COLLATE utf8mb4_general_ci=n.province COLLATE utf8mb4_general_ci +LEFT JOIN ln_asset_management.common_district city_region + ON city_region.CODE COLLATE utf8mb4_general_ci=n.city COLLATE utf8mb4_general_ci +LEFT JOIN ln_asset_management.tab_outside_hydrogen_site s ON s.id=( + SELECT MIN(matched.id) + FROM ln_asset_management.tab_outside_hydrogen_site matched + WHERE matched.fixed_station_name=n.site_name COLLATE utf8mb4_general_ci + OR matched.station_name=n.site_name COLLATE utf8mb4_general_ci + OR matched.fixed_station_name=n.site_short_name COLLATE utf8mb4_general_ci + OR matched.station_name=n.site_short_name COLLATE utf8mb4_general_ci +) +LEFT JOIN ( + SELECT station_id, + SUM(amount_kg) AS total_hydrogen_kg, + SUM(CASE WHEN refuel_date >= DATE_FORMAT(CURDATE(),'%Y-%m-01') + AND refuel_date < DATE_ADD(DATE_FORMAT(CURDATE(),'%Y-%m-01'),INTERVAL 1 MONTH) + THEN amount_kg ELSE 0 END) AS monthly_hydrogen_kg + FROM ln_asset_management.hydrogen_fuel_ledger + WHERE del_flag='0' + GROUP BY station_id +) l ON l.station_id=s.inner_site_id WHERE `+strings.Join(where, " AND ")+` -ORDER BY s.province,s.city,s.fixed_station_name,s.id +ORDER BY province_region.NAME,city_region.NAME,n.site_name,n.id LIMIT 2000`, args...) if err != nil { return nil, err @@ -220,9 +293,15 @@ LIMIT 2000`, args...) stations := make([]HydrogenStation, 0, 512) for rows.Next() { var station HydrogenStation - if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil { + if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative, &station.ContactPerson, &station.ContactPhone, &station.UnitPrice, &station.MonthlyHydrogenKg, &station.TotalHydrogenKg); err != nil { return nil, err } + if isExcelSinopecStation(station.Name, station.ShortName) { + station.Cooperative = true + } + if request.CooperateOnly != nil && station.Cooperative != *request.CooperateOnly { + continue + } stations = append(stations, station) } return stations, rows.Err() @@ -264,6 +343,48 @@ func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date return out, nil } +// MileageRollbacks returns source days that were rejected because the odometer +// moved backwards. They must not silently fall back to an earlier total mileage +// and be exposed as normal data. +func (r *MySQLRepository) MileageRollbacks(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]bool, error) { + out := make(map[string]bool) + if len(vins) == 0 { + return out, nil + } + vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",") + query := ` +SELECT DISTINCT vin,DATE_FORMAT(stat_date,'%Y-%m-%d') +FROM vehicle_daily_mileage_source +WHERE stat_date BETWEEN ? AND ? + AND vin IN (` + vinPlaceholders + `) + AND quality_reason='TOTAL_MILEAGE_ROLLBACK'` + args := make([]any, 0, len(vins)+2+len(protocols)) + args = append(args, startDate, endDate) + for _, vin := range vins { + args = append(args, vin) + } + if len(protocols) > 0 { + protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",") + query += "\n AND protocol IN (" + protocolPlaceholders + ")" + for _, protocol := range protocols { + args = append(args, protocol) + } + } + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var vin, date string + if err := rows.Scan(&vin, &date); err != nil { + return nil, err + } + out[dailyMileageKey(vin, date)] = true + } + return out, rows.Err() +} + func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) { if len(vins) == 0 { return map[string]DailyMileage{}, nil @@ -275,7 +396,7 @@ SELECT DATE_FORMAT(m.stat_date,'%Y-%m-%d'), m.protocol, m.daily_mileage_km, - m.latest_total_mileage_km, + COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km), COALESCE(DATE_FORMAT(( SELECT MAX(selected.latest_event_time) FROM vehicle_daily_mileage_source selected @@ -289,8 +410,8 @@ SELECT FROM vehicle_daily_mileage m WHERE m.stat_date BETWEEN ? AND ? AND m.vin IN (` + placeholders + `) - AND m.latest_total_mileage_km IS NOT NULL - AND m.latest_total_mileage_km>=0 + AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km) IS NOT NULL + AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km)>=0 AND m.daily_mileage_km>=0` args := make([]any, 0, len(vins)+2+len(protocols)*2) args = append(args, startDate, endDate) @@ -345,7 +466,7 @@ SELECT DATE_FORMAT(m.stat_date,'%Y-%m-%d'), m.protocol, m.daily_mileage_km, - m.latest_total_mileage_km, + COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km), COALESCE(DATE_FORMAT(( SELECT MAX(selected.latest_event_time) FROM vehicle_daily_mileage_source selected @@ -362,8 +483,8 @@ JOIN ( FROM vehicle_daily_mileage prior WHERE prior.stat_date=0 + AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km) IS NOT NULL + AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=0 AND prior.daily_mileage_km>=0` args := make([]any, 0, len(vins)+1+len(protocols)*2) args = append(args, beforeDate) diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go index 9d979085..81a14b5a 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go @@ -75,13 +75,62 @@ func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testin } } -func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) { +func TestHydrogenStationsUsesNewMasterAndExcelSinopecRoster(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() - mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0"). + + mock.ExpectQuery("cooperation_start_date.*FROM ln_asset_management\\.new_hydrogen_site n.*common_district province_region.*common_district city_region.*ORDER BY province_region\\.NAME,city_region\\.NAME,n\\.site_name,n\\.id"). + WithArgs("湖北省", "湖北省", "武汉市", "武汉市"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "short_name", "address", "longitude", "latitude", "province", "city", "district", "cooperative", "contact_person", "contact_phone", "unit_price", "monthly_hydrogen_kg", "total_hydrogen_kg", + }). + AddRow("1", "武汉群力加油站", "中国石化群力加油站", "地址一", 114.1, 30.2, "湖北省", "武汉市", "", false, "张工", "13800000001", 12.5, 12.5, 100.5). + AddRow("2", "已有合作日期站", "", "地址二", 120.1, 31.2, "上海市", "上海市", "", true, "李工", "13800000002", 13.0, 8.5, 80.5). + AddRow("3", "普通外部站", "", "地址三", 121.1, 32.2, "江苏省", "南京市", "", false, "", "", 0, 0, 0)) + + cooperateOnly := true + stations, err := NewMySQLRepository(db).HydrogenStations(context.Background(), HydrogenStationRequest{Province: "湖北省", City: "武汉市", CooperateOnly: &cooperateOnly}) + if err != nil { + t.Fatal(err) + } + if len(stations) != 2 { + t.Fatalf("expected roster and canonical cooperative stations, got %#v", stations) + } + if stations[0].Name != "武汉群力加油站" || !stations[0].Cooperative { + t.Fatalf("Excel SINOPEC station was not classified as cooperative: %#v", stations[0]) + } + if stations[1].Name != "已有合作日期站" || !stations[1].Cooperative { + t.Fatalf("canonical cooperative station was lost: %#v", stations[1]) + } + if stations[0].ContactPerson != "张工" || stations[0].ContactPhone != "13800000001" || stations[0].UnitPrice != 12.5 { + t.Fatalf("partner station contact and price were not mapped: %#v", stations[0]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestExcelSinopecRosterMatchesEitherExcelNameColumn(t *testing.T) { + for _, name := range []string{"中国石化青岛炼油化工有限责任公司", "青岛中石化加氢站", "南京溧水柘塘东站", "南京中石化溧水加氢站", "中国石化樟坑加油加氢站"} { + if !isExcelSinopecStation(name) { + t.Fatalf("expected roster match for %q", name) + } + } + if isExcelSinopecStation("普通外部加氢站") { + t.Fatal("non-roster station must not be classified as cooperative") + } +} + +func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0"). WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002"). WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}). AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00")) @@ -185,6 +234,30 @@ func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testin } } +func TestStationaryLocationPointsUsesTimeVINAndSmallBoundingBox(t *testing.T) { + mysqlDB, _, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer mysqlDB.Close() + tdDB, tdMock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer tdDB.Close() + start := time.Date(2026, 8, 6, 10, 0, 0, 0, time.FixedZone("CST", 8*3600)) + tdMock.ExpectQuery("FROM lingniu_vehicle_ts.vehicle_locations.*ts>='2026-08-06T10:00:00\\+08:00'.*vin IN \\('LTEST32960VIN0001'\\).*speed_kmh BETWEEN 0 AND 3\\.000.*ORDER BY vin,ts ASC,protocol ASC"). + WillReturnRows(sqlmock.NewRows([]string{"ts", "vin", "protocol", "longitude", "latitude", "speed_kmh"}). + AddRow(start.UnixMilli(), "LTEST32960VIN0001", "GB32960", 120.000001, 30.000001, 0.2)) + points, err := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts").StationaryLocationPoints(context.Background(), []string{"LTEST32960VIN0001"}, start, start.Add(time.Hour), 120, 30, 5, 3) + if err != nil || len(points) != 1 || points[0].SpeedKmh != 0.2 || !points[0].ObservedAt.Equal(start) { + t.Fatalf("points=%#v err=%v", points, err) + } + if err := tdMock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { @@ -192,19 +265,20 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) } defer db.Close() now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local) + dayStart := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local) vin := "LTEST32960VIN0001" mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location"). - WithArgs(vin, now.Add(-10*time.Minute)). - WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}). - AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)). - AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second))) + WithArgs(dayStart, vin, now.Add(-10*time.Minute)). + WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}). + AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true). + AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true)) points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now) if err != nil { t.Fatal(err) } point := points[vin] - if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) { + if point.Protocol != "GB32960" || point.SOCPercent == nil || *point.SOCPercent != 86.5 || !point.Online || !point.ActiveToday || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) { t.Fatalf("selected source and aggregate online state mismatch: %#v", point) } if err := mock.ExpectationsWereMet(); err != nil { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index 7e39a123..d322f882 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "errors" "fmt" + "math" "regexp" "sort" "strconv" @@ -34,10 +35,12 @@ type Repository interface { DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error) DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error) LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error) + MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error) CreateMileageSnapshot(context.Context, MileageSnapshot) error LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) + StationaryLocationPoints(context.Context, []string, time.Time, time.Time, float64, float64, float64, float64) ([]StationaryLocationPoint, error) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) Audit(context.Context, uint64, string, string, string, int, string) error @@ -50,6 +53,215 @@ type Repository interface { ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) } +const ( + stationaryCoordinateWGS84 = "WGS84" + stationaryCoordinateGCJ02 = "GCJ02" + stationaryDefaultRadiusMeters = 5.0 + stationaryMinimumRadiusMeters = 1.0 + stationaryMaximumRadiusMeters = 100.0 + stationaryMaxSpeedKmh = 3.0 + stationaryMinimumStay = time.Minute + stationarySegmentGap = 10 * time.Minute + stationaryQueryMaximumWindow = 24 * time.Hour +) + +func (s *Service) QueryStationaryVehicles(ctx context.Context, appKey, traceID string, request StationaryVehicleQueryRequest) ([]StationaryVehicleResult, error) { + plates, start, end, longitude, latitude, radiusMeters, err := s.validateStationaryVehicleQuery(request) + if err != nil { + return nil, err + } + app, vehicles, err := s.authorize(ctx, appKey, plates, start, end) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "denied", traceID, len(plates), err.Error()) + return nil, err + } + if len(plates) == 0 { + plates = vehiclePlates(vehicles) + } + points, err := s.repository.StationaryLocationPoints(ctx, vehicleVINs(vehicles), start, end, longitude, latitude, radiusMeters, stationaryMaxSpeedKmh) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "error", traceID, len(plates), err.Error()) + return nil, err + } + plateByVIN := make(map[string]string, len(vehicles)) + for plate, vehicle := range vehicles { + plateByVIN[vehicle.VIN] = plate + } + results := stationaryMatches(points, plateByVIN, longitude, latitude, radiusMeters) + _ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "success", traceID, len(results), "") + return results, nil +} + +func (s *Service) validateStationaryVehicleQuery(request StationaryVehicleQueryRequest) ([]string, time.Time, time.Time, float64, float64, float64, error) { + plates, err := normalizePlates(request.PlateNumbers, 2000) + if err != nil { + return nil, time.Time{}, time.Time{}, 0, 0, 0, err + } + start, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.StartTime), s.location) + if err != nil { + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid startTime", ErrInvalidRequest) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.EndTime), s.location) + if err != nil || !end.After(start) { + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid endTime", ErrInvalidRequest) + } + if end.Sub(start) > stationaryQueryMaximumWindow { + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: time window exceeds 24 hours", ErrInvalidRequest) + } + longitude, latitude := request.Longitude, request.Latitude + if !validCoordinate(longitude, latitude) { + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid coordinate", ErrInvalidRequest) + } + coordinateSystem := strings.ToUpper(strings.TrimSpace(request.CoordinateSystem)) + if coordinateSystem == "" { + coordinateSystem = stationaryCoordinateWGS84 + } + switch coordinateSystem { + case stationaryCoordinateWGS84: + case stationaryCoordinateGCJ02: + longitude, latitude = gcj02ToWGS84(longitude, latitude) + default: + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: coordinateSystem must be WGS84 or GCJ02", ErrInvalidRequest) + } + radiusMeters := stationaryDefaultRadiusMeters + if request.RadiusMeters != nil { + radiusMeters = *request.RadiusMeters + } + if math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) || radiusMeters < stationaryMinimumRadiusMeters || radiusMeters > stationaryMaximumRadiusMeters { + return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: radiusMeters must be between 1 and 100", ErrInvalidRequest) + } + return plates, start, end, longitude, latitude, radiusMeters, nil +} + +func gcj02ToWGS84(longitude, latitude float64) (float64, float64) { + // GCJ-02 only applies within mainland China's obfuscation area. Returning + // the supplied value elsewhere keeps the transformation safe for outlying + // stations and callers that supply valid geographic coordinates. + if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 { + return longitude, latitude + } + const semiMajorAxis = 6378245.0 + const eccentricitySquared = 0.006693421622965943 + longitudeOffset := transformGCJLongitude(longitude-105, latitude-35) + latitudeOffset := transformGCJLatitude(longitude-105, latitude-35) + radianLatitude := latitude / 180 * math.Pi + magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2) + squareRootMagic := math.Sqrt(magic) + convertedLatitude := latitude - latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi) + convertedLongitude := longitude - longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi) + return convertedLongitude, convertedLatitude +} + +func transformGCJLatitude(longitude, latitude float64) float64 { + value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude)) + value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3 + value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3 + value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3 + return value +} + +func transformGCJLongitude(longitude, latitude float64) float64 { + value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude)) + value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3 + value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3 + value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3 + return value +} + +func stationaryMatches(points []StationaryLocationPoint, plateByVIN map[string]string, longitude, latitude, radiusMeters float64) []StationaryVehicleResult { + if len(points) == 0 { + return []StationaryVehicleResult{} + } + sort.Slice(points, func(i, j int) bool { + if points[i].VIN != points[j].VIN { + return points[i].VIN < points[j].VIN + } + return points[i].ObservedAt.Before(points[j].ObservedAt) + }) + results := make([]StationaryVehicleResult, 0) + for from := 0; from < len(points); { + to := from + 1 + for to < len(points) && points[to].VIN == points[from].VIN { + to++ + } + for segmentStart := from; segmentStart < to; { + segmentEnd := segmentStart + 1 + for segmentEnd < to && points[segmentEnd].ObservedAt.Sub(points[segmentEnd-1].ObservedAt) <= stationarySegmentGap { + segmentEnd++ + } + if result, ok := stationaryMatch(points[segmentStart:segmentEnd], plateByVIN[points[from].VIN], longitude, latitude, radiusMeters); ok { + results = append(results, result) + } + segmentStart = segmentEnd + } + from = to + } + sort.Slice(results, func(i, j int) bool { + if results[i].MatchScore != results[j].MatchScore { + return results[i].MatchScore > results[j].MatchScore + } + if results[i].StayDurationSeconds != results[j].StayDurationSeconds { + return results[i].StayDurationSeconds > results[j].StayDurationSeconds + } + return results[i].VIN < results[j].VIN + }) + return results +} + +func stationaryMatch(points []StationaryLocationPoint, plate string, longitude, latitude, radiusMeters float64) (StationaryVehicleResult, bool) { + if len(points) < 2 { + return StationaryVehicleResult{}, false + } + duration := points[len(points)-1].ObservedAt.Sub(points[0].ObservedAt) + if duration < stationaryMinimumStay { + return StationaryVehicleResult{}, false + } + var totalDistance, totalSpeed, maxDistance, maxSpeed float64 + protocols := map[string]struct{}{} + for _, point := range points { + distance := haversineMeters(latitude, longitude, point.Latitude, point.Longitude) + if distance > radiusMeters || point.SpeedKmh < 0 || point.SpeedKmh > stationaryMaxSpeedKmh { + return StationaryVehicleResult{}, false + } + totalDistance += distance + totalSpeed += point.SpeedKmh + maxDistance = math.Max(maxDistance, distance) + maxSpeed = math.Max(maxSpeed, point.SpeedKmh) + protocols[point.Protocol] = struct{}{} + } + protocolList := make([]string, 0, len(protocols)) + for protocol := range protocols { + protocolList = append(protocolList, externalMileageProtocol(protocol)) + } + sort.Strings(protocolList) + averageDistance := totalDistance / float64(len(points)) + averageSpeed := totalSpeed / float64(len(points)) + score := 40*(1-averageDistance/radiusMeters) + 25*(1-maxSpeed/stationaryMaxSpeedKmh) + 20*math.Min(1, duration.Seconds()/900) + 15*math.Min(1, float64(len(points))/4) + return StationaryVehicleResult{ + VIN: points[0].VIN, + PlateNumber: plate, + StayStartTime: points[0].ObservedAt.Format("2006-01-02 15:04:05"), + StayEndTime: points[len(points)-1].ObservedAt.Format("2006-01-02 15:04:05"), + StayDurationSeconds: int64(duration.Seconds()), + StayDurationMinutes: round3(duration.Minutes()), + MatchScore: round3(math.Max(0, math.Min(100, score))), + AverageDistanceM: round3(averageDistance), + MaxDistanceM: round3(maxDistance), + AverageSpeedKmh: round3(averageSpeed), + MaxSpeedKmh: round3(maxSpeed), + MatchedSamples: len(points), + SourceProtocols: protocolList, + }, true +} + +func haversineMeters(latitude1, longitude1, latitude2, longitude2 float64) float64 { + const earthRadiusMeters = 6371000.0 + lat1, lat2 := latitude1*math.Pi/180, latitude2*math.Pi/180 + dLat, dLon := (latitude2-latitude1)*math.Pi/180, (longitude2-longitude1)*math.Pi/180 + a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2) + return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) { now := s.now().In(s.location) plates, err := normalizePlates(request.PlateNumbers, 2000) @@ -78,10 +290,11 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str if difference < 0 { difference = 0 } - item.Protocol = point.Protocol + item.Protocol = externalMileageProtocol(point.Protocol) item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05") item.TimeDifferenceSeconds = &difference item.Online = point.Online + item.ActiveToday = point.ActiveToday item.MotionStatus = "offline" if item.Online && point.SpeedKmh > 3 { item.MotionStatus = "driving" @@ -90,6 +303,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str } speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm) item.SpeedKmh, item.TotalMileageKm = &speed, &mileage + item.SOCPercent = point.SOCPercent if validCoordinate(point.Longitude, point.Latitude) { longitude, latitude := point.Longitude, point.Latitude item.Longitude, item.Latitude = &longitude, &latitude @@ -211,7 +425,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error()) return nil, err } - missingVINs := missingMileageVINs(vins, values) + rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error()) + return nil, err + } + missingVINs := missingMileageVINs(vins, values, rollbacks, date) carried := map[string]DailyMileage{} if len(missingVINs) > 0 { carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols) @@ -226,6 +445,8 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData} if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) { fillMileageResult(&item, value, value.MileageKm) + } else if rollbacks[dailyMileageKey(vehicle.VIN, date)] { + fillMileageAnomaly(&item) } else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) { fillMileageResult(&item, value, 0) } @@ -321,6 +542,7 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, } values := map[string]DailyMileage{} carried := map[string]DailyMileage{} + rollbacks := map[string]bool{} if len(positions) > 0 { vins := make([]string, 0, len(vinSet)) for vin := range vinSet { @@ -332,7 +554,12 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error()) return MileageRangeResponse{}, err } - missingVINs := missingMileageRangeInitialVINs(positions, values) + rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error()) + return MileageRangeResponse{}, err + } + missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks) if len(missingVINs) > 0 { carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols) if err != nil { @@ -355,6 +582,9 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) { fillMileageRangeResult(&item, value, value.MileageKm) carried[position.vehicle.VIN] = value + } else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] { + fillMileageRangeAnomaly(&item) + delete(carried, position.vehicle.VIN) } else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) { fillMileageRangeResult(&item, value, 0) } @@ -715,9 +945,12 @@ func validDailyMileage(value DailyMileage) bool { value.UpdatedAt != "" } -func missingMileageVINs(vins []string, values map[string]DailyMileage) []string { +func missingMileageVINs(vins []string, values map[string]DailyMileage, rollbacks map[string]bool, date string) []string { missing := make([]string, 0) for _, vin := range vins { + if rollbacks[dailyMileageKey(vin, date)] { + continue + } if value, ok := values[vin]; !ok || !validDailyMileage(value) { missing = append(missing, vin) } @@ -725,7 +958,7 @@ func missingMileageVINs(vins []string, values map[string]DailyMileage) []string return missing } -func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string { +func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage, rollbacks map[string]bool) []string { seen := make(map[string]struct{}, len(positions)) missing := make([]string, 0) for _, position := range positions { @@ -734,6 +967,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map continue } seen[vin] = struct{}{} + if rollbacks[dailyMileageKey(vin, position.date)] { + continue + } value, ok := values[dailyMileageKey(vin, position.date)] if !ok || !validDailyMileage(value) { missing = append(missing, vin) @@ -769,6 +1005,20 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM item.Status = StatusNormal } +const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK" + +func fillMileageAnomaly(item *MileageResult) { + item.DataQuality = stringPointer(mileageTotalRollbackQuality) + item.Status = StatusDataAnomaly +} + +func fillMileageRangeAnomaly(item *MileageRangeResult) { + item.DataQuality = stringPointer(mileageTotalRollbackQuality) + item.Status = StatusDataAnomaly +} + +func stringPointer(value string) *string { return &value } + func dailyMileageKey(vin, date string) string { return vin + "\x00" + date } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go index 11ad5744..d029edcd 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -12,27 +12,32 @@ import ( "time" ) +func float64Pointer(value float64) *float64 { return &value } + type fakeRepository struct { - app AppCredential - authErr error - vehicles map[string]AuthorizedVehicle - hydrogen map[string]DailyHydrogen - mileage map[string]DailyMileage - priorMileage map[string]DailyMileage - authorizedVIN bool - totalMileage *TotalMileagePoint - realtime map[string]RealtimeVehiclePoint - stations []HydrogenStation - audits []string - createdHash [sha256.Size]byte - createdPrefix string - requestedPlates []string - dailyVINs []string - dailyProtocols []string - rangeVINs []string - priorVINs []string - priorCalls int - snapshot MileageSnapshot + app AppCredential + authErr error + vehicles map[string]AuthorizedVehicle + hydrogen map[string]DailyHydrogen + mileage map[string]DailyMileage + priorMileage map[string]DailyMileage + rollbacks map[string]bool + authorizedVIN bool + totalMileage *TotalMileagePoint + realtime map[string]RealtimeVehiclePoint + stationary []StationaryLocationPoint + stations []HydrogenStation + audits []string + createdHash [sha256.Size]byte + createdPrefix string + requestedPlates []string + dailyVINs []string + dailyProtocols []string + rangeVINs []string + priorVINs []string + priorCalls int + snapshot MileageSnapshot + stationaryRadius float64 } func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) { @@ -64,6 +69,9 @@ func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ f.dailyProtocols = append([]string(nil), protocols...) return f.priorMileage, nil } +func (f *fakeRepository) MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error) { + return f.rollbacks, nil +} func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error { f.snapshot = snapshot return nil @@ -80,6 +88,10 @@ func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Tim func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) { return f.totalMileage, nil } +func (f *fakeRepository) StationaryLocationPoints(_ context.Context, _ []string, _ time.Time, _ time.Time, _ float64, _ float64, radiusMeters float64, _ float64) ([]StationaryLocationPoint, error) { + f.stationaryRadius = radiusMeters + return f.stationary, nil +} func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) { return f.realtime, nil } @@ -100,7 +112,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) { "浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"}, }, realtime: map[string]RealtimeVehiclePoint{ - "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true}, + "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, SOCPercent: float64Pointer(78.5), TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true}, }, stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}}, } @@ -112,7 +124,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) { if err != nil { t.Fatal(err) } - if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable { + if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable || vehicles[0].Protocol != "GB32960" || vehicles[0].SOCPercent == nil || *vehicles[0].SOCPercent != 78.5 { t.Fatalf("unexpected realtime vehicles: %#v", vehicles) } if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" { @@ -127,6 +139,76 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) { } } +func TestStationaryVehicleQueryBuildsAndRanksStayMatches(t *testing.T) { + location := time.FixedZone("CST", 8*3600) + start := time.Date(2026, 8, 6, 10, 0, 0, 0, location) + repository := &fakeRepository{ + app: AppCredential{ID: 9, Name: "refuel-check"}, + vehicles: map[string]AuthorizedVehicle{ + "浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"}, + "浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"}, + }, + stationary: []StationaryLocationPoint{ + {VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(10 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0}, + {VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(13 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0.1}, + {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0}, + {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(5 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0}, + {VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", ObservedAt: start.Add(6 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0}, + }, + } + service := NewService(repository) + service.now = func() time.Time { return start } + radiusMeters := 8.0 + values, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-stationary", StationaryVehicleQueryRequest{ + StartTime: start.Format("2006-01-02 15:04:05"), EndTime: start.Add(time.Hour).Format("2006-01-02 15:04:05"), Longitude: 120, Latitude: 30, RadiusMeters: &radiusMeters, + }) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].VIN != "LTEST32960VIN0001" || values[0].StayDurationSeconds != 300 || values[0].MatchedSamples != 3 || values[0].SourceProtocols[0] != "GB32960" || values[0].SourceProtocols[1] != "MQTT" || values[0].MatchScore <= values[1].MatchScore { + t.Fatalf("stationary matches=%#v", values) + } + if repository.stationaryRadius != radiusMeters { + t.Fatalf("stationary radius=%v, want %v", repository.stationaryRadius, radiusMeters) + } +} + +func TestStationaryVehicleQueryRejectsInvalidWindowAndCoordinate(t *testing.T) { + service := NewService(&fakeRepository{}) + if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-07 10:00:01", Longitude: 120, Latitude: 30}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("window error=%v", err) + } + if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 0, Latitude: 0}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("coordinate error=%v", err) + } + overRadius := 101.0 + if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 120, Latitude: 30, RadiusMeters: &overRadius}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("radius error=%v", err) + } +} + +func TestStationaryVehicleQueryConvertsGCJ02Coordinate(t *testing.T) { + service := NewService(&fakeRepository{}) + _, _, _, longitude, latitude, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{ + StartTime: "2026-08-06 17:53:01", + EndTime: "2026-08-06 17:55:01", + Longitude: 121.045455, + Latitude: 30.640433, + CoordinateSystem: "gcj02", + }) + if err != nil { + t.Fatal(err) + } + if longitude < 121.0409 || longitude > 121.0411 || latitude < 30.6425 || latitude > 30.6428 { + t.Fatalf("converted coordinate=%f,%f", longitude, latitude) + } + if _, _, _, _, _, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{ + StartTime: "2026-08-06 17:53:01", EndTime: "2026-08-06 17:55:01", Longitude: 121.045455, Latitude: 30.640433, CoordinateSystem: "BD09", + }); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("coordinateSystem error=%v", err) + } +} + func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) { now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600)) repository := &fakeRepository{ @@ -337,6 +419,28 @@ func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing } } +func TestMileageRollbackIsNotExposedAsNormalCarriedData(t *testing.T) { + const vin = "LNXNEGRR1SR321395" + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{"粤A08190F": {VIN: vin, Plate: "粤A08190F"}}, + priorMileage: map[string]DailyMileage{ + vin: {VIN: vin, Date: "2026-08-05", Protocol: "GB32960", TotalMileageKm: 48233.3, DataTime: "2026-08-05T23:00:00+08:00", UpdatedAt: "2026-08-05T23:00:01+08:00"}, + }, + rollbacks: map[string]bool{dailyMileageKey(vin, "2026-08-06"): true}, + } + service := NewService(repository) + result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-rollback", QueryRequest{ + PlateNumbers: []string{"粤A08190F"}, Date: "2026-08-06", + }) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].Status != StatusDataAnomaly || result[0].DataQuality == nil || *result[0].DataQuality != mileageTotalRollbackQuality || result[0].TotalMileageKm != nil || repository.priorCalls != 0 { + t.Fatalf("rollback result=%#v priorCalls=%d", result, repository.priorCalls) + } +} + func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) { repository := &fakeRepository{ app: AppCredential{ID: 7, Name: "partner"}, @@ -577,7 +681,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") allResponse := httptest.NewRecorder() handler.ServeHTTP(allResponse, allRequest) - if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) { + if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber": "粤A12345"`) { t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String()) } @@ -585,7 +689,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") rangeResponse := httptest.NewRecorder() handler.ServeHTTP(rangeResponse, rangeRequest) - if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) { + if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor": null`) { t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String()) } @@ -593,7 +697,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") badResponse := httptest.NewRecorder() handler.ServeHTTP(badResponse, badDate) - if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) { + if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code": "INVALID_DATE_FORMAT"`) { t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String()) } @@ -602,8 +706,8 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { badProtocolResponse := httptest.NewRecorder() handler.ServeHTTP(badProtocolResponse, badProtocol) if badProtocolResponse.Code != http.StatusBadRequest || - !strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) || - !strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) { + !strings.Contains(badProtocolResponse.Body.String(), `"message": "protocolPriority不能包含重复协议"`) || + !strings.Contains(badProtocolResponse.Body.String(), `"traceId": "`) { t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String()) } @@ -611,7 +715,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") nullProtocolResponse := httptest.NewRecorder() handler.ServeHTTP(nullProtocolResponse, nullProtocol) - if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) { + if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message": "protocolPriority不能为空"`) { t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String()) } } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/sinopec_roster.go b/vehicle-data-platform/apps/api/internal/openplatform/sinopec_roster.go new file mode 100644 index 00000000..2f89e23f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/sinopec_roster.go @@ -0,0 +1,84 @@ +package openplatform + +import "strings" + +// excelSinopecStationNames is the approved cooperative-station roster from +// “中石化加氢站订单来源统计.xlsx” (站点名称、简称). Both columns are retained so +// the classification survives the naming differences between OneOS imports. +var excelSinopecStationNames = map[string]struct{}{ + "中国石化青岛炼油化工有限责任公司": {}, + "青岛中石化加氢站": {}, + "武汉群力加油站": {}, + "中国石化群力加油站": {}, + "中国石化张家港朝阳加能站": {}, + "张家港中石化朝阳加氢站加氢站": {}, + "中国石化广州金坑加氢站": {}, + "广州中石化金坑加氢站": {}, + "中国石化广州开泰北加油加氢站": {}, + "广州中石化开泰北加氢站": {}, + "佛山佛西加气站": {}, + "佛山中石化佛西加氢站": {}, + "盐城创咏加氢站": {}, + "盐城中石化创咏加氢站": {}, + "宁波镇海区中国石化加氢站": {}, + "宁波镇海中石化加氢站": {}, + "佛山青龙加油站": {}, + "佛山中石化青龙加氢站": {}, + // OneOS uses “樟坑” while the approved Excel roster uses “青龙”; the + // station address is on 佛山青龙线, so retain both OneOS aliases. + "中国石化樟坑加油加氢站": {}, + "佛山中石化樟坑加氢站": {}, + "中石化西上海发展站加油站": {}, + "上海中石化西上海加氢站": {}, + "中石化青卫油氢混合站": {}, + "上海中石化青卫加氢站": {}, + "嘉兴滨海大道加油加气站": {}, + "嘉兴中石化滨海加氢站": {}, + "中国石化善通加油加氢站": {}, + "嘉善中石化善通加氢站": {}, + "中国石化站前路加气加氢站": {}, + "嘉善中石化站前路加氢站": {}, + "中国石化滨文路CNG加气站": {}, + "杭州中石化加氢站": {}, + "武汉革新大道加油站": {}, + "武汉中石化革新加氢站": {}, + "中国石化绿能加气加氢站": {}, + "桐乡中石化绿能加氢站": {}, + "中国石化青云店气能加油加气站": {}, + "北京中石化青云加氢站": {}, + "扬州文昌西路站": {}, + "扬州中石化文昌西路加氢站": {}, + "东明三路综合能源站": {}, + "广州中石化东明三路加氢站": {}, + "中国石化龙珠源加油加气站": {}, + "深圳中石化龙珠源加氢站": {}, + "中国石化江南西彭综合能源站": {}, + "重庆中石化西彭综合能源站": {}, + "成都天府机场高速北站": {}, + "成都中石化天府机场高速北站加氢站": {}, + "成都天府机场高速南站": {}, + "成都中石化天府机场高速南站加氢站": {}, + "永川高升加氢站": {}, + "重庆中石化高升加氢站": {}, + "高管汉宜潜江服务北站": {}, + "潜江中石化潜江服务区加氢站": {}, + "涪陵长寿化中大道加能站": {}, + "重庆中石化长寿化中大道加氢站": {}, + "江南空港加气站": {}, + "重庆中石化空港加氢站": {}, + "中国石化江南半山环道加能站": {}, + "重庆中石化半山环道加氢站": {}, + "武汉中石化双龙站": {}, + "南京溧水柘塘东站": {}, + "南京中石化溧水加氢站": {}, +} + +func isExcelSinopecStation(names ...string) bool { + for _, name := range names { + name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ") + if _, ok := excelSinopecStationNames[name]; ok { + return true + } + } + return false +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go index 014f7038..7fec3c28 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go @@ -1,9 +1,11 @@ package openplatform import ( + "bytes" "context" "database/sql" "encoding/json" + "errors" "fmt" "math" "sort" @@ -19,24 +21,39 @@ var hydrogenMassFields = []string{ "gd_fc_vehicle_hydrogen_mass_kg", } +const ( + hydrogenFuelCellActiveCurrentA = 1.0 + hydrogenRapidRecoveryWindow = time.Minute + hydrogenRapidRecoveryPressureMPa = 8.0 + hydrogenSegmentEndpointWindow = 5 + hydrogenSegmentMaxGap = 5 * time.Minute +) + func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat { + return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, false, nil) +} + +// BuildHydrogenDailyStatsOrdered avoids sorting the same high-volume day again +// when LoadHydrogenObservations has already returned VIN/event-time order. +func BuildHydrogenDailyStatsOrdered(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat { + return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, true, nil) +} + +func buildHydrogenDailyStatsWithParameters(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64, alreadyOrdered bool, parameters map[string]HydrogenCalculationParameters) []HydrogenDailyStat { if noiseKg <= 0 { noiseKg = 0.05 } if maxDropKg <= noiseKg { maxDropKg = 20 } - grouped := map[string]map[string][]HydrogenObservation{} + grouped := map[string][]HydrogenObservation{} for _, observation := range observations { observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN)) observation.Source = strings.TrimSpace(observation.Source) if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 { continue } - if grouped[observation.VIN] == nil { - grouped[observation.VIN] = map[string][]HydrogenObservation{} - } - grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation) + grouped[observation.VIN] = append(grouped[observation.VIN], observation) } vins := make([]string, 0, len(grouped)) for vin := range grouped { @@ -45,81 +62,236 @@ func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, no sort.Strings(vins) stats := make([]HydrogenDailyStat, 0, len(vins)) for _, vin := range vins { - var selected *HydrogenDailyStat - for source, values := range grouped[vin] { - candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg) - if selected == nil || betterHydrogenStat(candidate, *selected) { - copy := candidate - selected = © - } - } - if selected != nil { - stats = append(stats, *selected) - } + values, primarySource := mergeHydrogenObservations(grouped[vin], alreadyOrdered) + stats = append(stats, buildTrustedHydrogenDailyStat(vin, primarySource, date, values, noiseKg, maxDropKg, parameters[vin])) } return stats } -func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat { - sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) - stat := HydrogenDailyStat{ - VIN: vin, Source: source, Date: date, - FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg, - SampleCount: len(values), QualityStatus: "OK", +func mergeHydrogenObservations(values []HydrogenObservation, alreadyOrdered bool) ([]HydrogenObservation, string) { + sourceCounts := make(map[string]int) + for _, value := range values { + sourceCounts[value.Source]++ } - abnormalDrops := 0 - cycleMinimum := values[0].MassKg - for index := 1; index < len(values); index++ { - value := values[index] - sampleNoise := noiseKg - if value.NoiseKg > sampleNoise { - sampleNoise = value.NoiseKg - } - refuelThreshold := math.Max(1, cycleMinimum*0.05) - if value.RefuelThresholdKg > 0 { - refuelThreshold = value.RefuelThresholdKg - } - delta := cycleMinimum - value.MassKg - switch { - case value.MassKg-cycleMinimum > refuelThreshold: - stat.RefuelCount++ - cycleMinimum = value.MassKg - case delta > sampleNoise && delta <= maxDropKg: - stat.ConsumptionKg += delta - cycleMinimum = value.MassKg - case delta > maxDropKg: - abnormalDrops++ + primarySource := "" + primaryCount := -1 + for source, count := range sourceCounts { + if count > primaryCount || (count == primaryCount && source < primarySource) { + primarySource = source + primaryCount = count } } - stat.ConsumptionKg = round3(stat.ConsumptionKg) - if stat.SampleCount < 2 { - stat.QualityStatus = "NO_DATA" - stat.QualityReason = "有效车载氢量样本不足2条" - } else if abnormalDrops > 0 { - stat.QualityStatus = "SUSPECT" - stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg) + if !alreadyOrdered { + sort.SliceStable(values, func(i, j int) bool { + if !values[i].ObservedAt.Equal(values[j].ObservedAt) { + return values[i].ObservedAt.Before(values[j].ObservedAt) + } + return betterHydrogenDuplicate(values[i], values[j], sourceCounts) + }) } - return stat + merged := make([]HydrogenObservation, 0, len(values)) + for first := 0; first < len(values); { + last := first + 1 + best := values[first] + for last < len(values) && values[last].ObservedAt.Equal(values[first].ObservedAt) { + if betterHydrogenDuplicate(values[last], best, sourceCounts) { + best = values[last] + } + last++ + } + merged = append(merged, best) + first = last + } + return merged, primarySource } -func betterHydrogenStat(candidate, current HydrogenDailyStat) bool { - rank := func(status string) int { - switch status { - case "OK": - return 0 - case "SUSPECT": - return 1 - default: - return 2 +func betterHydrogenDuplicate(candidate, current HydrogenObservation, sourceCounts map[string]int) bool { + if candidate.FuelCellStateKnown != current.FuelCellStateKnown { + return candidate.FuelCellStateKnown + } + if sourceCounts[candidate.Source] != sourceCounts[current.Source] { + return sourceCounts[candidate.Source] > sourceCounts[current.Source] + } + if candidate.Source != current.Source { + return candidate.Source < current.Source + } + return candidate.MassKg < current.MassKg +} + +func hydrogenDailyMetadata(values []HydrogenObservation, noiseKg, maxDropKg float64) (float64, float64, int) { + cycleMinimum := values[0] + remainingMass := values[0].MassKg + refuelCount := 0 + for index := 1; index < len(values); index++ { + value := values[index] + previous := values[index-1] + intervalEligible := hydrogenConsumptionIntervalEligible(previous, value) + sampleNoise := hydrogenObservationNoise(value, noiseKg) + refuelThreshold := hydrogenObservationRefuelThreshold(value, cycleMinimum) + if intervalEligible || (value.FuelCellStateKnown && value.FuelCellActive) || value.MassKg-remainingMass > sampleNoise { + remainingMass = value.MassKg + } + delta := cycleMinimum.MassKg - value.MassKg + switch { + case value.MassKg-cycleMinimum.MassKg > refuelThreshold: + if !rapidHydrogenPressureRecovery(previous, value) { + refuelCount++ + } + cycleMinimum = value + case delta > sampleNoise && delta <= maxDropKg: + cycleMinimum = value } } - if rank(candidate.QualityStatus) != rank(current.QualityStatus) { - return rank(candidate.QualityStatus) < rank(current.QualityStatus) + return remainingMass, cycleMinimum.MassKg, refuelCount +} + +func hydrogenSegmentConsumption(values []HydrogenObservation, noiseKg, maxDropKg float64) (float64, int, int, int) { + accumulator := newHydrogenSegmentAccumulator(noiseKg, maxDropKg) + for _, value := range values { + accumulator.Add(value) } - if candidate.SampleCount != current.SampleCount { - return candidate.SampleCount > current.SampleCount + return accumulator.Finalize() +} + +type hydrogenSegmentAccumulator struct { + noiseKg float64 + maxDropKg float64 + previous HydrogenObservation + hasPrevious bool + cycleMinimum HydrogenObservation + segmentCount int + segmentFirst []HydrogenObservation + segmentTail []HydrogenObservation + consumption float64 + qualifiedSegments int + eligibleIntervals int + abnormalDrops int +} + +func newHydrogenSegmentAccumulator(noiseKg, maxDropKg float64) *hydrogenSegmentAccumulator { + return &hydrogenSegmentAccumulator{noiseKg: noiseKg, maxDropKg: maxDropKg} +} + +func (accumulator *hydrogenSegmentAccumulator) Add(value HydrogenObservation) { + if !accumulator.hasPrevious { + accumulator.hasPrevious = true + accumulator.previous = value + if hydrogenObservationCanStartSegment(value) { + accumulator.start(value) + } + return } - return candidate.Source < current.Source + previous := accumulator.previous + accumulator.previous = value + intervalEligible := hydrogenConsumptionIntervalEligible(previous, value) + if intervalEligible { + accumulator.eligibleIntervals++ + } + if accumulator.segmentCount == 0 || !intervalEligible { + accumulator.flush() + if hydrogenObservationCanStartSegment(value) { + accumulator.start(value) + } + return + } + if value.ObservedAt.Sub(previous.ObservedAt) > hydrogenSegmentMaxGap { + accumulator.flush() + accumulator.start(value) + return + } + if previous.MassKg-value.MassKg > accumulator.maxDropKg { + accumulator.abnormalDrops++ + accumulator.flush() + accumulator.start(value) + return + } + if value.MassKg-accumulator.cycleMinimum.MassKg > hydrogenObservationRefuelThreshold(value, accumulator.cycleMinimum) { + accumulator.flush() + accumulator.start(value) + return + } + accumulator.append(value) + if value.MassKg < accumulator.cycleMinimum.MassKg { + accumulator.cycleMinimum = value + } +} + +func (accumulator *hydrogenSegmentAccumulator) Finalize() (float64, int, int, int) { + accumulator.flush() + return accumulator.consumption, accumulator.qualifiedSegments, accumulator.eligibleIntervals, accumulator.abnormalDrops +} + +func (accumulator *hydrogenSegmentAccumulator) start(value HydrogenObservation) { + accumulator.segmentCount = 0 + accumulator.segmentFirst = accumulator.segmentFirst[:0] + accumulator.segmentTail = accumulator.segmentTail[:0] + accumulator.cycleMinimum = value + accumulator.append(value) +} + +func (accumulator *hydrogenSegmentAccumulator) append(value HydrogenObservation) { + accumulator.segmentCount++ + if len(accumulator.segmentFirst) < hydrogenSegmentEndpointWindow { + accumulator.segmentFirst = append(accumulator.segmentFirst, value) + } + if len(accumulator.segmentTail) == hydrogenSegmentEndpointWindow { + copy(accumulator.segmentTail, accumulator.segmentTail[1:]) + accumulator.segmentTail[len(accumulator.segmentTail)-1] = value + return + } + accumulator.segmentTail = append(accumulator.segmentTail, value) +} + +func (accumulator *hydrogenSegmentAccumulator) flush() { + if accumulator.segmentCount >= hydrogenSegmentEndpointWindow*2 { + accumulator.qualifiedSegments++ + accumulator.consumption += hydrogenEndpointDrop(accumulator.segmentFirst, accumulator.segmentTail, accumulator.noiseKg) + } + accumulator.segmentCount = 0 + accumulator.segmentFirst = accumulator.segmentFirst[:0] + accumulator.segmentTail = accumulator.segmentTail[:0] +} + +func hydrogenObservationCanStartSegment(value HydrogenObservation) bool { + return !value.FuelCellStateKnown || value.FuelCellActive +} + +func hydrogenEndpointDrop(start, end []HydrogenObservation, noiseKg float64) float64 { + startMass := hydrogenMedianMass(start) + endMass := hydrogenMedianMass(end) + segmentNoise := noiseKg + for _, value := range start { + segmentNoise = math.Max(segmentNoise, value.NoiseKg) + } + for _, value := range end { + segmentNoise = math.Max(segmentNoise, value.NoiseKg) + } + drop := startMass - endMass + if drop <= segmentNoise { + return 0 + } + return drop +} + +func hydrogenMedianMass(values []HydrogenObservation) float64 { + masses := make([]float64, len(values)) + for index, value := range values { + masses[index] = value.MassKg + } + sort.Float64s(masses) + return masses[len(masses)/2] +} + +func hydrogenObservationNoise(value HydrogenObservation, fallback float64) float64 { + return math.Max(fallback, value.NoiseKg) +} + +func hydrogenObservationRefuelThreshold(value, cycleMinimum HydrogenObservation) float64 { + threshold := math.Max(1, cycleMinimum.MassKg*0.05) + if value.RefuelThresholdKg > 0 { + threshold = value.RefuelThresholdKg + } + return threshold } func ExtractHydrogenMass(parsedJSON string) (float64, bool) { @@ -215,18 +387,90 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64 return capacities, rows.Err() } +func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) { + rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg +FROM vehicle_hydrogen_energy_parameter +WHERE active=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>=?) +ORDER BY vin,effective_from DESC`, date.Format("2006-01-02"), date.Format("2006-01-02")) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string]HydrogenCalculationParameters{} + for rows.Next() { + var vin string + var batteryCapacity, conversion float64 + if err := rows.Scan(&vin, &batteryCapacity, &conversion); err != nil { + return nil, err + } + if _, exists := result[vin]; exists { + continue + } + result[vin] = HydrogenCalculationParameters{ + BatteryCapacityKWh: batteryCapacity, HydrogenEnergyKWhKg: conversion, + } + } + return result, rows.Err() +} + func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) { + return loadHydrogenObservations(ctx, tdengine, database, start, end, capacities, "") +} + +func LoadHydrogenObservationVINs(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]string, error) { + query, err := buildHydrogenObservationVINQuery(database, start, end) + if err != nil { + return nil, err + } + rows, err := tdengine.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + vins := make([]string, 0) + seen := make(map[string]struct{}) + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + return nil, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + if !validHydrogenVIN(vin) { + continue + } + if _, ok := capacities[vin]; !ok { + continue + } + if _, ok := seen[vin]; ok { + continue + } + seen[vin] = struct{}{} + vins = append(vins, vin) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Strings(vins) + return vins, nil +} + +func LoadHydrogenObservationsForVIN(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64, vin string) ([]HydrogenObservation, error) { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if !validHydrogenVIN(vin) { + return nil, fmt.Errorf("invalid VIN %q", vin) + } + return loadHydrogenObservations(ctx, tdengine, database, start, end, capacities, vin) +} + +func loadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64, vin string) ([]HydrogenObservation, error) { database = strings.TrimSpace(database) if database == "" { database = "lingniu_vehicle_ts" } - query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json -FROM ` + database + `.raw_frames -WHERE protocol='GB32960' - AND ts>='` + quoteTDTime(start) + `' - AND ts<'` + quoteTDTime(end) + `' - AND parse_status='OK' -ORDER BY vin,source_endpoint,ts` + query, err := buildHydrogenObservationQuery(database, start, end, vin) + if err != nil { + return nil, err + } rows, err := tdengine.QueryContext(ctx, query) if err != nil { return nil, err @@ -235,14 +479,14 @@ ORDER BY vin,source_endpoint,ts` observations := make([]HydrogenObservation, 0) for rows.Next() { var vin, parsed string - var source sql.NullString + var source, eventID sql.NullString var unixMS int64 - if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil { + if err := rows.Scan(&vin, &source, &eventID, &unixMS, &parsed); err != nil { return nil, err } vin = strings.ToUpper(strings.TrimSpace(vin)) capacity, capacityOK := capacities[vin] - pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed) + pressure, temperature, fuelCellActive, fuelCellStateKnown, ok := extractHydrogenTelemetryFast(parsed) if !capacityOK || !ok { continue } @@ -252,23 +496,298 @@ ORDER BY vin,source_endpoint,ts` } stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity) noise := math.Min(1, math.Max(0.05, mass-stepMass)) - observations = append(observations, HydrogenObservation{ - VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass, + observation := HydrogenObservation{ + VIN: vin, Source: source.String, EventID: eventID.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass, TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature, NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05), - }) + FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown, + } + populateHydrogenObservationStateFast([]byte(parsed), &observation) + observations = append(observations, observation) } return observations, rows.Err() } +func buildHydrogenObservationQuery(database string, start, end time.Time, vin string) (string, error) { + if !validTDIdentifier(database) { + return "", fmt.Errorf("invalid TDengine database %q", database) + } + if !end.After(start) { + return "", errors.New("hydrogen observation end must be after start") + } + vinFilter := "" + if vin != "" { + if !validHydrogenVIN(vin) { + return "", fmt.Errorf("invalid VIN %q", vin) + } + vinFilter = "\n AND vin='" + vin + "'" + } + return `SELECT vin,source_endpoint,event_id,CAST(event_time AS BIGINT),parsed_json +FROM ` + database + `.raw_frames +WHERE protocol='GB32960' + AND ts>='` + quoteTDTime(start) + `' + AND ts<'` + quoteTDTime(end) + `' + AND event_time>='` + quoteTDTime(start) + `' + AND event_time<'` + quoteTDTime(end) + `'` + vinFilter + ` + AND parse_status='OK' + ORDER BY vin,event_time,source_endpoint,ts`, nil +} + +func populateHydrogenObservationStateFast(data []byte, observation *HydrogenObservation) { + if value, ok := jsonNumericField(data, "gb32960.vehicle.soc_percent"); ok && value >= 0 && value <= 100 { + observation.SOCPercent, observation.SOCKnown = value, true + } + if value, ok := jsonNumericField(data, "gb32960.vehicle.total_mileage_km"); ok && value >= 0 { + observation.MileageKm, observation.MileageKnown = value, true + } + if value, ok := jsonNumericField(data, "gb32960.vehicle.vehicle_status"); ok { + observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255 + } + if value, ok := jsonNumericField(data, "gb32960.vehicle.charge_status"); ok { + observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255 + } + if value, ok := jsonNumericField(data, "gb32960.vehicle.running_mode"); ok { + observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255 + } + voltage, voltageOK := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_voltage_v") + current, currentOK := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_current_a") + if voltageOK && currentOK && voltage > 0 && voltage <= 1000 && current >= 0 && current <= 2000 { + observation.FuelCellVoltageV = voltage + observation.FuelCellCurrentA = current + observation.FuelCellPowerKnown = true + } +} + +func buildHydrogenObservationVINQuery(database string, start, end time.Time) (string, error) { + database = strings.TrimSpace(database) + if database == "" { + database = "lingniu_vehicle_ts" + } + if !validTDIdentifier(database) { + return "", fmt.Errorf("invalid TDengine database %q", database) + } + if !end.After(start) { + return "", errors.New("hydrogen observation end must be after start") + } + return `SELECT DISTINCT vin +FROM ` + database + `.raw_frames +WHERE protocol='GB32960' + AND ts>='` + quoteTDTime(start) + `' + AND ts<'` + quoteTDTime(end) + `' + AND event_time>='` + quoteTDTime(start) + `' + AND event_time<'` + quoteTDTime(end) + `' + AND parse_status='OK' +ORDER BY vin`, nil +} + func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) { var fields map[string]any if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { return 0, 0, false } + return extractHydrogenPressureTemperatureFields(fields) +} + +func ExtractHydrogenTelemetry(parsedJSON string) (pressure, temperature float64, active, known, ok bool) { + var fields map[string]any + if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { + return 0, 0, false, false, false + } + pressure, temperature, ok = extractHydrogenPressureTemperatureFields(fields) + if !ok { + return 0, 0, false, false, false + } + active, known = extractHydrogenFuelCellStateFields(fields) + return pressure, temperature, active, known, true +} + +func extractHydrogenTelemetryFast(parsedJSON string) (pressure, temperature float64, active, known, ok bool) { + data := []byte(parsedJSON) + pressure, pressureOK := jsonNumericField(data, "gb32960.fuel_cell.max_hydrogen_pressure_mpa") + temperature, temperatureOK := jsonNumericField(data, "gb32960.fuel_cell.max_hydrogen_temperature_c") + if !pressureOK || !temperatureOK || !validHydrogenPressureTemperature(pressure, temperature) { + return 0, 0, false, false, false + } + if current, exists := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_current_a"); exists && current >= 0 { + return pressure, temperature, current > hydrogenFuelCellActiveCurrentA, true, true + } + if state, exists := jsonNumericField(data, "gb32960.gd_fc_stack.engine_work_state"); exists { + switch state { + case 2: + return pressure, temperature, true, true, true + case 0: + return pressure, temperature, false, true, true + } + } + return pressure, temperature, false, false, true +} + +func jsonNumericField(data []byte, key string) (float64, bool) { + needle := []byte(`"` + key + `"`) + for offset := 0; offset < len(data); { + match := bytes.Index(data[offset:], needle) + if match < 0 { + return 0, false + } + cursor := offset + match + len(needle) + for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') { + cursor++ + } + if cursor >= len(data) || data[cursor] != ':' { + offset = cursor + continue + } + cursor++ + for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') { + cursor++ + } + quoted := cursor < len(data) && data[cursor] == '"' + if quoted { + cursor++ + } + start := cursor + for cursor < len(data) && jsonNumberByte(data[cursor]) { + cursor++ + } + if start == cursor || (quoted && (cursor >= len(data) || data[cursor] != '"')) { + return 0, false + } + value, err := strconv.ParseFloat(string(data[start:cursor]), 64) + return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0) + } + return 0, false +} + +func jsonNumberByte(value byte) bool { + return value == '+' || value == '-' || value == '.' || value == 'e' || value == 'E' || + (value >= '0' && value <= '9') +} + +func extractHydrogenPressureTemperatureFields(fields map[string]any) (float64, float64, bool) { pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"]) temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) - return pressure, temperature, pressureOK && temperatureOK + return pressure, temperature, pressureOK && temperatureOK && validHydrogenPressureTemperature(pressure, temperature) +} + +func ExtractHydrogenFuelCellState(parsedJSON string) (active, known bool) { + var fields map[string]any + if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { + return false, false + } + return extractHydrogenFuelCellStateFields(fields) +} + +func extractHydrogenFuelCellStateFields(fields map[string]any) (active, known bool) { + if current, ok := numericValue(fields["gb32960.fuel_cell.fuel_cell_current_a"]); ok && current >= 0 { + return current > hydrogenFuelCellActiveCurrentA, true + } + if state, ok := numericValue(fields["gb32960.gd_fc_stack.engine_work_state"]); ok { + switch state { + case 2: + return true, true + case 0: + return false, true + } + } + return false, false +} + +func hydrogenConsumptionIntervalEligible(previous, current HydrogenObservation) bool { + if previous.FuelCellStateKnown || current.FuelCellStateKnown { + return previous.FuelCellStateKnown && previous.FuelCellActive && + current.FuelCellStateKnown && current.FuelCellActive + } + // Preserve compatibility for protocols or historical rows without a known + // fuel-cell state. Those intervals still rely on pressure hysteresis alone. + return true +} + +func rapidHydrogenPressureRecovery(previous, current HydrogenObservation) bool { + elapsed := current.ObservedAt.Sub(previous.ObservedAt) + return elapsed > 0 && elapsed <= hydrogenRapidRecoveryWindow && + current.PressureMPa-previous.PressureMPa >= hydrogenRapidRecoveryPressureMPa +} + +func validHydrogenPressureTemperature(pressureMPa, temperatureC float64) bool { + return pressureMPa > 0 && pressureMPa <= 70 && temperatureC > -40 && temperatureC <= 726.85 && + !math.IsNaN(pressureMPa) && !math.IsInf(pressureMPa, 0) && + !math.IsNaN(temperatureC) && !math.IsInf(temperatureC, 0) +} + +func validHydrogenVIN(vin string) bool { + if len(vin) != 17 { + return false + } + for _, character := range vin { + if character < '0' || character > '9' { + if character < 'A' || character > 'Z' { + return false + } + } + } + return true +} + +func validTDIdentifier(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character != '_' && (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') { + return false + } + } + return true +} + +func HydrogenObservationDateRange(ctx context.Context, tdengine *sql.DB, database, vin string) (time.Time, time.Time, bool, error) { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if !validHydrogenVIN(vin) { + return time.Time{}, time.Time{}, false, errors.New("invalid VIN") + } + return hydrogenObservationDateRange(ctx, tdengine, database, vin) +} + +func HydrogenObservationDateRangeForAll(ctx context.Context, tdengine *sql.DB, database string) (time.Time, time.Time, bool, error) { + return hydrogenObservationDateRange(ctx, tdengine, database, "") +} + +func hydrogenObservationDateRange(ctx context.Context, tdengine *sql.DB, database, vin string) (time.Time, time.Time, bool, error) { + database = strings.TrimSpace(database) + if database == "" { + database = "lingniu_vehicle_ts" + } + if !validTDIdentifier(database) { + return time.Time{}, time.Time{}, false, errors.New("invalid database") + } + vinFilter := "" + if vin != "" { + vinFilter = " AND vin='" + vin + "'" + } + loadBoundary := func(direction string) (time.Time, bool, error) { + query := `SELECT event_time +FROM ` + database + `.raw_frames +WHERE protocol='GB32960'` + vinFilter + ` AND parse_status='OK' AND event_time IS NOT NULL +ORDER BY event_time ` + direction + ` LIMIT 1` + row := tdengine.QueryRowContext(ctx, query) + var observed time.Time + if err := row.Scan(&observed); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, false, nil + } + return time.Time{}, false, err + } + return observed, true, nil + } + first, found, err := loadBoundary("ASC") + if err != nil || !found { + return time.Time{}, time.Time{}, false, err + } + last, found, err := loadBoundary("DESC") + if err != nil || !found { + return time.Time{}, time.Time{}, false, err + } + return first, last, true, nil } func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) { @@ -292,6 +811,26 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f } func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { + return replaceHydrogenDailyStats(ctx, db, date, "", stats, false) +} + +// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment +// handoff atomically. The stream resumes after the exact final observation +// included in the rebuild, so Kafka backlog already covered by the rebuild is +// ignored instead of counted twice. +func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { + return replaceHydrogenDailyStats(ctx, db, date, "", stats, true) +} + +func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if !validHydrogenVIN(vin) { + return fmt.Errorf("invalid VIN %q", vin) + } + return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false) +} + +func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -300,24 +839,122 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta // A pressure-based rebuild is authoritative for the whole day. Delete every // previous hydrogen row first so legacy rate/direct-mass results cannot remain // for vehicles without valid pressure observations in this run. - if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil { - return err - } - for _, stat := range stats { - if _, err := tx.ExecContext(ctx, ` -INSERT INTO vehicle_open_daily_energy( - vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg, - sample_count,refuel_count,quality_status,quality_reason,calculated_at -) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`, - stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, - stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, - ); err != nil { + if vin == "" { + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil { return err } } + if seedStream { + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?`, date); err != nil { + return err + } + } + for _, stat := range stats { + if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) { + return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin) + } + parameterJSON, err := json.Marshal(stat.CalculationParameters) + if err != nil { + return fmt.Errorf("encode hydrogen calculation parameters for %s: %w", stat.VIN, err) + } + evidenceJSON, err := json.Marshal(stat.Intervals) + if err != nil { + return fmt.Errorf("encode hydrogen interval evidence for %s: %w", stat.VIN, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO vehicle_open_daily_energy( + vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg, + sample_count,refuel_count,quality_status,quality_reason,calculated_at, + raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg, + soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km, + consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count, + invalid_segment_count,algorithm_version,parameter_json,evidence_json + ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, + stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, + stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg, + stat.SOCBalancedConsumptionKg, stat.MixedMileageKm, stat.PureElectricMileageKm, + stat.ConsumptionKgPer100Km, stat.SOCBalancedKgPer100Km, stat.ChargeCount, stat.QualifiedSegmentCount, + stat.InvalidSegmentCount, stat.CalculationParameters.AlgorithmVersion, parameterJSON, evidenceJSON, + ); err != nil { + return err + } + if seedStream { + stateJSON, err := hydrogenSegmentStreamSeedJSON(stat) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO vehicle_open_hydrogen_segment_stream_state( + vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg, + sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count, + last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason +) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`, + stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg, + stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount, + stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "", + stateJSON, stat.QualityStatus, stat.QualityReason, + ); err != nil { + return err + } + } + } return tx.Commit() } +func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) { + type segmentWindow struct { + Count int `json:"count"` + First []any `json:"first"` + Tail []any `json:"tail"` + CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"` + } + state := struct { + SourceEndpoint string `json:"sourceEndpoint"` + FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"` + SampleCount int `json:"sampleCount"` + RefuelCount int `json:"refuelCount"` + AbnormalDropCount int `json:"abnormalDropCount"` + EligibleIntervalCount int `json:"eligibleIntervalCount"` + QualifiedSegmentCount int `json:"qualifiedSegmentCount"` + FirstMassKg float64 `json:"firstMassKg"` + RemainingMassKg float64 `json:"remainingMassKg"` + MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"` + LastObservedMassKg float64 `json:"lastObservedMassKg"` + TankCapacityLiters float64 `json:"tankCapacityLiters"` + FirstPressureMpa float64 `json:"firstPressureMpa"` + LastPressureMpa float64 `json:"lastPressureMpa"` + FirstTemperatureC float64 `json:"firstTemperatureC"` + LastTemperatureC float64 `json:"lastTemperatureC"` + FirstEventTime time.Time `json:"firstEventTime"` + LastEventTime time.Time `json:"lastEventTime"` + LastEventID string `json:"lastEventId"` + LastFuelCellActive bool `json:"lastFuelCellActive"` + LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"` + Segment segmentWindow `json:"segment"` + }{ + SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg, + SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount, + AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount, + QualifiedSegmentCount: stat.QualifiedSegmentCount, + FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg, + MetadataCycleMinimumKg: stat.CycleMinimumMassKg, + LastObservedMassKg: stat.LastObservation.MassKg, + TankCapacityLiters: stat.LastObservation.TankCapacityLiter, + FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa, + FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC, + FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt, + LastFuelCellActive: stat.LastObservation.FuelCellActive, + LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown, + Segment: segmentWindow{First: []any{}, Tail: []any{}}, + } + return json.Marshal(state) +} + func numericValue(value any) (float64, bool) { switch typed := value.(type) { case float64: diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go index 8c3ae226..46ccc35a 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go @@ -1,54 +1,142 @@ package openplatform import ( + "context" + "encoding/json" + "regexp" + "strings" "testing" "time" + + "github.com/DATA-DOG/go-sqlmock" ) -func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) { +func TestBuildHydrogenDailyStatsUsesDirectValidBoundaries(t *testing.T) { base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - stats := BuildHydrogenDailyStats([]HydrogenObservation{ - {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00}, - {VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50}, - {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30}, - {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90}, - {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98}, - }, "2026-07-01", 0.05, 5) + values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10.5, 9.5) + stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 20) if len(stats) != 1 { t.Fatalf("stats = %#v", stats) } stat := stats[0] - if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" { + if stat.ConsumptionKg != 1 || stat.FirstMassKg != 10.5 || stat.LastMassKg != 9.5 { t.Fatalf("stat = %#v", stat) } } -func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) { - base := time.Now() - stats := BuildHydrogenDailyStats([]HydrogenObservation{ - {VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20}, - {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1}, - }, "2026-07-01", 0.05, 5) - if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 { +func TestBuildHydrogenDailyStatsMergesSequentialSources(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6) + for index := len(values) / 2; index < len(values); index++ { + values[index].Source = "source-b" + } + stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5) + if len(stats) != 1 { + t.Fatalf("stats = %#v", stats) + } + if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 { + t.Fatalf("stat = %#v", stats[0]) + } +} + +func TestBuildHydrogenDailyStatsDeduplicatesSameEventAcrossSources(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6) + duplicates := append([]HydrogenObservation(nil), values...) + for index := range duplicates { + duplicates[index].Source = "source-b" + } + stats := BuildHydrogenDailyStats(append(values, duplicates...), "2026-07-01", 0.05, 5) + if len(stats) != 1 || stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 { t.Fatalf("stats = %#v", stats) } } -func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) { - base := time.Now() - stats := BuildHydrogenDailyStats([]HydrogenObservation{ - {VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10}, - {VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8}, - {VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6}, - {VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20}, - {VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9}, - }, "2026-07-01", 0.05, 5) - if len(stats) != 1 { +func TestBuildHydrogenDailyStatsOrderedMatchesUnorderedCalculation(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6) + duplicates := append([]HydrogenObservation(nil), values...) + for index := range duplicates { + duplicates[index].Source = "source-b" + } + ordered := make([]HydrogenObservation, 0, len(values)+len(duplicates)) + for index := range values { + ordered = append(ordered, values[index], duplicates[index]) + } + stats := BuildHydrogenDailyStatsOrdered(ordered, "2026-07-01", 0.05, 5) + if len(stats) != 1 || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 { t.Fatalf("stats = %#v", stats) } - if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 { - t.Fatalf("stat = %#v", stats[0]) +} + +func TestBuildHydrogenDailyStatsDoesNotReplaceBoundariesWithWholeDayMinimum(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.8) + values = append(values[:5], append([]HydrogenObservation{{ + VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(5 * time.Second), + MassKg: 9, NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true, + SOCPercent: 50, SOCKnown: true, MileageKm: 10, MileageKnown: true, + VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true, + RunningMode: 2, RunningModeKnown: true, + }}, values[5:]...)...) + for index := 6; index < len(values); index++ { + values[index].ObservedAt = base.Add(time.Duration(index) * time.Second) } + stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5) + if len(stats) != 1 || stats[0].ConsumptionKg != 0.2 { + t.Fatalf("stats = %#v", stats) + } +} + +func TestHydrogenSegmentAccumulatorKeepsOnlyBoundedEndpointWindows(t *testing.T) { + base := time.Now() + accumulator := newHydrogenSegmentAccumulator(0.05, 20) + for index := 0; index < 10000; index++ { + accumulator.Add(HydrogenObservation{ + ObservedAt: base.Add(time.Duration(index) * time.Second), MassKg: 20 - float64(index)/10000, + NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true, + }) + } + if len(accumulator.segmentFirst) != hydrogenSegmentEndpointWindow || len(accumulator.segmentTail) != hydrogenSegmentEndpointWindow { + t.Fatalf("first=%d tail=%d", len(accumulator.segmentFirst), len(accumulator.segmentTail)) + } + consumption, segments, _, _ := accumulator.Finalize() + if consumption <= 0 || segments != 1 { + t.Fatalf("consumption=%v segments=%d", consumption, segments) + } +} + +func TestBuildHydrogenDailyStatsRejectsInactivePressureFall(t *testing.T) { + base := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) + stats := BuildHydrogenDailyStats([]HydrogenObservation{ + {VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10, PressureMPa: 28.2, VehicleState: 2, VehicleStateKnown: true}, + {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 8, PressureMPa: 22, VehicleState: 2, VehicleStateKnown: true}, + {VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Minute), MassKg: 5, PressureMPa: 12.8, VehicleState: 2, VehicleStateKnown: true}, + }, "2026-08-15", 0.05, 20) + if len(stats) != 1 || stats[0].ConsumptionKg != 0 || stats[0].QualityStatus != "NO_DATA" { + t.Fatalf("stats = %#v", stats) + } +} + +func hydrogenActiveTestSegment(vin, source string, start time.Time, startMass, endMass float64) []HydrogenObservation { + values := make([]HydrogenObservation, 0, hydrogenSegmentEndpointWindow*2) + for index := 0; index < hydrogenSegmentEndpointWindow*2; index++ { + mass := startMass + if index >= hydrogenSegmentEndpointWindow { + mass = endMass + } + values = append(values, HydrogenObservation{ + VIN: vin, Source: source, ObservedAt: start.Add(time.Duration(index) * time.Second), + MassKg: mass, PressureMPa: mass, NoiseKg: 0.05, RefuelThresholdKg: 1, + FuelCellActive: true, FuelCellStateKnown: true, + SOCPercent: 50, SOCKnown: true, + MileageKm: float64(index) * 2, MileageKnown: true, + VehicleState: 1, VehicleStateKnown: true, + ChargeState: 3, ChargeStateKnown: true, + RunningMode: 2, RunningModeKnown: true, + }) + } + return values } func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) { @@ -77,3 +165,238 @@ func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) { t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok) } } + +func TestExtractHydrogenPressureTemperatureRejectsPlaceholders(t *testing.T) { + for _, encoded := range []string{ + `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":0,"gb32960.fuel_cell.max_hydrogen_temperature_c":30}`, + `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":22.8,"gb32960.fuel_cell.max_hydrogen_temperature_c":-40}`, + } { + if _, _, ok := ExtractHydrogenPressureTemperature(encoded); ok { + t.Fatalf("pressure/temperature placeholder must be rejected: %s", encoded) + } + } +} + +func TestExtractHydrogenFuelCellState(t *testing.T) { + for _, test := range []struct { + encoded string + active bool + known bool + }{ + {`{"gb32960.gd_fc_stack.engine_work_state":2}`, true, true}, + {`{"gb32960.gd_fc_stack.engine_work_state":0}`, false, true}, + {`{"gb32960.gd_fc_stack.engine_work_state":1}`, false, false}, + {`{"gb32960.gd_fc_stack.engine_work_state":1,"gb32960.fuel_cell.fuel_cell_current_a":94.1}`, true, true}, + {`{"gb32960.gd_fc_stack.engine_work_state":2,"gb32960.fuel_cell.fuel_cell_current_a":0}`, false, true}, + {`{"gb32960.fuel_cell.fuel_cell_current_a":0.2}`, false, true}, + {`{}`, false, false}, + } { + active, known := ExtractHydrogenFuelCellState(test.encoded) + if active != test.active || known != test.known { + t.Fatalf("encoded=%s active=%v known=%v", test.encoded, active, known) + } + } +} + +func TestExtractHydrogenTelemetryReadsPressureTemperatureAndStateTogether(t *testing.T) { + pressure, temperature, active, known, ok := ExtractHydrogenTelemetry(`{ + "gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2, + "gb32960.fuel_cell.max_hydrogen_temperature_c":35, + "gb32960.gd_fc_stack.engine_work_state":2 + }`) + if !ok || pressure != 28.2 || temperature != 35 || !active || !known { + t.Fatalf("pressure=%v temperature=%v active=%v known=%v ok=%v", pressure, temperature, active, known, ok) + } +} + +func TestExtractHydrogenTelemetryFastMatchesJSONDecoder(t *testing.T) { + for _, encoded := range []string{ + `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35,"gb32960.gd_fc_stack.engine_work_state":2}`, + `{ "gb32960.fuel_cell.fuel_cell_current_a" : "0.5", "gb32960.fuel_cell.max_hydrogen_temperature_c" : "35", "gb32960.fuel_cell.max_hydrogen_pressure_mpa" : "28.2" }`, + `{"unrelated":"gb32960.fuel_cell.max_hydrogen_pressure_mpa","gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35}`, + } { + wantPressure, wantTemperature, wantActive, wantKnown, wantOK := ExtractHydrogenTelemetry(encoded) + pressure, temperature, active, known, ok := extractHydrogenTelemetryFast(encoded) + if pressure != wantPressure || temperature != wantTemperature || active != wantActive || known != wantKnown || ok != wantOK { + t.Fatalf("fast=(%v,%v,%v,%v,%v) decoded=(%v,%v,%v,%v,%v) input=%s", + pressure, temperature, active, known, ok, + wantPressure, wantTemperature, wantActive, wantKnown, wantOK, encoded) + } + } +} + +func TestHydrogenObservationQueryUsesEventTimeAndScopedVIN(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc) + query, err := buildHydrogenObservationQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), "LB9A32A24R0LS1376") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "CAST(event_time AS BIGINT)", + "ts>='2026-08-12T00:00:00+08:00'", + "ts<'2026-08-13T00:00:00+08:00'", + "event_time>='2026-08-12T00:00:00+08:00'", + "event_time<'2026-08-13T00:00:00+08:00'", + "vin='LB9A32A24R0LS1376'", + "protocol='GB32960'", + "ORDER BY vin,event_time,source_endpoint,ts", + } { + if !strings.Contains(query, want) { + t.Fatalf("query missing %q:\n%s", want, query) + } + } + if strings.Contains(query, "CAST(ts AS BIGINT)") { + t.Fatalf("received time must not become observation time:\n%s", query) + } + if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") { + t.Fatalf("hydrogen query must only use GB32960 frames:\n%s", query) + } + if strings.Contains(query, "2026-08-05") || strings.Contains(query, "2026-08-20") { + t.Fatalf("received-time window must not include delayed frames:\n%s", query) + } +} + +func TestHydrogenObservationVINQueryUsesSameDayGB32960Watermark(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc) + query, err := buildHydrogenObservationVINQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "SELECT DISTINCT vin", "protocol='GB32960'", + "ts>='2026-08-12T00:00:00+08:00'", "ts<'2026-08-13T00:00:00+08:00'", + "event_time>='2026-08-12T00:00:00+08:00'", "event_time<'2026-08-13T00:00:00+08:00'", + "ORDER BY vin", + } { + if !strings.Contains(query, want) { + t.Fatalf("query missing %q:\n%s", want, query) + } + } + if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") { + t.Fatalf("VIN query must only use GB32960:\n%s", query) + } +} + +func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT DISTINCT vin").WillReturnRows(sqlmock.NewRows([]string{"vin"}). + AddRow("lb9a32a25r0ls1452"). + AddRow("LB9A32A24R0LS1376")) + loc := time.FixedZone("Asia/Shanghai", 8*3600) + start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc) + vins, err := LoadHydrogenObservationVINs(context.Background(), db, "lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), map[string]float64{ + "LB9A32A25R0LS1452": 520, + }) + if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" { + t.Fatalf("vins=%v err=%v", vins, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + first := time.Date(2026, 6, 21, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + last := time.Date(2026, 8, 18, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + mock.ExpectQuery("ORDER BY event_time ASC LIMIT 1"). + WillReturnRows(sqlmock.NewRows([]string{"event_time"}).AddRow(first)) + mock.ExpectQuery("ORDER BY event_time DESC LIMIT 1"). + WillReturnRows(sqlmock.NewRows([]string{"event_time"}).AddRow(last)) + gotFirst, gotLast, found, err := HydrogenObservationDateRangeForAll(context.Background(), db, "lingniu_vehicle_ts") + if err != nil || !found || !gotFirst.Equal(first) || !gotLast.Equal(last) { + t.Fatalf("range first=%v last=%v found=%v err=%v", gotFirst, gotLast, found, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + vin := "LB9A32A24R0LS1376" + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?")). + WithArgs("2026-08-12", vin). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). + WithArgs(vin, "2026-08-12", "source-a", 8.691, 7.64, 10.515, 2803, 2, "OK", "", + 8.691, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + err = ReplaceHydrogenDailyStatsForVIN(context.Background(), db, "2026-08-12", vin, []HydrogenDailyStat{{ + VIN: vin, Date: "2026-08-12", Source: "source-a", ConsumptionKg: 8.691, + FirstMassKg: 7.64, LastMassKg: 10.515, SampleCount: 2803, RefuelCount: 2, QualityStatus: "OK", + }}) + if err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + vin := "LB9A32A24R0LS1376" + loc := time.FixedZone("Asia/Shanghai", 8*3600) + first := HydrogenObservation{VIN: vin, Source: "source-a", ObservedAt: time.Date(2026, 8, 19, 8, 0, 0, 0, loc), MassKg: 12, TankCapacityLiter: 520, PressureMPa: 30, TemperatureC: 31, FuelCellActive: true, FuelCellStateKnown: true} + last := HydrogenObservation{VIN: vin, Source: "source-b", ObservedAt: time.Date(2026, 8, 19, 11, 0, 0, 0, loc), MassKg: 9.4, TankCapacityLiter: 520, PressureMPa: 22, TemperatureC: 35, FuelCellActive: true, FuelCellStateKnown: true} + stat := HydrogenDailyStat{ + VIN: vin, Date: "2026-08-19", Source: "source-a", ConsumptionKg: 2.6, + FirstMassKg: 12, LastMassKg: 9.4, CycleMinimumMassKg: 9.4, + SampleCount: 500, RefuelCount: 1, EligibleIntervalCount: 460, QualifiedSegmentCount: 2, + FirstObservation: first, LastObservation: last, QualityStatus: "OK", + } + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")). + WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 3)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")). + WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). + WithArgs(vin, "2026-08-19", "source-a", 2.6, 12.0, 9.4, 500, 1, "OK", "", + 2.6, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 2, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state(")). + WithArgs(vin, "2026-08-19", "source-a", 2.6, 2.6, 500, 1, 0, 460, 2, 9.4, last.ObservedAt, "", sqlmock.AnyArg(), "OK", ""). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, "2026-08-19", []HydrogenDailyStat{stat}); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + + encoded, err := hydrogenSegmentStreamSeedJSON(stat) + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(encoded, &state); err != nil { + t.Fatal(err) + } + if state["lastObservedMassKg"] != 9.4 || state["finalizedConsumptionKg"] != 2.6 || state["lastEventTime"] == "" { + t.Fatalf("seed state=%s", encoded) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go b/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go index 1fbc8a0f..afd89db8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go +++ b/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go @@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping { var gb32960FieldReferences = map[string]gb32960FieldReference{ "gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, "gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, - "gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃油等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃油", "燃油驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, "gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"}, "gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"}, "gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index f85b23de..0e3f5e3b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -48,6 +48,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost) h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics) h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost) + h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/hydrogen-evidence", h.handleHydrogenDailyEvidence) h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary) h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses) h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary) @@ -862,6 +863,11 @@ func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Req h.write(w, r, data, err) } +func (h *Handler) handleHydrogenDailyEvidence(w http.ResponseWriter, r *http.Request) { + data, err := h.service.HydrogenDailyEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date")) + h.write(w, r, data, err) +} + func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) { defer r.Body.Close() var query MileageQuery diff --git a/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go b/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go new file mode 100644 index 00000000..73bac84b --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go @@ -0,0 +1,40 @@ +package platform + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEvents(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + columns := []string{ + "vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct", + "battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg", + "mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km", + "sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count", + "quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at", + } + parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}` + evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]` + mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow( + "LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126, + 56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2", + []byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000", + )) + result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26") + if err != nil || !found { + t.Fatalf("found=%v err=%v", found, err) + } + if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" { + t.Fatalf("result=%#v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 2ee1d144..9f76c870 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -1881,10 +1881,83 @@ type DailyMileageRow struct { PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"` HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` + HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"` + HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"` + HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"` + HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"` + HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"` Source string `json:"source"` AnomalySeverity string `json:"anomalySeverity,omitempty"` } +type HydrogenCalculationParameterEvidence struct { + BatteryCapacityKWh float64 `json:"batteryCapacityKWh"` + HydrogenEnergyKWhPerKg float64 `json:"hydrogenEnergyKWhPerKg"` + PowerOnDelaySeconds int `json:"powerOnDelaySeconds"` + PowerOffLeadSeconds int `json:"powerOffLeadSeconds"` + RefuelRiseMpa float64 `json:"refuelRiseMpa"` + RefuelSustainSeconds int `json:"refuelSustainSeconds"` + PureElectricDropMpa float64 `json:"pureElectricDropMpa"` + PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"` + AlgorithmVersion string `json:"algorithmVersion"` +} + +type HydrogenIntervalEvidenceRow struct { + Index int `json:"index"` + Type string `json:"type"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + StartEventID string `json:"startEventId"` + EndEventID string `json:"endEventId"` + Source string `json:"source"` + StartPressureMpa float64 `json:"startPressureMpa"` + EndPressureMpa float64 `json:"endPressureMpa"` + StartTemperatureC float64 `json:"startTemperatureC"` + EndTemperatureC float64 `json:"endTemperatureC"` + StartMassKg float64 `json:"startMassKg"` + EndMassKg float64 `json:"endMassKg"` + RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"` + StartSOCPercent *float64 `json:"startSocPercent,omitempty"` + EndSOCPercent *float64 `json:"endSocPercent,omitempty"` + BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"` + BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"` + SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"` + StartMileageKm *float64 `json:"startMileageKm,omitempty"` + EndMileageKm *float64 `json:"endMileageKm,omitempty"` + MileageKm *float64 `json:"mileageKm,omitempty"` + ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"` + SampleCount int `json:"sampleCount"` + QualityStatus string `json:"qualityStatus"` + QualityReason string `json:"qualityReason"` +} + +type HydrogenDailyEvidence struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + Date string `json:"date"` + Source string `json:"source"` + RawConsumptionKg float64 `json:"rawConsumptionKg"` + BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"` + BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"` + BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"` + SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"` + MixedMileageKm float64 `json:"mixedMileageKm"` + PureElectricMileageKm float64 `json:"pureElectricMileageKm"` + ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"` + SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"` + SampleCount int `json:"sampleCount"` + RefuelCount int `json:"refuelCount"` + ChargeCount int `json:"chargeCount"` + ValidSegmentCount int `json:"validSegmentCount"` + InvalidSegmentCount int `json:"invalidSegmentCount"` + QualityStatus string `json:"qualityStatus"` + QualityReason string `json:"qualityReason"` + AlgorithmVersion string `json:"algorithmVersion"` + Parameters HydrogenCalculationParameterEvidence `json:"parameters"` + Intervals []HydrogenIntervalEvidenceRow `json:"intervals"` + CalculatedAt string `json:"calculatedAt"` +} + // MileageQuery is the POST contract used by mileage statistics and daily // mileage queries. Array fields keep large vehicle selections out of the URL // and avoid ambiguity when clients submit thousands of VINs. diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 1e1d08c4..7ab924b6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -575,6 +575,12 @@ WHERE ` + strings.Join(where, " AND ") dailyMileageExpression + ` AS daily_mileage_km, ` + pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` + `MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` + + `MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` + + `MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` + + `MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` + + `MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` + + `COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` + + `COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` + groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`, Args: args, @@ -591,7 +597,9 @@ WHERE ` + strings.Join(where, " AND ") Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` + `COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` + `COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` + - `COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` + + `COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` + + `h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` + + `COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` + fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`, Args: args, CountText: `SELECT COUNT(*) ` + fromSQL, @@ -687,6 +695,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) { return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` + dailyMileageExpression + ` AS daily_mileage_km, ` + pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` + + `CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` + `MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` + latestMileageExpression + ` AS latest_mileage_km ` + `FROM vehicle_daily_mileage m @@ -702,8 +711,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery { base, args := buildMileageStatisticsBaseSQL(query) return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` + `COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` + - `COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` + - `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` + + `COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` + + `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` + + `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` + `COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args} } @@ -711,8 +721,9 @@ func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery { base, args := buildMileageStatisticsBaseSQL(query) return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` + `COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` + - `COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` + - `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` + + `COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` + + `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` + + `COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` + `COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args} } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 90405509..2563b67f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -1026,6 +1026,89 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil } +const latestRealtimeProjectionTimeSQL = "COALESCE(s.received_at, s.event_time, s.updated_at)" + +func buildLatestRealtimeFramesSQL(query RawFrameQuery) SQLQuery { + limit := query.Limit + if limit <= 0 || limit > len(canonicalVehicleProtocols) { + limit = len(canonicalVehicleProtocols) + } + where := []string{"s.vin = ?"} + args := []any{strings.TrimSpace(query.VIN)} + if protocol := strings.ToUpper(strings.TrimSpace(query.Protocol)); protocol != "" { + where = append(where, "s.protocol = ?") + args = append(args, protocol) + } + if value := strings.TrimSpace(query.DateFrom); value != "" { + if parsed, ok := parseTrackRequestTime(value); ok { + where = append(where, latestRealtimeProjectionTimeSQL+" >= ?") + args = append(args, parsed) + } + } + if value := strings.TrimSpace(query.DateTo); value != "" { + if parsed, ok := parseTrackRequestTime(value); ok { + where = append(where, latestRealtimeProjectionTimeSQL+" <= ?") + args = append(args, parsed) + } + } + args = append(args, limit) + return SQLQuery{ + Text: `SELECT COALESCE(NULLIF(s.event_id, ''), CONCAT('snapshot:', s.protocol, ':', s.vin)), ` + + `s.vin, COALESCE(s.plate, ''), s.protocol, ` + + `COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` + + `COALESCE(DATE_FORMAT(s.received_at, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` + + `COALESCE(s.peer, ''), COALESCE(s.parsed_json, '') ` + + `FROM vehicle_realtime_snapshot s WHERE ` + strings.Join(where, " AND ") + ` ` + + `ORDER BY ` + latestRealtimeProjectionTimeSQL + ` DESC, s.protocol ASC LIMIT ?`, + Args: args, + } +} + +// LatestRealtimeFrames returns one durable latest projection per protocol. +// Unlike Redis, these rows remain available after the online TTL expires. +func (s *ProductionStore) LatestRealtimeFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + built := buildLatestRealtimeFramesSQL(query) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) + if err != nil { + return Page[RawFrameRow]{}, err + } + defer rows.Close() + items := make([]RawFrameRow, 0, len(canonicalVehicleProtocols)) + for rows.Next() { + var row RawFrameRow + var parsedJSON string + if err := rows.Scan( + &row.ID, + &row.VIN, + &row.Plate, + &row.Protocol, + &row.DeviceTime, + &row.ServerTime, + &row.SourceEndpoint, + &parsedJSON, + ); err != nil { + return Page[RawFrameRow]{}, err + } + row.FrameType = "realtime_projection" + row.ParseStatus = "OK" + if query.IncludeFields || len(query.Fields) > 0 { + row.ParsedFields = parsedFieldsFromString(parsedJSON) + if len(query.Fields) > 0 { + row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields) + } + } + items = append(items, row) + } + if err := rows.Err(); err != nil { + return Page[RawFrameRow]{}, err + } + limit := query.Limit + if limit <= 0 || limit > len(canonicalVehicleProtocols) { + limit = len(canonicalVehicleProtocols) + } + return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil +} + func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) { built := buildMileageSummarySQL(query) var summary MileageSummary @@ -1054,15 +1137,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P items := make([]DailyMileageRow, 0) for rows.Next() { var row DailyMileageRow - var hydrogen sql.NullFloat64 - if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil { + var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64 + var evidenceAvailable int + if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, + &hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil { return Page[DailyMileageRow]{}, err } if hydrogen.Valid { consumption := hydrogen.Float64 row.HydrogenConsumptionKg = &consumption - row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1) + if physicalRate.Valid { + value := physicalRate.Float64 + row.HydrogenConsumptionKgPer100Km = &value + } else { + row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.PureHydrogenMileageKm, 1) + } } + if balanced.Valid { + value := balanced.Float64 + row.HydrogenSOCBalancedKg = &value + } + if balancedRate.Valid { + value := balancedRate.Float64 + row.HydrogenSOCBalancedKgPer100Km = &value + } + row.HydrogenEvidenceAvailable = evidenceAvailable == 1 items = append(items, row) } if err := rows.Err(); err != nil { @@ -1075,11 +1174,66 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } +func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, bool, error) { + row := s.db.QueryRowContext(ctx, `SELECT h.vin,COALESCE(NULLIF(b.plate,''),''),DATE_FORMAT(h.stat_date,'%Y-%m-%d'), + h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct, + h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg, + h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km, + h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count, + h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json, + DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f') +FROM vehicle_open_daily_energy h +LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin +WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN' +LIMIT 1`, vin, date) + var result HydrogenDailyEvidence + var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64 + var parameterJSON, evidenceJSON []byte + if err := row.Scan( + &result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg, + &socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm, + &physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount, + &result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason, + &result.AlgorithmVersion, ¶meterJSON, &evidenceJSON, &result.CalculatedAt, + ); err != nil { + if err == sql.ErrNoRows { + return HydrogenDailyEvidence{}, false, nil + } + return HydrogenDailyEvidence{}, false, err + } + result.BatterySOCDeltaPct = nullableFloatPointer(socDelta) + result.BatteryDischargeKWh = nullableFloatPointer(discharge) + result.BatteryEquivalentKg = nullableFloatPointer(equivalent) + result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced) + result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate) + result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate) + result.Intervals = []HydrogenIntervalEvidenceRow{} + if len(parameterJSON) > 0 { + if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil { + return HydrogenDailyEvidence{}, false, err + } + } + if len(evidenceJSON) > 0 { + if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil { + return HydrogenDailyEvidence{}, false, err + } + } + return result, true, nil +} + +func nullableFloatPointer(value sql.NullFloat64) *float64 { + if !value.Valid { + return nil + } + result := value.Float64 + return &result +} + func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { result := MileageStatistics{ DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{}, - Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的总里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)", + Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)", } summary := buildMileageStatisticsSummarySQL(query) if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan( diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index d3613b0d..ec51878b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -8,6 +8,8 @@ import ( "io" "strings" "testing" + + "github.com/DATA-DOG/go-sqlmock" ) func init() { @@ -141,7 +143,7 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing. } } -func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) { +func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) { rate := hydrogenRatePer100Km(7.3, 193.3, 2) if rate == nil || *rate < 3.77 || *rate > 3.78 { t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate) @@ -200,6 +202,61 @@ func TestRawFramesReturnsEmptyPageWhenTDengineTableIsMissing(t *testing.T) { } } +func TestBuildLatestRealtimeFramesSQLUsesDurableVINProtocolProjection(t *testing.T) { + built := buildLatestRealtimeFramesSQL(RawFrameQuery{ + VIN: "LMRKH9AC0R1004086", + Protocol: "yutong_mqtt", + DateFrom: "2026-07-01T00:00:00+08:00", + DateTo: "2026-08-25T18:00:00+08:00", + Limit: 10, + }) + for _, expected := range []string{ + "FROM vehicle_realtime_snapshot s", + "s.vin = ?", + "s.protocol = ?", + latestRealtimeProjectionTimeSQL + " >= ?", + latestRealtimeProjectionTimeSQL + " <= ?", + "ORDER BY " + latestRealtimeProjectionTimeSQL + " DESC", + } { + if !strings.Contains(built.Text, expected) { + t.Fatalf("latest projection SQL missing %q: %s", expected, built.Text) + } + } + if len(built.Args) != 5 || built.Args[0] != "LMRKH9AC0R1004086" || built.Args[1] != "YUTONG_MQTT" || built.Args[4] != len(canonicalVehicleProtocols) { + t.Fatalf("latest projection args = %#v", built.Args) + } +} + +func TestLatestRealtimeFramesMapsPersistentProjectionToRawEvidence(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("(?s)FROM vehicle_realtime_snapshot s WHERE s\\.vin = \\?"). + WithArgs("LMRKH9AC0R1004086", len(canonicalVehicleProtocols)). + WillReturnRows(sqlmock.NewRows([]string{"event_id", "vin", "plate", "protocol", "event_time", "received_at", "peer", "parsed_json"}). + AddRow("event-1", "LMRKH9AC0R1004086", "沪A03561F", "YUTONG_MQTT", "2026-08-25 16:49:56.451000", "2026-08-25 16:49:56.485000", "mqtt://yutong/4", `{"yutong_mqtt.data.speed_kmh":12.5}`)) + store := &ProductionStore{db: db} + page, err := store.LatestRealtimeFrames(t.Context(), RawFrameQuery{VIN: "LMRKH9AC0R1004086", IncludeFields: true, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Total != 1 { + t.Fatalf("latest projection page = %+v", page) + } + row := page.Items[0] + if row.ID != "event-1" || row.FrameType != "realtime_projection" || row.Protocol != "YUTONG_MQTT" || row.SourceEndpoint != "mqtt://yutong/4" { + t.Fatalf("latest projection row = %+v", row) + } + if got := row.ParsedFields["yutong_mqtt.data.speed_kmh"]; got != 12.5 { + t.Fatalf("projection parsed field = %#v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) { db, err := sql.Open("ops_health_test", "") if err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 29830e38..6a255212 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) { "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)", "SUM(d.pure_hydrogen_mileage_km)", - "COUNT(d.hydrogen_consumption_kg)", - "SUM(d.hydrogen_consumption_kg)", - "CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km", + "COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)", + "THEN d.hydrogen_consumption_kg ELSE 0 END", + "THEN d.hydrogen_matched_mileage_km ELSE 0 END", } { if !strings.Contains(summary.Text, want) { t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text) @@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) { } trend := buildMileageStatisticsTrendSQL(query) if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") || - !strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") { + !strings.Contains(trend.Text, "THEN d.hydrogen_consumption_kg ELSE 0 END") { t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text) } ranking := buildMileageStatisticsRankingSQL(query) diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index e0abe5d9..75da363e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -43,6 +43,10 @@ type Store interface { OpsHealth(context.Context) (OpsHealth, error) } +type HydrogenDailyEvidenceStore interface { + HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) +} + type VehicleOverviewBatchStore interface { VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) } @@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface { VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error) } +// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime +// projection. The projection is used as a time anchor for bounded TDengine +// lookups, so an offline vehicle can still resolve its latest evidence without +// scanning all retained RAW history. +type RealtimeSnapshotFrameStore interface { + LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error) +} + type RawFrameQuery struct { Protocol string `json:"protocol"` VIN string `json:"vin"` @@ -1029,7 +1041,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string if err != nil { return VehicleDetail{}, err } - raw, err := s.RawFrames(ctx, rawQuery) + raw, err := s.vehicleDetailRawPreview(ctx, rawQuery) if err != nil { return VehicleDetail{}, err } @@ -1094,6 +1106,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string }, nil } +const vehicleDetailRawProjectionLookback = 24 * time.Hour + +type vehicleDetailRawPreviewResult struct { + anchor RawFrameRow + page Page[RawFrameRow] +} + +// vehicleDetailRawPreview preserves the latest-ever semantics without issuing +// an unbounded TDengine query. MySQL supplies one persistent anchor per +// protocol; TDengine then reads only the day preceding that protocol's actual +// latest received time. If historical storage is unavailable, the projection +// itself remains useful evidence and the rest of the vehicle detail can load. +func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore) + if !ok { + return s.store.RawFrames(ctx, query) + } + anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{ + VIN: query.VIN, + Protocol: query.Protocol, + DateFrom: query.DateFrom, + DateTo: query.DateTo, + Fields: query.Fields, + IncludeFields: query.IncludeFields, + Limit: len(canonicalVehicleProtocols), + SkipCount: true, + }) + if err != nil { + return Page[RawFrameRow]{}, err + } + if len(anchors.Items) == 0 { + limit := query.Limit + if limit <= 0 { + limit = 10 + } + return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil + } + + results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items)) + for _, anchor := range anchors.Items { + anchor := anchor + go func() { + bounded, valid := boundedRawPreviewQuery(query, anchor) + if !valid { + results <- vehicleDetailRawPreviewResult{anchor: anchor} + return + } + page, queryErr := s.store.RawFrames(ctx, bounded) + if queryErr != nil { + results <- vehicleDetailRawPreviewResult{anchor: anchor} + return + } + results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page} + }() + } + + items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit) + for range anchors.Items { + result := <-results + if len(result.page.Items) == 0 { + items = append(items, result.anchor) + continue + } + items = append(items, result.page.Items...) + } + items = newestRawFrames(items, query.Limit) + limit := query.Limit + if limit <= 0 { + limit = 10 + } + return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil +} + +func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) { + anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime)) + if !ok { + return RawFrameQuery{}, false + } + start := anchorTime.Add(-vehicleDetailRawProjectionLookback) + end := anchorTime.Add(time.Minute) + if value := strings.TrimSpace(query.DateFrom); value != "" { + if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) { + start = scopedStart + } + } + if value := strings.TrimSpace(query.DateTo); value != "" { + if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) { + end = scopedEnd + } + } + if end.Before(start) { + return RawFrameQuery{}, false + } + bounded := query + bounded.Protocol = anchor.Protocol + bounded.DateFrom = start.Format(time.RFC3339Nano) + bounded.DateTo = end.Format(time.RFC3339Nano) + bounded.Offset = 0 + bounded.SkipCount = true + return bounded, true +} + +func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow { + if limit <= 0 { + limit = 10 + } + deduplicated := make([]RawFrameRow, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID) + if key != "\x00" && seen[key] { + continue + } + seen[key] = true + deduplicated = append(deduplicated, item) + } + sort.SliceStable(deduplicated, func(left, right int) bool { + leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime)) + rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime)) + if leftOK && rightOK && !leftTime.Equal(rightTime) { + return leftTime.After(rightTime) + } + if leftOK != rightOK { + return leftOK + } + if deduplicated[left].Protocol != deduplicated[right].Protocol { + return deduplicated[left].Protocol < deduplicated[right].Protocol + } + return deduplicated[left].ID < deduplicated[right].ID + }) + if len(deduplicated) > limit { + deduplicated = deduplicated[:limit] + } + return deduplicated +} + func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) { vin = strings.TrimSpace(vin) if vin == "" { @@ -1587,6 +1735,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string { func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus { scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{}) + _, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore) for index := range statuses { protocol := statuses[index].Protocol if protocol == "" { @@ -1599,7 +1748,7 @@ func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, sta statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) } } - if !statuses[index].HasRaw && scopeErr == nil { + if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection { page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true}) if err == nil && len(page.Items) > 0 { statuses[index].HasRaw = true @@ -1933,7 +2082,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates catalogResult := make(chan latestTelemetryCatalogResult, 1) for _, protocol := range canonicalVehicleProtocols { go func(protocol string) { - page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true}) + page, queryErr := s.vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true}) rawResult <- latestTelemetryRawResult{page: page, err: queryErr} }(protocol) } @@ -5409,6 +5558,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail return result, nil } +func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) { + vin = strings.ToUpper(strings.TrimSpace(vin)) + date = strings.TrimSpace(date) + if len(vin) != 17 { + return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"} + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"} + } + if !hydrogenConsumptionAllowed(ctx) { + return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"} + } + query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}} + resolvedQuery, err := s.resolveVehicleQuery(ctx, query) + if err != nil { + return HydrogenDailyEvidence{}, err + } + if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { + return HydrogenDailyEvidence{}, err + } + store, ok := s.store.(HydrogenDailyEvidenceStore) + if !ok { + return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"} + } + result, found, err := store.HydrogenDailyEvidence(ctx, vin, date) + if err != nil { + return HydrogenDailyEvidence{}, err + } + if !found { + return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"} + } + return result, nil +} + func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 5e72c771..e0b2b4d5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -543,6 +543,7 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing. type countingStore struct { *MockStore + rawMu sync.Mutex vehiclesCalls int vehicleRealtimeCalls int overviewBatchCalls int @@ -555,6 +556,17 @@ type countingStore struct { lastMileageStatisticsQuery url.Values } +type projectionCountingStore struct { + *countingStore + anchors Page[RawFrameRow] + projectionQueries []RawFrameQuery +} + +func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + s.projectionQueries = append(s.projectionQueries, query) + return s.anchors, nil +} + type hydrogenMetricsStore struct{ *MockStore } func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { @@ -572,15 +584,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) { consumption, rate := 3.1, 5.5 return MileageStatistics{ - PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1, + PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1, PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate, Trend: []MileageTrendPoint{{ - Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1, + Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate, }}, }, nil } +func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) { + return HydrogenDailyEvidence{ + VIN: "LB9A32A24R0LS1426", + Date: "2026-07-13", + AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2", + Intervals: []HydrogenIntervalEvidenceRow{{ + Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK", + }}, + }, true, nil +} + func newCountingStore() *countingStore { return &countingStore{MockStore: NewMockStore()} } @@ -622,6 +645,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing. if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 { t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items) } + evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13") + if err != nil { + t.Fatal(err) + } + if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" { + t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence) + } +} + +func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) { + service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()}) + validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + customer := WithPrincipal(context.Background(), Principal{ + Name: "业务客户", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, + }) + _, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13") + if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" { + t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err) + } } func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { @@ -643,7 +687,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query } func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + s.rawMu.Lock() s.rawFrameQueries = append(s.rawFrameQueries, query) + s.rawMu.Unlock() return s.MockStore.RawFrames(ctx, query) } @@ -692,6 +738,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) { } } +func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) { + base := newCountingStore() + store := &projectionCountingStore{ + countingStore: base, + anchors: Page[RawFrameRow]{Items: []RawFrameRow{ + {ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}}, + {ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}}, + {ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}}, + }}, + } + detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "") + if err != nil { + t.Fatal(err) + } + if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" { + t.Fatalf("projection queries = %+v", store.projectionQueries) + } + if len(store.rawFrameQueries) != 3 { + t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries) + } + for _, query := range store.rawFrameQueries { + if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount { + t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query) + } + if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") { + t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query) + } + if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") { + t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query) + } + } + if len(detail.Raw.Items) != 3 { + t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw) + } + projectionFallbacks := 0 + for _, item := range detail.Raw.Items { + if item.FrameType == "realtime_projection" { + projectionFallbacks++ + } + } + if projectionFallbacks != 2 { + t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items) + } +} + +func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) { + base := newCountingStore() + store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}} + detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "") + if err != nil { + t.Fatal(err) + } + if len(store.rawFrameQueries) != 0 { + t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries) + } + if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 { + t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw) + } +} + func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { s.lastMileageStatisticsQuery = cloneValues(query) return s.MockStore.MileageStatistics(ctx, query) diff --git a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx index d47ca8a1..fd9098a6 100644 --- a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx +++ b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx @@ -4,12 +4,12 @@ import { Icon, navigate } from "./ui"; type Topic = | "overview" | "quickstart" | "auth" | "errors" | "limits" - | "hydrogen" | "mileage" | "mileage-range" | "total-mileage" + | "hydrogen" | "mileage" | "mileage-range" | "total-mileage" | "stationary" | "vehicle-auth" | "key-rotation" | "quality" | "checklist"; const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [ { title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] }, - { title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] }, + { title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"], ["stationary", "加氢车辆停留核验"]] }, { title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] } ]; @@ -38,6 +38,7 @@ function TopicContent({ topic }: { topic: Topic }) { if (topic === "hydrogen" || topic === "mileage") return ; if (topic === "mileage-range") return ; if (topic === "total-mileage") return ; + if (topic === "stationary") return ; if (topic === "overview") return ; if (topic === "auth") return ; if (topic === "errors") return ; @@ -227,6 +228,23 @@ function TotalMileageReference() { ; } +function StationaryVehicleReference() { + return <> +

加氢车辆停留核验

用于加氢车牌核验。输入加氢站坐标、时间范围与核验半径,返回范围内低速静止的授权车辆停留区间,并按匹配度排序。

+ +

请求参数

字段必填说明
startTime开始时间,北京时间 YYYY-MM-DD HH:mm:ss
endTime结束时间;最大区间 24 小时
longitude加氢站 WGS-84 经度
latitude加氢站 WGS-84 纬度
radiusMeters核验半径,单位米;1–100,默认 5
plateNumbers待核验车牌;省略或空数组时核验全部有效授权车辆
+

请求示例

+

核验口径

使用 WGS-84/GPS 坐标,radiusMeters 可设为 1–100 米,省略时为 5 米;速度不超过 3 km/h。至少需要 2 条样本且停留不少于 60 秒。相邻样本间隔超过 10 分钟时视为不同停留。结果按 matchScore 降序,分数综合距离、速度、停留时长和样本数量,仅作为辅助核验,不替代加氢交易凭证。

+

响应字段

字段说明
stayStartTime / stayEndTime定位证据覆盖的停留开始、结束时间
stayDurationSeconds / stayDurationMinutes停留长度
matchScore0–100,越高表示匹配越充分
averageDistanceMeters / maxDistanceMeters与站点的距离证据
averageSpeedKmh / maxSpeedKmh静止速度证据
matchedSamples / sourceProtocols定位样本数量与来源协议
+ ; +} + function VehicleAuthGuide() { return <>

车辆授权

平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。

@@ -304,6 +322,7 @@ function tocFor(topic: Topic) { mileage: ["请求参数", "请求示例", "响应字段", "响应示例"], "mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"], "total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"], + stationary: ["请求参数", "请求示例", "核验口径", "响应字段"], "vehicle-auth": ["授权关系", "配置步骤"], "key-rotation": ["推荐流程", "权限要求"], quality: ["状态定义", "统计口径"], diff --git a/vehicle-data-platform/apps/open-portal/src/catalog.ts b/vehicle-data-platform/apps/open-portal/src/catalog.ts index a0d11535..7fc0db62 100644 --- a/vehicle-data-platform/apps/open-portal/src/catalog.ts +++ b/vehicle-data-platform/apps/open-portal/src/catalog.ts @@ -41,6 +41,16 @@ export const products: Product[] = [ path: "/api/v1/vehicles/total-mileage/query", unit: "km" }, + { + code: "stationary_vehicle_verification", + name: "加氢车辆停留核验", + description: "按加氢站坐标与时间区间核验授权车辆的 5 米内低速停留,并按匹配度排序。", + version: "v1", + status: "available", + method: "POST", + path: "/api/v1/vehicles/stationary/query", + unit: "辆" + }, { code: "realtime_vehicle", name: "车辆实时位置与状态", @@ -83,6 +93,17 @@ export const curlExample = (product: Product) => { "time": "2026-07-21 09:30:00", "protocol": "GB32960" }'`; + if (product.code === "stationary_vehicle_verification") return `curl --request POST \\ + --url https://open.d.lnoneos.com${product.path} \\ + --header 'Authorization: Bearer YOUR_APP_KEY' \\ + --header 'Content-Type: application/json' \\ + --data '{ + "startTime": "2026-08-06 10:00:00", + "endTime": "2026-08-06 12:00:00", + "longitude": 120.752312, + "latitude": 30.746281, + "radiusMeters": 5 + }'`; if (product.code === "mileage_range") return `curl --request POST \\ --url https://open.d.lnoneos.com${product.path} \\ --header 'Authorization: Bearer YOUR_APP_KEY' \\ diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index b47623a0..64471d01 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -255,15 +255,20 @@ test -n "$MYSQL_DSN" /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/033_open_mileage_range_snapshot.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \ - /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql \ + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/042_hydrogen_segment_stream_state.sql ``` Migration `023` creates vehicle open-platform appKey records, per-vehicle grant intervals, precomputed daily energy, and immutable API/admin audits. Apply it before serving `/api/v1/vehicles/*` or enabling `lingniu-vehicle-open-stat.timer`. -Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. The stat writer synchronizes capacity from `ln_asset_management.vehicle_info.vehicle_model_id → vehicle_model.tank_capacity` at startup and every six hours, then serves frame-time lookups from process memory. +Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `043` creates the effective-dated VIN energy-parameter table. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. At startup and every six hours, the stat writer projects `vehicle_model.tank_capacity` and `vehicle_model.battery_capacity` through `vehicle_info.vehicle_model_id` to VIN-scoped calculation parameters. Rated battery energy is therefore model-specific rather than hard-coded by tonnage. Migration `038` adds daily pure-hydrogen mileage to the elected mileage table and its per-source evidence table. Deploy the migration before the updated stat writer and API so GB32960 `engine_work_state=2` and Yutong `TRIANGLE_STATE=4/11` intervals can be accumulated and returned as `pureHydrogenMileageKm`. +Migration `042` creates the per-VIN/per-day bounded state used by the GB32960 hydrogen segment stream. Apply it before switching `vehicle-stat-writer`; the previous writer ignores the additive table, so rolling back the binary does not require dropping it. + +For the first switch to the segment stream, stop `lingniu-go-stat-writer.service`, run the current-day `open-platform-stat` rebuild once with `-seed-stream-state`, then switch and restart the writer. The seed transaction stores the rebuilt total and the exact last event-time watermark for every VIN, preventing already rebuilt Kafka backlog from being counted again. Do not pass this flag to the normal completed-day timer. + Migration `031` adds explicit metric, geofence, stationary and offline automation triggers. It must be applied before the API and both alert evaluators are restarted; geofence rules deliberately require one positioning protocol to avoid multi-source coordinate drift and duplicate boundary events. Migration `035` adds rule recipient-group references plus notification leases and dispatch indexes. Apply it before publishing an API/evaluator that writes `notification_targets_json` or enabling `lingniu-vehicle-alert-notification-dispatcher`. The dispatcher must remain disabled until every configured gateway has passed a signed canary that returns a non-empty provider message ID. diff --git a/vehicle-data-platform/docs/hydrogen-calculation-v2.md b/vehicle-data-platform/docs/hydrogen-calculation-v2.md new file mode 100644 index 00000000..7fe99f22 --- /dev/null +++ b/vehicle-data-platform/docs/hydrogen-calculation-v2.md @@ -0,0 +1,51 @@ +# 氢耗可信计算与溯源(V2) + +## 结果口径 + +- `raw_consumption_kg`:物理耗氢量,仅由GB/T 32960氢压、氢温、车型储氢容积经NIST实气压缩因子换算后,按有效区间质量下降累计。 +- `battery_equivalent_kg`:动力电池净放电能量折算氢量。仅使用已确认的车型/VIN电池容量;停车充电区间不参与计算。 +- `soc_balanced_consumption_kg`:SOC平衡氢耗,用于把不同首尾SOC的运行区间调整到可比较口径,不覆盖物理耗氢量。 + +压力—质量公式: + +```text +Z = 1 + Σ ai × (100 / Tk)^bi × P^ci +m = P × 1000 × 0.00201588 × V / (8.314472 × Tk × Z) +``` + +SOC能量守恒公式: + +```text +E_battery_discharge = C_battery × (SOC_start - SOC_end) / 100 +m_battery_equivalent = E_battery_discharge / 16 +m_soc_balanced = m_raw_hydrogen + m_battery_equivalent +``` + +电池净放电为车辆提供了额外能量,因此等效总能耗应增加;电池净充电时该项为负。邮件示例中将净放电量从物理耗氢中扣除,不符合能量守恒,V2不采用该符号方向。 + +## 区间规则 + +1. 上电后等待60秒、下电前提前60秒,端点使用连续5条有效样本的中位质量对应报文。 +2. 停车充电由整车充电状态与车辆状态识别,充电区间切断且不参与SOC修正;充电结束后重新建立基线。 +3. 压力累计上升超过3MPa且保持5分钟识别为加氢;无连续报文时仍保留质量回升阈值作为补充识别。 +4. 纯电状态30分钟内压力下降超过5MPa的样本标记无效。 +5. 数据间隔超过5分钟、质量异常下降、运行模式切换均切断区间。 +6. 每个有效区间至少10条样本;首尾各5条取中位质量以抑制单点波动。 + +## 溯源数据 + +每日结果保留算法版本、参数JSON和区间证据JSON。每个区间包含起止时间、起止原始报文ID、数据源、压力、温度、质量、SOC、里程、分项公式结果、样本数及质量原因。统计页面的“可溯源”入口展示相同信息。 + +车型/VIN的动力电池容量及氢电换算系数由 `vehicle_hydrogen_energy_parameter` 维护。未配置或未经确认时,系统仍输出物理耗氢,但不生成SOC平衡结果。 + +## 验证 + +```bash +cd vehicle-data-platform/apps/api +go test ./internal/openplatform -run 'TestPressureHydrogenMassNISTValidationGridHasAtLeast100Samples|TestTrustedHydrogenCalculatorProducesTraceableSOCBalancedResultFrom120Samples' -v + +cd ../web +NODE_OPTIONS=--localstorage-file=/tmp/codex-vitest-localstorage pnpm exec vitest run src/v2/pages/StatisticsPage.test.tsx +``` + +压力—质量测试使用12个压力点与10个温度点,共120个独立样本;日计算测试使用120条连续车辆报文,验证上下电窗口、区间证据和SOC能量守恒。 diff --git a/vehicle-data-platform/docs/open-platform-api.md b/vehicle-data-platform/docs/open-platform-api.md index 9ada4a69..ae9f934a 100644 --- a/vehicle-data-platform/docs/open-platform-api.md +++ b/vehicle-data-platform/docs/open-platform-api.md @@ -343,35 +343,40 @@ PUT /portal-api/account/password ## 用氢量统计 -当天数据由 `vehicle-stat-writer` 直接消费 GB32960 fields Kafka 流,统一采用压力法: +用氢量只接收 `GB32960` 帧,不读取 JT808、宇通 MQTT 或其他协议。统一采用压力法: ```text 剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000 ``` -- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比或百公里氢耗推算质量; -- `vehicle-stat-writer` 每六小时从 `ln_asset_management.vehicle_info → vehicle_model.tank_capacity` 同步 VIN 容积到本地 `vehicle_hydrogen_tank_capacity`; -- 同步完成后一次性加载到进程内存,高频帧计算不访问资产库、Redis或MySQL;缺少VIN容积时拒绝生成氢量; -- 压力法使用 NIST 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围。 +- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比、宇通 MQTT 剩余氢量或百公里氢耗推算质量; +- `vehicle-stat-writer` 启动时及每六小时从 `ln_asset_management.vehicle_info → vehicle_model` 同步车型参数:`tank_capacity` 按 VIN 投影到 `vehicle_hydrogen_tank_capacity`,`battery_capacity` 按 VIN 投影到 `vehicle_hydrogen_energy_parameter`; +- 额定电量按车辆关联的具体车型读取,不按吨位写死;后续生效日期更晚的人工业务参数仍可覆盖自动同步基线; +- 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量; +- 压力法使用 NIST 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围; +- GB32960 上报的是“最高氢压”和“最高氢温”,两者不保证来自同一探头,也不保证代表静置平衡后的储氢瓶状态。因此压力法结果是车载信号估算值,不能用加氢站计量值直接反标公式系数。 -每个有效样本在同一个 MySQL 事务中更新 -`vehicle_open_hydrogen_stream_state`,并投影到 +当天准实时数据由 `vehicle-stat-writer` 消费 GB32960 fields Kafka 流。每个有效样本在同一个 MySQL 事务中更新 +`vehicle_open_hydrogen_segment_stream_state`,并投影到 `vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按 -`VIN + 日期 + source_endpoint` 保存,每条消息只做常数次数据库操作,不扫描当天历史数据。 +`VIN + 日期` 保存并合并全部连接端点;每条消息只做常数次数据库操作,不扫描当天历史数据。 统计规则: -- 先按 VIN 和 `source_endpoint` 分别统计,避免多个采集源的序列交错产生虚假变化; -- 同一 VIN 优先采用质量正常、样本数量更多的数据源,并记录入库; -- 每次加氢之间维护质量低水位,只累计新的有效最低质量,避免压力温度波动反复计量; +- 已结束日期先按 VIN 合并全部 `source_endpoint`,同一事件时间只保留一条可信样本;连接端口仅用于追踪,不再作为互斥统计来源; +- 只有相邻两条样本都确认燃料电池处于工作状态时,才纳入工作段;优先采用广东扩展 `engine_work_state=2`,缺失时以燃料电池电流大于 `1 A` 作为回退;完全没有工作区间的日期标记为 `NO_DATA`; +- 加氢、超过 5 分钟的数据断点、燃料电池停机和异常跳变会切分工作段;每段开始与结束各取 5 条样本的氢质量中位数做差,再累加为当日用氢量; +- 每个可计算工作段至少需要 10 条有效样本;段首尾差值未超过动态噪声阈值时不累计; +- 燃料电池未工作时,下降的压力质量只更新防重复计算的低水位,不降低对外剩余氢量;压力或质量恢复上升后再更新剩余量; - 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`; - 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位; +- 相邻样本在 60 秒内无工作耗氢却恢复至少 `8 MPa` 时,按压力信号恢复处理,重置低水位但不增加加氢次数; - 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`; -- 至少两条有效样本才标记 `OK`; - 对外只返回 `quality_status=OK` 的结果。 -当天接口结果是准实时的进行中累计值。乱序、重复和迟到样本不会覆盖更新的状态;跨日迟到数据由 -`open-platform-stat` 夜间批处理兜底。批处理默认重算昨天和前天,以修正迟到上报,并作为已结束日期的权威结果。 +分段计算由流式状态机直接执行:每车仅保留当前段的首 5 帧、尾 5 帧、低水位、上一帧和事件时间水位,状态不会随当天帧数增长。水位只覆盖当前自然日 `00:00:00–24:00:00`,不向前后日期扩窗,也不借用次日帧;早于或等于当前事件时间水位的延迟帧直接忽略。流式结果采用与日终相同的工作段、中位数、加氢和异常跳变规则;已结束日期仍由 `open-platform-stat` 重算,作为最终对账结果。 + +历史重放只接收事件时间和接收时间同时落在统计日内的 GB32960 帧。每个统计日先读取有数据且配置了氢瓶容积的 VIN,再在同一进程内按单车单日并行查询;默认 4 个工作线程,全部车辆成功后才一次性事务替换当天结果。单车帧按 `event_time` 排序并单遍去重;JSON 只读取压力、温度和工作状态字段,不展开整帧,从而减少全车排序与反序列化开销。可通过 `-vin-workers` 或 `OPEN_STAT_VIN_WORKERS` 调整工作线程数。 ```bash /opt/lingniu-vehicle-platform/current/open-platform-stat \ @@ -380,3 +385,6 @@ PUT /portal-api/account/password systemctl enable --now lingniu-vehicle-open-stat.timer ``` + +流式算法首次切换时,先停止 `lingniu-go-stat-writer.service`,再对当天执行一次 +`-seed-stream-state`。该参数会在同一个 MySQL 事务中替换当天结果并写入每车最后事件时间水位;恢复 Kafka 消费后,已经包含在重算结果中的 backlog 不会再次累计。正常日终任务不使用此参数。 diff --git a/vehicle-data-platform/scripts/replay-all-hydrogen-history.sh b/vehicle-data-platform/scripts/replay-all-hydrogen-history.sh new file mode 100755 index 00000000..88b626d7 --- /dev/null +++ b/vehicle-data-platform/scripts/replay-all-hydrogen-history.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +OPEN_STAT_BIN=${OPEN_STAT_BIN:-/opt/lingniu-vehicle-platform/current/open-platform-stat} +BASE_ENV_FILE=${BASE_ENV_FILE:-/opt/lingniu-go-native/env/base.env} +PLATFORM_ENV_FILE=${PLATFORM_ENV_FILE:-/opt/lingniu-vehicle-platform/env/platform.env} +LOCK_FILE=${LOCK_FILE:-/run/lock/lingniu-hydrogen-history-replay.lock} + +test -x "$OPEN_STAT_BIN" || { printf 'open-platform-stat is not executable: %s\n' "$OPEN_STAT_BIN" >&2; exit 1; } +test -r "$BASE_ENV_FILE" || { printf 'base environment file is unavailable: %s\n' "$BASE_ENV_FILE" >&2; exit 1; } +test -r "$PLATFORM_ENV_FILE" || { printf 'platform environment file is unavailable: %s\n' "$PLATFORM_ENV_FILE" >&2; exit 1; } + +exec 9>"$LOCK_FILE" +flock -n 9 || { printf 'another hydrogen history replay is already running\n' >&2; exit 1; } + +run_stat() { + python3 -c ' +import os +import sys + +binary, base_env, platform_env, *args = sys.argv[1:] +for path in (base_env, platform_env): + with open(path, encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + os.environ[name] = value +os.execv(binary, [binary, *args]) +' "$OPEN_STAT_BIN" "$BASE_ENV_FILE" "$PLATFORM_ENV_FILE" "$@" +} + +if test "${1:-}" = "--from"; then + test "$#" -eq 4 && test "${3:-}" = "--to" || { + printf 'usage: %s --from yyyy-mm-dd --to yyyy-mm-dd\n' "$0" >&2 + exit 2 + } + start_date=$2 + end_date=$4 + while IFS= read -r replay_date; do + printf 'replay_date=%s status=starting\n' "$replay_date" + run_stat -date "$replay_date" -lookback-days 1 + done < <(python3 -c ' +import datetime +import sys + +start = datetime.datetime.strptime(sys.argv[1], "%Y-%m-%d").date() +end = datetime.datetime.strptime(sys.argv[2], "%Y-%m-%d").date() +if end < start: + raise SystemExit("end date must not precede start date") +current = start +while current <= end: + print(current.isoformat()) + current += datetime.timedelta(days=1) +' "$start_date" "$end_date") + exit 0 +fi + +if test "$#" -eq 0; then + run_stat -all-dates +else + run_stat "$@" +fi