功能:扩展开放平台氢耗溯源与合作站数据

This commit is contained in:
lingniu
2026-09-02 14:05:34 +08:00
parent 8759668d39
commit c6875fbb98
33 changed files with 5692 additions and 475 deletions
@@ -7,7 +7,10 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"sort"
"strconv" "strconv"
"strings"
"sync"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "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") 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") 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") 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() flag.Parse()
cfg := config.Load() cfg := config.Load()
@@ -50,24 +58,190 @@ func main() {
if *lookback < 1 || *lookback > 31 { if *lookback < 1 || *lookback > 31 {
log.Fatal("-lookback-days must be between 1 and 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() defer cancel()
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB) capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
if err != nil { if err != nil {
log.Fatalf("load hydrogen tank capacities: %v", err) log.Fatalf("load hydrogen tank capacities: %v", err)
} }
for offset := *lookback - 1; offset >= 0; offset-- { if normalizedVIN != "" {
start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset) if _, ok := capacities[normalizedVIN]; !ok {
observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities) log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
if err != nil {
log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err)
} }
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 { func envInt(name string, fallback int) int {
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ info:
license: license:
name: Proprietary name: Proprietary
description: | description: |
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。 向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位。
appKey 和逐车授权必须完整覆盖查询自然日。 appKey 和逐车授权必须完整覆盖查询自然日。
servers: servers:
- url: / - url: /
@@ -54,7 +54,7 @@ paths:
description: | description: |
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。 protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。 NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。 当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
operationId: queryDailyMileage operationId: queryDailyMileage
security: security:
@@ -92,7 +92,7 @@ paths:
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。 首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。 快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。 protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。 某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。
operationId: queryDailyMileageRange operationId: queryDailyMileageRange
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -160,6 +160,46 @@ paths:
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
'500': '500':
$ref: '#/components/responses/InternalError' $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: /api/v1/vehicles/realtime/query:
post: post:
tags: [合作方数据接口] tags: [合作方数据接口]
@@ -167,7 +207,9 @@ paths:
description: | description: |
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。 plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。 实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
在线且所选来源速度大于3km/h为行驶中,否则为静止中。 在线且所选来源速度大于3km/h为行驶中,否则为静止中。
operationId: queryRealtimeVehicles operationId: queryRealtimeVehicles
security: security:
@@ -492,6 +534,52 @@ components:
type: string type: string
enum: [GB32960, YUTONG_MQTT, JT808] enum: [GB32960, YUTONG_MQTT, JT808]
description: 可选;只接受平台统一协议标识;不传时按 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: RealtimeVehicleQuery:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -554,7 +642,7 @@ components:
type: number type: number
format: double format: double
nullable: true nullable: true
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null description: 当日所选协议最后有效终端累计总里程,kmGPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
dataTime: dataTime:
type: string type: string
format: date-time format: date-time
@@ -570,6 +658,11 @@ components:
enum: [GB32960, MQTT, JT808] enum: [GB32960, MQTT, JT808]
nullable: true nullable: true
description: 本行实际选中的来源协议;status=NO_DATA 时为 null description: 本行实际选中的来源协议;status=NO_DATA 时为 null
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageRangeResult: MileageRangeResult:
@@ -594,7 +687,7 @@ components:
format: double format: double
nullable: true nullable: true
minimum: 0 minimum: 0
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值 description: 当日所选协议终端累计总里程;GPS 日里程估算不会作为累计总里程,缺日时沿用此前最近有效值
dataTime: dataTime:
type: string type: string
format: date-time format: date-time
@@ -609,11 +702,17 @@ components:
enum: [GB32960, MQTT, JT808] enum: [GB32960, MQTT, JT808]
nullable: true nullable: true
description: 本行实际选中的来源协议;status=NO_DATA 时为 null description: 本行实际选中的来源协议;status=NO_DATA 时为 null
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
DataStatus: DataStatus:
type: string type: string
enum: [NORMAL, NO_DATA] enum: [NORMAL, NO_DATA, DATA_ANOMALY]
description: NORMAL=存在可用数据;NO_DATA=授权范围内无可用数据;DATA_ANOMALY=检测到数据异常,详情见 dataQuality
HydrogenQueryResponse: HydrogenQueryResponse:
allOf: allOf:
- $ref: '#/components/schemas/SuccessEnvelope' - $ref: '#/components/schemas/SuccessEnvelope'
@@ -694,6 +793,25 @@ components:
example: GB32960 > YUTONG_MQTT > JT808 example: GB32960 > YUTONG_MQTT > JT808
status: status:
$ref: '#/components/schemas/DataStatus' $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: TotalMileageQueryResponse:
allOf: allOf:
- $ref: '#/components/schemas/SuccessEnvelope' - $ref: '#/components/schemas/SuccessEnvelope'
@@ -702,22 +820,37 @@ components:
properties: properties:
data: data:
$ref: '#/components/schemas/TotalMileageResult' $ref: '#/components/schemas/TotalMileageResult'
StationaryVehicleQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/StationaryVehicleResult'
RealtimeVehicleResult: RealtimeVehicleResult:
type: object type: object
required: [vin, plateNumber, online, motionStatus, locationAvailable, status] required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
properties: properties:
vin: { type: string } vin: { type: string }
plateNumber: { type: string } plateNumber: { type: string }
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] } protocol:
longitude: { type: number, format: double, nullable: true } type: string
latitude: { type: number, format: double, nullable: true } enum: [GB32960, MQTT, JT808]
speedKmh: { type: number, format: double, nullable: true } description: 本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源
totalMileageKm: { type: number, format: double, nullable: true } longitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效经度;无位置时为 null }
recordTime: { type: string } latitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效纬度;无位置时为 null }
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 } 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秒内上报 } online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
motionStatus: { type: string, enum: [driving, idle, offline] } activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 }
locationAvailable: { type: boolean } motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/hidle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 }
locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 }
status: { $ref: '#/components/schemas/DataStatus' } status: { $ref: '#/components/schemas/DataStatus' }
RealtimeVehicleQueryResponse: RealtimeVehicleQueryResponse:
allOf: allOf:
@@ -46,7 +46,7 @@ func WithDocs(next http.Handler) http.Handler {
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "") serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
case SimpleDocsPath: case SimpleDocsPath:
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML, 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: default:
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} }
@@ -35,6 +35,9 @@ func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" { if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("documentation asset must set 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 + ":", HydrogenQueryPath + ":",
MileageQueryPath + ":", MileageQueryPath + ":",
MileageRangeQueryPath + ":", MileageRangeQueryPath + ":",
StationaryVehicleQueryPath + ":",
"/api/v2/open-platform/apps:", "/api/v2/open-platform/apps:",
"AppKeyAuth:", "AppKeyAuth:",
"AdminBearer:", "AdminBearer:",
"省略或传空数组时", "省略或传空数组时",
"protocolPriority:", "protocolPriority:",
"sourceProtocol:", "sourceProtocol:",
StationaryVehicleQueryPath,
"enum: [GB32960, MQTT, JT808]", "enum: [GB32960, MQTT, JT808]",
} { } {
if !strings.Contains(spec, want) { if !strings.Contains(spec, want) {
@@ -17,12 +17,13 @@ import (
) )
const ( const (
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query" HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
MileageQueryPath = "/api/v1/vehicles/mileage/query" MileageQueryPath = "/api/v1/vehicles/mileage/query"
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query" MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query" TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query" StationaryVehicleQueryPath = "/api/v1/vehicles/stationary/query"
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query" RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
) )
type Handler struct { type Handler struct {
@@ -61,6 +62,7 @@ func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage) h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange) h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage) 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 "+RealtimeVehicleQueryPath, h.realtimeVehicles)
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations) 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 "+MileageQueryPath, handler.mileage)
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange) handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage) 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 "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations) handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
return handler return handler
@@ -120,7 +123,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
func IsPublicPath(path string) bool { 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) { 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("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response) encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
_ = encoder.Encode(response)
} }
func externalBearer(r *http.Request) string { func externalBearer(r *http.Request) string {
@@ -759,6 +778,12 @@ func dataProducts() []DataProduct {
Version: "v1", Status: "available", Method: http.MethodPost, Version: "v1", Status: "available", Method: http.MethodPost,
Path: TotalMileageQueryPath, Unit: "km", 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: "车辆实时位置与状态", Code: "realtime_vehicle", Name: "车辆实时位置与状态",
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。", Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
@@ -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 ""
}
@@ -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)
}
}
@@ -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
}
@@ -6,8 +6,9 @@ import (
) )
const ( const (
StatusNormal = "NORMAL" StatusNormal = "NORMAL"
StatusNoData = "NO_DATA" StatusNoData = "NO_DATA"
StatusDataAnomaly = "DATA_ANOMALY"
) )
type QueryRequest struct { type QueryRequest struct {
@@ -60,6 +61,7 @@ type MileageResult struct {
DataTime *string `json:"dataTime"` DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"` UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"` SourceProtocol *string `json:"sourceProtocol"`
DataQuality *string `json:"dataQuality,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -72,6 +74,7 @@ type MileageRangeResult struct {
DataTime *string `json:"dataTime"` DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"` UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"` SourceProtocol *string `json:"sourceProtocol"`
DataQuality *string `json:"dataQuality,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -101,6 +104,44 @@ type TotalMileagePoint struct {
TotalMileageKm float64 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 { type RealtimeVehicleRequest struct {
PlateNumbers []string `json:"plateNumbers,omitempty"` PlateNumbers []string `json:"plateNumbers,omitempty"`
} }
@@ -111,22 +152,28 @@ type RealtimeVehiclePoint struct {
Longitude float64 Longitude float64
Latitude float64 Latitude float64
SpeedKmh float64 SpeedKmh float64
SOCPercent *float64
TotalMileageKm float64 TotalMileageKm float64
ObservedAt time.Time ObservedAt time.Time
Online bool Online bool
ActiveToday bool
} }
type RealtimeVehicleResult struct { type RealtimeVehicleResult struct {
VIN string `json:"vin"` VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"` 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"` Protocol string `json:"protocol,omitempty"`
Longitude *float64 `json:"longitude"` Longitude *float64 `json:"longitude"`
Latitude *float64 `json:"latitude"` Latitude *float64 `json:"latitude"`
SpeedKmh *float64 `json:"speedKmh"` SpeedKmh *float64 `json:"speedKmh"`
SOCPercent *float64 `json:"socPercent,omitempty"`
TotalMileageKm *float64 `json:"totalMileageKm"` TotalMileageKm *float64 `json:"totalMileageKm"`
RecordTime string `json:"recordTime,omitempty"` RecordTime string `json:"recordTime,omitempty"`
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"` TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
Online bool `json:"online"` Online bool `json:"online"`
ActiveToday bool `json:"activeToday"`
MotionStatus string `json:"motionStatus"` MotionStatus string `json:"motionStatus"`
LocationAvailable bool `json:"locationAvailable"` LocationAvailable bool `json:"locationAvailable"`
Status string `json:"status"` Status string `json:"status"`
@@ -139,16 +186,21 @@ type HydrogenStationRequest struct {
} }
type HydrogenStation struct { type HydrogenStation struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ShortName string `json:"shortName,omitempty"` ShortName string `json:"shortName,omitempty"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Province string `json:"province,omitempty"` Province string `json:"province,omitempty"`
City string `json:"city,omitempty"` City string `json:"city,omitempty"`
District string `json:"district,omitempty"` District string `json:"district,omitempty"`
Cooperative bool `json:"cooperative"` 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 { type ExternalResponse struct {
@@ -258,15 +310,83 @@ type MileageSnapshot struct {
} }
type HydrogenObservation struct { type HydrogenObservation struct {
VIN string VIN string
Source string Source string
ObservedAt time.Time EventID string
MassKg float64 ObservedAt time.Time
TankCapacityLiter float64 MassKg float64
PressureMPa float64 TankCapacityLiter float64
TemperatureC float64 PressureMPa float64
NoiseKg float64 TemperatureC float64
RefuelThresholdKg 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 { type HydrogenRateObservation struct {
@@ -288,16 +408,42 @@ type HydrogenRateDailyStat struct {
} }
type HydrogenDailyStat struct { type HydrogenDailyStat struct {
VIN string VIN string
Source string Source string
Date string Date string
ConsumptionKg float64 ConsumptionKg float64
FirstMassKg float64 FirstMassKg float64
LastMassKg float64 LastMassKg float64
SampleCount int CycleMinimumMassKg float64
RefuelCount int SampleCount int
QualityStatus string RefuelCount int
QualityReason string 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 { type PortalUser struct {
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -137,20 +138,65 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
return nil, nil 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(&timestampMS, &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) { func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
out := make(map[string]RealtimeVehiclePoint, len(vins)) out := make(map[string]RealtimeVehiclePoint, len(vins))
if len(vins) == 0 { if len(vins) == 0 {
return out, nil return out, nil
} }
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",") 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 { for _, vin := range vins {
args = append(args, vin) args = append(args, vin)
} }
args = append(args, now.Add(-10*time.Minute)) args = append(args, now.Add(-10*time.Minute))
query := ` query := `
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0), 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 FROM vehicle_realtime_location l
WHERE BINARY l.vin IN (` + placeholders + `) WHERE BINARY l.vin IN (` + placeholders + `)
ORDER BY l.vin, ORDER BY l.vin,
@@ -165,9 +211,14 @@ ORDER BY l.vin,
onlineThreshold := now.Add(-time.Minute) onlineThreshold := now.Add(-time.Minute)
for rows.Next() { for rows.Next() {
var point RealtimeVehiclePoint 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 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) point.Online = !point.ObservedAt.Before(onlineThreshold)
if selected, exists := out[point.VIN]; !exists { if selected, exists := out[point.VIN]; !exists {
out[point.VIN] = point out[point.VIN] = point
@@ -183,35 +234,57 @@ ORDER BY l.vin,
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) { func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
where := []string{ where := []string{
"s.longitude BETWEEN -180 AND 180", "n.del_flag='0'",
"s.latitude BETWEEN -90 AND 90", "n.longitude BETWEEN -180 AND 180",
"NOT (s.longitude=0 AND s.latitude=0)", "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 != "" { if request.Province != "" {
where = append(where, "s.province=?") where = append(where, "(n.province=? OR province_region.NAME=?)")
args = append(args, request.Province) args = append(args, request.Province, request.Province)
} }
if request.City != "" { if request.City != "" {
where = append(where, "s.city=?") where = append(where, "(n.city=? OR city_region.NAME=?)")
args = append(args, request.City) args = append(args, request.City, 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")
}
} }
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''), SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''),
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''), n.longitude,n.latitude,
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''), COALESCE(NULLIF(province_region.NAME,''),n.province,''),
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''),
FROM ln_asset_management.tab_outside_hydrogen_site s CASE WHEN s.inner_site_id IS NOT NULL OR (
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0' 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 ")+` 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...) LIMIT 2000`, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -220,9 +293,15 @@ LIMIT 2000`, args...)
stations := make([]HydrogenStation, 0, 512) stations := make([]HydrogenStation, 0, 512)
for rows.Next() { for rows.Next() {
var station HydrogenStation 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 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) stations = append(stations, station)
} }
return stations, rows.Err() return stations, rows.Err()
@@ -264,6 +343,48 @@ func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date
return out, nil 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) { func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 { if len(vins) == 0 {
return map[string]DailyMileage{}, nil return map[string]DailyMileage{}, nil
@@ -275,7 +396,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'), DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol, m.protocol,
m.daily_mileage_km, m.daily_mileage_km,
m.latest_total_mileage_km, COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT(( COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time) SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected FROM vehicle_daily_mileage_source selected
@@ -289,8 +410,8 @@ SELECT
FROM vehicle_daily_mileage m FROM vehicle_daily_mileage m
WHERE m.stat_date BETWEEN ? AND ? WHERE m.stat_date BETWEEN ? AND ?
AND m.vin IN (` + placeholders + `) AND m.vin IN (` + placeholders + `)
AND m.latest_total_mileage_km IS NOT NULL AND COALESCE(m.day_end_total_mileage_km,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)>=0
AND m.daily_mileage_km>=0` AND m.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+2+len(protocols)*2) args := make([]any, 0, len(vins)+2+len(protocols)*2)
args = append(args, startDate, endDate) args = append(args, startDate, endDate)
@@ -345,7 +466,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'), DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol, m.protocol,
m.daily_mileage_km, m.daily_mileage_km,
m.latest_total_mileage_km, COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT(( COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time) SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected FROM vehicle_daily_mileage_source selected
@@ -362,8 +483,8 @@ JOIN (
FROM vehicle_daily_mileage prior FROM vehicle_daily_mileage prior
WHERE prior.stat_date<? WHERE prior.stat_date<?
AND prior.vin IN (` + vinPlaceholders + `) AND prior.vin IN (` + vinPlaceholders + `)
AND prior.latest_total_mileage_km IS NOT NULL AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km) IS NOT NULL
AND prior.latest_total_mileage_km>=0 AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=0
AND prior.daily_mileage_km>=0` AND prior.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+1+len(protocols)*2) args := make([]any, 0, len(vins)+1+len(protocols)*2)
args = append(args, beforeDate) args = append(args, beforeDate)
@@ -75,13 +75,62 @@ func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testin
} }
} }
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) { func TestHydrogenStationsUsesNewMasterAndExcelSinopecRoster(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer db.Close() 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"). 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"}). 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")) 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) { func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -192,19 +265,20 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
} }
defer db.Close() defer db.Close()
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local) 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" vin := "LTEST32960VIN0001"
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location"). mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
WithArgs(vin, now.Add(-10*time.Minute)). WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}). 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, 1000, now.Add(-2*time.Minute)). 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, 0, now.Add(-20*time.Second))) 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) points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
point := points[vin] 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) t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
} }
if err := mock.ExpectationsWereMet(); err != nil { if err := mock.ExpectationsWereMet(); err != nil {
@@ -9,6 +9,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"math"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -34,10 +35,12 @@ type Repository interface {
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error) DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
DailyMileageRange(context.Context, []string, 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) 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 CreateMileageSnapshot(context.Context, MileageSnapshot) error
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error) LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, 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) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
Audit(context.Context, uint64, string, string, string, int, string) error Audit(context.Context, uint64, string, string, string, int, string) error
@@ -50,6 +53,215 @@ type Repository interface {
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) 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) { func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
now := s.now().In(s.location) now := s.now().In(s.location)
plates, err := normalizePlates(request.PlateNumbers, 2000) plates, err := normalizePlates(request.PlateNumbers, 2000)
@@ -78,10 +290,11 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
if difference < 0 { if difference < 0 {
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.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
item.TimeDifferenceSeconds = &difference item.TimeDifferenceSeconds = &difference
item.Online = point.Online item.Online = point.Online
item.ActiveToday = point.ActiveToday
item.MotionStatus = "offline" item.MotionStatus = "offline"
if item.Online && point.SpeedKmh > 3 { if item.Online && point.SpeedKmh > 3 {
item.MotionStatus = "driving" 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) speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
item.SOCPercent = point.SOCPercent
if validCoordinate(point.Longitude, point.Latitude) { if validCoordinate(point.Longitude, point.Latitude) {
longitude, latitude := point.Longitude, point.Latitude longitude, latitude := point.Longitude, point.Latitude
item.Longitude, item.Latitude = &longitude, &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()) _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err 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{} carried := map[string]DailyMileage{}
if len(missingVINs) > 0 { if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols) 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} item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) { if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, value.MileageKm) fillMileageResult(&item, value, value.MileageKm)
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
fillMileageAnomaly(&item)
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) { } else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, 0) fillMileageResult(&item, value, 0)
} }
@@ -321,6 +542,7 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
} }
values := map[string]DailyMileage{} values := map[string]DailyMileage{}
carried := map[string]DailyMileage{} carried := map[string]DailyMileage{}
rollbacks := map[string]bool{}
if len(positions) > 0 { if len(positions) > 0 {
vins := make([]string, 0, len(vinSet)) vins := make([]string, 0, len(vinSet))
for vin := range 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()) _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err 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 { if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols) carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
if err != nil { 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) { if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, value.MileageKm) fillMileageRangeResult(&item, value, value.MileageKm)
carried[position.vehicle.VIN] = value 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) { } else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, 0) fillMileageRangeResult(&item, value, 0)
} }
@@ -715,9 +945,12 @@ func validDailyMileage(value DailyMileage) bool {
value.UpdatedAt != "" 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) missing := make([]string, 0)
for _, vin := range vins { for _, vin := range vins {
if rollbacks[dailyMileageKey(vin, date)] {
continue
}
if value, ok := values[vin]; !ok || !validDailyMileage(value) { if value, ok := values[vin]; !ok || !validDailyMileage(value) {
missing = append(missing, vin) missing = append(missing, vin)
} }
@@ -725,7 +958,7 @@ func missingMileageVINs(vins []string, values map[string]DailyMileage) []string
return missing 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)) seen := make(map[string]struct{}, len(positions))
missing := make([]string, 0) missing := make([]string, 0)
for _, position := range positions { for _, position := range positions {
@@ -734,6 +967,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
continue continue
} }
seen[vin] = struct{}{} seen[vin] = struct{}{}
if rollbacks[dailyMileageKey(vin, position.date)] {
continue
}
value, ok := values[dailyMileageKey(vin, position.date)] value, ok := values[dailyMileageKey(vin, position.date)]
if !ok || !validDailyMileage(value) { if !ok || !validDailyMileage(value) {
missing = append(missing, vin) missing = append(missing, vin)
@@ -769,6 +1005,20 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
item.Status = StatusNormal 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 { func dailyMileageKey(vin, date string) string {
return vin + "\x00" + date return vin + "\x00" + date
} }
@@ -12,27 +12,32 @@ import (
"time" "time"
) )
func float64Pointer(value float64) *float64 { return &value }
type fakeRepository struct { type fakeRepository struct {
app AppCredential app AppCredential
authErr error authErr error
vehicles map[string]AuthorizedVehicle vehicles map[string]AuthorizedVehicle
hydrogen map[string]DailyHydrogen hydrogen map[string]DailyHydrogen
mileage map[string]DailyMileage mileage map[string]DailyMileage
priorMileage map[string]DailyMileage priorMileage map[string]DailyMileage
authorizedVIN bool rollbacks map[string]bool
totalMileage *TotalMileagePoint authorizedVIN bool
realtime map[string]RealtimeVehiclePoint totalMileage *TotalMileagePoint
stations []HydrogenStation realtime map[string]RealtimeVehiclePoint
audits []string stationary []StationaryLocationPoint
createdHash [sha256.Size]byte stations []HydrogenStation
createdPrefix string audits []string
requestedPlates []string createdHash [sha256.Size]byte
dailyVINs []string createdPrefix string
dailyProtocols []string requestedPlates []string
rangeVINs []string dailyVINs []string
priorVINs []string dailyProtocols []string
priorCalls int rangeVINs []string
snapshot MileageSnapshot 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) { 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...) f.dailyProtocols = append([]string(nil), protocols...)
return f.priorMileage, nil 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 { func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
f.snapshot = snapshot f.snapshot = snapshot
return nil 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) { func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
return f.totalMileage, nil 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) { func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
return f.realtime, nil return f.realtime, nil
} }
@@ -100,7 +112,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"}, "浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
}, },
realtime: map[string]RealtimeVehiclePoint{ 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}}, stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
} }
@@ -112,7 +124,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
} }
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" { 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) { func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600)) now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
repository := &fakeRepository{ 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) { func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
repository := &fakeRepository{ repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"}, app: AppCredential{ID: 7, Name: "partner"},
@@ -577,7 +681,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
allResponse := httptest.NewRecorder() allResponse := httptest.NewRecorder()
handler.ServeHTTP(allResponse, allRequest) 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()) 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") rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
rangeResponse := httptest.NewRecorder() rangeResponse := httptest.NewRecorder()
handler.ServeHTTP(rangeResponse, rangeRequest) 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()) 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") badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
badResponse := httptest.NewRecorder() badResponse := httptest.NewRecorder()
handler.ServeHTTP(badResponse, badDate) 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()) t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
} }
@@ -602,8 +706,8 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
badProtocolResponse := httptest.NewRecorder() badProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(badProtocolResponse, badProtocol) handler.ServeHTTP(badProtocolResponse, badProtocol)
if badProtocolResponse.Code != http.StatusBadRequest || if badProtocolResponse.Code != http.StatusBadRequest ||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) || !strings.Contains(badProtocolResponse.Body.String(), `"message": "protocolPriority不能包含重复协议"`) ||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) { !strings.Contains(badProtocolResponse.Body.String(), `"traceId": "`) {
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String()) 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") nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullProtocolResponse := httptest.NewRecorder() nullProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(nullProtocolResponse, nullProtocol) 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()) t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
} }
} }
@@ -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
}
@@ -1,9 +1,11 @@
package openplatform package openplatform
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math" "math"
"sort" "sort"
@@ -19,24 +21,39 @@ var hydrogenMassFields = []string{
"gd_fc_vehicle_hydrogen_mass_kg", "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 { 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 { if noiseKg <= 0 {
noiseKg = 0.05 noiseKg = 0.05
} }
if maxDropKg <= noiseKg { if maxDropKg <= noiseKg {
maxDropKg = 20 maxDropKg = 20
} }
grouped := map[string]map[string][]HydrogenObservation{} grouped := map[string][]HydrogenObservation{}
for _, observation := range observations { for _, observation := range observations {
observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN)) observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN))
observation.Source = strings.TrimSpace(observation.Source) 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 { if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 {
continue continue
} }
if grouped[observation.VIN] == nil { grouped[observation.VIN] = append(grouped[observation.VIN], observation)
grouped[observation.VIN] = map[string][]HydrogenObservation{}
}
grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation)
} }
vins := make([]string, 0, len(grouped)) vins := make([]string, 0, len(grouped))
for vin := range grouped { for vin := range grouped {
@@ -45,81 +62,236 @@ func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, no
sort.Strings(vins) sort.Strings(vins)
stats := make([]HydrogenDailyStat, 0, len(vins)) stats := make([]HydrogenDailyStat, 0, len(vins))
for _, vin := range vins { for _, vin := range vins {
var selected *HydrogenDailyStat values, primarySource := mergeHydrogenObservations(grouped[vin], alreadyOrdered)
for source, values := range grouped[vin] { stats = append(stats, buildTrustedHydrogenDailyStat(vin, primarySource, date, values, noiseKg, maxDropKg, parameters[vin]))
candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg)
if selected == nil || betterHydrogenStat(candidate, *selected) {
copy := candidate
selected = &copy
}
}
if selected != nil {
stats = append(stats, *selected)
}
} }
return stats return stats
} }
func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat { func mergeHydrogenObservations(values []HydrogenObservation, alreadyOrdered bool) ([]HydrogenObservation, string) {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) sourceCounts := make(map[string]int)
stat := HydrogenDailyStat{ for _, value := range values {
VIN: vin, Source: source, Date: date, sourceCounts[value.Source]++
FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg,
SampleCount: len(values), QualityStatus: "OK",
} }
abnormalDrops := 0 primarySource := ""
cycleMinimum := values[0].MassKg primaryCount := -1
for index := 1; index < len(values); index++ { for source, count := range sourceCounts {
value := values[index] if count > primaryCount || (count == primaryCount && source < primarySource) {
sampleNoise := noiseKg primarySource = source
if value.NoiseKg > sampleNoise { primaryCount = count
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++
} }
} }
stat.ConsumptionKg = round3(stat.ConsumptionKg) if !alreadyOrdered {
if stat.SampleCount < 2 { sort.SliceStable(values, func(i, j int) bool {
stat.QualityStatus = "NO_DATA" if !values[i].ObservedAt.Equal(values[j].ObservedAt) {
stat.QualityReason = "有效车载氢量样本不足2条" return values[i].ObservedAt.Before(values[j].ObservedAt)
} else if abnormalDrops > 0 { }
stat.QualityStatus = "SUSPECT" return betterHydrogenDuplicate(values[i], values[j], sourceCounts)
stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg) })
} }
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 { func betterHydrogenDuplicate(candidate, current HydrogenObservation, sourceCounts map[string]int) bool {
rank := func(status string) int { if candidate.FuelCellStateKnown != current.FuelCellStateKnown {
switch status { return candidate.FuelCellStateKnown
case "OK": }
return 0 if sourceCounts[candidate.Source] != sourceCounts[current.Source] {
case "SUSPECT": return sourceCounts[candidate.Source] > sourceCounts[current.Source]
return 1 }
default: if candidate.Source != current.Source {
return 2 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 remainingMass, cycleMinimum.MassKg, refuelCount
return rank(candidate.QualityStatus) < rank(current.QualityStatus) }
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 accumulator.Finalize()
return candidate.SampleCount > current.SampleCount }
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) { 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() 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) { 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) database = strings.TrimSpace(database)
if database == "" { if database == "" {
database = "lingniu_vehicle_ts" database = "lingniu_vehicle_ts"
} }
query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json query, err := buildHydrogenObservationQuery(database, start, end, vin)
FROM ` + database + `.raw_frames if err != nil {
WHERE protocol='GB32960' return nil, err
AND ts>='` + quoteTDTime(start) + `' }
AND ts<'` + quoteTDTime(end) + `'
AND parse_status='OK'
ORDER BY vin,source_endpoint,ts`
rows, err := tdengine.QueryContext(ctx, query) rows, err := tdengine.QueryContext(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -235,14 +479,14 @@ ORDER BY vin,source_endpoint,ts`
observations := make([]HydrogenObservation, 0) observations := make([]HydrogenObservation, 0)
for rows.Next() { for rows.Next() {
var vin, parsed string var vin, parsed string
var source sql.NullString var source, eventID sql.NullString
var unixMS int64 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 return nil, err
} }
vin = strings.ToUpper(strings.TrimSpace(vin)) vin = strings.ToUpper(strings.TrimSpace(vin))
capacity, capacityOK := capacities[vin] capacity, capacityOK := capacities[vin]
pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed) pressure, temperature, fuelCellActive, fuelCellStateKnown, ok := extractHydrogenTelemetryFast(parsed)
if !capacityOK || !ok { if !capacityOK || !ok {
continue continue
} }
@@ -252,23 +496,298 @@ ORDER BY vin,source_endpoint,ts`
} }
stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity) stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity)
noise := math.Min(1, math.Max(0.05, mass-stepMass)) noise := math.Min(1, math.Max(0.05, mass-stepMass))
observations = append(observations, HydrogenObservation{ observation := HydrogenObservation{
VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass, VIN: vin, Source: source.String, EventID: eventID.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass,
TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature, TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature,
NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05), 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() 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) { func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) {
var fields map[string]any var fields map[string]any
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
return 0, 0, false 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"]) pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) 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) { 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 { 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) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err 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 // A pressure-based rebuild is authoritative for the whole day. Delete every
// previous hydrogen row first so legacy rate/direct-mass results cannot remain // previous hydrogen row first so legacy rate/direct-mass results cannot remain
// for vehicles without valid pressure observations in this run. // 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 { if vin == "" {
return err 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, ` } else {
INSERT INTO vehicle_open_daily_energy( if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
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 {
return err 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() 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) { func numericValue(value any) (float64, bool) {
switch typed := value.(type) { switch typed := value.(type) {
case float64: case float64:
@@ -1,54 +1,142 @@
package openplatform package openplatform
import ( import (
"context"
"encoding/json"
"regexp"
"strings"
"testing" "testing"
"time" "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) base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
stats := BuildHydrogenDailyStats([]HydrogenObservation{ values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10.5, 9.5)
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00}, stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 20)
{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)
if len(stats) != 1 { if len(stats) != 1 {
t.Fatalf("stats = %#v", stats) t.Fatalf("stats = %#v", stats)
} }
stat := stats[0] 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) t.Fatalf("stat = %#v", stat)
} }
} }
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) { func TestBuildHydrogenDailyStatsMergesSequentialSources(t *testing.T) {
base := time.Now() base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
stats := BuildHydrogenDailyStats([]HydrogenObservation{ values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20}, for index := len(values) / 2; index < len(values); index++ {
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1}, values[index].Source = "source-b"
}, "2026-07-01", 0.05, 5) }
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 { 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) t.Fatalf("stats = %#v", stats)
} }
} }
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) { func TestBuildHydrogenDailyStatsOrderedMatchesUnorderedCalculation(t *testing.T) {
base := time.Now() base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
stats := BuildHydrogenDailyStats([]HydrogenObservation{ values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10}, duplicates := append([]HydrogenObservation(nil), values...)
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8}, for index := range duplicates {
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6}, duplicates[index].Source = "source-b"
{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}, ordered := make([]HydrogenObservation, 0, len(values)+len(duplicates))
}, "2026-07-01", 0.05, 5) for index := range values {
if len(stats) != 1 { 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) 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) { 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) 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)
}
}
@@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping {
var gb32960FieldReferences = map[string]gb32960FieldReference{ var gb32960FieldReferences = map[string]gb32960FieldReference{
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, "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.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.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"}, "gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"}, "gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
@@ -48,6 +48,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost) h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics) h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost) 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-summary", h.handleOnlineStatisticsSummary)
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses) h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary) 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) 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) { func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
defer r.Body.Close() defer r.Body.Close()
var query MileageQuery var query MileageQuery
@@ -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)
}
}
@@ -1881,10 +1881,83 @@ type DailyMileageRow struct {
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"` PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,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"` Source string `json:"source"`
AnomalySeverity string `json:"anomalySeverity,omitempty"` 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 // MileageQuery is the POST contract used by mileage statistics and daily
// mileage queries. Array fields keep large vehicle selections out of the URL // mileage queries. Array fields keep large vehicle selections out of the URL
// and avoid ambiguity when clients submit thousands of VINs. // and avoid ambiguity when clients submit thousands of VINs.
@@ -575,6 +575,12 @@ WHERE ` + strings.Join(where, " AND ")
dailyMileageExpression + ` AS daily_mileage_km, ` + dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` + pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` + `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 ` + `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 ?`, groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
Args: args, 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, ` + 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 - 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.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 ?`, fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args, Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL, 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, ` + return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
dailyMileageExpression + ` AS daily_mileage_km, ` + dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_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, ` + `MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
latestMileageExpression + ` AS latest_mileage_km ` + latestMileageExpression + ` AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m `FROM vehicle_daily_mileage m
@@ -702,8 +711,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query) base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` + return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` + `COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 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 THEN d.daily_mileage_km 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_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} `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) base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` + 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), ` + `COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 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 THEN d.daily_mileage_km 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_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} `COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
} }
@@ -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 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) { func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
built := buildMileageSummarySQL(query) built := buildMileageSummarySQL(query)
var summary MileageSummary var summary MileageSummary
@@ -1054,15 +1137,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
items := make([]DailyMileageRow, 0) items := make([]DailyMileageRow, 0)
for rows.Next() { for rows.Next() {
var row DailyMileageRow var row DailyMileageRow
var hydrogen sql.NullFloat64 var hydrogen, physicalRate, balanced, balancedRate 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 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 return Page[DailyMileageRow]{}, err
} }
if hydrogen.Valid { if hydrogen.Valid {
consumption := hydrogen.Float64 consumption := hydrogen.Float64
row.HydrogenConsumptionKg = &consumption 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) items = append(items, row)
} }
if err := rows.Err(); err != nil { 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 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, &parameterJSON, &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) { func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
result := MileageStatistics{ result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{}, 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) summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan( if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
@@ -8,6 +8,8 @@ import (
"io" "io"
"strings" "strings"
"testing" "testing"
"github.com/DATA-DOG/go-sqlmock"
) )
func init() { 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) rate := hydrogenRatePer100Km(7.3, 193.3, 2)
if rate == nil || *rate < 3.77 || *rate > 3.78 { if rate == nil || *rate < 3.77 || *rate > 3.78 {
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate) 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) { func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
db, err := sql.Open("ops_health_test", "") db, err := sql.Open("ops_health_test", "")
if err != nil { if err != nil {
@@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
"COUNT(DISTINCT d.vin)", "COUNT(DISTINCT d.vin)",
"SUM(d.daily_mileage_km)", "SUM(d.daily_mileage_km)",
"SUM(d.pure_hydrogen_mileage_km)", "SUM(d.pure_hydrogen_mileage_km)",
"COUNT(d.hydrogen_consumption_kg)", "COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)",
"SUM(d.hydrogen_consumption_kg)", "THEN d.hydrogen_consumption_kg ELSE 0 END",
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km", "THEN d.hydrogen_matched_mileage_km ELSE 0 END",
} { } {
if !strings.Contains(summary.Text, want) { if !strings.Contains(summary.Text, want) {
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text) t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
@@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
} }
trend := buildMileageStatisticsTrendSQL(query) trend := buildMileageStatisticsTrendSQL(query)
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") || 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) t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
} }
ranking := buildMileageStatisticsRankingSQL(query) ranking := buildMileageStatisticsRankingSQL(query)
@@ -43,6 +43,10 @@ type Store interface {
OpsHealth(context.Context) (OpsHealth, error) OpsHealth(context.Context) (OpsHealth, error)
} }
type HydrogenDailyEvidenceStore interface {
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
}
type VehicleOverviewBatchStore interface { type VehicleOverviewBatchStore interface {
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
} }
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error) 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 { type RawFrameQuery struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
VIN string `json:"vin"` VIN string `json:"vin"`
@@ -1029,7 +1041,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
if err != nil { if err != nil {
return VehicleDetail{}, err return VehicleDetail{}, err
} }
raw, err := s.RawFrames(ctx, rawQuery) raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
if err != nil { if err != nil {
return VehicleDetail{}, err return VehicleDetail{}, err
} }
@@ -1094,6 +1106,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
}, nil }, 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) { func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
vin = strings.TrimSpace(vin) vin = strings.TrimSpace(vin)
if 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 { func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{}) scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
for index := range statuses { for index := range statuses {
protocol := statuses[index].Protocol protocol := statuses[index].Protocol
if 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)) 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}) 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 { if err == nil && len(page.Items) > 0 {
statuses[index].HasRaw = true statuses[index].HasRaw = true
@@ -1933,7 +2082,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
catalogResult := make(chan latestTelemetryCatalogResult, 1) catalogResult := make(chan latestTelemetryCatalogResult, 1)
for _, protocol := range canonicalVehicleProtocols { for _, protocol := range canonicalVehicleProtocols {
go func(protocol string) { 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} rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
}(protocol) }(protocol)
} }
@@ -5409,6 +5558,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
return result, nil 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) { func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query) resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil { if err != nil {
@@ -543,6 +543,7 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
type countingStore struct { type countingStore struct {
*MockStore *MockStore
rawMu sync.Mutex
vehiclesCalls int vehiclesCalls int
vehicleRealtimeCalls int vehicleRealtimeCalls int
overviewBatchCalls int overviewBatchCalls int
@@ -555,6 +556,17 @@ type countingStore struct {
lastMileageStatisticsQuery url.Values 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 } type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { 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) { func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
consumption, rate := 3.1, 5.5 consumption, rate := 3.1, 5.5
return MileageStatistics{ 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, PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
Trend: []MileageTrendPoint{{ 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, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}}, }},
}, nil }, 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 { func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()} 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 { 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) 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) { 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) { func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.rawMu.Lock()
s.rawFrameQueries = append(s.rawFrameQueries, query) s.rawFrameQueries = append(s.rawFrameQueries, query)
s.rawMu.Unlock()
return s.MockStore.RawFrames(ctx, query) 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) { func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query) s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query) return s.MockStore.MileageStatistics(ctx, query)
@@ -4,12 +4,12 @@ import { Icon, navigate } from "./ui";
type Topic = type Topic =
| "overview" | "quickstart" | "auth" | "errors" | "limits" | "overview" | "quickstart" | "auth" | "errors" | "limits"
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage" | "hydrogen" | "mileage" | "mileage-range" | "total-mileage" | "stationary"
| "vehicle-auth" | "key-rotation" | "quality" | "checklist"; | "vehicle-auth" | "key-rotation" | "quality" | "checklist";
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [ const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] }, { 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", "上线检查清单"]] } { title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
]; ];
@@ -38,6 +38,7 @@ function TopicContent({ topic }: { topic: Topic }) {
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />; if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
if (topic === "mileage-range") return <MileageRangeReference />; if (topic === "mileage-range") return <MileageRangeReference />;
if (topic === "total-mileage") return <TotalMileageReference />; if (topic === "total-mileage") return <TotalMileageReference />;
if (topic === "stationary") return <StationaryVehicleReference />;
if (topic === "overview") return <Overview />; if (topic === "overview") return <Overview />;
if (topic === "auth") return <AuthGuide />; if (topic === "auth") return <AuthGuide />;
if (topic === "errors") return <ErrorGuide />; if (topic === "errors") return <ErrorGuide />;
@@ -227,6 +228,23 @@ function TotalMileageReference() {
</>; </>;
} }
function StationaryVehicleReference() {
return <>
<h1></h1><p className="docs-lead"></p>
<Endpoint method="POST" path="/api/v1/vehicles/stationary/query" />
<h2 id="请求参数"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody><tr><td><code>startTime</code></td><td></td><td> YYYY-MM-DD HH:mm:ss</td></tr><tr><td><code>endTime</code></td><td></td><td> 24 </td></tr><tr><td><code>longitude</code></td><td></td><td> WGS-84 </td></tr><tr><td><code>latitude</code></td><td></td><td> WGS-84 </td></tr><tr><td><code>radiusMeters</code></td><td></td><td>1100 5</td></tr><tr><td><code>plateNumbers</code></td><td></td><td></td></tr></tbody></table>
<h2 id="请求示例"></h2><CodeBlock language="json" value={`{
"startTime": "2026-08-06 10:00:00",
"endTime": "2026-08-06 12:00:00",
"longitude": 120.752312,
"latitude": 30.746281,
"radiusMeters": 8
}`} />
<h2 id="核验口径"></h2><p>使 WGS-84/GPS <code>radiusMeters</code> 1100 5 3 km/h 2 60 10 <code>matchScore</code> </p>
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th></tr></thead><tbody><tr><td><code>stayStartTime</code> / <code>stayEndTime</code></td><td></td></tr><tr><td><code>stayDurationSeconds</code> / <code>stayDurationMinutes</code></td><td></td></tr><tr><td><code>matchScore</code></td><td>0100</td></tr><tr><td><code>averageDistanceMeters</code> / <code>maxDistanceMeters</code></td><td></td></tr><tr><td><code>averageSpeedKmh</code> / <code>maxSpeedKmh</code></td><td></td></tr><tr><td><code>matchedSamples</code> / <code>sourceProtocols</code></td><td></td></tr></tbody></table>
</>;
}
function VehicleAuthGuide() { function VehicleAuthGuide() {
return <> return <>
<h1></h1><p className="docs-lead"> VIN/</p> <h1></h1><p className="docs-lead"> VIN/</p>
@@ -304,6 +322,7 @@ function tocFor(topic: Topic) {
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"], mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"], "mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"], "total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
stationary: ["请求参数", "请求示例", "核验口径", "响应字段"],
"vehicle-auth": ["授权关系", "配置步骤"], "vehicle-auth": ["授权关系", "配置步骤"],
"key-rotation": ["推荐流程", "权限要求"], "key-rotation": ["推荐流程", "权限要求"],
quality: ["状态定义", "统计口径"], quality: ["状态定义", "统计口径"],
@@ -41,6 +41,16 @@ export const products: Product[] = [
path: "/api/v1/vehicles/total-mileage/query", path: "/api/v1/vehicles/total-mileage/query",
unit: "km" 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", code: "realtime_vehicle",
name: "车辆实时位置与状态", name: "车辆实时位置与状态",
@@ -83,6 +93,17 @@ export const curlExample = (product: Product) => {
"time": "2026-07-21 09:30:00", "time": "2026-07-21 09:30:00",
"protocol": "GB32960" "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 \\ if (product.code === "mileage_range") return `curl --request POST \\
--url https://open.d.lnoneos.com${product.path} \\ --url https://open.d.lnoneos.com${product.path} \\
--header 'Authorization: Bearer YOUR_APP_KEY' \\ --header 'Authorization: Bearer YOUR_APP_KEY' \\
+7 -2
View File
@@ -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/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/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/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 `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 `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 `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. 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.
@@ -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能量守恒。
+22 -14
View File
@@ -343,35 +343,40 @@ PUT /portal-api/account/password
## 用氢量统计 ## 用氢量统计
当天数据由 `vehicle-stat-writer` 直接消费 GB32960 fields Kafka 流,统一采用压力法: 用氢量只接收 `GB32960` 帧,不读取 JT808、宇通 MQTT 或其他协议。统一采用压力法:
```text ```text
剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000 剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000
``` ```
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比或百公里氢耗推算质量; - 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比、宇通 MQTT 剩余氢量或百公里氢耗推算质量;
- `vehicle-stat-writer` 每六小时从 `ln_asset_management.vehicle_info → vehicle_model.tank_capacity` 同步 VIN 容积到本地 `vehicle_hydrogen_tank_capacity` - `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容积时拒绝生成氢量 - 额定电量按车辆关联的具体车型读取,不按吨位写死;后续生效日期更晚的人工业务参数仍可覆盖自动同步基线
- 压力法使用 NIST 实氢气密度方程,允许 070 MPa、220–1000 K 的输入范围。 - 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量;
- 压力法使用 NIST 实氢气密度方程,允许 070 MPa、220–1000 K 的输入范围;
- GB32960 上报的是“最高氢压”和“最高氢温”,两者不保证来自同一探头,也不保证代表静置平衡后的储氢瓶状态。因此压力法结果是车载信号估算值,不能用加氢站计量值直接反标公式系数。
每个有效样本在同一个 MySQL 事务中更新 当天准实时数据由 `vehicle-stat-writer` 消费 GB32960 fields Kafka 流。每个有效样本在同一个 MySQL 事务中更新
`vehicle_open_hydrogen_stream_state`,并投影到 `vehicle_open_hydrogen_segment_stream_state`,并投影到
`vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按 `vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按
`VIN + 日期 + source_endpoint` 保存,每条消息只做常数次数据库操作,不扫描当天历史数据。 `VIN + 日期` 保存并合并全部连接端点;每条消息只做常数次数据库操作,不扫描当天历史数据。
统计规则: 统计规则:
- 先按 VIN `source_endpoint` 分别统计,避免多个采集源的序列交错产生虚假变化 - 已结束日期先按 VIN 合并全部 `source_endpoint`,同一事件时间只保留一条可信样本;连接端口仅用于追踪,不再作为互斥统计来源
- 同一 VIN 优先采用质量正常、样本数量更多的数据源,并记录入库 - 只有相邻两条样本都确认燃料电池处于工作状态时,才纳入工作段;优先采用广东扩展 `engine_work_state=2`,缺失时以燃料电池电流大于 `1 A` 作为回退;完全没有工作区间的日期标记为 `NO_DATA`
- 每次加氢之间维护质量低水位,只累计新的有效最低质量,避免压力温度波动反复计量; - 加氢、超过 5 分钟的数据断点、燃料电池停机和异常跳变会切分工作段;每段开始与结束各取 5 条样本的氢质量中位数做差,再累加为当日用氢量;
- 每个可计算工作段至少需要 10 条有效样本;段首尾差值未超过动态噪声阈值时不累计;
- 燃料电池未工作时,下降的压力质量只更新防重复计算的低水位,不降低对外剩余氢量;压力或质量恢复上升后再更新剩余量;
- 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg` - 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`
- 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位; - 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位;
- 相邻样本在 60 秒内无工作耗氢却恢复至少 `8 MPa` 时,按压力信号恢复处理,重置低水位但不增加加氢次数;
- 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg` - 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`
- 至少两条有效样本才标记 `OK`
- 对外只返回 `quality_status=OK` 的结果。 - 对外只返回 `quality_status=OK` 的结果。
当天接口结果是准实时的进行中累计值。乱序、重复和迟到样本不会覆盖更新的状态;跨日迟到数据由 分段计算由流式状态机直接执行:每车仅保留当前段的首 5 帧、尾 5 帧、低水位、上一帧和事件时间水位,状态不会随当天帧数增长。水位只覆盖当前自然日 `00:00:0024:00:00`,不向前后日期扩窗,也不借用次日帧;早于或等于当前事件时间水位的延迟帧直接忽略。流式结果采用与日终相同的工作段、中位数、加氢和异常跳变规则;已结束日期仍由 `open-platform-stat` 重算,作为最终对账结果。
`open-platform-stat` 夜间批处理兜底。批处理默认重算昨天和前天,以修正迟到上报,并作为已结束日期的权威结果。
历史重放只接收事件时间和接收时间同时落在统计日内的 GB32960 帧。每个统计日先读取有数据且配置了氢瓶容积的 VIN,再在同一进程内按单车单日并行查询;默认 4 个工作线程,全部车辆成功后才一次性事务替换当天结果。单车帧按 `event_time` 排序并单遍去重;JSON 只读取压力、温度和工作状态字段,不展开整帧,从而减少全车排序与反序列化开销。可通过 `-vin-workers``OPEN_STAT_VIN_WORKERS` 调整工作线程数。
```bash ```bash
/opt/lingniu-vehicle-platform/current/open-platform-stat \ /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 systemctl enable --now lingniu-vehicle-open-stat.timer
``` ```
流式算法首次切换时,先停止 `lingniu-go-stat-writer.service`,再对当天执行一次
`-seed-stream-state`。该参数会在同一个 MySQL 事务中替换当天结果并写入每车最后事件时间水位;恢复 Kafka 消费后,已经包含在重算结果中的 backlog 不会再次累计。正常日终任务不使用此参数。
@@ -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