功能:扩展开放平台氢耗溯源与合作站数据
This commit is contained in:
@@ -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: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。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/h;idle=在线且速度不大于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(×tampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
points = append(points, point)
|
||||||
|
}
|
||||||
|
return points, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
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 = ©
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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, ¶meterJSON, &evidenceJSON, &result.CalculatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return HydrogenDailyEvidence{}, false, nil
|
||||||
|
}
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
|
||||||
|
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||||
|
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||||
|
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||||
|
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||||
|
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||||
|
result.Intervals = []HydrogenIntervalEvidenceRow{}
|
||||||
|
if len(parameterJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(evidenceJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableFloatPointer(value sql.NullFloat64) *float64 {
|
||||||
|
if !value.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value.Float64
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
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>核验半径,单位米;1–100,默认 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> 可设为 1–100 米,省略时为 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>0–100,越高表示匹配越充分</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' \\
|
||||||
|
|||||||
@@ -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能量守恒。
|
||||||
@@ -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 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围。
|
- 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量;
|
||||||
|
- 压力法使用 NIST 实氢气密度方程,允许 0–70 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:00–24: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
|
||||||
Reference in New Issue
Block a user