7 Commits
99 changed files with 10626 additions and 931 deletions
+11 -1
View File
@@ -513,12 +513,22 @@ BACKFILL_DATE_TO=2026-07-12 \
三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时,stat-writer 会按同一 VIN、自然日、协议和物理来源累计相邻坐标距离,状态持久化在 `vehicle_daily_gps_mileage_state`,并以 `quality_reason=gps_coordinate_accumulation` 写入低优先级候选。该路径既覆盖总里程字段缺失,也覆盖字段存在但数值与上一帧相同;重复总里程仍计入 `skipped_same_mileage` 证据,只有同时具备有效移动点时才增加 GPS 候选。JT808 兼容没有速度字段的存量终端;GB32960、宇通 MQTT 必须同时满足设备速度大于 0,且实时状态写入按 10 秒节流,避免静止 GPS 漂移和 1 秒级宇通上报放大 MySQL 压力。相邻点超过 10 分钟、时间倒序或隐含速度超过 220 km/h 的线段不累计;低于 100 米的自然日累计只保留在状态表中,不投影为业务候选,避免瞬时车速噪声和微小定位漂移生成虚假日里程。`stats-backfill` 每次扫描还会查找窗口内阈值规则上线前遗留的低于 100 米 GPS 候选:dry-run 只报告数量,正式回补在单车/日期/协议事务中删除候选并重新执行来源选举,确保最终表不会继续保留旧漂移值。选举顺序为“正向累计里程 > 正向 GPS 轨迹估算 > 累计里程零值占位”,因此稀疏累计里程造成的错误 0 km 会被已有移动轨迹纠正,真实正向累计里程恢复后仍会自动覆盖估算值;禁止把估算值混入平台累计里程归并。`vehicle_stat_samples_total{status="recovered_gps_coordinate"}``vehicle_stat_cache_entries{cache="gps_accumulation"}` 用于观察实时恢复量及节流缓存。`stats-backfill` 默认用 TDengine `vehicle_locations` 为缺少正向累计里程但存在可用移动线段的车辆补建同口径候选;可临时设置 `BACKFILL_GPS_FALLBACK=false` 禁用全部协议的降级路径,旧环境变量 `BACKFILL_JT808_GPS_FALLBACK` 继续兼容。
GB32960 与宇通 MQTT 的每日里程同时产出 `pure_hydrogen_mileage_km`。GB32960 采用燃料电池扩展中的 `engine_work_state=2`,宇通采用 `TRIANGLE_STATE IN (4,11)` 判定燃料电池处于纯氢工作模式;只有相邻两个有效累计里程点均为已识别的纯氢模式时才累计该段距离,模式切换、未知值、倒序和异常跳变均不计入纯氢里程。候选来源证据保存在 `vehicle_daily_mileage_source`,最终值随每日里程来源选举投影到 `vehicle_daily_mileage`,并始终限制为不大于对应日总里程。
GB32960 与宇通 MQTT 的每日里程同时产出 `pure_hydrogen_mileage_km`。GB32960 使用整车运行模式:`0x01` 为纯电、`0x02` 为混动、`0x03` 为燃油、`0xFE` 为异常、`0xFF` 为无效;氢能车辆以当前帧运行模式归属相邻里程区间,模式 `0x02` 计入纯氢里程、模式 `0x01` 计入纯电里程,因此在只有有效纯电/混动模式的车辆日满足“纯氢里程=总里程-纯电模式里程”。燃油、异常、无效、倒序和异常跳变不计入纯氢里程并应作为质量异常检查。宇通继续采用 `TRIANGLE_STATE IN (4,11)` 判定氢能参与。候选来源证据保存在 `vehicle_daily_mileage_source`,最终值随每日里程来源选举投影到 `vehicle_daily_mileage`,并始终限制为不大于对应日总里程。燃料电池启停仍由燃料电池子系统工作状态/电流判定,不与整车运行模式混用。
内部里程统计会关联 `vehicle_open_daily_energy``quality_status=OK` 的日用氢量,展示 `耗氢 kg`,并用同一车辆日的 `耗氢量 ÷ 纯氢里程 × 100` 计算百公里纯氢耗。区间百公里纯氢耗只汇总同时具备有效氢耗和纯氢里程的车辆日;`NO_DATA``SUSPECT` 或没有纯氢里程的数据展示为 `—`,禁止当作 `0` 或混入分母。
存量日期需要用原始报文扫描方式回算纯氢里程,因为默认 `last_diff` 只读取每日首尾累计里程,无法还原日内工作模式序列。先保持 `BACKFILL_DRY_RUN=true` 核对扫描量,再以 `BACKFILL_METHOD=scan BACKFILL_DRY_RUN=false BACKFILL_PROTOCOLS=GB32960,YUTONG_MQTT` 对指定日期窗口执行正式回算;扫描按事件时间顺序读取原始报文,并使用与实时统计完全相同的模式判定和相邻里程规则。
全车队重放必须使用 `scripts/replay-pure-hydrogen-mileage.sh`。脚本先取氢瓶容量配置 VIN 与目标窗口内真实存在 GB32960 帧的 VIN 交集,再按单 VIN 查询并受控并发,避免全表 `ORDER BY event_time`。默认只 dry-run;确认后显式增加 `--apply`。每台车保留独立日志,失败可按 VIN 重跑:
```bash
/opt/lingniu-go-native/tools/replay-pure-hydrogen-mileage.sh \
--date-from 2026-08-01 \
--date-to 2026-08-18 \
--parallel 4 \
--apply
```
生产定时补算使用 systemd timer,默认每天 01:30 补算昨天往前 3 天,覆盖上游晚到或断传后恢复的总里程字段:
```bash
@@ -0,0 +1,88 @@
// mileage-correction writes a confirmed daily terminal odometer correction.
// It is intentionally explicit: callers must provide the VIN, natural day and
// confirmed cumulative mileage; it never derives values from a guess.
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
)
func main() {
vin := flag.String("vin", "", "VIN")
date := flag.String("date", "", "natural day, yyyy-MM-dd")
total := flag.Float64("total-km", 0, "confirmed cumulative mileage, km")
flag.Parse()
if strings.TrimSpace(*vin) == "" || strings.TrimSpace(*date) == "" || *total <= 0 {
log.Fatal("vin, date and positive total-km are required")
}
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
log.Fatal("MYSQL_DSN is required")
}
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
log.Fatal(err)
}
eventTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*date)+" 23:59:59", loc)
if err != nil {
log.Fatal(err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
if err := db.PingContext(ctx); err != nil {
log.Fatal(err)
}
candidate := stats.SourceMileageSample{
VIN: strings.TrimSpace(*vin),
StatDate: strings.TrimSpace(*date),
Protocol: envelope.ProtocolGB32960,
SourceKey: "GB32960:manual-correction@manual",
SourceIP: "manual",
SourceEndpoint: "manual-correction",
FirstTotalKM: *total,
LatestTotalKM: *total,
DailyKM: 0,
SampleCount: 1,
FirstEventTime: eventTime,
LatestEventTime: eventTime,
QualityStatus: stats.QualityOK,
QualityReason: "manual_correction_confirmed_total",
}
if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil {
log.Fatal(err)
}
if err := stats.ProjectDailyMileage(ctx, db, candidate.VIN, candidate.StatDate, candidate.Protocol); err != nil {
log.Fatal(err)
}
// Keep both cumulative fields aligned. day_end_total_mileage_km is the API
// field of record, while latest_total_mileage_km remains useful to internal
// readers and must not retain the rejected reset value.
if _, err := db.ExecContext(ctx, `
UPDATE vehicle_daily_mileage
SET latest_total_mileage_km=?, day_end_total_mileage_km=?
WHERE vin=? AND stat_date=? AND protocol=?`,
*total, *total, candidate.VIN, candidate.StatDate, string(candidate.Protocol)); err != nil {
log.Fatal(err)
}
var daily, latest, dayEnd float64
if err := db.QueryRowContext(ctx, `SELECT daily_mileage_km,latest_total_mileage_km,day_end_total_mileage_km FROM vehicle_daily_mileage WHERE vin=? AND stat_date=? AND protocol=?`, candidate.VIN, candidate.StatDate, string(candidate.Protocol)).Scan(&daily, &latest, &dayEnd); err != nil {
log.Fatal(err)
}
fmt.Printf("corrected vin=%s date=%s protocol=%s daily_km=%.3f latest_total_km=%.3f day_end_total_km=%.3f\n", candidate.VIN, candidate.StatDate, candidate.Protocol, daily, latest, dayEnd)
}
@@ -82,6 +82,11 @@ func main() {
} else {
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
}
if loaded, err := writer.ReloadHydrogenRealtimeParameters(ctx); err != nil {
logger.Warn("hydrogen V3.5 parameter cache load failed", "error", err)
} else {
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(loaded))
}
if cfg.NormalizePlatformSourcesOnStart {
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
@@ -174,6 +179,15 @@ func syncHydrogenCapacities(ctx context.Context, logger interface {
logger.Warn("hydrogen tank capacity sync failed; retaining current cache", "source_schema", cfg.HydrogenCapacitySourceSchema, "error", err)
return
}
energyResult, energyErr := stats.SyncHydrogenEnergyParameters(syncCtx, db, cfg.HydrogenCapacitySourceSchema)
if energyErr != nil {
registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "error"})
logger.Warn("hydrogen battery capacity sync failed", "source_schema", cfg.HydrogenCapacitySourceSchema, "error", energyErr)
} else {
registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "ok"})
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_energy_parameter_last_sync_unix_seconds", nil)
logger.Info("hydrogen battery capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", energyResult.Read, "written", energyResult.Written, "deactivated", energyResult.Deactivated)
}
loaded, err := writer.ReloadHydrogenTankCapacities(syncCtx)
if err != nil {
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "cache_error"})
@@ -182,6 +196,13 @@ func syncHydrogenCapacities(ctx context.Context, logger interface {
}
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
parameterCount, parameterErr := writer.ReloadHydrogenRealtimeParameters(syncCtx)
if parameterErr != nil {
registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "cache_error"})
logger.Warn("hydrogen V3.5 parameter cache reload failed", "error", parameterErr)
} else {
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(parameterCount))
}
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil)
logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
}
+126 -10
View File
@@ -29,6 +29,7 @@ type config struct {
DateFrom string
DateTo string
Protocols []envelope.Protocol
VINs []string
Method string
Limit int
DryRun bool
@@ -39,6 +40,8 @@ type config struct {
GPSFallback bool
ProgressEvery int64
BaselineLookback int
ListHydrogenVINs bool
ListActiveVINs bool
Location *time.Location
}
@@ -117,6 +120,21 @@ func main() {
}
ctx := context.Background()
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
if err != nil {
fail("open mysql", err)
}
defer mysqlDB.Close()
if err := mysqlDB.PingContext(ctx); err != nil {
fail("ping mysql", err)
}
if cfg.ListHydrogenVINs {
if err := printActiveHydrogenVINs(ctx, mysqlDB); err != nil {
fail("list active hydrogen vins", err)
}
return
}
td, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
if err != nil {
fail("open tdengine", err)
@@ -125,14 +143,11 @@ func main() {
if err := td.PingContext(ctx); err != nil {
fail("ping tdengine", err)
}
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
if err != nil {
fail("open mysql", err)
if cfg.ListActiveVINs {
if err := printActiveFrameVINs(ctx, td, cfg); err != nil {
fail("list active frame vins", err)
}
defer mysqlDB.Close()
if err := mysqlDB.PingContext(ctx); err != nil {
fail("ping mysql", err)
return
}
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
@@ -516,10 +531,11 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
currentRow.TotalKM = previousRow.TotalKM
currentSources[i] = currentRow
}
usesPreviousDayBoundary := hasPrevious && stats.IsUsableDailyMileageBoundary(date, previousRow.TS, currentRow.FirstTS.Location())
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
if cfg.StationaryCarry {
agg.QualityReason = stats.QualityReasonStationaryCarry
} else if hasPrevious {
} else if usesPreviousDayBoundary {
agg.QualityReason = "realtime_location_fallback_historical_baseline"
} else {
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
@@ -909,7 +925,7 @@ func aggregateFromDailySource(date string, protocol envelope.Protocol, current d
firstEventTime := current.FirstTS
qualityStatus := stats.QualityOK
qualityReason := stats.QualityReasonCurrentDayFirst
if hasPrevious {
if hasPrevious && stats.IsUsableDailyMileageBoundary(date, previous.TS, current.FirstTS.Location()) {
firstKM = previous.TotalKM
firstEventTime = previous.TS
qualityStatus = stats.QualityOK
@@ -1295,10 +1311,32 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
}
fields[key] = value
}
promoteVehicleRunningModeForStats(protocol, fields, text)
promoteFuelCellWorkModeForStats(protocol, fields, text)
return fields
}
func promoteVehicleRunningModeForStats(protocol envelope.Protocol, fields map[string]any, text string) {
if protocol != envelope.ProtocolGB32960 || fields == nil {
return
}
if _, exists := fields[envelope.FieldVehicleRunningMode]; exists {
return
}
for _, key := range []string{"gb32960.vehicle.running_mode", "running_mode"} {
if value, exists := fields[key]; exists {
fields[envelope.FieldVehicleRunningMode] = value
return
}
}
for _, key := range []string{"gb32960.vehicle.running_mode", "running_mode"} {
if value, ok := extractJSONStringField(text, key); ok {
fields[envelope.FieldVehicleRunningMode] = value
return
}
}
}
func promoteFuelCellWorkModeForStats(protocol envelope.Protocol, fields map[string]any, text string) {
if fields == nil {
return
@@ -1383,6 +1421,7 @@ func loadConfig() (config, error) {
DateFrom: dateFrom,
DateTo: dateTo,
Protocols: protocols,
VINs: parseVINs(env("BACKFILL_VINS", "")),
Method: env("BACKFILL_METHOD", "last_diff"),
Limit: envInt("BACKFILL_LIMIT", 0),
DryRun: envBool("BACKFILL_DRY_RUN", true),
@@ -1393,10 +1432,29 @@ func loadConfig() (config, error) {
GPSFallback: envBool("BACKFILL_GPS_FALLBACK", envBool("BACKFILL_JT808_GPS_FALLBACK", true)),
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
ListHydrogenVINs: envBool("BACKFILL_LIST_HYDROGEN_VINS", false),
ListActiveVINs: envBool("BACKFILL_LIST_ACTIVE_VINS", false),
Location: loc,
}, nil
}
func parseVINs(value string) []string {
seen := make(map[string]struct{})
vins := make([]string, 0)
for _, raw := range strings.Split(value, ",") {
vin := strings.ToUpper(strings.TrimSpace(raw))
if vin == "" {
continue
}
if _, ok := seen[vin]; ok {
continue
}
seen[vin] = struct{}{}
vins = append(vins, vin)
}
return vins
}
func resolveBackfillDateRange(now time.Time, loc *time.Location) (string, string) {
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
@@ -1441,6 +1499,13 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err
}
where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")")
}
if len(cfg.VINs) > 0 {
quoted := make([]string, 0, len(cfg.VINs))
for _, vin := range cfg.VINs {
quoted = append(quoted, "'"+quote(vin)+"'")
}
where = append(where, "vin IN ("+strings.Join(quoted, ",")+")")
}
where = append(where, realtimeMileageFramePredicate())
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json, raw_text
FROM %s.raw_frames
@@ -1449,10 +1514,61 @@ WHERE %s
if cfg.Limit > 0 {
sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit)
}
slog.Info("query raw frames", "date_from", cfg.DateFrom, "date_to", cfg.DateTo, "protocols", cfg.Protocols, "limit", cfg.Limit)
slog.Info("query raw frames", "date_from", cfg.DateFrom, "date_to", cfg.DateTo, "protocols", cfg.Protocols, "vins", cfg.VINs, "limit", cfg.Limit)
return db.QueryContext(ctx, sqlText)
}
func printActiveHydrogenVINs(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `SELECT DISTINCT UPPER(TRIM(vin))
FROM vehicle_hydrogen_tank_capacity
WHERE active = 1 AND tank_capacity_l > 0 AND vin IS NOT NULL AND TRIM(vin) <> ''
ORDER BY UPPER(TRIM(vin))`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return err
}
fmt.Println(vin)
}
return rows.Err()
}
func printActiveFrameVINs(ctx context.Context, db *sql.DB, cfg config) error {
where := backfillTimePredicates(cfg, cfg.DateFrom, nextDate(cfg.DateTo))
where = append(where,
"parse_status = 'OK'",
"vin IS NOT NULL",
"vin <> ''",
)
if len(cfg.Protocols) > 0 {
quoted := make([]string, 0, len(cfg.Protocols))
for _, protocol := range cfg.Protocols {
quoted = append(quoted, "'"+quote(string(protocol))+"'")
}
where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")")
}
where = append(where, realtimeMileageFramePredicate())
rows, err := db.QueryContext(ctx, fmt.Sprintf(`SELECT DISTINCT vin
FROM %s.raw_frames
WHERE %s`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return err
}
fmt.Println(strings.ToUpper(strings.TrimSpace(vin)))
}
return rows.Err()
}
func backfillTimePredicates(cfg config, eventDateFrom string, eventDateToExclusive string) []string {
where := make([]string, 0, 4)
if !cfg.EventTimeFullScan {
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -189,7 +190,7 @@ func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
}
}
func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
func TestAggregateFromDailySourceUsesCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
current := dailySourceLast{
VIN: "LMRKH9AC2R1004087",
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
@@ -212,13 +213,13 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true)
if agg.FirstKM != 120672 || agg.LatestKM != 120788 {
if agg.FirstKM != 120778 || agg.LatestKM != 120788 {
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
}
if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
}
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
}
if agg.Count != 15 {
@@ -226,7 +227,7 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
}
}
func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
func TestAggregateFromDailySourceIgnoresHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
current := dailySourceLast{
VIN: "LNXNEGRR6SR319464",
@@ -248,10 +249,10 @@ func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
if agg.FirstKM != current.FirstTotalKM || agg.LatestKM != current.TotalKM {
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
}
if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
if !agg.FirstEventTime.Equal(current.FirstTS) || !agg.LatestEventTime.Equal(current.TS) {
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
}
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
@@ -285,7 +286,7 @@ func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testin
}
}
func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) {
func TestBuildLastDiffAggregatesStartsFreshBoundaryAfterEmptyDays(t *testing.T) {
tdDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -333,11 +334,11 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.
if agg == nil {
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
}
if agg.FirstKM != 100 || agg.LatestKM != 120 {
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
if agg.FirstKM != 120 || agg.LatestKM != 120 {
t.Fatalf("km range = %v -> %v, want 120 -> 120", agg.FirstKM, agg.LatestKM)
}
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
if !agg.FirstEventTime.Equal(dayThreeTS) || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
t.Fatalf("baseline = %v reason=%q, want day-three current-day baseline", agg.FirstEventTime, agg.QualityReason)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
@@ -601,10 +602,10 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes
if agg == nil {
t.Fatal("missing realtime-location fallback aggregate")
}
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
if agg.FirstKM != 120 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayThreeTS) {
t.Fatalf("fallback range = %v@%v -> %v, want 120@day-three -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
}
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
if agg.QualityReason != "realtime_location_fallback_current_day_first_baseline" {
t.Fatalf("quality reason = %q", agg.QualityReason)
}
if err := mysqlMock.ExpectationsWereMet(); err != nil {
@@ -823,6 +824,24 @@ func TestFieldsForStatsPromotesGB32960FuelCellEngineWorkState(t *testing.T) {
}
}
func TestFieldsForStatsPromotesGB32960VehicleRunningMode(t *testing.T) {
fields := fieldsForStats(envelope.ProtocolGB32960, "LTEST000000000001", `{
"gb32960.vehicle.total_mileage_km": "120.5",
"gb32960.vehicle.running_mode": "1"
}`)
if got := fields[envelope.FieldVehicleRunningMode]; got == nil {
t.Fatalf("fields missing GB32960 vehicle running mode: %#v", fields)
}
active, known := stats.PureHydrogenModeFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
Fields: fields,
})
if !known || active {
t.Fatalf("pure-hydrogen mode = known:%v active:%v, want known inactive", known, active)
}
}
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -1028,7 +1047,7 @@ func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
}
}
func TestAddSamplesBackfillsOnlyContinuousPureHydrogenIntervals(t *testing.T) {
func TestAddSamplesBackfillsTotalMileageMinusPureElectricIntervals(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
base := stats.MetricSample{
VIN: "LTEST000000000001",
@@ -1061,8 +1080,8 @@ func TestAddSamplesBackfillsOnlyContinuousPureHydrogenIntervals(t *testing.T) {
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
}
for _, agg := range aggregates {
if agg.PureHydrogenMileageKM != 5 {
t.Fatalf("pure-hydrogen mileage = %v, want 5", agg.PureHydrogenMileageKM)
if agg.PureHydrogenMileageKM != 7 {
t.Fatalf("pure-hydrogen mileage = %v, want 7", agg.PureHydrogenMileageKM)
}
if agg.PureHydrogenSampleCount != 2 {
t.Fatalf("pure-hydrogen sample count = %d, want 2", agg.PureHydrogenSampleCount)
@@ -1094,6 +1113,14 @@ func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
}
}
func TestParseVINsNormalizesAndDeduplicates(t *testing.T) {
got := parseVINs(" la9gg68l1pbaf4814, LFV123 ,LA9GG68L1PBAF4814, ")
want := []string{"LA9GG68L1PBAF4814", "LFV123"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseVINs() = %v, want %v", got, want)
}
}
func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
for _, want := range []string{
@@ -2786,7 +2786,7 @@ func findingsForTopicFlow(parsedByService map[string]parsedMetricSet, thresholds
var findings []string
findings = append(findings, findingsForGatewayFieldFlow(parsedByService["gateway"], thresholds)...)
findings = append(findings, findingsForGatewayFieldCounts(parsedByService["gateway"], thresholds)...)
findings = append(findings, findingsForGatewayToBridgeFlow(parsedByService["gateway"], parsedByService["bridge"], thresholds)...)
findings = append(findings, findingsForGatewayToBridgeFlow(parsedByService["gateway"], parsedByService["bridge"], parsedByService["fields-projector"], thresholds)...)
findings = append(findings, findingsForGatewayToFastWriterFlow(parsedByService["gateway"], parsedByService["fast-writer"], thresholds)...)
findings = append(findings, findingsForBridgeRawFanout(parsedByService["bridge"], parsedByService["history"], parsedByService["realtime"], thresholds)...)
findings = append(findings, findingsForBridgeToStatFieldFlow(parsedByService["bridge"], parsedByService["stat"], thresholds)...)
@@ -3045,12 +3045,13 @@ type gatewayPublishGroup struct {
count float64
}
func findingsForGatewayToBridgeFlow(gateway parsedMetricSet, bridge parsedMetricSet, thresholds Thresholds) []string {
func findingsForGatewayToBridgeFlow(gateway parsedMetricSet, bridge parsedMetricSet, fieldsProjector parsedMetricSet, thresholds Thresholds) []string {
if thresholds.GatewayBridgeMinPublish <= 0 {
return nil
}
gatewayPublishes := gatewayPublishGroups(gateway.samples)
bridgeWrites := metricValuesByTopic(bridge.samples, "vehicle_bridge_kafka_writes_total", "ok", "vehicle.")
projectorWrites := metricValuesByTopic(fieldsProjector.samples, "vehicle_fields_projector_kafka_writes_total", "ok", "vehicle.fields.go.")
var findings []string
for _, group := range gatewayPublishes {
if group.count < thresholds.GatewayBridgeMinPublish {
@@ -3066,7 +3067,7 @@ func findingsForGatewayToBridgeFlow(gateway parsedMetricSet, bridge parsedMetric
))
continue
}
if bridgeWrites[topic] <= 0 {
if bridgeWrites[topic] <= 0 && (group.kind != "fields" || projectorWrites[topic] <= 0) {
findings = append(findings, fmt.Sprintf(
"bridge writes missing for gateway %s publish protocol %s: gateway ok %.0f, expected topic %s, bridge writes ok %.0f",
group.kind,
@@ -2158,6 +2158,25 @@ vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.fields.go.yutong-mq
}
}
func TestEvaluateAcceptsFieldsProjectorForGatewayFieldsPublish(t *testing.T) {
report := EvaluateWithThresholds(map[string]string{
"gateway": `vehicle_service_info{service="vehicle-gateway"} 1
vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="ok"} 120`,
"bridge": `vehicle_service_info{service="nats-kafka-bridge"} 1
vehicle_bridge_config{setting="derive_fields_from_raw_enabled"} 0`,
"fields-projector": `vehicle_service_info{service="fields-projector"} 1
vehicle_kafka_consumer_info{service="fields-projector",group="vehicle-fields-projector-v1-jt808",topic="vehicle.raw.go.jt808.v1"} 1
vehicle_fields_projector_kafka_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 120`,
}, Thresholds{GatewayBridgeMinPublish: 100})
if report.Status != StatusOK {
t.Fatalf("status = %s, want ok: %#v", report.Status, report)
}
if len(report.Findings) != 0 {
t.Fatalf("findings = %#v, want none", report.Findings)
}
}
func TestEvaluateSkipsGatewayBridgeFlowWhenSamplesAreLow(t *testing.T) {
report := EvaluateWithThresholds(map[string]string{
"gateway": `vehicle_service_info{service="vehicle-gateway"} 1
@@ -38,6 +38,7 @@ const (
FieldLongitude = "longitude"
FieldLatitude = "latitude"
FieldSOCPercent = "soc_percent"
FieldVehicleRunningMode = "running_mode"
FieldFuelCellWorkMode = "fuel_cell_work_mode"
)
@@ -612,6 +612,13 @@ func (r *MySQLResolver) enrichResolvedJT808SourceMetadata(ctx context.Context, e
func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, cacheStatus string, err error) {
phone = normalizePhone(phone)
now := time.Now()
// The immutable snapshot is the authoritative vehicle binding. Registration
// and authentication frames can create a short-lived local session whose VIN
// is still "unknown"; letting that session win would permanently mask a known
// snapshot because every location frame refreshes the session TTL.
if snapshot, ok := r.snapshotRegistration(phone); ok && snapshot.canResolveVehicle() {
return snapshot.vin, snapshot.deviceID, snapshot.plate, "snapshot", nil
}
var stale registrationCacheEntry
hasStale := false
r.lookupMu.Lock()
@@ -659,6 +666,11 @@ func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone strin
return entry.vin, entry.deviceID, entry.plate, "", nil
}
func (e registrationCacheEntry) canResolveVehicle() bool {
vin := strings.TrimSpace(e.vin)
return (vin != "" && !strings.EqualFold(vin, "unknown")) || strings.TrimSpace(e.plate) != ""
}
func addIdentityMetadata(parsed map[string]any, source string, value string) {
if parsed == nil {
return
@@ -74,6 +74,62 @@ func TestSnapshotOnlyResolverMissDoesNotQueryMySQL(t *testing.T) {
}
}
func TestKnownSnapshotRecoversAfterUnknownJT808SessionCache(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
DisableRegistrationWrites: true,
LookupCacheTTL: 10 * time.Minute,
})
resolver.snapshot.Store(&identitySnapshot{
bindings: map[string]string{},
identifiers: map[string]vehicleIdentifierMatch{},
registrations: map[string]registrationCacheEntry{
"64100005824": {vin: "LB9A32A2XR0LS1401", plate: "粤AGP9713"},
},
sources: map[string]sourceMetadata{},
})
// A reconnect normally authenticates before the first location report. The
// authentication frame has no VIN and therefore creates an unknown session.
if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "64100005824",
Parsed: map[string]any{},
}); err != nil {
t.Fatalf("resolve authentication frame: %v", err)
}
if entry := resolver.registrationCache["64100005824"]; entry.vin != "unknown" {
t.Fatalf("authentication session vin = %q, want unknown setup", entry.vin)
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "64100005824",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("resolve location frame: %v", err)
}
if resolved.VIN != "LB9A32A2XR0LS1401" || resolved.Plate != "粤AGP9713" {
t.Fatalf("resolved identity = vin:%q plate:%q", resolved.VIN, resolved.Plate)
}
identityMetadata, _ := resolved.Parsed["identity"].(map[string]any)
if identityMetadata["cache_status"] != "snapshot" {
t.Fatalf("identity metadata = %#v, want authoritative snapshot", identityMetadata)
}
if entry := resolver.registrationCache["64100005824"]; entry.vin != "LB9A32A2XR0LS1401" {
t.Fatalf("location frame did not heal session cache: %+v", entry)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("snapshot-only recovery should not query mysql: %v", err)
}
}
func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
@@ -145,7 +145,7 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
units = append(units, map[string]any{"type": "0x01", "name": "vehicle", "value": unit})
fields["vehicle_status"] = unit["vehicle_status"]
fields["charge_status"] = unit["charge_status"]
fields["running_mode"] = unit["running_mode"]
fields[envelope.FieldVehicleRunningMode] = unit["running_mode"]
fields[envelope.FieldSpeedKMH] = unit["speed_kmh"]
fields[envelope.FieldTotalMileageKM] = unit["total_mileage_km"]
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
@@ -197,6 +197,7 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
t.Fatalf("vin = %q", env.VIN)
}
assertFloatField(t, env, envelope.FieldTotalMileageKM, 53490.9)
assertIntField(t, env, envelope.FieldVehicleRunningMode, 1)
assertIntField(t, env, envelope.FieldFuelCellWorkMode, 2)
assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8)
assertFloatField(t, env, envelope.FieldLongitude, 120.800326)
@@ -54,6 +54,7 @@ type Writer struct {
baselineKeysByPrefix map[string]map[string]struct{}
lastGPSAccumulation map[string]time.Time
hydrogenTankCapacities map[string]float64
hydrogenEnergyParameters map[string]HydrogenRealtimeParameters
gpsAccumulationInterval time.Duration
}
@@ -170,6 +171,7 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
baselineKeysByPrefix: map[string]map[string]struct{}{},
lastGPSAccumulation: map[string]time.Time{},
hydrogenTankCapacities: map[string]float64{},
hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{},
gpsAccumulationInterval: defaultGPSAccumulationInterval,
}
if query, ok := exec.(Queryer); ok {
@@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
return capacity, ok
}
func (w *Writer) ReloadHydrogenRealtimeParameters(ctx context.Context) (int, error) {
date := time.Now().In(w.loc).Format("2006-01-02")
parameters, err := LoadHydrogenRealtimeParameters(ctx, w.query, date)
if err != nil {
return 0, err
}
w.mu.Lock()
w.hydrogenEnergyParameters = parameters
w.mu.Unlock()
return len(parameters), nil
}
func (w *Writer) HydrogenRealtimeParameters(vin string) HydrogenRealtimeParameters {
vin = strings.ToUpper(strings.TrimSpace(vin))
w.mu.Lock()
defer w.mu.Unlock()
return normalizeHydrogenRealtimeParameters(w.hydrogenEnergyParameters[vin])
}
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -302,7 +323,9 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
DailyMileageSourceTableSQL,
DailyMileageTableSQL,
HydrogenTankCapacityTableSQL,
HydrogenEnergyParameterTableSQL,
HydrogenStreamStateTableSQL,
HydrogenSegmentStreamStateTableSQL,
} {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
@@ -313,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
return err
}
}
for _, statement := range HydrogenRealtimeAlterSQL {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
return err
}
}
return nil
}
@@ -331,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env)
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN)
hydrogen, err := AppendHydrogenStream(ctx, w.exec, env, w.loc, time.Now(), capacityLiters)
hydrogenParams := w.HydrogenRealtimeParameters(env.VIN)
hydrogen, err := AppendHydrogenV35Realtime(ctx, w.exec, env, w.loc, time.Now(), capacityLiters, hydrogenParams)
if err != nil {
return result, err
}
@@ -433,6 +462,15 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return result, err
}
// A new natural-day/source row may start from the previous day's
// odometer baseline. Attribute that initial delta to the current
// running mode so pure-hydrogen mileage remains the complement of
// pure-electric mileage from the first accepted sample onward.
if candidate.QualityStatus == QualityOK &&
candidate.LatestPureHydrogenModeKnown &&
candidate.LatestPureHydrogenActive {
candidate.PureHydrogenMileageKM = candidate.DailyKM
}
}
projectDaily := w.shouldProjectDailyMileage(sample)
if projectDaily {
@@ -868,6 +906,16 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
return err
}
if !found {
protocolBaseline, protocolFound, protocolErr := lookupPreviousProtocolBaseline(ctx, w.query, candidate.VIN, candidate.StatDate, candidate.Protocol)
if protocolErr != nil {
return protocolErr
}
if protocolFound && candidate.LatestTotalKM+maxNegativeMileageJitterKM < protocolBaseline.LatestTotalKM {
candidate.DailyKM = 0
candidate.QualityStatus = QualityInvalidDelta
candidate.QualityReason = QualityReasonTotalRollback
return nil
}
// If no earlier odometer exists at all, use the first current-day sample.
// Later samples retain that boundary through the in-memory baseline cache.
candidate.DailyKM = 0
@@ -875,9 +923,25 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
candidate.QualityReason = QualityReasonCurrentDayFirst
return nil
}
if !IsUsableDailyMileageBoundary(candidate.StatDate, baseline.LatestEventTime, w.loc) {
// A vehicle recovering after one or more empty natural days must start a
// fresh day boundary. Otherwise the complete offline-period odometer
// increase is incorrectly assigned to the recovery day.
candidate.FirstTotalKM = candidate.LatestTotalKM
candidate.FirstEventTime = candidate.LatestEventTime
candidate.DailyKM = 0
candidate.QualityStatus = QualityOK
candidate.QualityReason = QualityReasonCurrentDayFirst
return nil
}
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.FirstEventTime = baseline.LatestEventTime
candidate.DailyKM = DailyMileageFromDayBoundary(baseline.LatestTotalKM, candidate.LatestTotalKM)
if candidate.LatestTotalKM+maxNegativeMileageJitterKM < baseline.LatestTotalKM {
candidate.QualityStatus = QualityInvalidDelta
candidate.QualityReason = QualityReasonTotalRollback
return nil
}
candidate.QualityStatus = QualityOK
candidate.QualityReason = baseline.QualityReason
if candidate.QualityReason == "" {
@@ -1054,6 +1054,9 @@ func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1138,6 +1141,9 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1447,7 +1453,7 @@ func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T
}
}
func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
func TestWriterAppendStartsCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1488,18 +1494,18 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
"",
"LMRKH9AC2R1004087",
"",
float64(120672.0),
float64(120788.0),
approxFloat64{want: 116.0, tolerance: 0.000001},
float64(120788.0),
float64(0),
float64(0),
int64(1),
int64(0),
historicalTime,
currentTime,
currentTime,
false,
false,
QualityOK,
QualityReasonHistorical,
QualityReasonCurrentDayFirst,
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
@@ -1608,7 +1614,7 @@ func TestWriterApplyRealtimeBaselineKeepsNegativeHistoricalDeltaInvalid(t *testi
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1651,7 +1657,7 @@ func TestWriterApplyRealtimeBaselineKeepsInvalidWhenCurrentDayBaselineIsStillHig
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1691,7 +1697,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1705,7 +1711,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
}
}
func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
func TestWriterApplyRealtimeBaselineIgnoresPlausibleMultiDayFallbackDelta(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1734,18 +1740,65 @@ func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *tes
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical {
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM < 7833.4 || candidate.DailyKM > 7833.6 {
t.Fatalf("daily km = %v, want multi-day gap delta", candidate.DailyKM)
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want current-day first baseline after offline gap", candidate.DailyKM)
}
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
t.Fatalf("current-day boundary not retained: %#v", candidate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(t *testing.T) {
func TestWriterApplyRealtimeBaselineRejectsProtocolOdometerRollbackAfterSourceChange(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
previousTime := time.Date(2026, 8, 5, 22, 41, 0, 0, loc)
currentTime := time.Date(2026, 8, 6, 8, 12, 0, 0, loc)
candidate := SourceMileageSample{
VIN: "LNXNEGRR1SR321395",
StatDate: "2026-08-06",
Protocol: envelope.ProtocolGB32960,
SourceKey: "GB32960:new-device@8.134.95.166",
LatestTotalKM: 0.1,
LatestEventTime: currentTime,
}
// The new device has no source-key baseline, but this VIN already has a
// durable GB32960 odometer. The reset must not become a NORMAL day start.
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LNXNEGRR1SR321395", "2026-08-06", "GB32960", "GB32960:new-device@8.134.95.166").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LNXNEGRR1SR321395", "2026-08-06", "GB32960").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
AddRow(48233.3, previousTime))
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s, want %s/%s", candidate.QualityStatus, candidate.QualityReason, QualityInvalidDelta, QualityReasonTotalRollback)
}
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want 0", candidate.DailyKM)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterApplyRealtimeBaselineIgnoresHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1774,14 +1827,14 @@ func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(t *testing.T)
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM < 29999.9 || candidate.DailyKM > 30000.1 {
t.Fatalf("daily km = %v, want historical jump delta", candidate.DailyKM)
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want current-day first baseline", candidate.DailyKM)
}
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
t.Fatalf("current-day boundary not retained: %#v", candidate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
@@ -1815,6 +1868,9 @@ func TestWriterAppendMarksCandidateNoPreviousBaselineWhenPreviousBaselineMissing
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1894,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
t.Fatalf("Append() error = %v", err)
}
schemaCalls := 6 + len(DailyMileageAlterSQL)
schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL)
if len(exec.calls) != schemaCalls+5 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
@@ -1904,7 +1960,9 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_tank_capacity",
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_energy_parameter",
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_stream_state",
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state",
} {
if !strings.Contains(exec.calls[i].query, want) {
t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want)
@@ -2294,6 +2352,9 @@ func TestWriterRollsBackMileageTransactionWhenProjectionFails(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -0,0 +1,142 @@
package stats
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
)
const HydrogenEnergyParameterTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_hydrogen_energy_parameter (
vin VARCHAR(64) NOT NULL,
battery_capacity_kwh DECIMAL(12,3) NOT NULL,
hydrogen_energy_kwh_per_kg DECIMAL(12,3) NOT NULL DEFAULT 16.000,
confirmed_by VARCHAR(96) NOT NULL DEFAULT '',
source_note VARCHAR(255) NOT NULL DEFAULT '',
effective_from DATE NOT NULL,
effective_to DATE NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin, effective_from),
KEY idx_hydrogen_energy_parameter_active (active, effective_from, effective_to, vin),
CONSTRAINT chk_hydrogen_energy_parameter_capacity CHECK (battery_capacity_kwh > 0 AND battery_capacity_kwh <= 500),
CONSTRAINT chk_hydrogen_energy_parameter_conversion CHECK (hydrogen_energy_kwh_per_kg > 0 AND hydrogen_energy_kwh_per_kg <= 50),
CONSTRAINT chk_hydrogen_energy_parameter_dates CHECK (effective_to IS NULL OR effective_to >= effective_from)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
const (
hydrogenEnergyAssetSourceNote = "ln_asset_management.vehicle_model.battery_capacity"
hydrogenEnergyAssetConfirmedBy = "资产车型主数据自动同步"
hydrogenEnergyAssetEffectiveAt = "1970-01-01"
)
type HydrogenEnergyParameterSyncResult struct {
Read int
Written int
Deactivated int64
}
type hydrogenEnergyParameterRow struct {
VIN string
SourceVehicleID int64
CapacityKWh float64
SourceUpdatedAt sql.NullTime
}
// SyncHydrogenEnergyParameters projects each active hydrogen vehicle's rated
// battery capacity from the asset model master into the VIN-scoped calculation
// parameter table. The fixed, oldest effective date lets a later effective-dated
// business override win without disabling automatic master-data synchronization.
func SyncHydrogenEnergyParameters(ctx context.Context, db *sql.DB, sourceSchema string) (HydrogenEnergyParameterSyncResult, error) {
result := HydrogenEnergyParameterSyncResult{}
if db == nil {
return result, fmt.Errorf("mysql database is required")
}
sourceSchema = strings.TrimSpace(sourceSchema)
if !mysqlIdentifier.MatchString(sourceSchema) {
return result, fmt.Errorf("invalid asset schema %q", sourceSchema)
}
query := fmt.Sprintf(`SELECT vi.id,UPPER(TRIM(vi.vin)),vm.battery_capacity,
CASE
WHEN vi.update_time IS NULL THEN vm.update_time
WHEN vm.update_time IS NULL THEN vi.update_time
ELSE GREATEST(vi.update_time,vm.update_time)
END
FROM %s.vehicle_info vi
JOIN %s.vehicle_model vm ON vm.id=vi.vehicle_model_id
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
AND LENGTH(TRIM(vi.vin))=17
AND vm.tank_capacity>0
AND vm.battery_capacity>0 AND vm.battery_capacity<=500`, sourceSchema, sourceSchema)
rows, err := db.QueryContext(ctx, query)
if err != nil {
return result, fmt.Errorf("read asset hydrogen energy parameters: %w", err)
}
byVIN := map[string]hydrogenEnergyParameterRow{}
for rows.Next() {
var row hydrogenEnergyParameterRow
if err := rows.Scan(&row.SourceVehicleID, &row.VIN, &row.CapacityKWh, &row.SourceUpdatedAt); err != nil {
rows.Close()
return result, err
}
result.Read++
if row.CapacityKWh <= 0 || row.CapacityKWh > 500 {
continue
}
if previous, exists := byVIN[row.VIN]; !exists || newerEnergyParameterRow(row, previous) {
byVIN[row.VIN] = row
}
}
if err := rows.Close(); err != nil {
return result, err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_hydrogen_energy_parameter
SET active=0
WHERE active=1 AND source_note=?`, hydrogenEnergyAssetSourceNote); err != nil {
return result, err
}
const upsert = `INSERT INTO vehicle_hydrogen_energy_parameter(
vin,battery_capacity_kwh,hydrogen_energy_kwh_per_kg,confirmed_by,source_note,effective_from,effective_to,active
) VALUES(?,?,16.000,?,?,?,NULL,1)
ON DUPLICATE KEY UPDATE battery_capacity_kwh=VALUES(battery_capacity_kwh),
confirmed_by=VALUES(confirmed_by),source_note=VALUES(source_note),effective_to=NULL,active=1`
vins := make([]string, 0, len(byVIN))
for vin := range byVIN {
vins = append(vins, vin)
}
sort.Strings(vins)
for _, vin := range vins {
row := byVIN[vin]
if _, err := tx.ExecContext(ctx, upsert, row.VIN, row.CapacityKWh, hydrogenEnergyAssetConfirmedBy, hydrogenEnergyAssetSourceNote, hydrogenEnergyAssetEffectiveAt); err != nil {
return result, err
}
result.Written++
}
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM vehicle_hydrogen_energy_parameter
WHERE active=0 AND source_note=?`, hydrogenEnergyAssetSourceNote).Scan(&result.Deactivated); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
return result, nil
}
func newerEnergyParameterRow(left, right hydrogenEnergyParameterRow) bool {
if left.SourceUpdatedAt.Valid != right.SourceUpdatedAt.Valid {
return left.SourceUpdatedAt.Valid
}
if left.SourceUpdatedAt.Valid && !left.SourceUpdatedAt.Time.Equal(right.SourceUpdatedAt.Time) {
return left.SourceUpdatedAt.Time.After(right.SourceUpdatedAt.Time)
}
return left.SourceVehicleID > right.SourceVehicleID
}
@@ -0,0 +1,95 @@
package stats
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestSyncHydrogenEnergyParametersCopiesAssetModelCapacity(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
updatedAt := time.Date(2026, 8, 26, 10, 0, 0, 0, time.Local)
mock.ExpectQuery("FROM ln_asset_management\\.vehicle_info vi").
WillReturnRows(sqlmock.NewRows([]string{"id", "vin", "battery_capacity", "updated_at"}).
AddRow(1001, "LB9A32A24R0LS1037", 21.04, updatedAt).
AddRow(1002, "LB9A52124P0LS1001", 58.73, updatedAt))
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("UPDATE vehicle_hydrogen_energy_parameter")).
WithArgs(hydrogenEnergyAssetSourceNote).
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_hydrogen_energy_parameter(")).
WithArgs("LB9A32A24R0LS1037", 21.04, hydrogenEnergyAssetConfirmedBy, hydrogenEnergyAssetSourceNote, hydrogenEnergyAssetEffectiveAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_hydrogen_energy_parameter(")).
WithArgs("LB9A52124P0LS1001", 58.73, hydrogenEnergyAssetConfirmedBy, hydrogenEnergyAssetSourceNote, hydrogenEnergyAssetEffectiveAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*)")).
WithArgs(hydrogenEnergyAssetSourceNote).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectCommit()
result, err := SyncHydrogenEnergyParameters(context.Background(), db, "ln_asset_management")
if err != nil {
t.Fatal(err)
}
if result.Read != 2 || result.Written != 2 || result.Deactivated != 0 {
t.Fatalf("result=%+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSyncHydrogenEnergyParametersKeepsNewestDuplicateVIN(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
oldTime := time.Date(2026, 8, 20, 10, 0, 0, 0, time.Local)
newTime := oldTime.Add(24 * time.Hour)
mock.ExpectQuery("FROM ln_asset_management\\.vehicle_info vi").
WillReturnRows(sqlmock.NewRows([]string{"id", "vin", "battery_capacity", "updated_at"}).
AddRow(1001, "LB9A32A24R0LS1037", 20.00, oldTime).
AddRow(1002, "LB9A32A24R0LS1037", 21.04, newTime))
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("UPDATE vehicle_hydrogen_energy_parameter")).
WithArgs(hydrogenEnergyAssetSourceNote).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_hydrogen_energy_parameter(")).
WithArgs("LB9A32A24R0LS1037", 21.04, hydrogenEnergyAssetConfirmedBy, hydrogenEnergyAssetSourceNote, hydrogenEnergyAssetEffectiveAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*)")).
WithArgs(hydrogenEnergyAssetSourceNote).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectCommit()
result, err := SyncHydrogenEnergyParameters(context.Background(), db, "ln_asset_management")
if err != nil {
t.Fatal(err)
}
if result.Read != 2 || result.Written != 1 {
t.Fatalf("result=%+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSyncHydrogenEnergyParametersRejectsUnsafeSchema(t *testing.T) {
db, _, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := SyncHydrogenEnergyParameters(context.Background(), db, "ln_asset_management;DROP"); err == nil {
t.Fatal("unsafe schema accepted")
}
}
@@ -3,7 +3,10 @@ package stats
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math"
"sort"
"strings"
"time"
@@ -14,8 +17,14 @@ import (
const (
defaultHydrogenNoiseKG = 0.05
defaultHydrogenMaxDropKG = 20.0
hydrogenActiveCurrentA = 1.0
hydrogenMolarMassKGPerMol = 0.00201588
universalGasConstant = 8.314472
hydrogenSegmentWindowSize = 5
hydrogenSegmentMinSamples = hydrogenSegmentWindowSize * 2
hydrogenSegmentMaxGap = 5 * time.Minute
hydrogenRecoveryWindow = time.Minute
hydrogenRecoveryPressure = 8.0
)
var hydrogenPressureFieldKeys = []string{
@@ -28,6 +37,11 @@ var hydrogenTemperatureFieldKeys = []string{
"fuel_cell_max_hydrogen_temperature_c",
}
var hydrogenCurrentFieldKeys = []string{
"gb32960.fuel_cell.fuel_cell_current_a",
"fuel_cell_current_a",
}
var hydrogenDensityA = [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
var hydrogenDensityB = [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
var hydrogenDensityC = [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
@@ -52,6 +66,8 @@ const HydrogenStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hyd
first_event_time DATETIME(3) NOT NULL,
last_event_time DATETIME(3) NOT NULL,
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
last_fuel_cell_active TINYINT(1) NOT NULL DEFAULT 0,
last_fuel_cell_state_known TINYINT(1) NOT NULL DEFAULT 0,
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效压力质量样本不足2条',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -60,6 +76,31 @@ const HydrogenStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hyd
KEY idx_hydrogen_stream_date_quality (stat_date, quality_status, vin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state (
vin VARCHAR(64) NOT NULL,
stat_date DATE NOT NULL,
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
finalized_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
projected_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
sample_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
refuel_count INT UNSIGNED NOT NULL DEFAULT 0,
abnormal_drop_count INT UNSIGNED NOT NULL DEFAULT 0,
eligible_interval_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
qualified_segment_count INT UNSIGNED NOT NULL DEFAULT 0,
last_mass_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
last_event_time DATETIME(3) NOT NULL,
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
state_json JSON NOT NULL,
calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin, stat_date),
KEY idx_hydrogen_segment_stream_date_quality (stat_date, quality_status, vin),
CONSTRAINT chk_hydrogen_segment_stream_quality CHECK (quality_status IN ('OK', 'NO_DATA', 'SUSPECT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
type HydrogenStreamSample struct {
VIN string
Date string
@@ -72,6 +113,21 @@ type HydrogenStreamSample struct {
TemperatureC float64
NoiseKG float64
RefuelThresholdKG float64
FuelCellActive bool
FuelCellStateKnown bool
ConsumptionEligible bool
MileageKM float64
MileageKnown bool
SOCPercent float64
SOCKnown bool
VehicleState int
VehicleStateKnown bool
ChargeState int
ChargeStateKnown bool
RunningMode int
RunningModeKnown bool
FuelCellPowerKW float64
FuelCellPowerKnown bool
}
type HydrogenStreamResult struct {
@@ -82,6 +138,268 @@ type HydrogenStreamResult struct {
NotCurrent int
}
type hydrogenStreamEndpoint struct {
MassKG float64 `json:"massKg"`
NoiseKG float64 `json:"noiseKg"`
}
type hydrogenStreamSegmentWindow struct {
Count int64 `json:"count"`
First []hydrogenStreamEndpoint `json:"first"`
Tail []hydrogenStreamEndpoint `json:"tail"`
CycleMinimumMassKG float64 `json:"cycleMinimumMassKg"`
}
type hydrogenSegmentStreamState struct {
SourceEndpoint string `json:"sourceEndpoint"`
FinalizedConsumptionKG float64 `json:"finalizedConsumptionKg"`
SampleCount int64 `json:"sampleCount"`
RefuelCount int64 `json:"refuelCount"`
AbnormalDropCount int64 `json:"abnormalDropCount"`
EligibleIntervalCount int64 `json:"eligibleIntervalCount"`
QualifiedSegmentCount int64 `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 hydrogenStreamSegmentWindow `json:"segment"`
}
type hydrogenStreamBaseline struct {
SourceEndpoint string
ConsumptionKG float64
FirstMassKG float64
LastMassKG float64
SampleCount int64
RefuelCount int64
QualityStatus string
}
func newHydrogenSegmentStreamState(sample HydrogenStreamSample, baseline *hydrogenStreamBaseline) hydrogenSegmentStreamState {
state := hydrogenSegmentStreamState{
SourceEndpoint: sample.SourceEndpoint,
FirstMassKG: sample.MassKG, RemainingMassKG: sample.MassKG,
MetadataCycleMinimumKG: sample.MassKG, LastObservedMassKG: sample.MassKG,
TankCapacityLiters: sample.TankCapacityLiters,
FirstPressureMPa: sample.PressureMPa, LastPressureMPa: sample.PressureMPa,
FirstTemperatureC: sample.TemperatureC, LastTemperatureC: sample.TemperatureC,
FirstEventTime: sample.EventTime, LastEventTime: sample.EventTime, LastEventID: sample.EventID,
LastFuelCellActive: sample.FuelCellActive, LastFuelCellStateKnown: sample.FuelCellStateKnown,
SampleCount: 1,
}
if baseline != nil {
state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, baseline.SourceEndpoint)
state.FinalizedConsumptionKG = baseline.ConsumptionKG
state.SampleCount += baseline.SampleCount
state.RefuelCount = baseline.RefuelCount
if baseline.FirstMassKG >= 0 {
state.FirstMassKG = baseline.FirstMassKG
}
if baseline.LastMassKG >= 0 {
state.RemainingMassKG = baseline.LastMassKG
}
if baseline.QualityStatus == "OK" || baseline.QualityStatus == "SUSPECT" {
state.EligibleIntervalCount = 1
state.QualifiedSegmentCount = 1
}
if baseline.QualityStatus == "SUSPECT" {
state.AbnormalDropCount = 1
}
}
if hydrogenStreamCanStartSegment(sample) {
state.startSegment(sample)
}
return state
}
func (state *hydrogenSegmentStreamState) add(sample HydrogenStreamSample) bool {
if !sample.EventTime.After(state.LastEventTime) {
return false
}
previousTime := state.LastEventTime
previousMass := state.LastObservedMassKG
previousPressure := state.LastPressureMPa
intervalEligible := hydrogenStreamIntervalEligible(
state.LastFuelCellActive, state.LastFuelCellStateKnown,
sample.FuelCellActive, sample.FuelCellStateKnown,
)
if intervalEligible || (sample.FuelCellStateKnown && sample.FuelCellActive) ||
sample.MassKG-state.RemainingMassKG > sample.NoiseKG {
state.RemainingMassKG = sample.MassKG
}
delta := state.MetadataCycleMinimumKG - sample.MassKG
switch {
case sample.MassKG-state.MetadataCycleMinimumKG > sample.RefuelThresholdKG:
if !hydrogenStreamRapidRecovery(previousTime, previousPressure, sample) {
state.RefuelCount++
}
state.MetadataCycleMinimumKG = sample.MassKG
case delta > sample.NoiseKG && delta <= defaultHydrogenMaxDropKG:
state.MetadataCycleMinimumKG = sample.MassKG
}
if intervalEligible {
state.EligibleIntervalCount++
}
switch {
case state.Segment.Count == 0 || !intervalEligible:
state.flushSegment()
if hydrogenStreamCanStartSegment(sample) {
state.startSegment(sample)
}
case sample.EventTime.Sub(previousTime) > hydrogenSegmentMaxGap:
state.flushSegment()
state.startSegment(sample)
case previousMass-sample.MassKG > defaultHydrogenMaxDropKG:
state.AbnormalDropCount++
state.flushSegment()
state.startSegment(sample)
case sample.MassKG-state.Segment.CycleMinimumMassKG > sample.RefuelThresholdKG:
state.flushSegment()
state.startSegment(sample)
default:
state.appendSegmentSample(sample)
if sample.MassKG < state.Segment.CycleMinimumMassKG {
state.Segment.CycleMinimumMassKG = sample.MassKG
}
}
state.SourceEndpoint = sample.SourceEndpoint
state.SampleCount++
state.LastObservedMassKG = sample.MassKG
state.TankCapacityLiters = sample.TankCapacityLiters
state.LastPressureMPa = sample.PressureMPa
state.LastTemperatureC = sample.TemperatureC
state.LastEventTime = sample.EventTime
state.LastEventID = sample.EventID
state.LastFuelCellActive = sample.FuelCellActive
state.LastFuelCellStateKnown = sample.FuelCellStateKnown
return true
}
func (state *hydrogenSegmentStreamState) startSegment(sample HydrogenStreamSample) {
state.Segment = hydrogenStreamSegmentWindow{CycleMinimumMassKG: sample.MassKG}
state.appendSegmentSample(sample)
}
func (state *hydrogenSegmentStreamState) appendSegmentSample(sample HydrogenStreamSample) {
endpoint := hydrogenStreamEndpoint{MassKG: sample.MassKG, NoiseKG: sample.NoiseKG}
state.Segment.Count++
if len(state.Segment.First) < hydrogenSegmentWindowSize {
state.Segment.First = append(state.Segment.First, endpoint)
}
if len(state.Segment.Tail) == hydrogenSegmentWindowSize {
copy(state.Segment.Tail, state.Segment.Tail[1:])
state.Segment.Tail[len(state.Segment.Tail)-1] = endpoint
return
}
state.Segment.Tail = append(state.Segment.Tail, endpoint)
}
func (state *hydrogenSegmentStreamState) flushSegment() {
if state.Segment.Count >= hydrogenSegmentMinSamples {
state.QualifiedSegmentCount++
state.FinalizedConsumptionKG += hydrogenStreamEndpointDrop(state.Segment.First, state.Segment.Tail)
}
state.Segment = hydrogenStreamSegmentWindow{}
}
func (state hydrogenSegmentStreamState) projectedConsumptionKG() float64 {
consumption := state.FinalizedConsumptionKG
if state.Segment.Count >= hydrogenSegmentMinSamples {
consumption += hydrogenStreamEndpointDrop(state.Segment.First, state.Segment.Tail)
}
return roundHydrogenKG(consumption)
}
func (state hydrogenSegmentStreamState) quality() (string, string) {
qualified := state.QualifiedSegmentCount
if state.Segment.Count >= hydrogenSegmentMinSamples {
qualified++
}
switch {
case state.SampleCount < 2:
return "NO_DATA", "有效车载氢量样本不足2条"
case state.EligibleIntervalCount == 0:
return "NO_DATA", "无燃料电池工作状态下的有效压力区间"
case qualified == 0:
return "NO_DATA", "有效工作段关键帧不足10条"
case state.AbnormalDropCount > 0:
return "SUSPECT", "存在超过阈值的异常下降"
default:
return "OK", ""
}
}
func hydrogenStreamCanStartSegment(sample HydrogenStreamSample) bool {
return !sample.FuelCellStateKnown || sample.FuelCellActive
}
func hydrogenStreamIntervalEligible(previousActive, previousKnown, currentActive, currentKnown bool) bool {
if previousKnown || currentKnown {
return previousKnown && previousActive && currentKnown && currentActive
}
return true
}
func hydrogenStreamRapidRecovery(previousTime time.Time, previousPressure float64, sample HydrogenStreamSample) bool {
elapsed := sample.EventTime.Sub(previousTime)
return elapsed > 0 && elapsed <= hydrogenRecoveryWindow &&
sample.PressureMPa-previousPressure >= hydrogenRecoveryPressure
}
func hydrogenStreamEndpointDrop(first, tail []hydrogenStreamEndpoint) float64 {
if len(first) < hydrogenSegmentWindowSize || len(tail) < hydrogenSegmentWindowSize {
return 0
}
startMass := hydrogenStreamMedianMass(first)
endMass := hydrogenStreamMedianMass(tail)
noise := defaultHydrogenNoiseKG
for _, endpoint := range first {
noise = math.Max(noise, endpoint.NoiseKG)
}
for _, endpoint := range tail {
noise = math.Max(noise, endpoint.NoiseKG)
}
drop := startMass - endMass
if drop <= noise {
return 0
}
return drop
}
func hydrogenStreamMedianMass(values []hydrogenStreamEndpoint) float64 {
masses := make([]float64, len(values))
for index, value := range values {
masses[index] = value.MassKG
}
sort.Float64s(masses)
return masses[len(masses)/2]
}
func roundHydrogenKG(value float64) float64 {
return math.Round(value*1000) / 1000
}
func firstNonEmptyHydrogen(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func HydrogenDensityKGPerM3(pressureMPa, temperatureC float64) (float64, bool) {
temperatureK := temperatureC + 273.15
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 {
@@ -126,6 +444,9 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
if !temperatureOK {
return HydrogenStreamSample{}, "missing_temperature", false
}
if !validHydrogenPressureTemperature(pressure, temperature) {
return HydrogenStreamSample{}, "invalid_pressure_temperature", false
}
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
return HydrogenStreamSample{}, "missing_tank_capacity", false
}
@@ -137,6 +458,33 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
noiseKG := math.Max(defaultHydrogenNoiseKG, mass-pressureStepMass)
noiseKG = math.Min(noiseKG, 1.0)
refuelThresholdKG := math.Max(1.0, mass*0.05)
fuelCellActive, fuelCellStateKnown := FuelCellActiveModeFromEnvelope(env)
if !fuelCellStateKnown {
if currentA, currentOK := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys); currentOK && currentA >= 0 {
fuelCellActive = currentA > hydrogenActiveCurrentA
fuelCellStateKnown = true
}
}
mileageKM, mileageKnown := firstHydrogenNumber(env.Fields, []string{
envelope.FieldTotalMileageKM, "gb32960.vehicle.total_mileage_km",
})
socPercent, socKnown := firstHydrogenNumber(env.Fields, []string{
envelope.FieldSOCPercent, "gb32960.vehicle.soc_percent",
})
vehicleStateValue, vehicleStateKnown := firstHydrogenNumber(env.Fields, []string{
"vehicle_status", "gb32960.vehicle.vehicle_status",
})
chargeStateValue, chargeStateKnown := firstHydrogenNumber(env.Fields, []string{
"charge_status", "gb32960.vehicle.charge_status",
})
runningModeValue, runningModeKnown := firstHydrogenNumber(env.Fields, []string{
envelope.FieldVehicleRunningMode, "gb32960.vehicle.running_mode",
})
fuelCellVoltage, voltageKnown := firstHydrogenNumber(env.Fields, []string{
"fuel_cell_voltage_v", "gb32960.fuel_cell.fuel_cell_voltage_v",
})
fuelCellCurrent, currentKnown := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys)
fuelCellPowerKnown := voltageKnown && currentKnown && fuelCellVoltage >= 0 && fuelCellCurrent >= 0
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
@@ -157,9 +505,23 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
EventID: env.StableEventID(), EventTime: eventTime, MassKG: mass,
TankCapacityLiters: tankCapacityLiters, PressureMPa: pressure, TemperatureC: temperature,
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
ConsumptionEligible: !fuelCellStateKnown || fuelCellActive,
MileageKM: mileageKM, MileageKnown: mileageKnown,
SOCPercent: socPercent, SOCKnown: socKnown && socPercent >= 0 && socPercent <= 100,
VehicleState: int(vehicleStateValue), VehicleStateKnown: vehicleStateKnown,
ChargeState: int(chargeStateValue), ChargeStateKnown: chargeStateKnown,
RunningMode: int(runningModeValue), RunningModeKnown: runningModeKnown,
FuelCellPowerKW: fuelCellVoltage * fuelCellCurrent / 1000, FuelCellPowerKnown: fuelCellPowerKnown,
}, "", true
}
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 firstHydrogenNumber(fields map[string]any, keys []string) (float64, bool) {
for _, key := range keys {
if value, ok := telemetry.Number(fields, key); ok {
@@ -193,23 +555,32 @@ func AppendHydrogenStream(ctx context.Context, exec Execer, env envelope.FrameEn
return result, err
}
defer tx.Rollback()
write, err := tx.ExecContext(ctx, upsertHydrogenStreamStateSQL,
sample.VIN, sample.Date, sample.SourceEndpoint, sample.MassKG, sample.MassKG, sample.MassKG,
sample.TankCapacityLiters, sample.PressureMPa, sample.PressureMPa,
sample.TemperatureC, sample.TemperatureC, sample.EventTime, sample.EventTime, sample.EventID,
sample.NoiseKG, defaultHydrogenMaxDropKG, sample.RefuelThresholdKG,
defaultHydrogenMaxDropKG, defaultHydrogenMaxDropKG, defaultHydrogenMaxDropKG,
sample.RefuelThresholdKG, sample.NoiseKG, defaultHydrogenMaxDropKG,
)
state, found, err := selectHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date)
if err != nil {
return result, err
}
affected, _ := write.RowsAffected()
if affected == 0 {
if found {
if !state.add(sample) {
result.Duplicate = 1
return result, tx.Commit()
}
if _, err := tx.ExecContext(ctx, projectHydrogenStreamDailySQL, sample.VIN, sample.Date); err != nil {
if err := updateHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
} else {
baseline, baselineFound, err := selectHydrogenStreamBaseline(ctx, tx, sample.VIN, sample.Date)
if err != nil {
return result, err
}
if !baselineFound {
baseline = nil
}
state = newHydrogenSegmentStreamState(sample, baseline)
if err := insertHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
}
if err := projectHydrogenSegmentStreamDaily(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
@@ -219,50 +590,114 @@ func AppendHydrogenStream(ctx context.Context, exec Execer, env envelope.FrameEn
return result, nil
}
const upsertHydrogenStreamStateSQL = `
INSERT INTO vehicle_open_hydrogen_stream_state(
vin,stat_date,source_endpoint,first_mass_kg,last_mass_kg,cycle_min_mass_kg,consumption_kg,sample_count,
refuel_count,abnormal_drop_count,tank_capacity_l,first_pressure_mpa,last_pressure_mpa,
first_temperature_c,last_temperature_c,calculation_method,
first_event_time,last_event_time,last_event_id,quality_status,quality_reason
) VALUES(?,?,?,?,?,?,0,1,0,0,?,?,?,?,?,'PRESSURE_NIST',?,?,?,'NO_DATA','有效压力质量样本不足2条')
ON DUPLICATE KEY UPDATE
consumption_kg = consumption_kg + IF(VALUES(last_event_time)>last_event_time
AND cycle_min_mass_kg-VALUES(last_mass_kg)>? AND cycle_min_mass_kg-VALUES(last_mass_kg)<=?,
cycle_min_mass_kg-VALUES(last_mass_kg),0),
refuel_count = refuel_count + IF(VALUES(last_event_time)>last_event_time
AND VALUES(last_mass_kg)-cycle_min_mass_kg>?,1,0),
quality_status = IF(VALUES(last_event_time)<=last_event_time,quality_status,
IF(abnormal_drop_count+IF(cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0)>0,'SUSPECT','OK')),
quality_reason = IF(VALUES(last_event_time)<=last_event_time,quality_reason,
IF(abnormal_drop_count+IF(cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0)>0,'存在超过阈值的异常下降','')),
abnormal_drop_count = abnormal_drop_count + IF(VALUES(last_event_time)>last_event_time
AND cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0),
cycle_min_mass_kg = IF(VALUES(last_event_time)>last_event_time,
IF(VALUES(last_mass_kg)-cycle_min_mass_kg>?,VALUES(last_mass_kg),
IF(cycle_min_mass_kg-VALUES(last_mass_kg)>? AND cycle_min_mass_kg-VALUES(last_mass_kg)<=?,VALUES(last_mass_kg),cycle_min_mass_kg)),
cycle_min_mass_kg),
sample_count = sample_count + IF(VALUES(last_event_time)>last_event_time,1,0),
tank_capacity_l = IF(VALUES(last_event_time)>last_event_time,VALUES(tank_capacity_l),tank_capacity_l),
last_pressure_mpa = IF(VALUES(last_event_time)>last_event_time,VALUES(last_pressure_mpa),last_pressure_mpa),
last_temperature_c = IF(VALUES(last_event_time)>last_event_time,VALUES(last_temperature_c),last_temperature_c),
calculation_method = 'PRESSURE_NIST',
last_mass_kg = IF(VALUES(last_event_time)>last_event_time,VALUES(last_mass_kg),last_mass_kg),
last_event_id = IF(VALUES(last_event_time)>last_event_time,VALUES(last_event_id),last_event_id),
last_event_time = GREATEST(last_event_time,VALUES(last_event_time))`
func selectHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenSegmentStreamState, bool, error) {
var encoded []byte
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
if errors.Is(err, sql.ErrNoRows) {
return hydrogenSegmentStreamState{}, false, nil
}
if err != nil {
return hydrogenSegmentStreamState{}, false, err
}
var state hydrogenSegmentStreamState
if err := json.Unmarshal(encoded, &state); err != nil {
return hydrogenSegmentStreamState{}, false, err
}
return state, true, nil
}
const projectHydrogenStreamDailySQL = `
INSERT INTO vehicle_open_daily_energy(
func selectHydrogenStreamBaseline(ctx context.Context, tx *sql.Tx, vin, date string) (*hydrogenStreamBaseline, bool, error) {
var baseline hydrogenStreamBaseline
var firstMass, lastMass sql.NullFloat64
err := tx.QueryRowContext(ctx, selectHydrogenStreamBaselineSQL, vin, date).Scan(
&baseline.SourceEndpoint, &baseline.ConsumptionKG, &firstMass, &lastMass,
&baseline.SampleCount, &baseline.RefuelCount, &baseline.QualityStatus,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
baseline.FirstMassKG = -1
baseline.LastMassKG = -1
if firstMass.Valid {
baseline.FirstMassKG = firstMass.Float64
}
if lastMass.Valid {
baseline.LastMassKG = lastMass.Float64
}
return &baseline, true, nil
}
func insertHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
qualityStatus, qualityReason := state.quality()
_, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL,
vin, date, state.SourceEndpoint, state.FinalizedConsumptionKG, state.projectedConsumptionKG(),
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EligibleIntervalCount,
state.QualifiedSegmentCount, state.RemainingMassKG, state.LastEventTime, state.LastEventID,
encoded, qualityStatus, qualityReason,
)
return err
}
func updateHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
qualityStatus, qualityReason := state.quality()
_, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL,
state.SourceEndpoint, state.FinalizedConsumptionKG, state.projectedConsumptionKG(),
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EligibleIntervalCount,
state.QualifiedSegmentCount, state.RemainingMassKG, state.LastEventTime, state.LastEventID,
encoded, qualityStatus, qualityReason, vin, date,
)
return err
}
func projectHydrogenSegmentStreamDaily(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
qualityStatus, qualityReason := state.quality()
_, err := tx.ExecContext(ctx, projectHydrogenSegmentStreamDailySQL,
vin, date, state.SourceEndpoint, state.projectedConsumptionKG(),
state.FirstMassKG, state.RemainingMassKG, state.SampleCount, state.RefuelCount,
qualityStatus, qualityReason,
)
return err
}
const selectHydrogenSegmentStreamStateSQL = `SELECT state_json
FROM vehicle_open_hydrogen_segment_stream_state
WHERE vin=? AND stat_date=?
FOR UPDATE`
const selectHydrogenStreamBaselineSQL = `SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,
sample_count,refuel_count,quality_status
FROM vehicle_open_daily_energy
WHERE vin=? AND stat_date=? AND energy_type='HYDROGEN'
LIMIT 1`
const insertHydrogenSegmentStreamStateSQL = `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_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`
const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state
SET 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='PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',quality_status=?,quality_reason=?
WHERE vin=? AND stat_date=?`
const projectHydrogenSegmentStreamDailySQL = `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
)
SELECT vin,stat_date,'HYDROGEN',source_endpoint,consumption_kg,'kg',first_mass_kg,last_mass_kg,
sample_count,refuel_count,quality_status,quality_reason,NOW(3)
FROM vehicle_open_hydrogen_stream_state
WHERE vin=? AND stat_date=?
ORDER BY CASE quality_status WHEN 'OK' THEN 0 WHEN 'SUSPECT' THEN 1 ELSE 2 END,
sample_count DESC,source_endpoint ASC
LIMIT 1
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))
ON DUPLICATE KEY UPDATE
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),unit='kg',
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),
@@ -2,8 +2,10 @@ package stats
import (
"context"
"database/sql"
"math"
"regexp"
"strings"
"testing"
"time"
@@ -47,6 +49,52 @@ func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) {
}
}
func TestHydrogenStreamSampleExtractsV35Inputs(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 9, 4, 10, 30, 0, 0, loc)
env := pressureEnvelope(eventTime)
env.Fields[envelope.FieldTotalMileageKM] = 12005.6
env.Fields[envelope.FieldSOCPercent] = 72.5
env.Fields[envelope.FieldVehicleRunningMode] = 2
env.Fields[envelope.FieldFuelCellWorkMode] = 2
env.Fields["vehicle_status"] = 1
env.Fields["charge_status"] = 3
env.Fields["fuel_cell_voltage_v"] = 480.0
env.Fields["fuel_cell_current_a"] = 100.0
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
if !ok || reason != "" {
t.Fatalf("sample rejected: reason=%q", reason)
}
if !sample.MileageKnown || sample.MileageKM != 12005.6 || !sample.SOCKnown || sample.SOCPercent != 72.5 ||
!sample.VehicleStateKnown || sample.VehicleState != 1 || !sample.ChargeStateKnown || sample.ChargeState != 3 ||
!sample.RunningModeKnown || sample.RunningMode != 2 || !sample.FuelCellPowerKnown || sample.FuelCellPowerKW != 48 {
t.Fatalf("V3.5 fields=%#v", sample)
}
}
func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
env := pressureEnvelope(eventTime)
env.Fields[envelope.FieldFuelCellWorkMode] = 0
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
if !ok || reason != "" || !sample.FuelCellStateKnown || sample.FuelCellActive || sample.ConsumptionEligible {
t.Fatalf("inactive sample classification=%#v reason=%q ok=%v", sample, reason, ok)
}
}
func TestHydrogenStreamFallsBackToFuelCellCurrent(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
env := pressureEnvelope(eventTime)
delete(env.Fields, envelope.FieldFuelCellWorkMode)
env.Fields["fuel_cell_current_a"] = 0.2
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
if !ok || reason != "" || !sample.FuelCellStateKnown || sample.FuelCellActive || sample.ConsumptionEligible {
t.Fatalf("current fallback classification=%#v reason=%q ok=%v", sample, reason, ok)
}
}
func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
@@ -56,6 +104,20 @@ func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
}
}
func TestHydrogenStreamRejectsInvalidPressureTemperaturePlaceholders(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 8, 12, 8, 11, 16, 0, loc)
for _, fields := range []struct{ pressure, temperature float64 }{{0, 30}, {22.8, -40}} {
env := pressureEnvelope(eventTime)
env.Fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"] = fields.pressure
env.Fields["gb32960.fuel_cell.max_hydrogen_temperature_c"] = fields.temperature
_, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
if ok || reason != "invalid_pressure_temperature" {
t.Fatalf("placeholder pressure=%v temperature=%v accepted: ok=%v reason=%q", fields.pressure, fields.temperature, ok, reason)
}
}
}
func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
density, ok := HydrogenDensityKGPerM3(10, 26.85)
if !ok || math.Abs(density-7.625) > 0.01 {
@@ -63,6 +125,144 @@ func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
}
}
func hydrogenSegmentTestSample(at time.Time, mass float64, source string, active bool) HydrogenStreamSample {
return HydrogenStreamSample{
VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: source,
EventID: at.Format(time.RFC3339Nano), EventTime: at, MassKG: mass,
TankCapacityLiters: 520, PressureMPa: mass, TemperatureC: 30,
NoiseKG: 0.05, RefuelThresholdKG: 1,
FuelCellActive: active, FuelCellStateKnown: true, ConsumptionEligible: active,
}
}
func TestHydrogenSegmentStreamUsesEndpointMediansAcrossSources(t *testing.T) {
base := time.Date(2026, 8, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
for index := 1; index < 10; index++ {
mass := 10.0
if index >= 5 {
mass = 9.6
}
source := "source-a"
if index >= 6 {
source = "source-b"
}
if !state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, source, true)) {
t.Fatalf("sample %d rejected", index)
}
}
quality, reason := state.quality()
if state.projectedConsumptionKG() != 0.4 || quality != "OK" || reason != "" || state.SampleCount != 10 {
t.Fatalf("state=%#v consumption=%.3f quality=%s reason=%q", state, state.projectedConsumptionKG(), quality, reason)
}
if state.SourceEndpoint != "source-b" || len(state.Segment.First) != 5 || len(state.Segment.Tail) != 5 {
t.Fatalf("endpoint/window state=%#v", state)
}
}
func TestHydrogenSegmentStreamFinalizesOnInactiveFrame(t *testing.T) {
base := time.Now()
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
for index := 1; index < 10; index++ {
mass := 10.0
if index >= 5 {
mass = 9.6
}
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-a", true))
}
state.add(hydrogenSegmentTestSample(base.Add(10*time.Second), 9.5, "source-a", false))
if math.Abs(state.FinalizedConsumptionKG-0.4) > 0.0001 || state.Segment.Count != 0 || state.QualifiedSegmentCount != 1 {
t.Fatalf("state=%#v", state)
}
}
func TestHydrogenSegmentStreamIgnoresOutOfOrderAndKeepsBoundedWindow(t *testing.T) {
base := time.Now()
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 20, "source-a", true), nil)
if state.add(hydrogenSegmentTestSample(base, 19, "source-b", true)) {
t.Fatal("same-time duplicate was accepted")
}
for index := 1; index <= 10000; index++ {
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 20-float64(index)/20000, "source-b", true))
}
if len(state.Segment.First) != 5 || len(state.Segment.Tail) != 5 || state.SampleCount != 10001 {
t.Fatalf("first=%d tail=%d samples=%d", len(state.Segment.First), len(state.Segment.Tail), state.SampleCount)
}
}
func TestHydrogenSegmentStreamInactiveDropDoesNotConsume(t *testing.T) {
base := time.Now()
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", false), nil)
for index := 1; index < 20; index++ {
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 10-float64(index)/10, "source-a", false))
}
quality, _ := state.quality()
if state.projectedConsumptionKG() != 0 || state.RemainingMassKG != 10 || quality != "NO_DATA" {
t.Fatalf("state=%#v quality=%s", state, quality)
}
}
func TestHydrogenSegmentStreamSplitsAndAccumulatesRefuelCycles(t *testing.T) {
base := time.Now()
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
for index := 1; index < 10; index++ {
mass := 10.0
if index >= 5 {
mass = 9.6
}
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-a", true))
}
state.add(hydrogenSegmentTestSample(base.Add(10*time.Second), 12, "source-b", true))
for index := 11; index < 20; index++ {
mass := 12.0
if index >= 15 {
mass = 11.7
}
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-b", true))
}
if got := state.projectedConsumptionKG(); math.Abs(got-0.7) > 0.0001 {
t.Fatalf("consumption=%.3f state=%#v", got, state)
}
if state.QualifiedSegmentCount != 1 || state.RefuelCount != 1 || state.Segment.Count != 10 {
t.Fatalf("state=%#v", state)
}
}
func TestHydrogenSegmentStreamFiltersAbnormalDrop(t *testing.T) {
base := time.Now()
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 30, "source-a", true), nil)
state.add(hydrogenSegmentTestSample(base.Add(time.Second), 5, "source-a", true))
for index := 2; index < 12; index++ {
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 5, "source-a", true))
}
quality, _ := state.quality()
if state.AbnormalDropCount != 1 || state.projectedConsumptionKG() != 0 || quality != "SUSPECT" {
t.Fatalf("state=%#v quality=%s", state, quality)
}
}
func TestHydrogenSegmentStreamSeedsDayEndBaselineWithoutRecounting(t *testing.T) {
base := time.Now()
baseline := &hydrogenStreamBaseline{
SourceEndpoint: "source-a", ConsumptionKG: 3.2, FirstMassKG: 14,
LastMassKG: 10, SampleCount: 100, RefuelCount: 1, QualityStatus: "OK",
}
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-b", true), baseline)
for index := 1; index < 10; index++ {
mass := 10.0
if index >= 5 {
mass = 9.8
}
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-b", true))
}
if got := state.projectedConsumptionKG(); math.Abs(got-3.4) > 0.0001 {
t.Fatalf("consumption=%.3f state=%#v", got, state)
}
if state.FirstMassKG != 14 || state.SampleCount != 110 || state.RefuelCount != 1 {
t.Fatalf("state=%#v", state)
}
}
func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -72,14 +272,20 @@ func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
2050.0, 6.0, 6.0, 44.0, 44.0, eventTime, eventTime, "event-1",
sqlmock.AnyArg(), 20.0, sqlmock.AnyArg(), 20.0, 20.0, 20.0,
sqlmock.AnyArg(), sqlmock.AnyArg(), 20.0).
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000",
sqlmock.AnyArg(), sqlmock.AnyArg(), int64(1), int64(0), int64(0), int64(0), int64(0),
sqlmock.AnyArg(), eventTime, "event-1", sqlmock.AnyArg(), "NO_DATA", "有效车载氢量样本不足2条").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", 0.0,
sqlmock.AnyArg(), sqlmock.AnyArg(), int64(1), int64(0), "NO_DATA", "有效车载氢量样本不足2条").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
@@ -104,12 +310,14 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).WillReturnError(sql.ErrNoRows)
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
writer := NewWriter(db, loc)
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
if err != nil {
t.Fatalf("AppendWithResult() error = %v", err)
@@ -122,17 +330,17 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
}
}
func TestHydrogenStreamSQLGuardsDuplicatesAndRecordsPressure(t *testing.T) {
func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) {
for _, want := range []string{
"VALUES(last_event_time)>last_event_time",
"cycle_min_mass_kg-VALUES(last_mass_kg)>?",
"tank_capacity_l",
"last_pressure_mpa",
"last_temperature_c",
"PRESSURE_NIST",
"PRIMARY KEY (vin, stat_date)",
"state_json JSON NOT NULL",
HydrogenV35RealtimeAlgorithmVersion,
} {
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(upsertHydrogenStreamStateSQL) {
t.Fatalf("stream upsert missing %q", want)
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
t.Fatalf("segment stream schema missing %q", want)
}
}
if !strings.Contains(selectHydrogenSegmentStreamStateSQL, "FOR UPDATE") {
t.Fatal("segment state must be locked before update")
}
}
@@ -0,0 +1,780 @@
package stats
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
HydrogenV35RealtimeAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"
hydrogenV35EnergyKWhPerKG = 16.0
hydrogenV35MixedMinimumKM = 10.0
hydrogenV35MixedConfirmSamples = 3
hydrogenV35MixedConfirmWindow = 30 * time.Second
hydrogenV35ContinuityGap = 5 * time.Minute
hydrogenV35PowerIntegrationGap = 2 * time.Minute
hydrogenV35RefuelObservationWindow = 10 * time.Minute
hydrogenV35RefuelThermalWindow = 30 * time.Minute
hydrogenV35RefuelSustain = 60 * time.Second
hydrogenV35RefuelRiseMPa = 3.0
hydrogenV35RefuelTemperatureRiseC = 3.0
)
// HydrogenRealtimeParameters are the effective-dated vehicle parameters used by
// the V3.5 preview. The physical pressure/temperature result remains available
// without a battery capacity, but SOC-balanced results are then marked suspect.
type HydrogenRealtimeParameters struct {
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
HydrogenEnergyKWhKG float64 `json:"hydrogenEnergyKWhPerKg"`
}
func normalizeHydrogenRealtimeParameters(value HydrogenRealtimeParameters) HydrogenRealtimeParameters {
if value.HydrogenEnergyKWhKG <= 0 {
value.HydrogenEnergyKWhKG = hydrogenV35EnergyKWhPerKG
}
return value
}
// LoadHydrogenRealtimeParameters loads the currently effective parameter row
// for every VIN. Rows are ordered newest-first so a business override wins over
// the oldest automatically synchronized model value.
func LoadHydrogenRealtimeParameters(ctx context.Context, query Queryer, date string) (map[string]HydrogenRealtimeParameters, error) {
result := map[string]HydrogenRealtimeParameters{}
if query == nil {
return result, errors.New("hydrogen parameter query is unavailable")
}
rows, err := query.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, date)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin string
var value HydrogenRealtimeParameters
if err := rows.Scan(&vin, &value.BatteryCapacityKWh, &value.HydrogenEnergyKWhKG); err != nil {
return nil, err
}
vin = strings.ToUpper(strings.TrimSpace(vin))
if len(vin) != 17 {
continue
}
if _, exists := result[vin]; !exists {
result[vin] = normalizeHydrogenRealtimeParameters(value)
}
}
return result, rows.Err()
}
type hydrogenV35Point struct {
EventID string `json:"eventId"`
ObservedAt time.Time `json:"observedAt"`
MassKG float64 `json:"massKg"`
PressureMPa float64 `json:"pressureMpa"`
TemperatureC float64 `json:"temperatureC"`
NoiseKG float64 `json:"noiseKg"`
MileageKM float64 `json:"mileageKm"`
MileageKnown bool `json:"mileageKnown"`
SOCPercent float64 `json:"socPercent"`
SOCKnown bool `json:"socKnown"`
RunningMode int `json:"runningMode"`
FuelCellActive bool `json:"fuelCellActive"`
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
FuelCellPowerKW float64 `json:"fuelCellPowerKw"`
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
}
func hydrogenV35PointFromSample(sample HydrogenStreamSample) hydrogenV35Point {
return hydrogenV35Point{
EventID: sample.EventID, ObservedAt: sample.EventTime, MassKG: sample.MassKG,
PressureMPa: sample.PressureMPa, TemperatureC: sample.TemperatureC, NoiseKG: sample.NoiseKG,
MileageKM: sample.MileageKM, MileageKnown: sample.MileageKnown,
SOCPercent: sample.SOCPercent, SOCKnown: sample.SOCKnown, RunningMode: sample.RunningMode,
FuelCellActive: sample.FuelCellActive, FuelCellStateKnown: sample.FuelCellStateKnown,
FuelCellPowerKW: sample.FuelCellPowerKW, FuelCellPowerKnown: sample.FuelCellPowerKnown,
}
}
type hydrogenV35CycleState struct {
First hydrogenV35Point `json:"first"`
Last hydrogenV35Point `json:"last"`
HasRun bool `json:"hasRun"`
StartsPure bool `json:"startsPure"`
MixedLocked bool `json:"mixedLocked"`
MixedConfirmed bool `json:"mixedConfirmed"`
MixedStart hydrogenV35Point `json:"mixedStart"`
MixedCandidate hydrogenV35Point `json:"mixedCandidate"`
MixedCandidateSet bool `json:"mixedCandidateSet"`
MixedCandidateN int `json:"mixedCandidateCount"`
MixedCandidateAt time.Time `json:"mixedCandidateLastAt"`
MixedEnergyKWh float64 `json:"mixedEnergyKWh"`
CandidateEnergyKWh float64 `json:"candidateEnergyKWh"`
}
type hydrogenV35RefuelCandidate struct {
Set bool `json:"set"`
Boundary hydrogenV35Point `json:"boundary"`
BaselinePressureMPa float64 `json:"baselinePressureMpa"`
BaselineMassKG float64 `json:"baselineMassKg"`
HighSamples int `json:"highSamples"`
PreviousHydrogen hydrogenV35Point `json:"previousHydrogen"`
PreviousHydrogenExists bool `json:"previousHydrogenExists"`
FirstHydrogenAfter hydrogenV35Point `json:"firstHydrogenAfter"`
HydrogenRunsAfter int64 `json:"hydrogenRunsAfter"`
}
type hydrogenV35RealtimeState struct {
AlgorithmVersion string `json:"algorithmVersion"`
SourceEndpoint string `json:"sourceEndpoint"`
SampleCount int64 `json:"sampleCount"`
LastEventID string `json:"lastEventId"`
LastEventTime time.Time `json:"lastEventTime"`
Parameters HydrogenRealtimeParameters `json:"parameters"`
FirstMassKG float64 `json:"firstMassKg"`
LastMassKG float64 `json:"lastMassKg"`
FirstEffective hydrogenV35Point `json:"firstEffective"`
LastEffective hydrogenV35Point `json:"lastEffective"`
HasEffective bool `json:"hasEffective"`
EffectiveRunCount int64 `json:"effectiveRunCount"`
LastBoundaryWasEffective bool `json:"lastBoundaryWasEffective"`
Cycle hydrogenV35CycleState `json:"cycle"`
InitialStateUsed bool `json:"initialStateUsed"`
Charging bool `json:"charging"`
ChargeCount int64 `json:"chargeCount"`
ChargeStartSOC float64 `json:"chargeStartSoc"`
ChargeLastSOC float64 `json:"chargeLastSoc"`
ChargeSOCKnown bool `json:"chargeSocKnown"`
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
CompletedPureKM float64 `json:"completedPureKm"`
CompletedMixedKM float64 `json:"completedMixedKm"`
CompletedSOCDelta float64 `json:"completedSocDelta"`
CompletedMixedEnergyKWh float64 `json:"completedMixedEnergyKWh"`
CompletedMixedCycles int64 `json:"completedMixedCycles"`
HydrogenSegmentStart hydrogenV35Point `json:"hydrogenSegmentStart"`
LastHydrogenRun hydrogenV35Point `json:"lastHydrogenRun"`
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
HydrogenSegmentRuns int64 `json:"hydrogenSegmentRuns"`
FinalizedHydrogenKG float64 `json:"finalizedHydrogenKg"`
HydrogenSegments int64 `json:"hydrogenSegments"`
RefuelCount int64 `json:"refuelCount"`
RefuelAmountKG float64 `json:"refuelAmountKg"`
RefuelCandidate hydrogenV35RefuelCandidate `json:"refuelCandidate"`
MinimumPressureMPa float64 `json:"minimumPressureMpa"`
MinimumMassKG float64 `json:"minimumMassKg"`
MinimumObservedAt time.Time `json:"minimumObservedAt"`
PreviousSample hydrogenV35Point `json:"previousSample"`
HasPreviousSample bool `json:"hasPreviousSample"`
AbnormalDropCount int64 `json:"abnormalDropCount"`
InvalidSampleCount int64 `json:"invalidSampleCount"`
PurePressurePeakMPa float64 `json:"purePressurePeakMpa"`
PurePressurePeakAt time.Time `json:"purePressurePeakAt"`
HasPurePressurePeak bool `json:"hasPurePressurePeak"`
InactiveStart hydrogenV35Point `json:"inactiveStart"`
InactiveLast hydrogenV35Point `json:"inactiveLast"`
InactiveCount int `json:"inactiveCount"`
SuspectedLeakCount int64 `json:"suspectedLeakCount"`
SuspectedLeakMaxMPa float64 `json:"suspectedLeakMaxMpa"`
}
type hydrogenV35Projection struct {
ConsumptionKG float64
SOCDeltaPercent *float64
BatteryEnergyChangeKWh *float64
ElectricEquivalentKG *float64
CorrectedConsumptionKG *float64
PureMileageKM float64
MixedMileageKM float64
ConsumptionPer100KM *float64
CorrectedPer100KM *float64
QualityStatus string
QualityReason string
FuelCellEnergyKG float64
ValidSegmentCount int64
}
func newHydrogenV35RealtimeState(sample HydrogenStreamSample, params HydrogenRealtimeParameters) hydrogenV35RealtimeState {
params = normalizeHydrogenRealtimeParameters(params)
state := hydrogenV35RealtimeState{
AlgorithmVersion: HydrogenV35RealtimeAlgorithmVersion,
SourceEndpoint: sample.SourceEndpoint, Parameters: params,
FirstMassKG: sample.MassKG, LastMassKG: sample.MassKG,
}
state.add(sample)
return state
}
func (state *hydrogenV35RealtimeState) add(sample HydrogenStreamSample) bool {
if !state.LastEventTime.IsZero() && !sample.EventTime.After(state.LastEventTime) {
return false
}
state.AlgorithmVersion = HydrogenV35RealtimeAlgorithmVersion
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, state.SourceEndpoint)
state.SampleCount++
state.LastEventID, state.LastEventTime = sample.EventID, sample.EventTime
point := hydrogenV35PointFromSample(sample)
pressureInvalid := state.updatePurePressureValidity(sample)
if pressureInvalid {
state.InvalidSampleCount++
}
state.updateLeak(point)
state.updateRefuel(sample, point)
effective := sample.VehicleStateKnown && sample.VehicleState == 1 &&
!(sample.ChargeStateKnown && sample.ChargeState == 1) && !pressureInvalid &&
sample.MileageKnown && sample.SOCKnown && sample.RunningModeKnown
externalCharging := sample.ChargeStateKnown && sample.ChargeState == 1 && sample.VehicleStateKnown &&
(sample.VehicleState == 1 || sample.VehicleState == 2)
if externalCharging {
state.startOrContinueCharge(sample)
state.LastBoundaryWasEffective = false
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
if state.Charging {
state.finishCharge()
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
}
if !effective {
state.LastBoundaryWasEffective = false
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
previousEffective, compareAbnormalDrop := state.LastEffective, state.LastBoundaryWasEffective
if state.HasEffective && sample.EventTime.Sub(state.LastEffective.ObservedAt) > hydrogenV35ContinuityGap {
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
}
if !state.HasEffective {
state.FirstEffective = point
state.FirstMassKG = point.MassKG
state.HasEffective = true
}
state.LastEffective = point
state.LastMassKG = point.MassKG
state.EffectiveRunCount++
state.addCycleRun(point)
if !sample.FuelCellStateKnown || sample.FuelCellActive {
state.addHydrogenRun(point)
}
if compareAbnormalDrop && previousEffective.MassKG-point.MassKG > defaultHydrogenMaxDropKG {
state.AbnormalDropCount++
}
state.LastBoundaryWasEffective = true
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
func (state *hydrogenV35RealtimeState) updatePurePressureValidity(sample HydrogenStreamSample) bool {
if !sample.RunningModeKnown || sample.RunningMode != 1 {
state.HasPurePressurePeak = false
return false
}
if !state.HasPurePressurePeak || sample.EventTime.Sub(state.PurePressurePeakAt) > 30*time.Minute {
state.PurePressurePeakMPa, state.PurePressurePeakAt, state.HasPurePressurePeak = sample.PressureMPa, sample.EventTime, true
return false
}
invalid := state.PurePressurePeakMPa-sample.PressureMPa > 5
if sample.PressureMPa > state.PurePressurePeakMPa {
state.PurePressurePeakMPa, state.PurePressurePeakAt = sample.PressureMPa, sample.EventTime
}
return invalid
}
func (state *hydrogenV35RealtimeState) startOrContinueCharge(sample HydrogenStreamSample) {
if !state.Charging {
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
state.Charging = true
state.ChargeCount++
state.ChargeSOCKnown = sample.SOCKnown
state.ChargeStartSOC, state.ChargeLastSOC = sample.SOCPercent, sample.SOCPercent
return
}
if sample.SOCKnown {
if !state.ChargeSOCKnown {
state.ChargeStartSOC = sample.SOCPercent
state.ChargeSOCKnown = true
}
state.ChargeLastSOC = sample.SOCPercent
}
}
func (state *hydrogenV35RealtimeState) finishCharge() {
if state.ChargeSOCKnown && state.Parameters.BatteryCapacityKWh > 0 && state.ChargeLastSOC > state.ChargeStartSOC {
state.ChargeEnergyKWh += state.Parameters.BatteryCapacityKWh * (state.ChargeLastSOC - state.ChargeStartSOC) / 100
}
state.Charging, state.ChargeSOCKnown = false, false
state.InitialStateUsed = true
}
func (state *hydrogenV35RealtimeState) addCycleRun(point hydrogenV35Point) {
cycle := &state.Cycle
if !cycle.HasRun {
cycle.HasRun, cycle.First, cycle.Last = true, point, point
cycle.MixedLocked = !state.InitialStateUsed
cycle.StartsPure = point.RunningMode == 1 && !cycle.MixedLocked
if cycle.MixedLocked || !cycle.StartsPure {
cycle.MixedConfirmed, cycle.MixedStart = true, point
}
state.InitialStateUsed = true
return
}
previous := cycle.Last
cycle.Last = point
if cycle.MixedConfirmed {
cycle.MixedEnergyKWh += hydrogenV35PowerBetween(previous, point)
return
}
if point.RunningMode != 2 || (cycle.MixedCandidateSet && point.ObservedAt.Sub(cycle.MixedCandidateAt) > hydrogenV35MixedConfirmWindow) {
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
}
if point.RunningMode != 2 {
return
}
if !cycle.MixedCandidateSet {
cycle.MixedCandidate, cycle.MixedCandidateSet = point, true
cycle.MixedCandidateN = 1
cycle.CandidateEnergyKWh = 0
} else {
cycle.MixedCandidateN++
cycle.CandidateEnergyKWh += hydrogenV35PowerBetween(previous, point)
}
cycle.MixedCandidateAt = point.ObservedAt
if cycle.MixedCandidateN >= hydrogenV35MixedConfirmSamples {
cycle.MixedConfirmed = true
cycle.MixedStart = cycle.MixedCandidate
cycle.MixedEnergyKWh = cycle.CandidateEnergyKWh
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
}
}
func hydrogenV35PowerBetween(previous, current hydrogenV35Point) float64 {
gap := current.ObservedAt.Sub(previous.ObservedAt)
if gap <= 0 || gap > hydrogenV35PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown {
return 0
}
return (math.Max(0, previous.FuelCellPowerKW) + math.Max(0, current.FuelCellPowerKW)) / 2 * gap.Hours()
}
func hydrogenV35CycleTotals(cycle hydrogenV35CycleState) (pureKM, mixedKM, socDelta, energyKWh float64, mixed bool) {
if !cycle.HasRun {
return 0, 0, 0, 0, false
}
if !cycle.MixedConfirmed {
if cycle.StartsPure {
return math.Max(0, cycle.Last.MileageKM-cycle.First.MileageKM), 0, 0, 0, false
}
return 0, 0, 0, 0, false
}
if cycle.StartsPure {
pureKM = math.Max(0, cycle.MixedStart.MileageKM-cycle.First.MileageKM)
}
mixedKM = math.Max(0, cycle.Last.MileageKM-cycle.MixedStart.MileageKM)
socDelta = cycle.Last.SOCPercent - cycle.MixedStart.SOCPercent
return pureKM, mixedKM, socDelta, cycle.MixedEnergyKWh, true
}
func (state *hydrogenV35RealtimeState) finishCycle() {
pureKM, mixedKM, socDelta, energyKWh, mixed := hydrogenV35CycleTotals(state.Cycle)
state.CompletedPureKM += pureKM
state.CompletedMixedKM += mixedKM
state.CompletedSOCDelta += socDelta
state.CompletedMixedEnergyKWh += energyKWh
if mixed {
state.CompletedMixedCycles++
}
}
func (state *hydrogenV35RealtimeState) addHydrogenRun(point hydrogenV35Point) {
if state.RefuelCandidate.Set && !point.ObservedAt.Before(state.RefuelCandidate.Boundary.ObservedAt) {
if state.RefuelCandidate.HydrogenRunsAfter == 0 {
state.RefuelCandidate.FirstHydrogenAfter = point
}
state.RefuelCandidate.HydrogenRunsAfter++
}
if !state.HasHydrogenSegment {
state.HydrogenSegmentStart, state.LastHydrogenRun, state.HasHydrogenSegment = point, point, true
state.HydrogenSegmentRuns = 1
return
}
state.LastHydrogenRun = point
state.HydrogenSegmentRuns++
}
func (state *hydrogenV35RealtimeState) updateRefuel(sample HydrogenStreamSample, point hydrogenV35Point) {
if state.MinimumObservedAt.IsZero() || sample.EventTime.Sub(state.MinimumObservedAt) > hydrogenV35RefuelObservationWindow {
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
}
if state.RefuelCandidate.Set {
age := sample.EventTime.Sub(state.RefuelCandidate.Boundary.ObservedAt)
if age > hydrogenV35RefuelObservationWindow || sample.PressureMPa-state.RefuelCandidate.BaselinePressureMPa < hydrogenV35RefuelRiseMPa {
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
} else {
state.RefuelCandidate.HighSamples++
if age >= hydrogenV35RefuelSustain || state.RefuelCandidate.HighSamples >= hydrogenV35MixedConfirmSamples {
state.confirmRefuel(point)
}
}
}
if sample.PressureMPa < state.MinimumPressureMPa {
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
}
thermalRefuel := state.thermalRefuelContext(sample)
ordinaryRefuel := sample.PressureMPa-state.MinimumPressureMPa >= hydrogenV35RefuelRiseMPa && state.refuelContext(sample)
if state.RefuelCandidate.Set || (!ordinaryRefuel && !thermalRefuel) {
return
}
baselinePressure, baselineMass := state.MinimumPressureMPa, state.MinimumMassKG
if thermalRefuel {
baselinePressure, baselineMass = state.PreviousSample.PressureMPa, state.PreviousSample.MassKG
}
candidate := hydrogenV35RefuelCandidate{
Set: true, Boundary: point, BaselinePressureMPa: baselinePressure,
BaselineMassKG: baselineMass, HighSamples: 1,
}
if state.HasHydrogenSegment {
candidate.PreviousHydrogen, candidate.PreviousHydrogenExists = state.LastHydrogenRun, true
}
state.RefuelCandidate = candidate
}
func (state *hydrogenV35RealtimeState) thermalRefuelContext(sample HydrogenStreamSample) bool {
if !state.HasPreviousSample || !sample.MileageKnown || !state.PreviousSample.MileageKnown {
return false
}
gap := sample.EventTime.Sub(state.PreviousSample.ObservedAt)
return gap > 0 && gap <= hydrogenV35RefuelThermalWindow &&
sample.PressureMPa-state.PreviousSample.PressureMPa >= hydrogenV35RefuelRiseMPa &&
sample.TemperatureC-state.PreviousSample.TemperatureC >= hydrogenV35RefuelTemperatureRiseC &&
math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 5
}
func (state *hydrogenV35RealtimeState) refuelContext(sample HydrogenStreamSample) bool {
if !state.HasPreviousSample {
return false
}
if sample.MileageKnown && state.PreviousSample.MileageKnown {
return math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 0.2
}
return (sample.VehicleStateKnown && sample.VehicleState == 2) || (sample.FuelCellStateKnown && !sample.FuelCellActive)
}
func (state *hydrogenV35RealtimeState) confirmRefuel(current hydrogenV35Point) {
candidate := state.RefuelCandidate
if candidate.PreviousHydrogenExists && state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
drop := state.HydrogenSegmentStart.MassKG - candidate.PreviousHydrogen.MassKG
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, candidate.PreviousHydrogen.NoiseKG) {
drop = 0
}
state.FinalizedHydrogenKG += drop
state.HydrogenSegments++
}
state.RefuelCount++
state.RefuelAmountKG += math.Max(0, current.MassKG-candidate.BaselineMassKG)
if candidate.HydrogenRunsAfter > 0 {
state.HydrogenSegmentStart = candidate.FirstHydrogenAfter
state.HasHydrogenSegment = true
state.HydrogenSegmentRuns = candidate.HydrogenRunsAfter
} else {
state.HasHydrogenSegment = false
state.HydrogenSegmentRuns = 0
}
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = current.PressureMPa, current.MassKG, current.ObservedAt
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
}
func (state *hydrogenV35RealtimeState) updateLeak(point hydrogenV35Point) {
inactive := point.FuelCellStateKnown && !point.FuelCellActive
if !inactive {
state.finishInactiveLeak()
return
}
if state.InactiveCount > 0 && point.ObservedAt.Sub(state.InactiveLast.ObservedAt) > hydrogenV35ContinuityGap {
state.finishInactiveLeak()
}
if state.InactiveCount == 0 {
state.InactiveStart = point
}
state.InactiveLast = point
state.InactiveCount++
}
func (state *hydrogenV35RealtimeState) finishInactiveLeak() {
if state.InactiveCount >= 3 && state.InactiveLast.ObservedAt.Sub(state.InactiveStart.ObservedAt) >= 5*time.Minute {
pressureDrop := state.InactiveStart.PressureMPa - state.InactiveLast.PressureMPa
massDrop := state.InactiveStart.MassKG - state.InactiveLast.MassKG
if pressureDrop >= 0.5 && massDrop >= math.Max(0.05, math.Max(state.InactiveStart.NoiseKG, state.InactiveLast.NoiseKG)) {
state.SuspectedLeakCount++
state.SuspectedLeakMaxMPa = math.Max(state.SuspectedLeakMaxMPa, pressureDrop)
}
}
state.InactiveCount = 0
}
func (state hydrogenV35RealtimeState) projection() hydrogenV35Projection {
// Evaluate an open inactive segment at the current watermark on this copy.
// Persisted state remains open so later frames can continue the same segment.
state.finishInactiveLeak()
pureKM, mixedKM, socDelta, mixedEnergyKWh, currentMixed := hydrogenV35CycleTotals(state.Cycle)
pureKM += state.CompletedPureKM
mixedKM += state.CompletedMixedKM
socDelta += state.CompletedSOCDelta
mixedEnergyKWh += state.CompletedMixedEnergyKWh
mixedCycles := state.CompletedMixedCycles
if currentMixed {
mixedCycles++
}
physical := state.FinalizedHydrogenKG
hydrogenSegments := state.HydrogenSegments
if state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
drop := state.HydrogenSegmentStart.MassKG - state.LastHydrogenRun.MassKG
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, state.LastHydrogenRun.NoiseKG) {
drop = 0
}
physical += drop
hydrogenSegments++
}
params := normalizeHydrogenRealtimeParameters(state.Parameters)
fuelCellEnergyKG := mixedEnergyKWh / params.HydrogenEnergyKWhKG
electricEquivalentKG := 0.0
if params.BatteryCapacityKWh > 0 {
electricEquivalentKG = params.BatteryCapacityKWh * socDelta / 100 / params.HydrogenEnergyKWhKG
}
usedEnergyFallback := mixedKM >= hydrogenV35MixedMinimumKM && fuelCellEnergyKG > 0 && (physical <= 0 || physical-electricEquivalentKG <= 0)
if usedEnergyFallback {
physical = math.Max(physical, fuelCellEnergyKG)
}
corrected := math.Max(0, physical-electricEquivalentKG)
result := hydrogenV35Projection{
ConsumptionKG: roundHydrogenKG(physical), PureMileageKM: roundHydrogenKG(pureKM), MixedMileageKM: roundHydrogenKG(mixedKM),
FuelCellEnergyKG: roundHydrogenKG(fuelCellEnergyKG), ValidSegmentCount: hydrogenSegments, QualityStatus: "OK",
}
if params.BatteryCapacityKWh > 0 {
soc, battery, equivalent, balanced := roundHydrogenKG(socDelta), roundHydrogenKG(params.BatteryCapacityKWh*socDelta/100), roundHydrogenKG(electricEquivalentKG), roundHydrogenKG(corrected)
result.SOCDeltaPercent, result.BatteryEnergyChangeKWh = &soc, &battery
result.ElectricEquivalentKG, result.CorrectedConsumptionKG = &equivalent, &balanced
}
if mixedKM > 0 {
physicalRate := roundHydrogenKG(physical * 100 / mixedKM)
result.ConsumptionPer100KM = &physicalRate
if result.CorrectedConsumptionKG != nil {
correctedRate := roundHydrogenKG(corrected * 100 / mixedKM)
result.CorrectedPer100KM = &correctedRate
}
}
reasons := make([]string, 0, 5)
totalMileage := 0.0
if state.HasEffective {
totalMileage = state.LastEffective.MileageKM - state.FirstEffective.MileageKM
}
switch {
case state.EffectiveRunCount < 2:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "有效运行分界点不足2条")
case totalMileage < 1:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前有效总里程不足1km")
case mixedCycles == 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "尚未形成确认的用氢混动周期")
case mixedKM < hydrogenV35MixedMinimumKM:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前混动里程不足10km")
case hydrogenSegments == 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "燃料电池有效工作氢量边界不足")
case corrected <= 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "现有报文不足以形成可信的正耗氢结果")
}
if result.QualityStatus != "NO_DATA" && params.BatteryCapacityKWh <= 0 {
result.QualityStatus, reasons = "SUSPECT", append(reasons, "车型动力电池容量未确认")
}
if state.AbnormalDropCount > 0 {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "检测到氢量异常大幅下降")
}
if state.SuspectedLeakCount > 0 {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "检测到燃料电池未工作期间持续压降")
}
if usedEnergyFallback {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "压力边界不足,暂用燃料电池功率积分折算")
}
if result.QualityStatus == "NO_DATA" {
result.ConsumptionPer100KM = nil
result.CorrectedPer100KM = nil
}
result.QualityReason = strings.Join(reasons, "")
return result
}
func AppendHydrogenV35Realtime(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64, params HydrogenRealtimeParameters) (HydrogenStreamResult, error) {
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, now, tankCapacityLiters)
if !ok {
result := HydrogenStreamResult{}
switch reason {
case "unsupported_protocol", "missing_pressure", "missing_temperature":
return result, nil
case "not_current_date":
result.NotCurrent = 1
default:
result.Invalid = 1
}
return result, nil
}
result := HydrogenStreamResult{Found: 1}
beginner, ok := exec.(txBeginner)
if !ok {
return result, sql.ErrTxDone
}
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
state, found, err := selectHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date)
if err != nil {
return result, err
}
if !found || state.AlgorithmVersion != HydrogenV35RealtimeAlgorithmVersion {
state = newHydrogenV35RealtimeState(sample, params)
if err := insertHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
} else {
if params.BatteryCapacityKWh > 0 {
state.Parameters = normalizeHydrogenRealtimeParameters(params)
} else {
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
}
if !state.add(sample) {
result.Duplicate = 1
return result, tx.Commit()
}
if err := updateHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
}
if err := projectHydrogenV35Realtime(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Written = 1
return result, nil
}
func selectHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenV35RealtimeState, bool, error) {
var encoded []byte
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
if errors.Is(err, sql.ErrNoRows) {
return hydrogenV35RealtimeState{}, false, nil
}
if err != nil {
return hydrogenV35RealtimeState{}, false, err
}
var state hydrogenV35RealtimeState
if err := json.Unmarshal(encoded, &state); err != nil {
return hydrogenV35RealtimeState{}, false, nil
}
return state, true, nil
}
func insertHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
projection := state.projection()
_, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL,
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded, projection.QualityStatus, projection.QualityReason)
return err
}
func updateHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
projection := state.projection()
_, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL,
state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded,
projection.QualityStatus, projection.QualityReason, vin, date)
return err
}
func projectHydrogenV35Realtime(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
projection := state.projection()
parameters, err := json.Marshal(map[string]any{
"batteryCapacityKWh": state.Parameters.BatteryCapacityKWh,
"hydrogenEnergyKWhPerKg": normalizeHydrogenRealtimeParameters(state.Parameters).HydrogenEnergyKWhKG,
"algorithmVersion": HydrogenV35RealtimeAlgorithmVersion,
})
if err != nil {
return err
}
evidence, err := json.Marshal(map[string]any{
"calculationPhase": "PRELIMINARY", "lastEventId": state.LastEventID,
"lastEventTime": state.LastEventTime, "refuelAmountKg": roundHydrogenKG(state.RefuelAmountKG),
"suspectedLeakCount": state.SuspectedLeakCount, "fuelCellEnergyHydrogenKg": projection.FuelCellEnergyKG,
})
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, projectHydrogenV35RealtimeSQL,
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
projection.SOCDeltaPercent, projection.BatteryEnergyChangeKWh, projection.ElectricEquivalentKG,
projection.CorrectedConsumptionKG, projection.MixedMileageKM, projection.PureMileageKM,
projection.ConsumptionPer100KM, projection.CorrectedPer100KM,
state.FirstMassKG, state.LastMassKG, state.SampleCount, state.RefuelCount, state.ChargeCount,
projection.ValidSegmentCount, state.InvalidSampleCount, HydrogenV35RealtimeAlgorithmVersion,
parameters, evidence, projection.QualityStatus, projection.QualityReason)
return err
}
const projectHydrogenV35RealtimeSQL = `INSERT INTO vehicle_open_daily_energy(
vin,stat_date,energy_type,source_endpoint,consumption_kg,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,
unit,first_mass_kg,last_mass_kg,sample_count,refuel_count,charge_count,valid_segment_count,
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json,
quality_status,quality_reason,calculated_at
) VALUES(?,?,'HYDROGEN',?,?,?,?,?,?,?,?,?,?,?,'kg',?,?,?,?,?,?,?,?,'PRELIMINARY',?,?,?,?,NOW(3))
ON DUPLICATE KEY UPDATE
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),raw_consumption_kg=VALUES(raw_consumption_kg),
battery_soc_delta_pct=VALUES(battery_soc_delta_pct),battery_discharge_kwh=VALUES(battery_discharge_kwh),
battery_equivalent_kg=VALUES(battery_equivalent_kg),soc_balanced_consumption_kg=VALUES(soc_balanced_consumption_kg),
mixed_mileage_km=VALUES(mixed_mileage_km),pure_electric_mileage_km=VALUES(pure_electric_mileage_km),
consumption_kg_per_100km=VALUES(consumption_kg_per_100km),soc_balanced_kg_per_100km=VALUES(soc_balanced_kg_per_100km),
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),sample_count=VALUES(sample_count),
refuel_count=VALUES(refuel_count),charge_count=VALUES(charge_count),valid_segment_count=VALUES(valid_segment_count),
invalid_segment_count=VALUES(invalid_segment_count),algorithm_version=VALUES(algorithm_version),
calculation_phase='PRELIMINARY',parameter_json=VALUES(parameter_json),evidence_json=VALUES(evidence_json),
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)`
@@ -0,0 +1,157 @@
package stats
import (
"context"
"math"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func hydrogenV35TestSample(at time.Time, mass, pressure, mileage, soc float64, runningMode int) HydrogenStreamSample {
return HydrogenStreamSample{
VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: "source-a",
EventID: at.Format(time.RFC3339Nano), EventTime: at,
MassKG: mass, PressureMPa: pressure, TemperatureC: 30, NoiseKG: 0.05, RefuelThresholdKG: 1,
MileageKM: mileage, MileageKnown: true, SOCPercent: soc, SOCKnown: true,
VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true,
RunningMode: runningMode, RunningModeKnown: true,
FuelCellActive: runningMode == 2, FuelCellStateKnown: true,
FuelCellPowerKW: 40, FuelCellPowerKnown: true, ConsumptionEligible: true,
TankCapacityLiters: 520,
}
}
func TestHydrogenV35RealtimeConfirmsMixedAndBalancesSOC(t *testing.T) {
base := time.Date(2026, 9, 4, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
charging := hydrogenV35TestSample(base, 10.1, 30, 100, 90, 1)
charging.ChargeState = 1
state := newHydrogenV35RealtimeState(charging, HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16})
values := []HydrogenStreamSample{
hydrogenV35TestSample(base.Add(time.Minute), 10.0, 29.5, 100, 90, 1),
hydrogenV35TestSample(base.Add(2*time.Minute), 9.8, 29, 105, 88, 1),
hydrogenV35TestSample(base.Add(3*time.Minute), 9.7, 28.5, 106, 87, 2),
hydrogenV35TestSample(base.Add(3*time.Minute+10*time.Second), 9.2, 27, 112, 84, 2),
hydrogenV35TestSample(base.Add(3*time.Minute+20*time.Second), 8.5, 25, 120, 80, 2),
}
for _, value := range values {
if !state.add(value) {
t.Fatalf("sample %s rejected", value.EventID)
}
}
projection := state.projection()
if projection.QualityStatus != "OK" {
t.Fatalf("quality=%s reason=%s state=%#v", projection.QualityStatus, projection.QualityReason, state)
}
if math.Abs(projection.ConsumptionKG-1.2) > 0.001 || math.Abs(projection.PureMileageKM-6) > 0.001 || math.Abs(projection.MixedMileageKM-14) > 0.001 {
t.Fatalf("projection=%#v", projection)
}
if projection.SOCDeltaPercent == nil || math.Abs(*projection.SOCDeltaPercent-(-7)) > 0.001 {
t.Fatalf("SOC projection=%#v", projection)
}
if projection.CorrectedConsumptionKG == nil || math.Abs(*projection.CorrectedConsumptionKG-1.55) > 0.001 {
t.Fatalf("balanced projection=%#v", projection)
}
}
func TestHydrogenV35RealtimeWaitsForRefuelConfirmation(t *testing.T) {
base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
state.add(hydrogenV35TestSample(base.Add(time.Minute), 9, 19, 101, 79, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 12.5, 22.5, 101, 79, 2))
if state.RefuelCount != 0 || !state.RefuelCandidate.Set {
t.Fatalf("refuel confirmed too early: %#v", state.RefuelCandidate)
}
state.add(hydrogenV35TestSample(base.Add(2*time.Minute+10*time.Second), 12.4, 22.4, 101, 79, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute+20*time.Second), 12.3, 22.3, 101, 79, 2))
if state.RefuelCount != 1 || state.RefuelCandidate.Set || state.HydrogenSegments != 1 {
t.Fatalf("refuel not confirmed: count=%d segments=%d candidate=%#v", state.RefuelCount, state.HydrogenSegments, state.RefuelCandidate)
}
state.add(hydrogenV35TestSample(base.Add(3*time.Minute), 11.5, 21, 112, 75, 2))
if got := state.projection().ConsumptionKG; math.Abs(got-2.0) > 0.001 {
t.Fatalf("consumption=%.3f, want 2.000; state=%#v", got, state)
}
}
func TestHydrogenV35RealtimeDetectsThermalRefuelAcrossReportingGap(t *testing.T) {
base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
first := hydrogenV35TestSample(base, 5, 10, 100, 80, 2)
first.TemperatureC = 20
state := newHydrogenV35RealtimeState(first, HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
for index, offset := range []time.Duration{21*time.Minute + 50*time.Second, 22 * time.Minute, 22*time.Minute + 10*time.Second} {
value := hydrogenV35TestSample(base.Add(offset), 9-float64(index)/10, 14-float64(index)/10, 100.3, 80, 2)
value.TemperatureC = 24
state.add(value)
}
if state.RefuelCount != 1 {
t.Fatalf("thermal refuel count=%d candidate=%#v", state.RefuelCount, state.RefuelCandidate)
}
}
func TestHydrogenV35RealtimeRejectsLateFrameWithoutChangingState(t *testing.T) {
base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{})
before := state.SampleCount
if state.add(hydrogenV35TestSample(base, 9, 19, 101, 79, 2)) {
t.Fatal("same-time frame was accepted")
}
if state.SampleCount != before || state.LastMassKG != 10 {
t.Fatalf("late frame changed state: %#v", state)
}
}
func TestHydrogenV35RealtimeNoDataDoesNotExposePer100KMRates(t *testing.T) {
base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
state.add(hydrogenV35TestSample(base.Add(time.Minute), 9.5, 19, 105, 78, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 9, 18, 109, 76, 2))
projection := state.projection()
if projection.QualityStatus != "NO_DATA" {
t.Fatalf("quality=%s, want NO_DATA", projection.QualityStatus)
}
if projection.ConsumptionPer100KM != nil || projection.CorrectedPer100KM != nil {
t.Fatalf("NO_DATA projection exposes per-100km rates: %#v", projection)
}
}
func TestLoadHydrogenRealtimeParametersKeepsNewestEffectiveRow(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg")).
WithArgs("2026-09-04", "2026-09-04").
WillReturnRows(sqlmock.NewRows([]string{"vin", "battery_capacity_kwh", "hydrogen_energy_kwh_per_kg"}).
AddRow("LA9GG64L0NBAF4175", 80.0, 15.8).
AddRow("LA9GG64L0NBAF4175", 75.0, 16.0))
values, err := LoadHydrogenRealtimeParameters(context.Background(), db, "2026-09-04")
if err != nil {
t.Fatal(err)
}
if got := values["LA9GG64L0NBAF4175"]; got.BatteryCapacityKWh != 80 || got.HydrogenEnergyKWhKG != 15.8 {
t.Fatalf("parameters=%#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHydrogenV35RealtimeSQLMarksPreviewWithoutOverwritingFinalSemantics(t *testing.T) {
for _, want := range []string{
"calculation_phase", "'PRELIMINARY'", "algorithm_version=VALUES(algorithm_version)",
"soc_balanced_consumption_kg", "consumption_kg_per_100km",
} {
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(projectHydrogenV35RealtimeSQL) {
t.Fatalf("realtime projection SQL missing %q", want)
}
}
if got := strings.Count(strings.Split(projectHydrogenV35RealtimeSQL, "ON DUPLICATE KEY UPDATE")[0], "?"); got != 25 {
t.Fatalf("realtime projection placeholders=%d, want 25", got)
}
}
@@ -15,14 +15,40 @@ const (
yutongFuelCellDrivingMode = 11
)
// PureHydrogenModeFromEnvelope returns whether the reported vehicle is in a
// fuel-cell-only work mode and whether the source value is understood.
// PureHydrogenModeFromEnvelope returns whether hydrogen power participates in
// vehicle propulsion and whether the source value is understood.
//
// The 32960 fuel-cell extension reports engine_work_state; production frames
// use mode 2 for normal fuel-cell operation. Yutong reports the equivalent
// state through TRIANGLE_STATE; production frames use 1 for inactive and 4/11
// for active fuel-cell operating modes.
// GB32960's standard vehicle running mode is authoritative when present:
// mode 1 is pure electric, mode 2 is hybrid, and mode 3 is fuel powered. Mode
// 2 is counted as hydrogen-participating mileage for the hydrogen fleet; mode
// 1 is excluded. Fuel, abnormal, and invalid values are left unclassified.
// Older frames without the standard field fall back to the fuel-cell extension
// engine_work_state. Yutong reports the equivalent state through
// TRIANGLE_STATE; production frames use 1 for inactive and 4/11 for active.
func PureHydrogenModeFromEnvelope(env envelope.FrameEnvelope) (active bool, known bool) {
if env.Protocol == envelope.ProtocolGB32960 {
if value, exists := env.Fields[envelope.FieldVehicleRunningMode]; exists {
mode, ok := integerMode(value)
if !ok {
return false, false
}
switch mode {
case 1:
return false, true
case 2:
return true, true
default:
return false, false
}
}
}
return FuelCellActiveModeFromEnvelope(env)
}
// FuelCellActiveModeFromEnvelope reads the fuel-cell subsystem work state.
// Hydrogen consumption segmentation uses this signal (and its current-based
// fallback) independently from the whole-vehicle running mode used by mileage.
func FuelCellActiveModeFromEnvelope(env envelope.FrameEnvelope) (active bool, known bool) {
value, exists := env.Fields[envelope.FieldFuelCellWorkMode]
if !exists {
return false, false
@@ -56,8 +82,9 @@ func PureHydrogenModeFromEnvelope(env envelope.FrameEnvelope) (active bool, know
}
// PureHydrogenMileageDelta returns the odometer delta that can be attributed
// to pure-hydrogen operation. Both endpoints must report a known active mode;
// this deliberately leaves transitions and missing mode data unclassified.
// to hydrogen-participating operation. The current endpoint owns the interval,
// matching the daily rule: total mileage minus intervals ending in pure-electric
// mode. Missing or invalid current modes remain unclassified.
func PureHydrogenMileageDelta(
previousKM float64,
currentKM float64,
@@ -67,7 +94,7 @@ func PureHydrogenMileageDelta(
currentKnown bool,
) float64 {
delta := currentKM - previousKM
if !previousKnown || !previousActive || !currentKnown || !currentActive {
if !currentKnown || !currentActive {
return 0
}
if delta < 0 || delta > maxSelectedDailyMileageKM {
@@ -15,14 +15,24 @@ func TestPureHydrogenModeFromEnvelope(t *testing.T) {
known bool
}{
{
name: "gb32960 fuel cell mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 2}},
name: "gb32960 hybrid running mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldVehicleRunningMode: 2}},
active: true,
known: true,
},
{
name: "gb32960 electric mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 1}},
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldVehicleRunningMode: 1, envelope.FieldFuelCellWorkMode: 2}},
known: true,
},
{
name: "gb32960 fuel running mode is not hydrogen",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldVehicleRunningMode: 3}},
},
{
name: "gb32960 extension fallback",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 2}},
active: true,
known: true,
},
{
@@ -62,7 +72,7 @@ func TestPureHydrogenModeFromEnvelope(t *testing.T) {
}
}
func TestPureHydrogenMileageDeltaRequiresContinuousKnownActiveMode(t *testing.T) {
func TestPureHydrogenMileageDeltaUsesCurrentKnownActiveMode(t *testing.T) {
tests := []struct {
name string
previousKM, currentKM float64
@@ -81,15 +91,25 @@ func TestPureHydrogenMileageDeltaRequiresContinuousKnownActiveMode(t *testing.T)
want: 6.5,
},
{
name: "transition into active is unclassified",
name: "transition into active belongs to active interval",
previousKM: 100,
currentKM: 106.5,
previousKnown: true,
currentActive: true,
currentKnown: true,
want: 6.5,
},
{
name: "unknown endpoint is unclassified",
name: "previous endpoint can be unknown",
previousKM: 100,
currentKM: 106.5,
previousActive: true,
currentActive: true,
currentKnown: true,
want: 6.5,
},
{
name: "unknown current endpoint is unclassified",
previousKM: 100,
currentKM: 106.5,
previousActive: true,
@@ -8,6 +8,7 @@ const DailyMileageTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage (
daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
latest_total_mileage_km DECIMAL(18,3) NULL,
day_end_total_mileage_km DECIMAL(18,3) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (vin, stat_date, protocol),
KEY idx_stat_date (stat_date),
@@ -22,6 +23,7 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN source_id BIGINT NULL AFTER protocol",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0 AFTER daily_mileage_km",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN day_end_total_mileage_km DECIMAL(18,3) NULL AFTER latest_total_mileage_km",
"ALTER TABLE vehicle_daily_mileage ADD KEY idx_source_id (source_id)",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN first_total_mileage_km",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_key",
@@ -36,6 +38,13 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN pure_hydrogen_sample_count BIGINT NOT NULL DEFAULT 0 AFTER sample_count",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN latest_pure_hydrogen_active TINYINT(1) NOT NULL DEFAULT 0 AFTER latest_event_time",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN latest_pure_hydrogen_mode_known TINYINT(1) NOT NULL DEFAULT 0 AFTER latest_pure_hydrogen_active",
"ALTER TABLE vehicle_open_hydrogen_stream_state ADD COLUMN last_fuel_cell_active TINYINT(1) NOT NULL DEFAULT 0 AFTER last_event_id",
"ALTER TABLE vehicle_open_hydrogen_stream_state ADD COLUMN last_fuel_cell_state_known TINYINT(1) NOT NULL DEFAULT 0 AFTER last_fuel_cell_active",
}
var HydrogenRealtimeAlterSQL = []string{
"ALTER TABLE vehicle_open_hydrogen_segment_stream_state MODIFY COLUMN calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5'",
"ALTER TABLE vehicle_open_daily_energy ADD COLUMN calculation_phase VARCHAR(16) NOT NULL DEFAULT 'FINAL' AFTER algorithm_version",
}
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
@@ -13,6 +13,7 @@ const (
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
QualityReasonTotalRollback = "TOTAL_MILEAGE_ROLLBACK"
QualityReasonHistorical = "historical_source_baseline"
QualityReasonCurrentDayFirst = "current_day_first_baseline"
QualityReasonStationaryCarry = "stationary_carry_forward"
@@ -136,6 +137,26 @@ func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM
return currentDayLatestKM - previousBaselineKM
}
// IsUsableDailyMileageBoundary reports whether a persisted baseline belongs to
// the current natural day or its immediately preceding natural day. A baseline
// from an older day represents an offline gap; using it would attribute the
// whole gap to the day on which the vehicle came back online.
func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *time.Location) bool {
if strings.TrimSpace(statDate) == "" || baselineTime.IsZero() {
return false
}
if loc == nil {
loc = baselineTime.Location()
}
day, err := time.ParseInLocation("2006-01-02", statDate, loc)
if err != nil {
return false
}
baseline := baselineTime.In(loc)
baselineDay := time.Date(baseline.Year(), baseline.Month(), baseline.Day(), 0, 0, 0, 0, loc)
return !baselineDay.Before(day.AddDate(0, 0, -1)) && !baselineDay.After(day)
}
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
return 0, true, "negative_jitter_clamped"
@@ -444,8 +465,6 @@ const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL +
const upsertSourcePureHydrogenIncrementSQL = `CASE
WHEN latest_event_time IS NOT NULL
AND VALUES(latest_event_time) > latest_event_time
AND latest_pure_hydrogen_mode_known = 1
AND latest_pure_hydrogen_active = 1
AND VALUES(latest_pure_hydrogen_mode_known) = 1
AND VALUES(latest_pure_hydrogen_active) = 1
AND VALUES(latest_total_mileage_km) >= latest_total_mileage_km
@@ -731,9 +750,32 @@ const selectedSourceIdentitySampleCountSQL = `(
= COALESCE(NULLIF(TRIM(s2.phone), ''), NULLIF(TRIM(s2.device_id), ''), s2.source_key)
)`
// projectDayEndTotalMileageSQL intentionally uses an independent source choice
// from the daily-mileage projection. GPS coordinate accumulation can be the
// best evidence for distance travelled during the day, but it must never
// replace a terminal-reported odometer as the day-end cumulative mileage.
const projectDayEndTotalMileageSQL = `COALESCE((
SELECT total_candidate.latest_total_mileage_km
FROM vehicle_daily_mileage_source total_candidate
WHERE total_candidate.vin = s.vin
AND total_candidate.stat_date = s.stat_date
AND total_candidate.protocol = s.protocol
AND total_candidate.quality_status = '` + QualityOK + `'
AND total_candidate.latest_total_mileage_km IS NOT NULL
AND total_candidate.latest_total_mileage_km >= 0
ORDER BY
CASE WHEN COALESCE(total_candidate.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1 ELSE 0 END,
CASE WHEN total_candidate.latest_total_mileage_km > total_candidate.daily_mileage_km THEN 0 ELSE 1 END,
total_candidate.is_selected DESC,
total_candidate.latest_event_time DESC,
total_candidate.sample_count DESC,
total_candidate.source_key ASC
LIMIT 1
), s.latest_total_mileage_km)`
const projectDailyMileageSQL = `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km)
(vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km, day_end_total_mileage_km)
SELECT
s.vin,
s.stat_date,
@@ -741,7 +783,8 @@ SELECT
ds.id,
s.daily_mileage_km,
LEAST(s.pure_hydrogen_mileage_km, s.daily_mileage_km),
s.latest_total_mileage_km
s.latest_total_mileage_km,
` + projectDayEndTotalMileageSQL + `
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
@@ -774,6 +817,7 @@ ON DUPLICATE KEY UPDATE
daily_mileage_km = VALUES(daily_mileage_km),
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
day_end_total_mileage_km = VALUES(day_end_total_mileage_km),
updated_at = CURRENT_TIMESTAMP
`
@@ -866,6 +910,35 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
}, latestTotal.Valid, nil
}
// lookupPreviousProtocolBaseline is deliberately source-key independent. A
// device replacement must not allow a near-zero odometer to become a normal
// continuation of the same vehicle/protocol history.
func lookupPreviousProtocolBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol) (sourceBaseline, bool, error) {
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return sourceBaseline{}, false, nil
}
rows, err := query.QueryContext(ctx, previousProtocolBaselineSQL, vin, statDate, string(protocol))
if err != nil {
return sourceBaseline{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{}, false, nil
}
var total sql.NullFloat64
var event sql.NullTime
if err := rows.Scan(&total, &event); err != nil {
return sourceBaseline{}, false, err
}
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{LatestTotalKM: total.Float64, LatestEventTime: event.Time, QualityReason: QualityReasonHistorical}, total.Valid, nil
}
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
// same VIN, protocol and source before statDate. Missing calendar days are
// skipped automatically.
@@ -889,3 +962,14 @@ WHERE vin = ?
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`
const previousProtocolBaselineSQL = `
SELECT latest_total_mileage_km, latest_event_time
FROM vehicle_daily_mileage_source
WHERE vin=? AND stat_date<? AND protocol=?
AND quality_status='OK'
AND latest_total_mileage_km IS NOT NULL AND latest_total_mileage_km>0
AND latest_event_time IS NOT NULL
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`
@@ -211,12 +211,10 @@ func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
}
}
func TestUpsertSourceMileageAccumulatesOnlyContinuousPureHydrogenIntervals(t *testing.T) {
func TestUpsertSourceMileageAttributesDeltaToCurrentPureHydrogenMode(t *testing.T) {
for _, want := range []string{
"pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE",
"VALUES(latest_event_time) > latest_event_time",
"latest_pure_hydrogen_mode_known = 1",
"latest_pure_hydrogen_active = 1",
"VALUES(latest_pure_hydrogen_mode_known) = 1",
"VALUES(latest_pure_hydrogen_active) = 1",
"VALUES(latest_total_mileage_km) - latest_total_mileage_km",
@@ -281,6 +279,26 @@ func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
}
}
func TestIsUsableDailyMileageBoundaryRejectsOlderOfflineGap(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
for _, test := range []struct {
name string
baseline time.Time
want bool
}{
{name: "current day cache", baseline: time.Date(2026, 8, 4, 19, 1, 26, 0, loc), want: true},
{name: "previous natural day", baseline: time.Date(2026, 8, 3, 23, 59, 59, 0, loc), want: true},
{name: "older offline gap", baseline: time.Date(2026, 5, 21, 23, 13, 2, 0, loc), want: false},
{name: "future boundary", baseline: time.Date(2026, 8, 5, 0, 0, 0, 0, loc), want: false},
} {
t.Run(test.name, func(t *testing.T) {
if got := IsUsableDailyMileageBoundary("2026-08-04", test.baseline, loc); got != test.want {
t.Fatalf("IsUsableDailyMileageBoundary() = %v, want %v", got, test.want)
}
})
}
}
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
exec := &recordingExec{}
sample := SourceMileageSample{
@@ -612,6 +630,15 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"project": projectFinal,
"mark": markSelected,
} {
// The project statement also contains an independent day-end odometer
// subquery. Check the daily-mileage election order after its main source
// FROM clause, rather than mistaking the odometer fallback's freshness
// order for the elected daily-distance source order.
if queryName == "project" {
if index := strings.Index(query, "FROM vehicle_daily_mileage_source s\nLEFT JOIN"); index >= 0 {
query = query[index:]
}
}
identityScore := strings.Index(query, "SUM(identity_source.sample_count)")
freshness := strings.Index(query, "latest_event_time DESC")
rowSamples := strings.LastIndex(query, "sample_count DESC")
@@ -699,3 +726,16 @@ func expectProjectDailyMileageSQL(mock sqlmock.Sqlmock, vin string, statDate str
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
}
func TestProjectDailyMileagePersistsIndependentDayEndTotal(t *testing.T) {
for _, want := range []string{
"day_end_total_mileage_km",
"total_candidate.quality_status = 'OK'",
"total_candidate.quality_reason, '') = 'gps_coordinate_accumulation'",
"day_end_total_mileage_km = VALUES(day_end_total_mileage_km)",
} {
if !strings.Contains(projectDailyMileageSQL, want) {
t.Fatalf("project SQL missing %q:\n%s", want, projectDailyMileageSQL)
}
}
}
@@ -245,6 +245,16 @@ func TestDailyMileageSchemaIncludesPureHydrogenEvidence(t *testing.T) {
}
}
func TestDailyMileageSchemaIncludesDayEndTotalMileage(t *testing.T) {
const column = "day_end_total_mileage_km DECIMAL(18,3) NULL"
if !strings.Contains(DailyMileageTableSQL, column) {
t.Fatalf("daily mileage schema missing %q:\n%s", column, DailyMileageTableSQL)
}
if !containsStatement(DailyMileageAlterSQL, "ALTER TABLE vehicle_daily_mileage ADD COLUMN "+column) {
t.Fatalf("daily mileage alter SQL missing %q: %#v", column, DailyMileageAlterSQL)
}
}
func containsStatement(statements []string, fragment string) bool {
for _, statement := range statements {
if strings.Contains(statement, fragment) {
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
BINARY="${BINARY:-/opt/lingniu-go-native/current/stats-backfill}"
DATE_FROM="${DATE_FROM:-}"
DATE_TO="${DATE_TO:-}"
PARALLEL="${PARALLEL:-4}"
APPLY=false
WORK_DIR=""
usage() {
cat <<'USAGE'
Usage: replay-pure-hydrogen-mileage.sh --date-from YYYY-MM-DD --date-to YYYY-MM-DD [options]
Options:
--apply Write the rebuilt daily mileage rows. Default is dry-run.
--parallel N Concurrent VIN workers. Default: 4.
--binary PATH stats-backfill binary path.
--work-dir PATH Keep VIN lists and per-VIN logs in this directory.
USAGE
}
if [[ "${1:-}" == "--worker" ]]; then
vin="$2"
dry_run="$3"
log_dir="$4"
env \
BACKFILL_METHOD=scan \
BACKFILL_DRY_RUN="$dry_run" \
BACKFILL_DATE_FROM="$DATE_FROM" \
BACKFILL_DATE_TO="$DATE_TO" \
BACKFILL_PROTOCOLS=GB32960 \
BACKFILL_VINS="$vin" \
BACKFILL_PROGRESS_EVERY=100000 \
"$BINARY" >"$log_dir/$vin.log" 2>&1
printf 'completed vin=%s\n' "$vin"
exit 0
fi
while (($#)); do
case "$1" in
--date-from) DATE_FROM="$2"; shift 2 ;;
--date-to) DATE_TO="$2"; shift 2 ;;
--parallel) PARALLEL="$2"; shift 2 ;;
--binary) BINARY="$2"; shift 2 ;;
--work-dir) WORK_DIR="$2"; shift 2 ;;
--apply) APPLY=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ ! "$DATE_FROM" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ || ! "$DATE_TO" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "--date-from and --date-to are required" >&2
exit 2
fi
if [[ ! "$PARALLEL" =~ ^[1-9][0-9]*$ ]]; then
echo "--parallel must be a positive integer" >&2
exit 2
fi
if [[ ! -x "$BINARY" ]]; then
echo "stats-backfill is not executable: $BINARY" >&2
exit 2
fi
if [[ -z "$WORK_DIR" ]]; then
WORK_DIR="$(mktemp -d /tmp/pure-hydrogen-replay.XXXXXX)"
else
mkdir -p "$WORK_DIR"
fi
LOG_DIR="$WORK_DIR/logs"
mkdir -p "$LOG_DIR"
hydrogen_vins="$WORK_DIR/hydrogen-vins.txt"
active_vins="$WORK_DIR/active-gb32960-vins.txt"
target_vins="$WORK_DIR/target-vins.txt"
pending_vins="$WORK_DIR/pending-vins.txt"
BACKFILL_LIST_HYDROGEN_VINS=true "$BINARY" | LC_ALL=C sort -u >"$hydrogen_vins"
env \
BACKFILL_LIST_ACTIVE_VINS=true \
BACKFILL_DATE_FROM="$DATE_FROM" \
BACKFILL_DATE_TO="$DATE_TO" \
BACKFILL_PROTOCOLS=GB32960 \
"$BINARY" | LC_ALL=C sort -u >"$active_vins"
comm -12 "$hydrogen_vins" "$active_vins" >"$target_vins"
target_count="$(wc -l <"$target_vins" | tr -d ' ')"
: >"$pending_vins"
while IFS= read -r vin; do
if [[ -f "$LOG_DIR/$vin.log" ]] && grep -q 'stats backfill complete' "$LOG_DIR/$vin.log"; then
continue
fi
echo "$vin" >>"$pending_vins"
done <"$target_vins"
pending_count="$(wc -l <"$pending_vins" | tr -d ' ')"
dry_run=true
if [[ "$APPLY" == true ]]; then
dry_run=false
fi
printf 'pure-hydrogen replay: date=%s..%s vins=%s pending=%s parallel=%s dry_run=%s work_dir=%s\n' \
"$DATE_FROM" "$DATE_TO" "$target_count" "$pending_count" "$PARALLEL" "$dry_run" "$WORK_DIR"
if [[ "$pending_count" == "0" ]]; then
printf 'pure-hydrogen replay complete: vins=%s logs=%s\n' "$target_count" "$LOG_DIR"
exit 0
fi
export BINARY DATE_FROM DATE_TO
set +e
xargs -I '{}' -P "$PARALLEL" "$0" --worker '{}' "$dry_run" "$LOG_DIR" <"$pending_vins"
xargs_status=$?
set -e
failed_vins="$WORK_DIR/failed-vins.txt"
: >"$failed_vins"
while IFS= read -r vin; do
if [[ ! -f "$LOG_DIR/$vin.log" ]] || ! grep -q 'stats backfill complete' "$LOG_DIR/$vin.log"; then
echo "$vin" >>"$failed_vins"
fi
done <"$target_vins"
if [[ "$xargs_status" != "0" || -s "$failed_vins" ]]; then
echo "replay incomplete; inspect $failed_vins and $LOG_DIR" >&2
exit 1
fi
printf 'pure-hydrogen replay complete: vins=%s logs=%s\n' "$target_count" "$LOG_DIR"
+25 -10
View File
@@ -52,6 +52,9 @@ function stationSearchScore(station, query) {
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
function adminNameKey(value) { return String(value || '').trim().replace(/(特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区|省|市)$/, ''); }
function isMunicipalityProvince(value) { return ['北京', '上海', '天津', '重庆'].includes(adminNameKey(value)); }
function isMunicipalityCity(province, city) { return isMunicipalityProvince(province) && adminNameKey(province) === adminNameKey(city); }
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
@@ -106,7 +109,14 @@ function renderList() {
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)}`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join('');
box.innerHTML = display.slice(0, 100).map((node, index) => {
const station = node.kind === 'station' ? node.station : null, distance = station ? stationDistance(station) : Number.POSITIVE_INFINITY;
const partnerTag = station ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '';
const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)}`;
const price = station?.cooperative ? stationUnitPrice(station) : '';
const valueStack = price || rowValue ? `<span class="r-value-stack">${price ? `<span class="station-list-price">${escapeHTML(price)}</span>` : ''}${rowValue ? `<span class="r-val">${escapeHTML(rowValue)}</span>` : ''}</span>` : '';
return `<button type="button" class="rank-glass-row${station && state.selected?.id === station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${valueStack}</button>`;
}).join('');
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
}
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
@@ -117,7 +127,7 @@ function fuzzySearchScore(value, query) {
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
}
function compactPath(parts) { return parts.filter(Boolean).join(' · '); }
function compactPath(parts) { return parts.filter(Boolean).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
function stationLocationOptions() {
const groups = new Map();
const add = (level, station) => {
@@ -127,12 +137,13 @@ function stationLocationOptions() {
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
groups.get(key).stations.push(station);
};
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city && !isMunicipalityCity(station.province, station.city)) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
return [...groups.values()].map(option => {
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
const label = option.level === 'district' ? option.district : stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city');
return { ...option, label, path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
});
}
function stationLocationSuggestions(query) {
@@ -180,9 +191,10 @@ function regionPickerOptions() {
const picker = state.regionPicker; if (!picker) return [];
const all = stationLocationOptions(); const needle = normalize(picker.search);
if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
if (picker.level === 'province') return all.filter(option => option.level === 'province');
if (picker.level === 'province') return all.filter(option => option.level === 'province').sort((left, right) => right.stations.length - left.stations.length || left.label.localeCompare(right.label, 'zh-CN'));
if (picker.level === 'city' && isMunicipalityProvince(picker.province)) return all.filter(option => option.level === 'district' && option.province === picker.province);
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city);
return all.filter(option => option.level === 'district' && option.province === picker.province && (!picker.city || option.city === picker.city));
}
function regionPickerOptionPath(option) {
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
@@ -213,7 +225,7 @@ function renderRegionPicker() {
}
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' && isMunicipalityProvince(option.province) ? 'district' : option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.level === 'province' && isMunicipalityProvince(option.province) ? '' : option.city || ''; renderRegionPicker(); }
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
@@ -239,11 +251,14 @@ function selectStation(station, fit = false) {
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
const distance = stationDistance(station), partnerFields = cooperative ? `${detailField('单价', stationUnitPrice(station) || '暂无')}${detailField('联系人', station.contactPerson || '暂无')}${detailPhoneField('联系方式', station.contactPhone)}` : '';
document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${partnerFields}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
}
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); }
function detailPhoneField(label, phone) { const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, ''); return `<div class="detail-field"><dt>${escapeHTML(label)}</dt><dd>${href ? `<a class="detail-phone-link" href="tel:${escapeAttribute(href)}">${escapeHTML(value)}</a>` : '暂无'}</dd></div>`; }
function stationUnitPrice(station) { const value = Number(station?.unitPrice); return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : ''; }
function stationRegion(station) { return compactPath([station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]')); }
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
@@ -254,7 +269,7 @@ function distanceKm(origin, target) { const radians = value => value * Math.PI /
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'), target = window.open('', '_blank'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => { const url = `https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`; if (target) { target.opener = null; target.location.href = url; } else window.open(url, '_blank', 'noopener'); }, 140); }
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
+44 -2
View File
@@ -31,6 +31,8 @@ STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120")
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
def _static_asset_version():
@@ -80,15 +82,50 @@ def _post_open_platform(path, body):
return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True).start()
def _cached(key, ttl_seconds, loader):
now = time.time()
refresh_in_background = False
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
with load_lock:
with _cache_lock:
cached = _cache.get(key)
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (now, value)
_cache[key] = (time.time(), value)
return value
@@ -137,7 +174,7 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
return
if path == "/api/stations":
try:
self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory))
payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc:
self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, {
@@ -146,6 +183,8 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
"message": "加氢站数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/assets/logo_light.svg":
self._serve_logo()
return
@@ -213,7 +252,10 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
+5
View File
@@ -66,6 +66,9 @@
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; }
.station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; }
.station-navigation-shell .r-value-stack { display: flex; flex: 0 0 auto; align-items: flex-end; gap: 4px; margin-left: auto; }
.station-navigation-shell .station-list-price { color: var(--accent-primary); font: 700 9px/1 'JetBrains Mono', monospace; letter-spacing: -.03em; white-space: nowrap; }
.station-navigation-shell .detail-phone-link { color: var(--accent-primary); font: inherit; font-weight: 700; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent); text-underline-offset: 2px; }
.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; }
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
@media (max-width: 760px) {
@@ -209,6 +212,8 @@
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
.station-navigation-shell .glass-cell { padding: 6px; }
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
.station-navigation-shell .r-value-stack { flex-direction: column; align-items: flex-end; gap: 3px; }
.station-navigation-shell .station-list-price { font-size: 8px; }
}
@media (max-width: 390px) {
+48 -1
View File
@@ -63,6 +63,30 @@ vm.runInNewContext(`
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
assert.equal(sandbox.regionCityLabels, '佛山,广州');
assert.match(sandbox.regionSearchLabels, /嘉兴/);
vm.runInNewContext(`
state.stations = [
{ id: 'sh-hp', name: '上海黄浦站', province: '上海市', city: '上海市', district: '黄浦区', longitude: 121.5, latitude: 31.2, cooperative: true },
{ id: 'sh-pd', name: '上海浦东站', province: '上海市', city: '上海市', district: '浦东新区', longitude: 121.6, latitude: 31.2, cooperative: true },
{ id: 'gd-gz', name: '广州站', province: '广东省', city: '广州市', district: '天河区', longitude: 113.3, latitude: 23.1, cooperative: true },
{ id: 'zj-hz-1', name: '杭州站一', province: '浙江省', city: '杭州市', district: '西湖区', longitude: 120.1, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-2', name: '杭州站二', province: '浙江省', city: '杭州市', district: '上城区', longitude: 120.2, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-3', name: '杭州站三', province: '浙江省', city: '杭州市', district: '拱墅区', longitude: 120.3, latitude: 30.2, cooperative: true }
];
globalThis.shanghaiCityOption = stationLocationOptions().find(option => option.level === 'city' && option.province === '上海市');
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
globalThis.primaryRegionLabels = regionPickerOptions().map(option => option.label).join(',');
state.regionPicker = { level: 'city', province: '上海市', city: '', search: '', draft: [], options: [] };
globalThis.shanghaiDistrictLabels = regionPickerOptions().map(option => option.label).sort().join(',');
globalThis.shanghaiPath = compactPath(['上海市', '上海市', '黄浦区']);
globalThis.shanghaiRegion = stationRegion(state.stations[0]);
globalThis.directMunicipalityFlags = ['北京市', '上海市', '天津市', '重庆市'].map(isMunicipalityProvince).join(',');
`, sandbox);
assert.equal(sandbox.shanghaiCityOption, undefined);
assert.equal(sandbox.primaryRegionLabels, '浙江,上海,广东');
assert.equal(sandbox.shanghaiDistrictLabels, '浦东新区,黄浦区');
assert.equal(sandbox.shanghaiPath, '上海市 · 黄浦区');
assert.equal(sandbox.shanghaiRegion, '上海市 · 黄浦区');
assert.equal(sandbox.directMunicipalityFlags, 'true,true,true,true');
vm.runInNewContext(`
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
state.locationFilters = [];
@@ -87,6 +111,26 @@ vm.runInNewContext(`
`, sandbox);
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
assert.equal(sandbox.stationUnitPrice({ unitPrice: 12.5 }), '¥12.50 / kg');
assert.equal(sandbox.stationUnitPrice({ unitPrice: 0 }), '');
assert.match(sandbox.detailPhoneField('联系方式', '138 0000 0000'), /href="tel:13800000000"/);
vm.runInNewContext(`
const detailNodes = {
detailName: { textContent: '' }, detailRegion: { textContent: '' },
detailType: { textContent: '', classList: { toggle() {} } },
detailFields: { innerHTML: '' }, stationDetail: { hidden: true }
};
document.getElementById = id => detailNodes[id] || null;
state.map = null; state.selected = null;
selectStation({ id: 'partner-detail', name: '合作站', cooperative: true, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, contactPerson: '张工', contactPhone: '138 0000 0000', unitPrice: 12.5 });
globalThis.partnerDetailHtml = detailNodes.detailFields.innerHTML;
selectStation({ id: 'external-detail', name: '外部站', cooperative: false, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.4, latitude: 23.1, contactPerson: '李工', contactPhone: '13900000000', unitPrice: 10.5 });
globalThis.externalDetailHtml = detailNodes.detailFields.innerHTML;
`, sandbox);
assert.match(sandbox.partnerDetailHtml, /张工/);
assert.match(sandbox.partnerDetailHtml, /tel:13800000000/);
assert.match(sandbox.partnerDetailHtml, /¥12\.50 \/ kg/);
assert.doesNotMatch(sandbox.externalDetailHtml, /李工|13900000000|¥10\.50/);
assert.match(source, /zoom < 11\) return 'city'/);
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
@@ -106,12 +150,15 @@ assert.match(source, /focusSearchResults\(\)/);
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
assert.match(source, /station-list-partner-tag/);
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
assert.match(source, /node\.kind === 'station' \? '' : `\$\{formatNumber\(node\.count\)\} 座`/);
assert.match(source, /const rowValue = Number\.isFinite\(distance\)/);
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
assert.match(source, /uri\.amap\.com\/navigation/);
assert.match(source, /window\.open\('', '_blank'\)/);
assert.doesNotMatch(source, /window\.location\.assign/);
assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
assert.match(source, /station-list-price/);
assert.match(styles, /station-summary-strip/);
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
+25
View File
@@ -2,6 +2,8 @@ import importlib.util
import io
import json
from pathlib import Path
import threading
import time
import unittest
from unittest.mock import patch
@@ -49,6 +51,29 @@ class StationNavigationTest(unittest.TestCase):
self.assertEqual(calls, ["load"])
self.assertEqual(first, second)
def test_expired_directory_returns_stale_value_while_refreshing_in_background(self):
started = threading.Event()
release = threading.Event()
def loader():
started.set()
release.wait(1)
return {"version": 2}
cache = {"station-directory": (time.time() - 121, {"version": 1})}
with patch.object(server, "_cache", cache), \
patch.object(server, "_cache_refreshing", set()), \
patch.object(server, "_cache_load_locks", {}):
result = server._cached("station-directory", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["station-directory"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["station-directory"][1], {"version": 2})
if __name__ == "__main__":
unittest.main()
@@ -7,7 +7,10 @@ import (
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
@@ -22,6 +25,12 @@ func main() {
lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date")
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild")
brand := flag.String("brand", "", "optional exact vehicle brand for an atomic scoped rebuild")
vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day")
allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin")
dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL")
seedStream := flag.Bool("seed-stream-state", false, "atomically seed current-day segment stream watermarks during a controlled writer handoff")
flag.Parse()
cfg := config.Load()
@@ -50,24 +59,224 @@ func main() {
if *lookback < 1 || *lookback > 31 {
log.Fatal("-lookback-days must be between 1 and 31")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
if *vinWorkers < 1 || *vinWorkers > 16 {
log.Fatal("-vin-workers must be between 1 and 16")
}
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
normalizedBrand := strings.TrimSpace(*brand)
if normalizedVIN != "" && normalizedBrand != "" {
log.Fatal("-vin and -brand cannot be combined")
}
if *seedStream && normalizedVIN != "" {
log.Fatal("-seed-stream-state requires an all-vehicle rebuild")
}
if *seedStream && normalizedBrand != "" {
log.Fatal("-seed-stream-state cannot be combined with -brand")
}
if *seedStream && *dryRun {
log.Fatal("-seed-stream-state cannot be combined with -dry-run")
}
tdengineDB.SetMaxOpenConns(*vinWorkers)
timeout := 30 * time.Minute
if *allDates {
timeout = 6 * time.Hour
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
if err != nil {
log.Fatalf("load hydrogen tank capacities: %v", err)
}
for offset := *lookback - 1; offset >= 0; offset-- {
start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset)
observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities)
if normalizedVIN != "" {
if _, ok := capacities[normalizedVIN]; !ok {
log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
}
}
var brandVINs []string
if normalizedBrand != "" {
brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand)
if err != nil {
log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err)
log.Fatalf("load VINs for brand %q: %v", normalizedBrand, 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)
if len(brandVINs) == 0 {
log.Fatalf("no vehicles found for brand %q", normalizedBrand)
}
fmt.Printf("date=%s observations=%d vehicles=%d\n", start.Format("2006-01-02"), len(observations), len(stats))
brandSet := make(map[string]struct{}, len(brandVINs))
for _, brandVIN := range brandVINs {
brandSet[brandVIN] = struct{}{}
}
for capacityVIN := range capacities {
if _, ok := brandSet[capacityVIN]; !ok {
delete(capacities, capacityVIN)
}
}
if len(capacities) == 0 {
log.Fatalf("no active hydrogen tank capacities found for brand %q", normalizedBrand)
}
fmt.Printf("brand=%s scoped_vins=%d capacity_vins=%d\n", normalizedBrand, len(brandVINs), len(capacities))
}
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
} else if normalizedBrand != "" {
scope = "brand:" + normalizedBrand
}
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 normalizedBrand != "" {
err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, 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 {
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return
}
}
if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") {
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r))
return
}
required := requiredRole(r)
if roleRank(principal.Role) < roleRank(required) {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
switch {
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
return "monitor"
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
return "shared"
case path == "/api/v2/tracks":
return "tracks"
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
var adminMenuSet = map[string]bool{
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
"alerts": true, "access": true, "operations": true, "users": true,
}
type authUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
@@ -551,14 +556,13 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local
}
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
menus := append([]string(nil), adminMenus...)
menus := []string{}
vehicles := []string{}
vehicleGrants := []platform.VehicleGrant{}
businessScopeLevel := ""
departmentIDs := []string{}
responsibleUserID := ""
if user.UserType == "customer" {
menus = []string{}
if user.UserType == "customer" || user.UserType == "admin" {
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
if err != nil {
return platform.Principal{}, err
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
rows.Close()
return platform.Principal{}, err
}
if customerMenuSet[value] {
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
menus = append(menus, value)
}
}
if err := rows.Close(); err != nil {
return platform.Principal{}, err
}
rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
}
if user.UserType == "admin" && len(menus) == 0 {
menus = append([]string(nil), adminMenus...)
}
if user.UserType == "customer" {
rows, err := s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
if err != nil {
return platform.Principal{}, err
}
@@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
}
}
func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local",
})
if err != nil {
t.Fatal(err)
}
if !principal.CanMenu("operations") || principal.CanMenu("users") {
t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -1,6 +1,7 @@
package app
import (
"crypto/sha256"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
}
}
func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) {
token := "restricted-admin-token-at-least-16"
hash := sha256.Sum256([]byte(token))
authenticator := &apiAuthenticator{
mode: "enforce",
tokens: []tokenPrincipal{{
hash: hash,
principal: platform.Principal{
Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin",
MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"},
},
}},
}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := authenticator.middleware(next)
usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)
usersRequest.Header.Set("Authorization", "Bearer "+token)
usersResponse := httptest.NewRecorder()
handler.ServeHTTP(usersResponse, usersRequest)
if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") {
t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String())
}
operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil)
operationsRequest.Header.Set("Authorization", "Bearer "+token)
operationsResponse := httptest.NewRecorder()
handler.ServeHTTP(operationsResponse, operationsRequest)
if operationsResponse.Code != http.StatusNoContent {
t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code)
}
}
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
@@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
}
}
func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) {
const token = "customer-token-at-least-16"
hash := sha256.Sum256([]byte(token))
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
for _, menu := range customerMenuKeys {
t.Run(menu, func(t *testing.T) {
authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{
hash: hash,
principal: platform.Principal{
Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu},
},
}}}
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
authenticator.middleware(next).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String())
}
})
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{
{AuthMode: "enforce"},
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ info:
license:
name: Proprietary
description: |
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位。
appKey 和逐车授权必须完整覆盖查询自然日。
servers:
- url: /
@@ -54,7 +54,7 @@ paths:
description: |
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
operationId: queryDailyMileage
security:
@@ -92,7 +92,7 @@ paths:
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。
operationId: queryDailyMileageRange
security:
- AppKeyAuth: []
@@ -160,6 +160,46 @@ paths:
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/stationary/query:
post:
tags: [合作方数据接口]
summary: 加氢站车辆停留核验
description: |
用于加氢车牌核验。传入加氢站经度、纬度、坐标系、半径和北京时间区间,返回范围内速度不大于 3 km/h 的授权车辆停留区间。
coordinateSystem 支持 WGS84(默认)和 GCJ02(高德坐标);服务端会将 GCJ02 转换为 WGS84 后进行核验。
同一车辆相邻静止点间隔超过 10 分钟会拆分为不同停留;仅返回至少 2 个定位样本且持续不少于 60 秒的停留。
最大查询区间为 24 小时。结果按 matchScore 从高到低排序;分数综合平均距离、最高速度、停留时长和采样数量,供人工核验使用,不等同于加氢交易凭证。
operationId: queryStationaryVehicles
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StationaryVehicleQuery'
example:
startTime: '2026-08-06 10:00:00'
endTime: '2026-08-06 12:00:00'
longitude: 120.752312
latitude: 30.746281
coordinateSystem: GCJ02
radiusMeters: 5
responses:
'200':
description: 查询成功;无匹配车辆时 data 为空数组
content:
application/json:
schema:
$ref: '#/components/schemas/StationaryVehicleQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/realtime/query:
post:
tags: [合作方数据接口]
@@ -167,7 +207,9 @@ paths:
description: |
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
operationId: queryRealtimeVehicles
security:
@@ -492,6 +534,52 @@ components:
type: string
enum: [GB32960, YUTONG_MQTT, JT808]
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
StationaryVehicleQuery:
type: object
additionalProperties: false
required: [startTime, endTime, longitude, latitude]
properties:
startTime:
type: string
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
description: 开始时间,北京时间,yyyy-MM-dd HH:mm:ss
endTime:
type: string
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
description: 结束时间,北京时间;须晚于开始时间,最长 24 小时
longitude:
type: number
format: double
minimum: -180
maximum: 180
description: 加氢站经度;坐标系由 coordinateSystem 指定
latitude:
type: number
format: double
minimum: -90
maximum: 90
description: 加氢站纬度;坐标系由 coordinateSystem 指定
coordinateSystem:
type: string
enum: [WGS84, GCJ02]
default: WGS84
description: 坐标系;WGS84 为默认值,GCJ02 为高德地图坐标。GCJ02 会在服务端转换为 WGS84 后核验
radiusMeters:
type: number
format: double
minimum: 1
maximum: 100
default: 5
description: 核验半径,单位米;省略时为 5 米
plateNumbers:
type: array
maxItems: 2000
uniqueItems: true
items:
type: string
minLength: 1
maxLength: 32
description: 可选;省略或传空数组时核验整个时间区间均有效授权的全部车辆
RealtimeVehicleQuery:
type: object
additionalProperties: false
@@ -532,6 +620,13 @@ components:
format: double
nullable: true
description: 单日用氢量,kg;无数据时为 null
calculationPhase:
type: string
enum: [PRELIMINARY, FINAL]
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
algorithmVersion:
type: string
description: 氢耗计算算法版本
status:
$ref: '#/components/schemas/DataStatus'
MileageResult:
@@ -554,7 +649,7 @@ components:
type: number
format: double
nullable: true
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null
description: 当日所选协议最后有效终端累计总里程,kmGPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
dataTime:
type: string
format: date-time
@@ -570,6 +665,11 @@ components:
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
status:
$ref: '#/components/schemas/DataStatus'
MileageRangeResult:
@@ -594,7 +694,7 @@ components:
format: double
nullable: true
minimum: 0
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
description: 当日所选协议终端累计总里程;GPS 日里程估算不会作为累计总里程,缺日时沿用此前最近有效值
dataTime:
type: string
format: date-time
@@ -609,11 +709,17 @@ components:
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
status:
$ref: '#/components/schemas/DataStatus'
DataStatus:
type: string
enum: [NORMAL, NO_DATA]
enum: [NORMAL, NO_DATA, DATA_ANOMALY]
description: NORMAL=存在可用数据;NO_DATA=授权范围内无可用数据;DATA_ANOMALY=检测到数据异常,详情见 dataQuality
HydrogenQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
@@ -694,6 +800,25 @@ components:
example: GB32960 > YUTONG_MQTT > JT808
status:
$ref: '#/components/schemas/DataStatus'
StationaryVehicleResult:
type: object
required: [vin, plateNumber, stayStartTime, stayEndTime, stayDurationSeconds, stayDurationMinutes, matchScore, averageDistanceMeters, maxDistanceMeters, averageSpeedKmh, maxSpeedKmh, matchedSamples, sourceProtocols]
properties:
vin: { type: string, description: 车辆唯一 VIN }
plateNumber: { type: string, description: 车辆车牌号 }
stayStartTime: { type: string, description: 停留开始定位时间,北京时间 }
stayEndTime: { type: string, description: 停留结束定位时间,北京时间 }
stayDurationSeconds: { type: integer, description: 停留长度,秒 }
stayDurationMinutes: { type: number, format: double, description: 停留长度,分钟 }
matchScore: { type: number, format: double, minimum: 0, maximum: 100, description: 匹配度,越高表示位置更接近、速度更低、停留更久且样本更充分 }
averageDistanceMeters: { type: number, format: double }
maxDistanceMeters: { type: number, format: double }
averageSpeedKmh: { type: number, format: double }
maxSpeedKmh: { type: number, format: double, maximum: 3 }
matchedSamples: { type: integer, minimum: 2 }
sourceProtocols:
type: array
items: { type: string, enum: [GB32960, MQTT, JT808] }
TotalMileageQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
@@ -702,22 +827,37 @@ components:
properties:
data:
$ref: '#/components/schemas/TotalMileageResult'
StationaryVehicleQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/StationaryVehicleResult'
RealtimeVehicleResult:
type: object
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
properties:
vin: { type: string }
plateNumber: { type: string }
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] }
longitude: { type: number, format: double, nullable: true }
latitude: { type: number, format: double, nullable: true }
speedKmh: { type: number, format: double, nullable: true }
totalMileageKm: { type: number, format: double, nullable: true }
recordTime: { type: string }
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 }
protocol:
type: string
enum: [GB32960, MQTT, JT808]
description: 本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源
longitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效经度;无位置时为 null }
latitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效纬度;无位置时为 null }
speedKmh: { type: number, format: double, nullable: true, description: 所选来源瞬时速度,单位 km/h }
socPercent: { type: number, format: double, minimum: 0, maximum: 100, description: 所选来源的动力电池荷电状态,单位 %;无有效采集值时字段省略 }
totalMileageKm: { type: number, format: double, nullable: true, description: 所选来源累计总里程,单位 km }
recordTime: { type: string, description: 所选来源实际记录时间,北京时间 }
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0, description: 当前查询时间减 recordTime,单位秒 }
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
motionStatus: { type: string, enum: [driving, idle, offline] }
locationAvailable: { type: boolean }
activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 }
motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/hidle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 }
locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 }
status: { $ref: '#/components/schemas/DataStatus' }
RealtimeVehicleQueryResponse:
allOf:
@@ -46,7 +46,7 @@ func WithDocs(next http.Handler) http.Handler {
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
case SimpleDocsPath:
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
default:
next.ServeHTTP(w, r)
}
@@ -35,6 +35,9 @@ func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("documentation asset must set nosniff")
}
if test.path == SimpleDocsPath && !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'") {
t.Fatal("simple documentation must allow its bundled interactive field renderer")
}
})
}
}
@@ -61,12 +64,14 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
HydrogenQueryPath + ":",
MileageQueryPath + ":",
MileageRangeQueryPath + ":",
StationaryVehicleQueryPath + ":",
"/api/v2/open-platform/apps:",
"AppKeyAuth:",
"AdminBearer:",
"省略或传空数组时",
"protocolPriority:",
"sourceProtocol:",
StationaryVehicleQueryPath,
"enum: [GB32960, MQTT, JT808]",
} {
if !strings.Contains(spec, want) {
@@ -21,6 +21,7 @@ const (
MileageQueryPath = "/api/v1/vehicles/mileage/query"
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
StationaryVehicleQueryPath = "/api/v1/vehicles/stationary/query"
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
)
@@ -61,6 +62,7 @@ func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
h.mux.HandleFunc("POST "+StationaryVehicleQueryPath, h.stationaryVehicles)
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
}
@@ -110,6 +112,7 @@ func NewDataHandler(service *Service) *Handler {
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
handler.mux.HandleFunc("POST "+StationaryVehicleQueryPath, handler.stationaryVehicles)
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
return handler
@@ -120,7 +123,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func IsPublicPath(path string) bool {
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
}
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request StationaryVehicleQueryRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryStationaryVehicles(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
@@ -684,7 +701,9 @@ func writeExternal(w http.ResponseWriter, status int, response any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
_ = encoder.Encode(response)
}
func externalBearer(r *http.Request) string {
@@ -759,6 +778,12 @@ func dataProducts() []DataProduct {
Version: "v1", Status: "available", Method: http.MethodPost,
Path: TotalMileageQueryPath, Unit: "km",
},
{
Code: "stationary_vehicle_verification", Name: "加氢车辆停留核验",
Description: "按加氢站坐标与时间区间,核验授权车辆是否在 5 米内低速停留,并按匹配度排序。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: StationaryVehicleQueryPath, Unit: "辆",
},
{
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
@@ -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
}
@@ -8,6 +8,7 @@ import (
const (
StatusNormal = "NORMAL"
StatusNoData = "NO_DATA"
StatusDataAnomaly = "DATA_ANOMALY"
)
type QueryRequest struct {
@@ -48,6 +49,8 @@ type HydrogenResult struct {
PlateNumber string `json:"plateNumber"`
Date string `json:"date"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
CalculationPhase string `json:"calculationPhase,omitempty"`
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
Status string `json:"status"`
}
@@ -60,6 +63,7 @@ type MileageResult struct {
DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"`
DataQuality *string `json:"dataQuality,omitempty"`
Status string `json:"status"`
}
@@ -72,6 +76,7 @@ type MileageRangeResult struct {
DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"`
DataQuality *string `json:"dataQuality,omitempty"`
Status string `json:"status"`
}
@@ -101,6 +106,44 @@ type TotalMileagePoint struct {
TotalMileageKm float64
}
// StationaryVehicleQueryRequest verifies whether an authorized vehicle stayed
// at a refuelling-site coordinate during a supplied time window. Coordinates
// may be supplied in WGS84 or GCJ02 (Amap) longitude/latitude.
type StationaryVehicleQueryRequest struct {
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
CoordinateSystem string `json:"coordinateSystem,omitempty"`
RadiusMeters *float64 `json:"radiusMeters,omitempty"`
PlateNumbers []string `json:"plateNumbers,omitempty"`
}
type StationaryVehicleResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
StayStartTime string `json:"stayStartTime"`
StayEndTime string `json:"stayEndTime"`
StayDurationSeconds int64 `json:"stayDurationSeconds"`
StayDurationMinutes float64 `json:"stayDurationMinutes"`
MatchScore float64 `json:"matchScore"`
AverageDistanceM float64 `json:"averageDistanceMeters"`
MaxDistanceM float64 `json:"maxDistanceMeters"`
AverageSpeedKmh float64 `json:"averageSpeedKmh"`
MaxSpeedKmh float64 `json:"maxSpeedKmh"`
MatchedSamples int `json:"matchedSamples"`
SourceProtocols []string `json:"sourceProtocols"`
}
type StationaryLocationPoint struct {
VIN string
Protocol string
ObservedAt time.Time
Longitude float64
Latitude float64
SpeedKmh float64
}
type RealtimeVehicleRequest struct {
PlateNumbers []string `json:"plateNumbers,omitempty"`
}
@@ -111,22 +154,28 @@ type RealtimeVehiclePoint struct {
Longitude float64
Latitude float64
SpeedKmh float64
SOCPercent *float64
TotalMileageKm float64
ObservedAt time.Time
Online bool
ActiveToday bool
}
type RealtimeVehicleResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
// Protocol is the single, canonical source protocol for this realtime record.
// It is normalized to GB32960, MQTT, or JT808.
Protocol string `json:"protocol,omitempty"`
Longitude *float64 `json:"longitude"`
Latitude *float64 `json:"latitude"`
SpeedKmh *float64 `json:"speedKmh"`
SOCPercent *float64 `json:"socPercent,omitempty"`
TotalMileageKm *float64 `json:"totalMileageKm"`
RecordTime string `json:"recordTime,omitempty"`
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
Online bool `json:"online"`
ActiveToday bool `json:"activeToday"`
MotionStatus string `json:"motionStatus"`
LocationAvailable bool `json:"locationAvailable"`
Status string `json:"status"`
@@ -149,6 +198,11 @@ type HydrogenStation struct {
City string `json:"city,omitempty"`
District string `json:"district,omitempty"`
Cooperative bool `json:"cooperative"`
ContactPerson string `json:"contactPerson,omitempty"`
ContactPhone string `json:"contactPhone,omitempty"`
UnitPrice float64 `json:"unitPrice,omitempty"`
MonthlyHydrogenKg float64 `json:"monthlyHydrogenKg"`
TotalHydrogenKg float64 `json:"totalHydrogenKg"`
}
type ExternalResponse struct {
@@ -234,6 +288,8 @@ type DailyHydrogen struct {
ConsumptionKg float64
SampleCount int
QualityStatus string
CalculationPhase string
AlgorithmVersion string
}
type DailyMileage struct {
@@ -260,6 +316,7 @@ type MileageSnapshot struct {
type HydrogenObservation struct {
VIN string
Source string
EventID string
ObservedAt time.Time
MassKg float64
TankCapacityLiter float64
@@ -267,6 +324,73 @@ type HydrogenObservation struct {
TemperatureC float64
NoiseKg float64
RefuelThresholdKg float64
FuelCellActive bool
FuelCellStateKnown bool
FuelCellVoltageV float64
FuelCellCurrentA float64
FuelCellPowerKnown bool
SOCPercent float64
SOCKnown bool
MileageKm float64
MileageKnown bool
VehicleState int
VehicleStateKnown bool
ChargeState int
ChargeStateKnown bool
RunningMode int
RunningModeKnown bool
BoundaryEligible bool
BoundaryReason string
RefuelBoundary bool
RefuelAmountKg float64
PressureInvalid bool
}
type HydrogenCalculationParameters struct {
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
HydrogenEnergyKWhKg float64 `json:"hydrogenEnergyKWhPerKg"`
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
RefuelRiseMPa float64 `json:"refuelRiseMpa"`
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
PureElectricDropMPA float64 `json:"pureElectricDropMpa"`
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
InitialChargeCycleKnown bool `json:"initialChargeCycleKnown,omitempty"`
InitialMixedLocked bool `json:"initialMixedLocked,omitempty"`
AlgorithmVersion string `json:"algorithmVersion"`
}
type HydrogenIntervalEvidence struct {
Index int `json:"index"`
Type string `json:"type"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
StartEventID string `json:"startEventId"`
EndEventID string `json:"endEventId"`
Source string `json:"source"`
StartPressureMPa float64 `json:"startPressureMpa"`
EndPressureMPa float64 `json:"endPressureMpa"`
StartTemperatureC float64 `json:"startTemperatureC"`
EndTemperatureC float64 `json:"endTemperatureC"`
StartMassKg float64 `json:"startMassKg"`
EndMassKg float64 `json:"endMassKg"`
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryEnergyChangeKWh *float64 `json:"batteryEnergyChangeKWh,omitempty"`
ElectricEquivalentKg *float64 `json:"electricEquivalentHydrogenKg,omitempty"`
CorrectedConsumptionKg *float64 `json:"correctedHydrogenConsumptionKg,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
MileageKm *float64 `json:"mileageKm,omitempty"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
}
type HydrogenRateObservation struct {
@@ -294,8 +418,34 @@ type HydrogenDailyStat struct {
ConsumptionKg float64
FirstMassKg float64
LastMassKg float64
CycleMinimumMassKg float64
SampleCount int
RefuelCount int
RefuelAmountKg float64
AbnormalDropCount int
SuspectedLeakCount int
SuspectedLeakMaxPressureDropMPa float64
EligibleIntervalCount int
QualifiedSegmentCount int
ChargeCount int
ChargeEnergyKWh float64
InvalidSegmentCount int
BatterySOCDeltaPct *float64
BatteryEnergyChangeKWh *float64
ElectricEquivalentKg *float64
CorrectedConsumptionKg *float64
BatteryDischargeKWh *float64
BatteryEquivalentKg *float64
SOCBalancedConsumptionKg *float64
MixedMileageKm float64
PureElectricMileageKm float64
ConsumptionKgPer100Km *float64
SOCBalancedKgPer100Km *float64
CalculationParameters HydrogenCalculationParameters
Intervals []HydrogenIntervalEvidence
HydrogenIntervals []HydrogenIntervalEvidence
FirstObservation HydrogenObservation
LastObservation HydrogenObservation
QualityStatus string
QualityReason string
}
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -137,20 +138,65 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
return nil, nil
}
func (r *MySQLRepository) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) {
if r.tdengine == nil || r.tdDatabase == "" {
return nil, errors.New("TDengine is not configured for stationary vehicle query")
}
if len(vins) == 0 {
return []StationaryLocationPoint{}, nil
}
// Use a small bounding box as TDengine's prefilter, then apply the exact
// 5-metre great-circle calculation in the service before returning a match.
latitudeDelta := radiusMeters / 111320.0
longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180))
quotedVINs := make([]string, 0, len(vins))
for _, vin := range vins {
quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'")
}
startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''")
endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''")
query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` +
` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` +
` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` +
` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) +
` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) +
` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) +
` ORDER BY vin,ts ASC,protocol ASC`
rows, err := r.tdengine.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
points := make([]StationaryLocationPoint, 0)
for rows.Next() {
var point StationaryLocationPoint
var timestampMS int64
if err := rows.Scan(&timestampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
return nil, err
}
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
points = append(points, point)
}
return points, rows.Err()
}
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
out := make(map[string]RealtimeVehiclePoint, len(vins))
if len(vins) == 0 {
return out, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
args := make([]any, 0, len(vins)+1)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
args := make([]any, 0, len(vins)+2)
args = append(args, dayStart)
for _, vin := range vins {
args = append(args, vin)
}
args = append(args, now.Add(-10*time.Minute))
query := `
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today
FROM vehicle_realtime_location l
WHERE BINARY l.vin IN (` + placeholders + `)
ORDER BY l.vin,
@@ -165,9 +211,14 @@ ORDER BY l.vin,
onlineThreshold := now.Add(-time.Minute)
for rows.Next() {
var point RealtimeVehiclePoint
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
var soc sql.NullFloat64
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday); err != nil {
return nil, err
}
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
value := round3(soc.Float64)
point.SOCPercent = &value
}
point.Online = !point.ObservedAt.Before(onlineThreshold)
if selected, exists := out[point.VIN]; !exists {
out[point.VIN] = point
@@ -183,35 +234,57 @@ ORDER BY l.vin,
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
where := []string{
"s.longitude BETWEEN -180 AND 180",
"s.latitude BETWEEN -90 AND 90",
"NOT (s.longitude=0 AND s.latitude=0)",
"n.del_flag='0'",
"n.longitude BETWEEN -180 AND 180",
"n.latitude BETWEEN -90 AND 90",
"NOT (n.longitude=0 AND n.latitude=0)",
}
args := make([]any, 0, 3)
args := make([]any, 0, 4)
if request.Province != "" {
where = append(where, "s.province=?")
args = append(args, request.Province)
where = append(where, "(n.province=? OR province_region.NAME=?)")
args = append(args, request.Province, request.Province)
}
if request.City != "" {
where = append(where, "s.city=?")
args = append(args, request.City)
}
if request.CooperateOnly != nil {
if *request.CooperateOnly {
where = append(where, "s.inner_site_id IS NOT NULL")
} else {
where = append(where, "s.inner_site_id IS NULL")
}
where = append(where, "(n.city=? OR city_region.NAME=?)")
args = append(args, request.City, request.City)
}
rows, err := r.db.QueryContext(ctx, `
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
FROM ln_asset_management.tab_outside_hydrogen_site s
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''),
n.longitude,n.latitude,
COALESCE(NULLIF(province_region.NAME,''),n.province,''),
COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''),
CASE WHEN s.inner_site_id IS NOT NULL OR (
n.cooperation_start_date IS NOT NULL
AND n.cooperation_start_date<=CURDATE()
AND (n.cooperation_end_date IS NULL OR n.cooperation_end_date>=CURDATE())
) THEN 1 ELSE 0 END,
COALESCE(n.contact_person,''),COALESCE(NULLIF(n.mobile_phone,''),n.fixed_phone,''),COALESCE(n.cost_price,0),
COALESCE(l.monthly_hydrogen_kg,0),COALESCE(l.total_hydrogen_kg,n.fill_weight,0)
FROM ln_asset_management.new_hydrogen_site n
LEFT JOIN ln_asset_management.common_district province_region
ON province_region.CODE COLLATE utf8mb4_general_ci=n.province COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.common_district city_region
ON city_region.CODE COLLATE utf8mb4_general_ci=n.city COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.tab_outside_hydrogen_site s ON s.id=(
SELECT MIN(matched.id)
FROM ln_asset_management.tab_outside_hydrogen_site matched
WHERE matched.fixed_station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.fixed_station_name=n.site_short_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_short_name COLLATE utf8mb4_general_ci
)
LEFT JOIN (
SELECT station_id,
SUM(amount_kg) AS total_hydrogen_kg,
SUM(CASE WHEN refuel_date >= DATE_FORMAT(CURDATE(),'%Y-%m-01')
AND refuel_date < DATE_ADD(DATE_FORMAT(CURDATE(),'%Y-%m-01'),INTERVAL 1 MONTH)
THEN amount_kg ELSE 0 END) AS monthly_hydrogen_kg
FROM ln_asset_management.hydrogen_fuel_ledger
WHERE del_flag='0'
GROUP BY station_id
) l ON l.station_id=s.inner_site_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY s.province,s.city,s.fixed_station_name,s.id
ORDER BY province_region.NAME,city_region.NAME,n.site_name,n.id
LIMIT 2000`, args...)
if err != nil {
return nil, err
@@ -220,9 +293,15 @@ LIMIT 2000`, args...)
stations := make([]HydrogenStation, 0, 512)
for rows.Next() {
var station HydrogenStation
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative, &station.ContactPerson, &station.ContactPhone, &station.UnitPrice, &station.MonthlyHydrogenKg, &station.TotalHydrogenKg); err != nil {
return nil, err
}
if isExcelSinopecStation(station.Name, station.ShortName) {
station.Cooperative = true
}
if request.CooperateOnly != nil && station.Cooperative != *request.CooperateOnly {
continue
}
stations = append(stations, station)
}
return stations, rows.Err()
@@ -233,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
return map[string]DailyHydrogen{}, nil
}
query, args := inQuery(`
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
calculation_phase,algorithm_version
FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
rows, err := r.db.QueryContext(ctx, query, args...)
@@ -244,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() {
var value DailyHydrogen
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
return nil, err
}
out[value.VIN] = value
@@ -264,6 +344,48 @@ func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date
return out, nil
}
// MileageRollbacks returns source days that were rejected because the odometer
// moved backwards. They must not silently fall back to an earlier total mileage
// and be exposed as normal data.
func (r *MySQLRepository) MileageRollbacks(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]bool, error) {
out := make(map[string]bool)
if len(vins) == 0 {
return out, nil
}
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
query := `
SELECT DISTINCT vin,DATE_FORMAT(stat_date,'%Y-%m-%d')
FROM vehicle_daily_mileage_source
WHERE stat_date BETWEEN ? AND ?
AND vin IN (` + vinPlaceholders + `)
AND quality_reason='TOTAL_MILEAGE_ROLLBACK'`
args := make([]any, 0, len(vins)+2+len(protocols))
args = append(args, startDate, endDate)
for _, vin := range vins {
args = append(args, vin)
}
if len(protocols) > 0 {
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
query += "\n AND protocol IN (" + protocolPlaceholders + ")"
for _, protocol := range protocols {
args = append(args, protocol)
}
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin, date string
if err := rows.Scan(&vin, &date); err != nil {
return nil, err
}
out[dailyMileageKey(vin, date)] = true
}
return out, rows.Err()
}
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
@@ -275,7 +397,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
@@ -289,8 +411,8 @@ SELECT
FROM vehicle_daily_mileage m
WHERE m.stat_date BETWEEN ? AND ?
AND m.vin IN (` + placeholders + `)
AND m.latest_total_mileage_km IS NOT NULL
AND m.latest_total_mileage_km>=0
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km) IS NOT NULL
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km)>=0
AND m.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+2+len(protocols)*2)
args = append(args, startDate, endDate)
@@ -345,7 +467,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
@@ -362,8 +484,8 @@ JOIN (
FROM vehicle_daily_mileage prior
WHERE prior.stat_date<?
AND prior.vin IN (` + vinPlaceholders + `)
AND 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) IS NOT NULL
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=0
AND prior.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+1+len(protocols)*2)
args = append(args, beforeDate)
@@ -75,13 +75,62 @@ func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testin
}
}
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) {
func TestHydrogenStationsUsesNewMasterAndExcelSinopecRoster(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0").
mock.ExpectQuery("cooperation_start_date.*FROM ln_asset_management\\.new_hydrogen_site n.*common_district province_region.*common_district city_region.*ORDER BY province_region\\.NAME,city_region\\.NAME,n\\.site_name,n\\.id").
WithArgs("湖北省", "湖北省", "武汉市", "武汉市").
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "short_name", "address", "longitude", "latitude", "province", "city", "district", "cooperative", "contact_person", "contact_phone", "unit_price", "monthly_hydrogen_kg", "total_hydrogen_kg",
}).
AddRow("1", "武汉群力加油站", "中国石化群力加油站", "地址一", 114.1, 30.2, "湖北省", "武汉市", "", false, "张工", "13800000001", 12.5, 12.5, 100.5).
AddRow("2", "已有合作日期站", "", "地址二", 120.1, 31.2, "上海市", "上海市", "", true, "李工", "13800000002", 13.0, 8.5, 80.5).
AddRow("3", "普通外部站", "", "地址三", 121.1, 32.2, "江苏省", "南京市", "", false, "", "", 0, 0, 0))
cooperateOnly := true
stations, err := NewMySQLRepository(db).HydrogenStations(context.Background(), HydrogenStationRequest{Province: "湖北省", City: "武汉市", CooperateOnly: &cooperateOnly})
if err != nil {
t.Fatal(err)
}
if len(stations) != 2 {
t.Fatalf("expected roster and canonical cooperative stations, got %#v", stations)
}
if stations[0].Name != "武汉群力加油站" || !stations[0].Cooperative {
t.Fatalf("Excel SINOPEC station was not classified as cooperative: %#v", stations[0])
}
if stations[1].Name != "已有合作日期站" || !stations[1].Cooperative {
t.Fatalf("canonical cooperative station was lost: %#v", stations[1])
}
if stations[0].ContactPerson != "张工" || stations[0].ContactPhone != "13800000001" || stations[0].UnitPrice != 12.5 {
t.Fatalf("partner station contact and price were not mapped: %#v", stations[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestExcelSinopecRosterMatchesEitherExcelNameColumn(t *testing.T) {
for _, name := range []string{"中国石化青岛炼油化工有限责任公司", "青岛中石化加氢站", "南京溧水柘塘东站", "南京中石化溧水加氢站", "中国石化樟坑加油加氢站"} {
if !isExcelSinopecStation(name) {
t.Fatalf("expected roster match for %q", name)
}
}
if isExcelSinopecStation("普通外部加氢站") {
t.Fatal("non-roster station must not be classified as cooperative")
}
}
func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
@@ -185,6 +234,30 @@ func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testin
}
}
func TestStationaryLocationPointsUsesTimeVINAndSmallBoundingBox(t *testing.T) {
mysqlDB, _, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mysqlDB.Close()
tdDB, tdMock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer tdDB.Close()
start := time.Date(2026, 8, 6, 10, 0, 0, 0, time.FixedZone("CST", 8*3600))
tdMock.ExpectQuery("FROM lingniu_vehicle_ts.vehicle_locations.*ts>='2026-08-06T10:00:00\\+08:00'.*vin IN \\('LTEST32960VIN0001'\\).*speed_kmh BETWEEN 0 AND 3\\.000.*ORDER BY vin,ts ASC,protocol ASC").
WillReturnRows(sqlmock.NewRows([]string{"ts", "vin", "protocol", "longitude", "latitude", "speed_kmh"}).
AddRow(start.UnixMilli(), "LTEST32960VIN0001", "GB32960", 120.000001, 30.000001, 0.2))
points, err := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts").StationaryLocationPoints(context.Background(), []string{"LTEST32960VIN0001"}, start, start.Add(time.Hour), 120, 30, 5, 3)
if err != nil || len(points) != 1 || points[0].SpeedKmh != 0.2 || !points[0].ObservedAt.Equal(start) {
t.Fatalf("points=%#v err=%v", points, err)
}
if err := tdMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -192,19 +265,20 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
}
defer db.Close()
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
dayStart := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
vin := "LTEST32960VIN0001"
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
WithArgs(vin, now.Add(-10*time.Minute)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}).
AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)).
AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second)))
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}).
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true).
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true))
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
if err != nil {
t.Fatal(err)
}
point := points[vin]
if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
if point.Protocol != "GB32960" || point.SOCPercent == nil || *point.SOCPercent != 86.5 || !point.Online || !point.ActiveToday || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
}
if err := mock.ExpectationsWereMet(); err != nil {
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"math"
"regexp"
"sort"
"strconv"
@@ -34,10 +35,12 @@ type Repository interface {
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error)
CreateMileageSnapshot(context.Context, MileageSnapshot) error
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
StationaryLocationPoints(context.Context, []string, time.Time, time.Time, float64, float64, float64, float64) ([]StationaryLocationPoint, error)
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
Audit(context.Context, uint64, string, string, string, int, string) error
@@ -50,6 +53,215 @@ type Repository interface {
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
}
const (
stationaryCoordinateWGS84 = "WGS84"
stationaryCoordinateGCJ02 = "GCJ02"
stationaryDefaultRadiusMeters = 5.0
stationaryMinimumRadiusMeters = 1.0
stationaryMaximumRadiusMeters = 100.0
stationaryMaxSpeedKmh = 3.0
stationaryMinimumStay = time.Minute
stationarySegmentGap = 10 * time.Minute
stationaryQueryMaximumWindow = 24 * time.Hour
)
func (s *Service) QueryStationaryVehicles(ctx context.Context, appKey, traceID string, request StationaryVehicleQueryRequest) ([]StationaryVehicleResult, error) {
plates, start, end, longitude, latitude, radiusMeters, err := s.validateStationaryVehicleQuery(request)
if err != nil {
return nil, err
}
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "denied", traceID, len(plates), err.Error())
return nil, err
}
if len(plates) == 0 {
plates = vehiclePlates(vehicles)
}
points, err := s.repository.StationaryLocationPoints(ctx, vehicleVINs(vehicles), start, end, longitude, latitude, radiusMeters, stationaryMaxSpeedKmh)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "error", traceID, len(plates), err.Error())
return nil, err
}
plateByVIN := make(map[string]string, len(vehicles))
for plate, vehicle := range vehicles {
plateByVIN[vehicle.VIN] = plate
}
results := stationaryMatches(points, plateByVIN, longitude, latitude, radiusMeters)
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "success", traceID, len(results), "")
return results, nil
}
func (s *Service) validateStationaryVehicleQuery(request StationaryVehicleQueryRequest) ([]string, time.Time, time.Time, float64, float64, float64, error) {
plates, err := normalizePlates(request.PlateNumbers, 2000)
if err != nil {
return nil, time.Time{}, time.Time{}, 0, 0, 0, err
}
start, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.StartTime), s.location)
if err != nil {
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid startTime", ErrInvalidRequest)
}
end, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.EndTime), s.location)
if err != nil || !end.After(start) {
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid endTime", ErrInvalidRequest)
}
if end.Sub(start) > stationaryQueryMaximumWindow {
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: time window exceeds 24 hours", ErrInvalidRequest)
}
longitude, latitude := request.Longitude, request.Latitude
if !validCoordinate(longitude, latitude) {
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid coordinate", ErrInvalidRequest)
}
coordinateSystem := strings.ToUpper(strings.TrimSpace(request.CoordinateSystem))
if coordinateSystem == "" {
coordinateSystem = stationaryCoordinateWGS84
}
switch coordinateSystem {
case stationaryCoordinateWGS84:
case stationaryCoordinateGCJ02:
longitude, latitude = gcj02ToWGS84(longitude, latitude)
default:
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: coordinateSystem must be WGS84 or GCJ02", ErrInvalidRequest)
}
radiusMeters := stationaryDefaultRadiusMeters
if request.RadiusMeters != nil {
radiusMeters = *request.RadiusMeters
}
if math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) || radiusMeters < stationaryMinimumRadiusMeters || radiusMeters > stationaryMaximumRadiusMeters {
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: radiusMeters must be between 1 and 100", ErrInvalidRequest)
}
return plates, start, end, longitude, latitude, radiusMeters, nil
}
func gcj02ToWGS84(longitude, latitude float64) (float64, float64) {
// GCJ-02 only applies within mainland China's obfuscation area. Returning
// the supplied value elsewhere keeps the transformation safe for outlying
// stations and callers that supply valid geographic coordinates.
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
return longitude, latitude
}
const semiMajorAxis = 6378245.0
const eccentricitySquared = 0.006693421622965943
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
radianLatitude := latitude / 180 * math.Pi
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
squareRootMagic := math.Sqrt(magic)
convertedLatitude := latitude - latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
convertedLongitude := longitude - longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
return convertedLongitude, convertedLatitude
}
func transformGCJLatitude(longitude, latitude float64) float64 {
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
return value
}
func transformGCJLongitude(longitude, latitude float64) float64 {
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
return value
}
func stationaryMatches(points []StationaryLocationPoint, plateByVIN map[string]string, longitude, latitude, radiusMeters float64) []StationaryVehicleResult {
if len(points) == 0 {
return []StationaryVehicleResult{}
}
sort.Slice(points, func(i, j int) bool {
if points[i].VIN != points[j].VIN {
return points[i].VIN < points[j].VIN
}
return points[i].ObservedAt.Before(points[j].ObservedAt)
})
results := make([]StationaryVehicleResult, 0)
for from := 0; from < len(points); {
to := from + 1
for to < len(points) && points[to].VIN == points[from].VIN {
to++
}
for segmentStart := from; segmentStart < to; {
segmentEnd := segmentStart + 1
for segmentEnd < to && points[segmentEnd].ObservedAt.Sub(points[segmentEnd-1].ObservedAt) <= stationarySegmentGap {
segmentEnd++
}
if result, ok := stationaryMatch(points[segmentStart:segmentEnd], plateByVIN[points[from].VIN], longitude, latitude, radiusMeters); ok {
results = append(results, result)
}
segmentStart = segmentEnd
}
from = to
}
sort.Slice(results, func(i, j int) bool {
if results[i].MatchScore != results[j].MatchScore {
return results[i].MatchScore > results[j].MatchScore
}
if results[i].StayDurationSeconds != results[j].StayDurationSeconds {
return results[i].StayDurationSeconds > results[j].StayDurationSeconds
}
return results[i].VIN < results[j].VIN
})
return results
}
func stationaryMatch(points []StationaryLocationPoint, plate string, longitude, latitude, radiusMeters float64) (StationaryVehicleResult, bool) {
if len(points) < 2 {
return StationaryVehicleResult{}, false
}
duration := points[len(points)-1].ObservedAt.Sub(points[0].ObservedAt)
if duration < stationaryMinimumStay {
return StationaryVehicleResult{}, false
}
var totalDistance, totalSpeed, maxDistance, maxSpeed float64
protocols := map[string]struct{}{}
for _, point := range points {
distance := haversineMeters(latitude, longitude, point.Latitude, point.Longitude)
if distance > radiusMeters || point.SpeedKmh < 0 || point.SpeedKmh > stationaryMaxSpeedKmh {
return StationaryVehicleResult{}, false
}
totalDistance += distance
totalSpeed += point.SpeedKmh
maxDistance = math.Max(maxDistance, distance)
maxSpeed = math.Max(maxSpeed, point.SpeedKmh)
protocols[point.Protocol] = struct{}{}
}
protocolList := make([]string, 0, len(protocols))
for protocol := range protocols {
protocolList = append(protocolList, externalMileageProtocol(protocol))
}
sort.Strings(protocolList)
averageDistance := totalDistance / float64(len(points))
averageSpeed := totalSpeed / float64(len(points))
score := 40*(1-averageDistance/radiusMeters) + 25*(1-maxSpeed/stationaryMaxSpeedKmh) + 20*math.Min(1, duration.Seconds()/900) + 15*math.Min(1, float64(len(points))/4)
return StationaryVehicleResult{
VIN: points[0].VIN,
PlateNumber: plate,
StayStartTime: points[0].ObservedAt.Format("2006-01-02 15:04:05"),
StayEndTime: points[len(points)-1].ObservedAt.Format("2006-01-02 15:04:05"),
StayDurationSeconds: int64(duration.Seconds()),
StayDurationMinutes: round3(duration.Minutes()),
MatchScore: round3(math.Max(0, math.Min(100, score))),
AverageDistanceM: round3(averageDistance),
MaxDistanceM: round3(maxDistance),
AverageSpeedKmh: round3(averageSpeed),
MaxSpeedKmh: round3(maxSpeed),
MatchedSamples: len(points),
SourceProtocols: protocolList,
}, true
}
func haversineMeters(latitude1, longitude1, latitude2, longitude2 float64) float64 {
const earthRadiusMeters = 6371000.0
lat1, lat2 := latitude1*math.Pi/180, latitude2*math.Pi/180
dLat, dLon := (latitude2-latitude1)*math.Pi/180, (longitude2-longitude1)*math.Pi/180
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
now := s.now().In(s.location)
plates, err := normalizePlates(request.PlateNumbers, 2000)
@@ -78,10 +290,11 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
if difference < 0 {
difference = 0
}
item.Protocol = point.Protocol
item.Protocol = externalMileageProtocol(point.Protocol)
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
item.TimeDifferenceSeconds = &difference
item.Online = point.Online
item.ActiveToday = point.ActiveToday
item.MotionStatus = "offline"
if item.Online && point.SpeedKmh > 3 {
item.MotionStatus = "driving"
@@ -90,6 +303,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
}
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
item.SOCPercent = point.SOCPercent
if validCoordinate(point.Longitude, point.Latitude) {
longitude, latitude := point.Longitude, point.Latitude
item.Longitude, item.Latitude = &longitude, &latitude
@@ -177,11 +391,15 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
// Sampling sufficiency is decided by the producer and persisted in
// quality_status. Imported refuelling-ledger rows can be authoritative
// with one transaction, while pressure-derived rows require two samples.
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
if value, ok := values[vehicle.VIN]; ok {
item.CalculationPhase = value.CalculationPhase
item.AlgorithmVersion = value.AlgorithmVersion
if strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
}
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
@@ -211,7 +429,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
missingVINs := missingMileageVINs(vins, values)
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
carried := map[string]DailyMileage{}
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
@@ -226,6 +449,8 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, value.MileageKm)
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
fillMileageAnomaly(&item)
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, 0)
}
@@ -321,6 +546,7 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
}
values := map[string]DailyMileage{}
carried := map[string]DailyMileage{}
rollbacks := map[string]bool{}
if len(positions) > 0 {
vins := make([]string, 0, len(vinSet))
for vin := range vinSet {
@@ -332,7 +558,12 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
missingVINs := missingMileageRangeInitialVINs(positions, values)
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
if err != nil {
@@ -355,6 +586,9 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, value.MileageKm)
carried[position.vehicle.VIN] = value
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
fillMileageRangeAnomaly(&item)
delete(carried, position.vehicle.VIN)
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, 0)
}
@@ -715,9 +949,12 @@ func validDailyMileage(value DailyMileage) bool {
value.UpdatedAt != ""
}
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
func missingMileageVINs(vins []string, values map[string]DailyMileage, rollbacks map[string]bool, date string) []string {
missing := make([]string, 0)
for _, vin := range vins {
if rollbacks[dailyMileageKey(vin, date)] {
continue
}
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
missing = append(missing, vin)
}
@@ -725,7 +962,7 @@ func missingMileageVINs(vins []string, values map[string]DailyMileage) []string
return missing
}
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage, rollbacks map[string]bool) []string {
seen := make(map[string]struct{}, len(positions))
missing := make([]string, 0)
for _, position := range positions {
@@ -734,6 +971,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
continue
}
seen[vin] = struct{}{}
if rollbacks[dailyMileageKey(vin, position.date)] {
continue
}
value, ok := values[dailyMileageKey(vin, position.date)]
if !ok || !validDailyMileage(value) {
missing = append(missing, vin)
@@ -769,6 +1009,20 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
item.Status = StatusNormal
}
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
func fillMileageAnomaly(item *MileageResult) {
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
item.Status = StatusDataAnomaly
}
func fillMileageRangeAnomaly(item *MileageRangeResult) {
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
item.Status = StatusDataAnomaly
}
func stringPointer(value string) *string { return &value }
func dailyMileageKey(vin, date string) string {
return vin + "\x00" + date
}
@@ -12,6 +12,8 @@ import (
"time"
)
func float64Pointer(value float64) *float64 { return &value }
type fakeRepository struct {
app AppCredential
authErr error
@@ -19,9 +21,11 @@ type fakeRepository struct {
hydrogen map[string]DailyHydrogen
mileage map[string]DailyMileage
priorMileage map[string]DailyMileage
rollbacks map[string]bool
authorizedVIN bool
totalMileage *TotalMileagePoint
realtime map[string]RealtimeVehiclePoint
stationary []StationaryLocationPoint
stations []HydrogenStation
audits []string
createdHash [sha256.Size]byte
@@ -33,6 +37,7 @@ type fakeRepository struct {
priorVINs []string
priorCalls int
snapshot MileageSnapshot
stationaryRadius float64
}
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
@@ -64,6 +69,9 @@ func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _
f.dailyProtocols = append([]string(nil), protocols...)
return f.priorMileage, nil
}
func (f *fakeRepository) MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error) {
return f.rollbacks, nil
}
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
f.snapshot = snapshot
return nil
@@ -80,6 +88,10 @@ func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Tim
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
return f.totalMileage, nil
}
func (f *fakeRepository) StationaryLocationPoints(_ context.Context, _ []string, _ time.Time, _ time.Time, _ float64, _ float64, radiusMeters float64, _ float64) ([]StationaryLocationPoint, error) {
f.stationaryRadius = radiusMeters
return f.stationary, nil
}
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
return f.realtime, nil
}
@@ -100,7 +112,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
},
realtime: map[string]RealtimeVehiclePoint{
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, SOCPercent: float64Pointer(78.5), TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
},
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
}
@@ -112,7 +124,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable || vehicles[0].Protocol != "GB32960" || vehicles[0].SOCPercent == nil || *vehicles[0].SOCPercent != 78.5 {
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
}
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
@@ -127,6 +139,76 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
}
}
func TestStationaryVehicleQueryBuildsAndRanksStayMatches(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
start := time.Date(2026, 8, 6, 10, 0, 0, 0, location)
repository := &fakeRepository{
app: AppCredential{ID: 9, Name: "refuel-check"},
vehicles: map[string]AuthorizedVehicle{
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
},
stationary: []StationaryLocationPoint{
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(10 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0},
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(13 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0.1},
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(5 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
{VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", ObservedAt: start.Add(6 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
},
}
service := NewService(repository)
service.now = func() time.Time { return start }
radiusMeters := 8.0
values, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-stationary", StationaryVehicleQueryRequest{
StartTime: start.Format("2006-01-02 15:04:05"), EndTime: start.Add(time.Hour).Format("2006-01-02 15:04:05"), Longitude: 120, Latitude: 30, RadiusMeters: &radiusMeters,
})
if err != nil {
t.Fatal(err)
}
if len(values) != 2 || values[0].VIN != "LTEST32960VIN0001" || values[0].StayDurationSeconds != 300 || values[0].MatchedSamples != 3 || values[0].SourceProtocols[0] != "GB32960" || values[0].SourceProtocols[1] != "MQTT" || values[0].MatchScore <= values[1].MatchScore {
t.Fatalf("stationary matches=%#v", values)
}
if repository.stationaryRadius != radiusMeters {
t.Fatalf("stationary radius=%v, want %v", repository.stationaryRadius, radiusMeters)
}
}
func TestStationaryVehicleQueryRejectsInvalidWindowAndCoordinate(t *testing.T) {
service := NewService(&fakeRepository{})
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-07 10:00:01", Longitude: 120, Latitude: 30}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("window error=%v", err)
}
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 0, Latitude: 0}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("coordinate error=%v", err)
}
overRadius := 101.0
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 120, Latitude: 30, RadiusMeters: &overRadius}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("radius error=%v", err)
}
}
func TestStationaryVehicleQueryConvertsGCJ02Coordinate(t *testing.T) {
service := NewService(&fakeRepository{})
_, _, _, longitude, latitude, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
StartTime: "2026-08-06 17:53:01",
EndTime: "2026-08-06 17:55:01",
Longitude: 121.045455,
Latitude: 30.640433,
CoordinateSystem: "gcj02",
})
if err != nil {
t.Fatal(err)
}
if longitude < 121.0409 || longitude > 121.0411 || latitude < 30.6425 || latitude > 30.6428 {
t.Fatalf("converted coordinate=%f,%f", longitude, latitude)
}
if _, _, _, _, _, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
StartTime: "2026-08-06 17:53:01", EndTime: "2026-08-06 17:55:01", Longitude: 121.045455, Latitude: 30.640433, CoordinateSystem: "BD09",
}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("coordinateSystem error=%v", err)
}
}
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
repository := &fakeRepository{
@@ -179,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
},
hydrogen: map[string]DailyHydrogen{
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
},
mileage: map[string]DailyMileage{
"LTEST32960VIN0001": {
@@ -200,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
t.Fatalf("hydrogen = %#v", hydrogen)
}
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
}
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
if err != nil {
t.Fatal(err)
@@ -337,6 +422,28 @@ func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing
}
}
func TestMileageRollbackIsNotExposedAsNormalCarriedData(t *testing.T) {
const vin = "LNXNEGRR1SR321395"
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{"粤A08190F": {VIN: vin, Plate: "粤A08190F"}},
priorMileage: map[string]DailyMileage{
vin: {VIN: vin, Date: "2026-08-05", Protocol: "GB32960", TotalMileageKm: 48233.3, DataTime: "2026-08-05T23:00:00+08:00", UpdatedAt: "2026-08-05T23:00:01+08:00"},
},
rollbacks: map[string]bool{dailyMileageKey(vin, "2026-08-06"): true},
}
service := NewService(repository)
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-rollback", QueryRequest{
PlateNumbers: []string{"粤A08190F"}, Date: "2026-08-06",
})
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Status != StatusDataAnomaly || result[0].DataQuality == nil || *result[0].DataQuality != mileageTotalRollbackQuality || result[0].TotalMileageKm != nil || repository.priorCalls != 0 {
t.Fatalf("rollback result=%#v priorCalls=%d", result, repository.priorCalls)
}
}
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"},
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -1,54 +1,142 @@
package openplatform
import (
"context"
"encoding/json"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) {
func TestBuildHydrogenDailyStatsUsesDirectValidBoundaries(t *testing.T) {
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
stats := BuildHydrogenDailyStats([]HydrogenObservation{
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00},
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98},
}, "2026-07-01", 0.05, 5)
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10.5, 9.5)
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 20)
if len(stats) != 1 {
t.Fatalf("stats = %#v", stats)
}
stat := stats[0]
if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" {
if stat.ConsumptionKg != 1 || stat.FirstMassKg != 10.5 || stat.LastMassKg != 9.5 {
t.Fatalf("stat = %#v", stat)
}
}
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) {
base := time.Now()
stats := BuildHydrogenDailyStats([]HydrogenObservation{
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1},
}, "2026-07-01", 0.05, 5)
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 {
func TestBuildHydrogenDailyStatsMergesSequentialSources(t *testing.T) {
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
for index := len(values) / 2; index < len(values); index++ {
values[index].Source = "source-b"
}
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5)
if len(stats) != 1 {
t.Fatalf("stats = %#v", stats)
}
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
t.Fatalf("stat = %#v", stats[0])
}
}
func TestBuildHydrogenDailyStatsDeduplicatesSameEventAcrossSources(t *testing.T) {
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
duplicates := append([]HydrogenObservation(nil), values...)
for index := range duplicates {
duplicates[index].Source = "source-b"
}
stats := BuildHydrogenDailyStats(append(values, duplicates...), "2026-07-01", 0.05, 5)
if len(stats) != 1 || stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
t.Fatalf("stats = %#v", stats)
}
}
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) {
base := time.Now()
stats := BuildHydrogenDailyStats([]HydrogenObservation{
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10},
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8},
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6},
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20},
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9},
}, "2026-07-01", 0.05, 5)
if len(stats) != 1 {
func TestBuildHydrogenDailyStatsOrderedMatchesUnorderedCalculation(t *testing.T) {
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
duplicates := append([]HydrogenObservation(nil), values...)
for index := range duplicates {
duplicates[index].Source = "source-b"
}
ordered := make([]HydrogenObservation, 0, len(values)+len(duplicates))
for index := range values {
ordered = append(ordered, values[index], duplicates[index])
}
stats := BuildHydrogenDailyStatsOrdered(ordered, "2026-07-01", 0.05, 5)
if len(stats) != 1 || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
t.Fatalf("stats = %#v", stats)
}
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 {
t.Fatalf("stat = %#v", stats[0])
}
func TestBuildHydrogenDailyStatsDoesNotReplaceBoundariesWithWholeDayMinimum(t *testing.T) {
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.8)
values = append(values[:5], append([]HydrogenObservation{{
VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(5 * time.Second),
MassKg: 9, NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true,
SOCPercent: 50, SOCKnown: true, MileageKm: 10, MileageKnown: true,
VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true,
RunningMode: 2, RunningModeKnown: true,
}}, values[5:]...)...)
for index := 6; index < len(values); index++ {
values[index].ObservedAt = base.Add(time.Duration(index) * time.Second)
}
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5)
if len(stats) != 1 || stats[0].ConsumptionKg != 0.2 {
t.Fatalf("stats = %#v", stats)
}
}
func TestHydrogenSegmentAccumulatorKeepsOnlyBoundedEndpointWindows(t *testing.T) {
base := time.Now()
accumulator := newHydrogenSegmentAccumulator(0.05, 20)
for index := 0; index < 10000; index++ {
accumulator.Add(HydrogenObservation{
ObservedAt: base.Add(time.Duration(index) * time.Second), MassKg: 20 - float64(index)/10000,
NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true,
})
}
if len(accumulator.segmentFirst) != hydrogenSegmentEndpointWindow || len(accumulator.segmentTail) != hydrogenSegmentEndpointWindow {
t.Fatalf("first=%d tail=%d", len(accumulator.segmentFirst), len(accumulator.segmentTail))
}
consumption, segments, _, _ := accumulator.Finalize()
if consumption <= 0 || segments != 1 {
t.Fatalf("consumption=%v segments=%d", consumption, segments)
}
}
func TestBuildHydrogenDailyStatsRejectsInactivePressureFall(t *testing.T) {
base := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
stats := BuildHydrogenDailyStats([]HydrogenObservation{
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10, PressureMPa: 28.2, VehicleState: 2, VehicleStateKnown: true},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 8, PressureMPa: 22, VehicleState: 2, VehicleStateKnown: true},
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Minute), MassKg: 5, PressureMPa: 12.8, VehicleState: 2, VehicleStateKnown: true},
}, "2026-08-15", 0.05, 20)
if len(stats) != 1 || stats[0].ConsumptionKg != 0 || stats[0].QualityStatus != "NO_DATA" {
t.Fatalf("stats = %#v", stats)
}
}
func hydrogenActiveTestSegment(vin, source string, start time.Time, startMass, endMass float64) []HydrogenObservation {
values := make([]HydrogenObservation, 0, hydrogenSegmentEndpointWindow*2)
for index := 0; index < hydrogenSegmentEndpointWindow*2; index++ {
mass := startMass
if index >= hydrogenSegmentEndpointWindow {
mass = endMass
}
values = append(values, HydrogenObservation{
VIN: vin, Source: source, ObservedAt: start.Add(time.Duration(index) * time.Second),
MassKg: mass, PressureMPa: mass, NoiseKg: 0.05, RefuelThresholdKg: 1,
FuelCellActive: true, FuelCellStateKnown: true,
SOCPercent: 50, SOCKnown: true,
MileageKm: float64(index) * 2, MileageKnown: true,
VehicleState: 1, VehicleStateKnown: true,
ChargeState: 3, ChargeStateKnown: true,
RunningMode: 2, RunningModeKnown: true,
})
}
return values
}
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
@@ -77,3 +165,319 @@ func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) {
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
}
}
func TestExtractHydrogenPressureTemperatureRejectsPlaceholders(t *testing.T) {
for _, encoded := range []string{
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":0,"gb32960.fuel_cell.max_hydrogen_temperature_c":30}`,
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":22.8,"gb32960.fuel_cell.max_hydrogen_temperature_c":-40}`,
} {
if _, _, ok := ExtractHydrogenPressureTemperature(encoded); ok {
t.Fatalf("pressure/temperature placeholder must be rejected: %s", encoded)
}
}
}
func TestExtractHydrogenFuelCellState(t *testing.T) {
for _, test := range []struct {
encoded string
active bool
known bool
}{
{`{"gb32960.gd_fc_stack.engine_work_state":2}`, true, true},
{`{"gb32960.gd_fc_stack.engine_work_state":0}`, false, true},
{`{"gb32960.gd_fc_stack.engine_work_state":1}`, false, false},
{`{"gb32960.gd_fc_stack.engine_work_state":1,"gb32960.fuel_cell.fuel_cell_current_a":94.1}`, true, true},
{`{"gb32960.gd_fc_stack.engine_work_state":2,"gb32960.fuel_cell.fuel_cell_current_a":0}`, false, true},
{`{"gb32960.fuel_cell.fuel_cell_current_a":0.2}`, false, true},
{`{}`, false, false},
} {
active, known := ExtractHydrogenFuelCellState(test.encoded)
if active != test.active || known != test.known {
t.Fatalf("encoded=%s active=%v known=%v", test.encoded, active, known)
}
}
}
func TestExtractHydrogenTelemetryReadsPressureTemperatureAndStateTogether(t *testing.T) {
pressure, temperature, active, known, ok := ExtractHydrogenTelemetry(`{
"gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,
"gb32960.fuel_cell.max_hydrogen_temperature_c":35,
"gb32960.gd_fc_stack.engine_work_state":2
}`)
if !ok || pressure != 28.2 || temperature != 35 || !active || !known {
t.Fatalf("pressure=%v temperature=%v active=%v known=%v ok=%v", pressure, temperature, active, known, ok)
}
}
func TestExtractHydrogenTelemetryFastMatchesJSONDecoder(t *testing.T) {
for _, encoded := range []string{
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35,"gb32960.gd_fc_stack.engine_work_state":2}`,
`{ "gb32960.fuel_cell.fuel_cell_current_a" : "0.5", "gb32960.fuel_cell.max_hydrogen_temperature_c" : "35", "gb32960.fuel_cell.max_hydrogen_pressure_mpa" : "28.2" }`,
`{"unrelated":"gb32960.fuel_cell.max_hydrogen_pressure_mpa","gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35}`,
} {
wantPressure, wantTemperature, wantActive, wantKnown, wantOK := ExtractHydrogenTelemetry(encoded)
pressure, temperature, active, known, ok := extractHydrogenTelemetryFast(encoded)
if pressure != wantPressure || temperature != wantTemperature || active != wantActive || known != wantKnown || ok != wantOK {
t.Fatalf("fast=(%v,%v,%v,%v,%v) decoded=(%v,%v,%v,%v,%v) input=%s",
pressure, temperature, active, known, ok,
wantPressure, wantTemperature, wantActive, wantKnown, wantOK, encoded)
}
}
}
func TestHydrogenObservationQueryUsesEventTimeAndScopedVIN(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
query, err := buildHydrogenObservationQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), "LB9A32A24R0LS1376")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"CAST(event_time AS BIGINT)",
"ts>='2026-08-12T00:00:00+08:00'",
"ts<'2026-08-13T00:00:00+08:00'",
"event_time>='2026-08-12T00:00:00+08:00'",
"event_time<'2026-08-13T00:00:00+08:00'",
"vin='LB9A32A24R0LS1376'",
"protocol='GB32960'",
"ORDER BY vin,event_time,source_endpoint,ts",
} {
if !strings.Contains(query, want) {
t.Fatalf("query missing %q:\n%s", want, query)
}
}
if strings.Contains(query, "CAST(ts AS BIGINT)") {
t.Fatalf("received time must not become observation time:\n%s", query)
}
if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") {
t.Fatalf("hydrogen query must only use GB32960 frames:\n%s", query)
}
if strings.Contains(query, "2026-08-05") || strings.Contains(query, "2026-08-20") {
t.Fatalf("received-time window must not include delayed frames:\n%s", query)
}
}
func TestHydrogenObservationVINQueryUsesSameDayGB32960Watermark(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
query, err := buildHydrogenObservationVINQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"SELECT DISTINCT vin", "protocol='GB32960'",
"ts>='2026-08-12T00:00:00+08:00'", "ts<'2026-08-13T00:00:00+08:00'",
"event_time>='2026-08-12T00:00:00+08:00'", "event_time<'2026-08-13T00:00:00+08:00'",
"ORDER BY vin",
} {
if !strings.Contains(query, want) {
t.Fatalf("query missing %q:\n%s", want, query)
}
}
if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") {
t.Fatalf("VIN query must only use GB32960:\n%s", query)
}
}
func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT DISTINCT vin").WillReturnRows(sqlmock.NewRows([]string{"vin"}).
AddRow("lb9a32a25r0ls1452").
AddRow("LB9A32A24R0LS1376"))
loc := time.FixedZone("Asia/Shanghai", 8*3600)
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
vins, err := LoadHydrogenObservationVINs(context.Background(), db, "lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), map[string]float64{
"LB9A32A25R0LS1452": 520,
})
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
t.Fatalf("vins=%v err=%v", vins, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")).
WithArgs("现代").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).
AddRow("lb9a32a25r0ls1452").
AddRow("invalid"))
vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ")
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 TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"}
stat := HydrogenDailyStat{
VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5,
FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK",
}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")).
WithArgs(stat.Date, vins[0], vins[1]).
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg,
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()
if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); 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["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion {
t.Fatalf("seed state=%s", encoded)
}
}
func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
stat := HydrogenDailyStat{
VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a",
QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条",
}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason,
0.0, 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()
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping {
var gb32960FieldReferences = map[string]gb32960FieldReference{
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃", "燃驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
@@ -48,6 +48,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/hydrogen-evidence", h.handleHydrogenDailyEvidence)
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
@@ -862,6 +863,11 @@ func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Req
h.write(w, r, data, err)
}
func (h *Handler) handleHydrogenDailyEvidence(w http.ResponseWriter, r *http.Request) {
data, err := h.service.HydrogenDailyEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date"))
h.write(w, r, data, err)
}
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
defer r.Body.Close()
var query MileageQuery
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} {
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426", `"brandName":"飞驰"`, `"modelName":"新能源运营车"`} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("response missing %q: %s", want, rec.Body.String())
}
@@ -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)
}
}
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
}
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
m.profileMu.RLock()
defer m.profileMu.RUnlock()
vehicles := filterVehicles(m.vehicles, query)
byVIN := map[string]*VehicleCoverageRow{}
for _, vehicle := range vehicles {
@@ -805,6 +807,12 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
}
items := make([]VehicleCoverageRow, 0, len(byVIN))
for _, row := range byVIN {
if profile, ok := m.profiles[row.VIN]; ok {
row.BrandName = firstNonEmpty(profile.BrandName, row.OEM)
row.ModelName = profile.ModelName
} else {
row.BrandName = row.OEM
}
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
for _, vehicle := range vehicles {
@@ -1230,8 +1238,24 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
var allowedVINs map[string]bool
if query.ScopeVINs != nil {
allowedVINs = make(map[string]bool, len(query.ScopeVINs))
for _, vin := range query.ScopeVINs {
allowedVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
}
}
for _, keyword := range normalizedKeywords(query.Keywords) {
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
if allowedVINs != nil {
scoped := vehicles[:0]
for _, vehicle := range vehicles {
if allowedVINs[strings.ToUpper(strings.TrimSpace(vehicle.VIN))] {
scoped = append(scoped, vehicle)
}
}
vehicles = scoped
}
resolution := buildVehicleIdentityResolution(keyword, vehicles)
identity := resolveVehicleIdentity(keyword, vehicles)
resolvedVIN := ""
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
resolvedVIN = resolution.VIN
}
if resolvedVIN == "" {
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
if query.ScopeVINs != nil {
missing.VIN = ""
}
items = append(items, missing)
continue
}
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
Plate string `json:"plate"`
Phone string `json:"phone"`
OEM string `json:"oem"`
BrandName string `json:"brandName"`
ModelName string `json:"modelName"`
Protocols []string `json:"protocols"`
MissingProtocols []string `json:"missingProtocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
@@ -1881,10 +1883,83 @@ type DailyMileageRow struct {
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"`
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
Source string `json:"source"`
AnomalySeverity string `json:"anomalySeverity,omitempty"`
}
type HydrogenCalculationParameterEvidence struct {
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
HydrogenEnergyKWhPerKg float64 `json:"hydrogenEnergyKWhPerKg"`
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
RefuelRiseMpa float64 `json:"refuelRiseMpa"`
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
PureElectricDropMpa float64 `json:"pureElectricDropMpa"`
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
AlgorithmVersion string `json:"algorithmVersion"`
}
type HydrogenIntervalEvidenceRow struct {
Index int `json:"index"`
Type string `json:"type"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
StartEventID string `json:"startEventId"`
EndEventID string `json:"endEventId"`
Source string `json:"source"`
StartPressureMpa float64 `json:"startPressureMpa"`
EndPressureMpa float64 `json:"endPressureMpa"`
StartTemperatureC float64 `json:"startTemperatureC"`
EndTemperatureC float64 `json:"endTemperatureC"`
StartMassKg float64 `json:"startMassKg"`
EndMassKg float64 `json:"endMassKg"`
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
MileageKm *float64 `json:"mileageKm,omitempty"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
}
type HydrogenDailyEvidence struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
ChargeCount int `json:"chargeCount"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
}
// MileageQuery is the POST contract used by mileage statistics and daily
// mileage queries. Array fields keep large vehicle selections out of the URL
// and avoid ambiguity when clients submit thousands of VINs.
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
}
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_profile p ON p.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
@@ -169,6 +170,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
Text: `SELECT v.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
`COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` +
`COALESCE(MAX(p.model_name), '') AS model_name, ` +
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
@@ -575,6 +578,12 @@ WHERE ` + strings.Join(where, " AND ")
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
`MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` +
`COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` +
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
Args: args,
@@ -591,7 +600,9 @@ WHERE ` + strings.Join(where, " AND ")
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
`h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL,
@@ -687,6 +698,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
latestMileageExpression + ` AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m
@@ -702,8 +714,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
}
@@ -711,8 +724,9 @@ func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
}
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
}
func (p Principal) CanMenu(key string) bool {
if p.UserType == "admin" || p.Role == "admin" {
return true
}
for _, value := range p.MenuKeys {
if value == key {
return true
}
}
// Legacy admin principals did not carry an explicit menu list. Keep those
// principals unrestricted while allowing persisted admin menus to remove a
// sensitive surface such as account management.
if len(p.MenuKeys) == 0 && (p.UserType == "admin" || p.Role == "admin") {
return true
}
return false
}
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
var protocols string
var onlineProtocols string
var online int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.BrandName, &row.ModelName, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
return Page[VehicleCoverageRow]{}, err
}
row.Protocols = splitCSV(protocols)
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
continue
}
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
if query.ScopeVINs != nil {
missing.VIN = ""
}
items = append(items, missing)
}
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
@@ -518,6 +522,18 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
protocolJoin = " AND s.protocol = ? "
args = append(args, protocol)
}
scopeWhere := ""
if query.ScopeVINs != nil {
scopeVINs := normalizedKeywords(query.ScopeVINs)
if len(scopeVINs) == 0 {
scopeWhere = " AND 1 = 0 "
} else {
scopeWhere = " AND i.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(scopeVINs)), ",") + ") "
for _, vin := range scopeVINs {
args = append(args, strings.ToUpper(vin))
}
}
}
return SQLQuery{
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
@@ -534,7 +550,7 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
`WHERE ` + realtimeWhere + ` ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`,
`WHERE i.vin IS NOT NULL AND i.vin <> ''` + scopeWhere + `GROUP BY i.vin`,
Args: args,
}
}
@@ -1026,6 +1042,89 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
const latestRealtimeProjectionTimeSQL = "COALESCE(s.received_at, s.event_time, s.updated_at)"
func buildLatestRealtimeFramesSQL(query RawFrameQuery) SQLQuery {
limit := query.Limit
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
limit = len(canonicalVehicleProtocols)
}
where := []string{"s.vin = ?"}
args := []any{strings.TrimSpace(query.VIN)}
if protocol := strings.ToUpper(strings.TrimSpace(query.Protocol)); protocol != "" {
where = append(where, "s.protocol = ?")
args = append(args, protocol)
}
if value := strings.TrimSpace(query.DateFrom); value != "" {
if parsed, ok := parseTrackRequestTime(value); ok {
where = append(where, latestRealtimeProjectionTimeSQL+" >= ?")
args = append(args, parsed)
}
}
if value := strings.TrimSpace(query.DateTo); value != "" {
if parsed, ok := parseTrackRequestTime(value); ok {
where = append(where, latestRealtimeProjectionTimeSQL+" <= ?")
args = append(args, parsed)
}
}
args = append(args, limit)
return SQLQuery{
Text: `SELECT COALESCE(NULLIF(s.event_id, ''), CONCAT('snapshot:', s.protocol, ':', s.vin)), ` +
`s.vin, COALESCE(s.plate, ''), s.protocol, ` +
`COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
`COALESCE(DATE_FORMAT(s.received_at, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
`COALESCE(s.peer, ''), COALESCE(s.parsed_json, '') ` +
`FROM vehicle_realtime_snapshot s WHERE ` + strings.Join(where, " AND ") + ` ` +
`ORDER BY ` + latestRealtimeProjectionTimeSQL + ` DESC, s.protocol ASC LIMIT ?`,
Args: args,
}
}
// LatestRealtimeFrames returns one durable latest projection per protocol.
// Unlike Redis, these rows remain available after the online TTL expires.
func (s *ProductionStore) LatestRealtimeFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
built := buildLatestRealtimeFramesSQL(query)
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
return Page[RawFrameRow]{}, err
}
defer rows.Close()
items := make([]RawFrameRow, 0, len(canonicalVehicleProtocols))
for rows.Next() {
var row RawFrameRow
var parsedJSON string
if err := rows.Scan(
&row.ID,
&row.VIN,
&row.Plate,
&row.Protocol,
&row.DeviceTime,
&row.ServerTime,
&row.SourceEndpoint,
&parsedJSON,
); err != nil {
return Page[RawFrameRow]{}, err
}
row.FrameType = "realtime_projection"
row.ParseStatus = "OK"
if query.IncludeFields || len(query.Fields) > 0 {
row.ParsedFields = parsedFieldsFromString(parsedJSON)
if len(query.Fields) > 0 {
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
}
}
items = append(items, row)
}
if err := rows.Err(); err != nil {
return Page[RawFrameRow]{}, err
}
limit := query.Limit
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
limit = len(canonicalVehicleProtocols)
}
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
}
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
built := buildMileageSummarySQL(query)
var summary MileageSummary
@@ -1054,15 +1153,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
items := make([]DailyMileageRow, 0)
for rows.Next() {
var row DailyMileageRow
var hydrogen sql.NullFloat64
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64
var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
&hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
return Page[DailyMileageRow]{}, err
}
if hydrogen.Valid {
consumption := hydrogen.Float64
row.HydrogenConsumptionKg = &consumption
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
if physicalRate.Valid {
value := physicalRate.Float64
row.HydrogenConsumptionKgPer100Km = &value
} else {
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.PureHydrogenMileageKm, 1)
}
}
if balanced.Valid {
value := balanced.Float64
row.HydrogenSOCBalancedKg = &value
}
if balancedRate.Valid {
value := balancedRate.Float64
row.HydrogenSOCBalancedKgPer100Km = &value
}
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
items = append(items, row)
}
if err := rows.Err(); err != nil {
@@ -1075,11 +1190,66 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, bool, error) {
row := s.db.QueryRowContext(ctx, `SELECT h.vin,COALESCE(NULLIF(b.plate,''),''),DATE_FORMAT(h.stat_date,'%Y-%m-%d'),
h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct,
h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg,
h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km,
h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count,
h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json,
DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
FROM vehicle_open_daily_energy h
LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin
WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
LIMIT 1`, vin, date)
var result HydrogenDailyEvidence
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64
var parameterJSON, evidenceJSON []byte
if err := row.Scan(
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm,
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount,
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
&result.AlgorithmVersion, &parameterJSON, &evidenceJSON, &result.CalculatedAt,
); err != nil {
if err == sql.ErrNoRows {
return HydrogenDailyEvidence{}, false, nil
}
return HydrogenDailyEvidence{}, false, err
}
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.Intervals = []HydrogenIntervalEvidenceRow{}
if len(parameterJSON) > 0 {
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
return HydrogenDailyEvidence{}, false, err
}
}
if len(evidenceJSON) > 0 {
if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil {
return HydrogenDailyEvidence{}, false, err
}
}
return result, true, nil
}
func nullableFloatPointer(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
result := value.Float64
return &result
}
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
@@ -8,6 +8,8 @@ import (
"io"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func init() {
@@ -141,7 +143,24 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
}
}
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
func TestBuildVehicleServiceOverviewBatchSQLRestrictsCustomerVINScope(t *testing.T) {
built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{
Keywords: []string{"粤A"}, ScopeVINs: []string{"vin001", "VIN002"},
})
if !strings.Contains(built.Text, "i.vin IN (?,?)") {
t.Fatalf("batch overview SQL should restrict results to granted VINs, got %s", built.Text)
}
if got := built.Args[len(built.Args)-2:]; got[0] != "VIN001" || got[1] != "VIN002" {
t.Fatalf("customer VIN scope should be normalized and bound last, got %#v", built.Args)
}
empty := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{Keywords: []string{"粤A"}, ScopeVINs: []string{}})
if !strings.Contains(empty.Text, "AND 1 = 0") {
t.Fatalf("empty customer VIN scope must fail closed, got %s", empty.Text)
}
}
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
if rate == nil || *rate < 3.77 || *rate > 3.78 {
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
@@ -200,6 +219,61 @@ func TestRawFramesReturnsEmptyPageWhenTDengineTableIsMissing(t *testing.T) {
}
}
func TestBuildLatestRealtimeFramesSQLUsesDurableVINProtocolProjection(t *testing.T) {
built := buildLatestRealtimeFramesSQL(RawFrameQuery{
VIN: "LMRKH9AC0R1004086",
Protocol: "yutong_mqtt",
DateFrom: "2026-07-01T00:00:00+08:00",
DateTo: "2026-08-25T18:00:00+08:00",
Limit: 10,
})
for _, expected := range []string{
"FROM vehicle_realtime_snapshot s",
"s.vin = ?",
"s.protocol = ?",
latestRealtimeProjectionTimeSQL + " >= ?",
latestRealtimeProjectionTimeSQL + " <= ?",
"ORDER BY " + latestRealtimeProjectionTimeSQL + " DESC",
} {
if !strings.Contains(built.Text, expected) {
t.Fatalf("latest projection SQL missing %q: %s", expected, built.Text)
}
}
if len(built.Args) != 5 || built.Args[0] != "LMRKH9AC0R1004086" || built.Args[1] != "YUTONG_MQTT" || built.Args[4] != len(canonicalVehicleProtocols) {
t.Fatalf("latest projection args = %#v", built.Args)
}
}
func TestLatestRealtimeFramesMapsPersistentProjectionToRawEvidence(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("(?s)FROM vehicle_realtime_snapshot s WHERE s\\.vin = \\?").
WithArgs("LMRKH9AC0R1004086", len(canonicalVehicleProtocols)).
WillReturnRows(sqlmock.NewRows([]string{"event_id", "vin", "plate", "protocol", "event_time", "received_at", "peer", "parsed_json"}).
AddRow("event-1", "LMRKH9AC0R1004086", "沪A03561F", "YUTONG_MQTT", "2026-08-25 16:49:56.451000", "2026-08-25 16:49:56.485000", "mqtt://yutong/4", `{"yutong_mqtt.data.speed_kmh":12.5}`))
store := &ProductionStore{db: db}
page, err := store.LatestRealtimeFrames(t.Context(), RawFrameQuery{VIN: "LMRKH9AC0R1004086", IncludeFields: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("latest projection page = %+v", page)
}
row := page.Items[0]
if row.ID != "event-1" || row.FrameType != "realtime_projection" || row.Protocol != "YUTONG_MQTT" || row.SourceEndpoint != "mqtt://yutong/4" {
t.Fatalf("latest projection row = %+v", row)
}
if got := row.ParsedFields["yutong_mqtt.data.speed_kmh"]; got != 12.5 {
t.Fatalf("projection parsed field = %#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
db, err := sql.Open("ops_health_test", "")
if err != nil {
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
func TestBuildVehicleCoverageSQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
for _, want := range []string{"LEFT JOIN vehicle_profile p ON p.vin = v.vin", "brand_name", "model_name", "GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}
@@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
"COUNT(DISTINCT d.vin)",
"SUM(d.daily_mileage_km)",
"SUM(d.pure_hydrogen_mileage_km)",
"COUNT(d.hydrogen_consumption_kg)",
"SUM(d.hydrogen_consumption_kg)",
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
"COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)",
"THEN d.hydrogen_consumption_kg ELSE 0 END",
"THEN d.hydrogen_matched_mileage_km ELSE 0 END",
} {
if !strings.Contains(summary.Text, want) {
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
@@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
}
trend := buildMileageStatisticsTrendSQL(query)
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
!strings.Contains(trend.Text, "THEN d.hydrogen_consumption_kg ELSE 0 END") {
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
}
ranking := buildMileageStatisticsRankingSQL(query)
@@ -43,6 +43,10 @@ type Store interface {
OpsHealth(context.Context) (OpsHealth, error)
}
type HydrogenDailyEvidenceStore interface {
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
}
type VehicleOverviewBatchStore interface {
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
}
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
}
// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime
// projection. The projection is used as a time anchor for bounded TDengine
// lookups, so an offline vehicle can still resolve its latest evidence without
// scanning all retained RAW history.
type RealtimeSnapshotFrameStore interface {
LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
}
type RawFrameQuery struct {
Protocol string `json:"protocol"`
VIN string `json:"vin"`
@@ -83,6 +95,7 @@ type VehicleOverviewBatchQuery struct {
Protocol string `json:"protocol"`
Limit int `json:"limit"`
Offset int `json:"offset"`
ScopeVINs []string `json:"-"`
}
type Service struct {
@@ -857,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
}
}
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
scopeVINs := principalVehicleVINScope(ctx)
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword},
Protocol: protocol,
Limit: 1,
ScopeVINs: scopeVINs,
})
if err != nil {
return VehicleServiceOverview{}, err
@@ -915,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
query.Keywords = keywords
query.Limit = limit
query.Offset = offset
query.ScopeVINs = principalVehicleVINScope(ctx)
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, query)
if err != nil {
return Page[VehicleServiceOverview]{}, err
}
page = restrictVehicleOverviewPage(ctx, page)
page.Total = total
page.Limit = limit
page.Offset = offset
@@ -936,6 +953,34 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func principalVehicleVINScope(ctx context.Context) []string {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return nil
}
scope := make([]string, 0, len(principal.VehicleVINs))
for _, vin := range principal.VehicleVINs {
if vin = strings.ToUpper(strings.TrimSpace(vin)); vin != "" {
scope = append(scope, vin)
}
}
return scope
}
func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return page
}
for index := range page.Items {
if principal.CanVIN(strings.ToUpper(strings.TrimSpace(page.Items[index].VIN))) {
continue
}
page.Items[index] = *buildVehicleServiceOverview("", "", &VehicleIdentityResolution{Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
}
return page
}
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol)
@@ -1029,7 +1074,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
if err != nil {
return VehicleDetail{}, err
}
raw, err := s.RawFrames(ctx, rawQuery)
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
if err != nil {
return VehicleDetail{}, err
}
@@ -1094,6 +1139,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
}, nil
}
const vehicleDetailRawProjectionLookback = 24 * time.Hour
type vehicleDetailRawPreviewResult struct {
anchor RawFrameRow
page Page[RawFrameRow]
}
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
// an unbounded TDengine query. MySQL supplies one persistent anchor per
// protocol; TDengine then reads only the day preceding that protocol's actual
// latest received time. If historical storage is unavailable, the projection
// itself remains useful evidence and the rest of the vehicle detail can load.
func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore)
if !ok {
return s.store.RawFrames(ctx, query)
}
anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{
VIN: query.VIN,
Protocol: query.Protocol,
DateFrom: query.DateFrom,
DateTo: query.DateTo,
Fields: query.Fields,
IncludeFields: query.IncludeFields,
Limit: len(canonicalVehicleProtocols),
SkipCount: true,
})
if err != nil {
return Page[RawFrameRow]{}, err
}
if len(anchors.Items) == 0 {
limit := query.Limit
if limit <= 0 {
limit = 10
}
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
}
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
for _, anchor := range anchors.Items {
anchor := anchor
go func() {
bounded, valid := boundedRawPreviewQuery(query, anchor)
if !valid {
results <- vehicleDetailRawPreviewResult{anchor: anchor}
return
}
page, queryErr := s.store.RawFrames(ctx, bounded)
if queryErr != nil {
results <- vehicleDetailRawPreviewResult{anchor: anchor}
return
}
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
}()
}
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
for range anchors.Items {
result := <-results
if len(result.page.Items) == 0 {
items = append(items, result.anchor)
continue
}
items = append(items, result.page.Items...)
}
items = newestRawFrames(items, query.Limit)
limit := query.Limit
if limit <= 0 {
limit = 10
}
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
}
func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) {
anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime))
if !ok {
return RawFrameQuery{}, false
}
start := anchorTime.Add(-vehicleDetailRawProjectionLookback)
end := anchorTime.Add(time.Minute)
if value := strings.TrimSpace(query.DateFrom); value != "" {
if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) {
start = scopedStart
}
}
if value := strings.TrimSpace(query.DateTo); value != "" {
if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) {
end = scopedEnd
}
}
if end.Before(start) {
return RawFrameQuery{}, false
}
bounded := query
bounded.Protocol = anchor.Protocol
bounded.DateFrom = start.Format(time.RFC3339Nano)
bounded.DateTo = end.Format(time.RFC3339Nano)
bounded.Offset = 0
bounded.SkipCount = true
return bounded, true
}
func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow {
if limit <= 0 {
limit = 10
}
deduplicated := make([]RawFrameRow, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID)
if key != "\x00" && seen[key] {
continue
}
seen[key] = true
deduplicated = append(deduplicated, item)
}
sort.SliceStable(deduplicated, func(left, right int) bool {
leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime))
rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime))
if leftOK && rightOK && !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
if leftOK != rightOK {
return leftOK
}
if deduplicated[left].Protocol != deduplicated[right].Protocol {
return deduplicated[left].Protocol < deduplicated[right].Protocol
}
return deduplicated[left].ID < deduplicated[right].ID
})
if len(deduplicated) > limit {
deduplicated = deduplicated[:limit]
}
return deduplicated
}
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
vin = strings.TrimSpace(vin)
if vin == "" {
@@ -1587,6 +1768,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
for index := range statuses {
protocol := statuses[index].Protocol
if protocol == "" {
@@ -1599,7 +1781,7 @@ func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, sta
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
}
}
if !statuses[index].HasRaw && scopeErr == nil {
if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection {
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true})
if err == nil && len(page.Items) > 0 {
statuses[index].HasRaw = true
@@ -1933,7 +2115,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
catalogResult := make(chan latestTelemetryCatalogResult, 1)
for _, protocol := range canonicalVehicleProtocols {
go func(protocol string) {
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
page, queryErr := s.vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
}(protocol)
}
@@ -5409,6 +5591,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
return result, nil
}
func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) {
vin = strings.ToUpper(strings.TrimSpace(vin))
date = strings.TrimSpace(date)
if len(vin) != 17 {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"}
}
if _, err := time.Parse("2006-01-02", date); err != nil {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"}
}
if !hydrogenConsumptionAllowed(ctx) {
return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"}
}
query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}}
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return HydrogenDailyEvidence{}, err
}
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
return HydrogenDailyEvidence{}, err
}
store, ok := s.store.(HydrogenDailyEvidenceStore)
if !ok {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"}
}
result, found, err := store.HydrogenDailyEvidence(ctx, vin, date)
if err != nil {
return HydrogenDailyEvidence{}, err
}
if !found {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"}
}
return result, nil
}
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
@@ -543,9 +543,11 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
type countingStore struct {
*MockStore
rawMu sync.Mutex
vehiclesCalls int
vehicleRealtimeCalls int
overviewBatchCalls int
lastOverviewBatchQuery VehicleOverviewBatchQuery
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
lastHistoryQuery url.Values
@@ -555,6 +557,17 @@ type countingStore struct {
lastMileageStatisticsQuery url.Values
}
type projectionCountingStore struct {
*countingStore
anchors Page[RawFrameRow]
projectionQueries []RawFrameQuery
}
func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.projectionQueries = append(s.projectionQueries, query)
return s.anchors, nil
}
type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
@@ -572,15 +585,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
consumption, rate := 3.1, 5.5
return MileageStatistics{
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
Trend: []MileageTrendPoint{{
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}},
}, nil
}
func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) {
return HydrogenDailyEvidence{
VIN: "LB9A32A24R0LS1426",
Date: "2026-07-13",
AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
Intervals: []HydrogenIntervalEvidenceRow{{
Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK",
}},
}, true, nil
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
@@ -622,6 +646,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
}
evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13")
if err != nil {
t.Fatal(err)
}
if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" {
t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence)
}
}
func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) {
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
customer := WithPrincipal(context.Background(), Principal{
Name: "业务客户", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
_, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13")
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err)
}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
@@ -643,7 +688,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query
}
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.rawMu.Lock()
s.rawFrameQueries = append(s.rawFrameQueries, query)
s.rawMu.Unlock()
return s.MockStore.RawFrames(ctx, query)
}
@@ -692,6 +739,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
}
}
func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{
countingStore: base,
anchors: Page[RawFrameRow]{Items: []RawFrameRow{
{ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}},
{ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}},
{ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}},
}},
}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" {
t.Fatalf("projection queries = %+v", store.projectionQueries)
}
if len(store.rawFrameQueries) != 3 {
t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries)
}
for _, query := range store.rawFrameQueries {
if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount {
t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query)
}
if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") {
t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query)
}
if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") {
t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query)
}
}
if len(detail.Raw.Items) != 3 {
t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw)
}
projectionFallbacks := 0
for _, item := range detail.Raw.Items {
if item.FrameType == "realtime_projection" {
projectionFallbacks++
}
}
if projectionFallbacks != 2 {
t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items)
}
}
func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.rawFrameQueries) != 0 {
t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries)
}
if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 {
t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw)
}
}
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query)
@@ -904,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++
s.lastOverviewBatchQuery = query
return s.MockStore.VehicleServiceOverviews(ctx, query)
}
@@ -940,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
}
}
func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) {
store := newCountingStore()
service := NewService(store)
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"},
})
page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200,
})
if err != nil {
t.Fatalf("customer batch lookup returned error: %v", err)
}
if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" {
t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs)
}
if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" {
t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page)
}
}
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
service := NewService(NewMockStore())
summary, err := service.VehicleServiceSummary(context.Background())
@@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler {
}
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
if info, err := os.Stat(path); err == nil && !info.IsDir() {
setCachePolicy(w, r.URL.Path)
fs.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
})
}
func setCachePolicy(w http.ResponseWriter, path string) {
if path == "/" || path == "/index.html" || path == "/app-config.js" {
w.Header().Set("Cache-Control", "no-store")
return
}
if strings.HasPrefix(path, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
}
@@ -0,0 +1,45 @@
package static
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestHandlerDisablesCachingForApplicationShell(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app-config.js"), []byte("config"), 0o600); err != nil {
t.Fatal(err)
}
handler := Handler(dir, http.NotFoundHandler())
for _, path := range []string{"/", "/index.html", "/app-config.js", "/users"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if got := response.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("%s Cache-Control=%q, want no-store", path, got)
}
}
}
func TestHandlerCachesHashedAssetsImmutably(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "assets")
if err := os.Mkdir(assets, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assets, "app-1234.js"), []byte("app"), 0o600); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
Handler(dir, http.NotFoundHandler()).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app-1234.js", nil))
if got := response.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("asset Cache-Control=%q", got)
}
}
@@ -4,12 +4,12 @@ import { Icon, navigate } from "./ui";
type Topic =
| "overview" | "quickstart" | "auth" | "errors" | "limits"
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage"
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage" | "stationary"
| "vehicle-auth" | "key-rotation" | "quality" | "checklist";
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] },
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] },
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"], ["stationary", "加氢车辆停留核验"]] },
{ title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
];
@@ -38,6 +38,7 @@ function TopicContent({ topic }: { topic: Topic }) {
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
if (topic === "mileage-range") return <MileageRangeReference />;
if (topic === "total-mileage") return <TotalMileageReference />;
if (topic === "stationary") return <StationaryVehicleReference />;
if (topic === "overview") return <Overview />;
if (topic === "auth") return <AuthGuide />;
if (topic === "errors") return <ErrorGuide />;
@@ -135,7 +136,7 @@ function LimitsGuide() {
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
const product = products[kind === "hydrogen" ? 0 : 1];
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315,\n "calculationPhase": "FINAL",\n "algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
return <>
<h1>{product.name}</h1><p className="docs-lead">{product.description} AppKey</p>
<Endpoint method={product.method} path={product.path} />
@@ -149,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
}`} />
{kind === "mileage" && <><h2 id="协议优先级"></h2><p><code>protocolPriority</code> <code>GB32960</code><code>MQTT</code><code>JT808</code></p><h2 id="缺日补齐"></h2><p><code>dailyMileageKm</code> 0沿<code>updatedAt</code> NO_DATA</p></>}
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL NO_DATA</td></tr></tbody></table>
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td> PRELIMINARY FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td></td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL NO_DATA</td></tr></tbody></table>
<h2 id="响应示例"></h2><CodeBlock language="json" value={`{
"code": "SUCCESS",
"message": "success",
@@ -227,6 +228,23 @@ function TotalMileageReference() {
</>;
}
function StationaryVehicleReference() {
return <>
<h1></h1><p className="docs-lead"></p>
<Endpoint method="POST" path="/api/v1/vehicles/stationary/query" />
<h2 id="请求参数"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody><tr><td><code>startTime</code></td><td></td><td> YYYY-MM-DD HH:mm:ss</td></tr><tr><td><code>endTime</code></td><td></td><td> 24 </td></tr><tr><td><code>longitude</code></td><td></td><td> WGS-84 </td></tr><tr><td><code>latitude</code></td><td></td><td> WGS-84 </td></tr><tr><td><code>radiusMeters</code></td><td></td><td>1100 5</td></tr><tr><td><code>plateNumbers</code></td><td></td><td></td></tr></tbody></table>
<h2 id="请求示例"></h2><CodeBlock language="json" value={`{
"startTime": "2026-08-06 10:00:00",
"endTime": "2026-08-06 12:00:00",
"longitude": 120.752312,
"latitude": 30.746281,
"radiusMeters": 8
}`} />
<h2 id="核验口径"></h2><p>使 WGS-84/GPS <code>radiusMeters</code> 1100 5 3 km/h 2 60 10 <code>matchScore</code> </p>
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th></tr></thead><tbody><tr><td><code>stayStartTime</code> / <code>stayEndTime</code></td><td></td></tr><tr><td><code>stayDurationSeconds</code> / <code>stayDurationMinutes</code></td><td></td></tr><tr><td><code>matchScore</code></td><td>0100</td></tr><tr><td><code>averageDistanceMeters</code> / <code>maxDistanceMeters</code></td><td></td></tr><tr><td><code>averageSpeedKmh</code> / <code>maxSpeedKmh</code></td><td></td></tr><tr><td><code>matchedSamples</code> / <code>sourceProtocols</code></td><td></td></tr></tbody></table>
</>;
}
function VehicleAuthGuide() {
return <>
<h1></h1><p className="docs-lead"> VIN/</p>
@@ -304,6 +322,7 @@ function tocFor(topic: Topic) {
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
stationary: ["请求参数", "请求示例", "核验口径", "响应字段"],
"vehicle-auth": ["授权关系", "配置步骤"],
"key-rotation": ["推荐流程", "权限要求"],
quality: ["状态定义", "统计口径"],
@@ -41,6 +41,16 @@ export const products: Product[] = [
path: "/api/v1/vehicles/total-mileage/query",
unit: "km"
},
{
code: "stationary_vehicle_verification",
name: "加氢车辆停留核验",
description: "按加氢站坐标与时间区间核验授权车辆的 5 米内低速停留,并按匹配度排序。",
version: "v1",
status: "available",
method: "POST",
path: "/api/v1/vehicles/stationary/query",
unit: "辆"
},
{
code: "realtime_vehicle",
name: "车辆实时位置与状态",
@@ -83,6 +93,17 @@ export const curlExample = (product: Product) => {
"time": "2026-07-21 09:30:00",
"protocol": "GB32960"
}'`;
if (product.code === "stationary_vehicle_verification") return `curl --request POST \\
--url https://open.d.lnoneos.com${product.path} \\
--header 'Authorization: Bearer YOUR_APP_KEY' \\
--header 'Content-Type: application/json' \\
--data '{
"startTime": "2026-08-06 10:00:00",
"endTime": "2026-08-06 12:00:00",
"longitude": 120.752312,
"latitude": 30.746281,
"radiusMeters": 5
}'`;
if (product.code === "mileage_range") return `curl --request POST \\
--url https://open.d.lnoneos.com${product.path} \\
--header 'Authorization: Bearer YOUR_APP_KEY' \\
@@ -24,6 +24,7 @@ import type {
AlertRuleRevision,
AlertSummary,
DailyMileageRow,
HydrogenDailyEvidence,
DashboardSummary,
HistoryLocationRow,
HistoryDataResponse,
@@ -487,6 +488,10 @@ export const api = {
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)),
hydrogenDailyEvidence: (vin: string, date: string, signal?: AbortSignal) => request<HydrogenDailyEvidence>(
`/api/v2/vehicles/${encodeURIComponent(vin)}/hydrogen-evidence?date=${encodeURIComponent(date)}`,
withSignal(undefined, signal)
),
mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)),
@@ -470,6 +470,8 @@ export interface VehicleCoverageRow {
plate: string;
phone: string;
oem: string;
brandName?: string;
modelName?: string;
protocols: string[];
missingProtocols: string[];
sourceStatus: VehicleSourceStatus[];
@@ -908,10 +910,40 @@ export interface DailyMileageRow {
pureHydrogenMileageKm?: number;
hydrogenConsumptionKg?: number | null;
hydrogenConsumptionKgPer100Km?: number | null;
hydrogenSocBalancedKg?: number | null;
hydrogenSocBalancedKgPer100Km?: number | null;
hydrogenEvidenceAvailable?: boolean;
hydrogenQualityStatus?: string;
hydrogenAlgorithmVersion?: string;
source: string;
anomalySeverity?: string;
}
export interface HydrogenIntervalEvidence {
index: number; type: 'MIXED' | 'PURE_ELECTRIC'; startTime: string; endTime: string;
startEventId: string; endEventId: string; source: string;
startPressureMpa: number; endPressureMpa: number; startTemperatureC: number; endTemperatureC: number;
startMassKg: number; endMassKg: number; rawHydrogenConsumptionKg: number;
startSocPercent?: number; endSocPercent?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
socBalancedConsumptionKg?: number; startMileageKm?: number; endMileageKm?: number; mileageKm?: number;
consumptionKgPer100Km?: number; sampleCount: number; qualityStatus: string; qualityReason: string;
}
export interface HydrogenDailyEvidence {
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
refuelCount: number; chargeCount: number; validSegmentCount: number; invalidSegmentCount: number;
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
parameters: {
batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number;
powerOffLeadSeconds: number; refuelRiseMpa: number; refuelSustainSeconds: number;
pureElectricDropMpa: number; pureElectricWindowSeconds: number; algorithmVersion: string;
};
intervals: HydrogenIntervalEvidence[];
}
export interface MileageSummary {
vehicleCount: number;
recordCount: number;
@@ -261,6 +261,7 @@ export function VehicleDetail({
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
const latestRaw = detail?.raw?.items?.[0];
const latestRawType = latestRaw?.frameType === 'realtime_projection' ? '最新状态投影' : latestRaw?.frameType;
const qualityCount = detail?.quality?.total ?? 0;
const priorityQualityIssue = useMemo(() => {
const items = detail?.quality?.items ?? [];
@@ -617,7 +618,7 @@ export function VehicleDetail({
{
label: '历史数据',
value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0),
detail: latestRaw?.frameType || '解析字段',
detail: latestRawType || '解析字段',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
},
{
@@ -766,7 +767,7 @@ export function VehicleDetail({
{
title: '历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
meta: latestRaw?.frameType || '字段明细',
meta: latestRawType || '字段明细',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
disabled: !hasResolvedVIN,
@@ -863,7 +864,7 @@ export function VehicleDetail({
},
{
title: '查询历史数据',
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`,
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRawType || '-'}`,
acceptance: '关键字段来自解析字段而不是页面二次推断。',
action: '历史数据',
disabled: !hasResolvedVIN,
@@ -938,7 +939,7 @@ export function VehicleDetail({
{
title: '查询历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新历史数据类型 ${latestRaw.frameType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。',
detail: latestRawType ? `最新历史数据类型 ${latestRawType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
primaryAction: '历史数据',
secondaryAction: '导出档案',
@@ -991,7 +992,7 @@ export function VehicleDetail({
{
title: '历史数据查询',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : 'RAW 与解析字段证据',
detail: latestRawType ? `最新 ${latestRawType}` : 'RAW 与解析字段证据',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '查询历史',
disabled: !hasResolvedVIN,
@@ -1047,7 +1048,7 @@ export function VehicleDetail({
{
title: '数据导出',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段',
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与字段',
action: '历史导出',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
@@ -1085,7 +1086,7 @@ export function VehicleDetail({
{
title: '历史证据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与解析字段',
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与解析字段',
action: '历史查询',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
@@ -2175,7 +2176,7 @@ export function VehicleDetail({
dataSource={detail?.raw?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '帧类型', dataIndex: 'frameType', width: 190, render: (value: string) => value === 'realtime_projection' ? '最新状态投影' : value },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
@@ -75,6 +75,28 @@ test('presents failed credentials as a clear Semi UI alert without losing the lo
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
});
test('does not flash the anonymous session error while a login request is pending', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let resolveLogin!: (value: { accessToken: string; expiresAt: string; session: { name: string; role: string } }) => void;
mocks.session.mockImplementation(async () => {
if (getAccessToken() === 'authenticated-token') return { name: 'ln-bm', role: 'admin', authMode: 'enforce' };
throw new Error('需要有效的访问令牌');
});
mocks.login.mockReturnValue(new Promise((resolve) => { resolveLogin = resolve; }));
render(<QueryClientProvider client={client}><AuthGate><div></div></AuthGate></QueryClientProvider>);
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'ln-bm' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } });
fireEvent.click(screen.getByRole('button', { name: '登录工作台' }));
expect(screen.getByRole('button', { name: '正在登录…' })).toBeDisabled();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
resolveLogin({ accessToken: 'authenticated-token', expiresAt: new Date().toISOString(), session: { name: 'ln-bm', role: 'admin' } });
expect(await screen.findByText('已进入车辆数据中台')).toBeInTheDocument();
expect(screen.queryByText('需要有效的访问令牌')).not.toBeInTheDocument();
});
test('treats login and logout as complete query and mutation cache boundaries', async () => {
mocks.logout.mockResolvedValue({ loggedOut: true });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -192,7 +192,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
return <AuthLoadingState />;
}
if (!session.data) {
const error = loginError || (attempted && session.error && !getAccessToken() ? session.error.message : '');
const error = loginError || (!loginPending && attempted && session.error && !getAccessToken() ? session.error.message : '');
return <div className="v2-auth-screen">
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
<header>
@@ -1,5 +1,15 @@
import { afterEach, expect, test, vi } from 'vitest';
import { canAdminister, canOperate, clearAccessToken, getAccessToken, notifyUnauthorizedSession, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
import {
canAdminister,
canOperate,
clearAccessToken,
getAccessToken,
hasMenu,
notifyUnauthorizedSession,
PLATFORM_UNAUTHORIZED_EVENT,
setAccessToken,
type PlatformSession,
} from './session';
afterEach(() => {
window.sessionStorage.clear();
@@ -21,6 +31,17 @@ test('role helpers follow the server permission hierarchy', () => {
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
});
test('an administrator can have account management removed explicitly', () => {
const session = {
role: 'admin',
userType: 'admin',
menuKeys: ['monitor', 'operations'],
} as PlatformSession;
expect(hasMenu(session, 'operations')).toBe(true);
expect(hasMenu(session, 'users')).toBe(false);
});
test('invalidates only the currently active rejected token', () => {
const unauthorized = vi.fn();
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
@@ -29,9 +29,8 @@ export function canOperate(session: PlatformSession) {
}
export function hasMenu(session: PlatformSession, menu: string) {
if (session.role === 'admin') return true;
if (!session.menuKeys) return session.role !== 'customer';
return session.menuKeys.includes(menu);
if (session.menuKeys) return session.menuKeys.includes(menu);
return session.role !== 'customer';
}
export function canAdminister(session: PlatformSession) {
@@ -10,8 +10,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [
{ vin: 'LTEST000000000001', plate: '粤A12345' },
{ vin: 'LTEST000000000002', plate: '粤A54321' }
{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' },
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
],
mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' },
@@ -26,25 +26,29 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
const sheet = workbook.getWorksheet('里程查询')!;
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
expect(sheet.getCell('C6').value).toBeInstanceOf(Date);
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('C6').value).toBe('品牌');
expect(sheet.getCell('D6').value).toBe('车型');
expect(sheet.getCell('E6').value).toBeInstanceOf(Date);
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('F6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
expect(sheet.getCell('C7').value).toBe(88.7);
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 });
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"');
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
expect(sheet.getCell('G6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
expect(sheet.getCell('C7').value).toBe('现代');
expect(sheet.getCell('D7').value).toBe('XCIENT');
expect(sheet.getCell('E7').value).toBe(88.7);
expect(sheet.getCell('G7').value).toMatchObject({ formula: 'SUM(E7:F7)', result: 193.3 });
expect(sheet.getCell('G7').numFmt).toBe('#,##0.0" km"');
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 4, ySplit: 6, showGridLines: false });
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 7 } });
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
expect(hydrogenSheet.getRow(1).values).toEqual([
undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
]);
expect(hydrogenSheet.getRow(2).values).toEqual([
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', 88.7, 56.2, 3.1, 5.5, 'GB32960'
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 88.7, 56.2, 3.1, 5.5, 'GB32960'
]);
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 8 } });
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 10 } });
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});
@@ -1,7 +1,7 @@
import type { DailyMileageRow } from '../../api/types';
import { downloadBlob } from './download';
export type MileageExportVehicle = { vin: string; plate: string };
export type MileageExportVehicle = { vin: string; plate: string; brandName?: string; modelName?: string };
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
@@ -27,11 +27,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
workbook.calcProperties.fullCalcOnLoad = true;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
properties: { defaultRowHeight: 21 }
});
const firstDateColumn = 3;
const firstDateColumn = 5;
const lastDateColumn = firstDateColumn + input.dates.length - 1;
const totalColumn = lastDateColumn + 1;
const lastColumnLetter = excelColumn(totalColumn);
@@ -72,14 +72,14 @@ export async function createMileageWorkbook(input: MileageExportInput) {
sheet.getRow(5).height = 8;
const header = sheet.getRow(headerRowNumber);
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
header.values = ['车牌', 'VIN', '品牌', '车型', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
header.height = 30;
header.eachCell((cell, columnNumber) => {
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
cell.alignment = {
vertical: 'middle',
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
horizontal: columnNumber <= 4 ? 'left' : isDateHeader ? 'center' : 'right'
};
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
@@ -97,11 +97,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
const row = sheet.getRow(rowNumber);
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
row.values = [vehicle.plate || '未绑定', vehicle.vin, vehicle.brandName || '待维护', vehicle.modelName || '待维护', ...dailyValues, null];
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 4 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
@@ -116,6 +116,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
sheet.getColumn(1).width = 15;
sheet.getColumn(2).width = 24;
sheet.getColumn(3).width = 18;
sheet.getColumn(4).width = 24;
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
sheet.getColumn(totalColumn).width = 17;
if (input.vehicles.length) {
@@ -135,7 +137,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
properties: { defaultRowHeight: 21 }
});
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
const hydrogenHeader = hydrogenSheet.getRow(1);
hydrogenHeader.height = 30;
hydrogenHeader.eachCell((cell) => {
@@ -144,13 +146,16 @@ export async function createMileageWorkbook(input: MileageExportInput) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
});
const plateByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate || '未绑定']));
const vehicleByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle]));
const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
energyRows.forEach((value, index) => {
const vehicle = vehicleByVIN.get(value.vin);
const row = hydrogenSheet.addRow([
value.date,
plateByVIN.get(value.vin) ?? value.plate ?? '未绑定',
vehicle?.plate || value.plate || '未绑定',
value.vin,
vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护',
value.dailyMileageKm,
value.pureHydrogenMileageKm ?? null,
value.hydrogenConsumptionKg ?? null,
@@ -160,17 +165,17 @@ export async function createMileageWorkbook(input: MileageExportInput) {
row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 4 && columnNumber <= 7 ? 'right' : 'left' };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 9 ? 'right' : 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= 4 && columnNumber <= 7) cell.numFmt = '#,##0.0';
if (columnNumber >= 6 && columnNumber <= 9) cell.numFmt = '#,##0.0';
});
});
[13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
[13, 15, 24, 18, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
hydrogenSheet.getColumn(index + 1).width = width;
});
if (energyRows.length) {
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } };
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } };
}
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
return workbook;
@@ -136,6 +136,23 @@ test('summarizes only material automation changes before release', () => {
{ key: 'status', label: '发布状态', before: '启用', after: '停用' }
]);
expect(automationReleaseChanges(baseline, baseline)).toEqual([]);
const pressureBaseline = {
...baseline,
id: 'hydrogen-pressure-rule',
name: '氢系统压力过低',
metric: 'hydrogen_pressure_mpa',
operator: 'lt',
threshold: 2,
recoveryOperator: 'gt',
recoveryThreshold: 3,
description: ''
};
const pressureDraft = { ...pressureBaseline, recoveryThreshold: 3.5, description: '压力恢复后自动关闭事件' };
expect(automationReleaseChanges(pressureBaseline, pressureDraft, { hydrogen_pressure_mpa: '最高氢气压力' }, { hydrogen_pressure_mpa: 'MPa' })).toEqual([
{ key: 'recovery', label: '恢复条件', before: '> 3 MPa', after: '> 3.5 MPa' },
{ key: 'description', label: '规则说明', before: '未填写', after: '压力恢复后自动关闭事件' }
]);
});
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
@@ -207,7 +224,7 @@ test('keeps creation contextual to the automation workspace', async () => {
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/);
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
const nameInput = screen.getByLabelText('规则名称');
const thresholdInput = screen.getByLabelText('触发阈值');
@@ -972,7 +989,7 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/);
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '系统自动做什么' })).not.toBeInTheDocument();
@@ -981,15 +998,15 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:测试规则' }));
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ }));
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
@@ -1083,7 +1100,7 @@ test('keeps a changed mobile automation in its editor until discard is confirmed
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor')).not.toBeInTheDocument());
});
test('opens desktop automation editing in a centered review dialog and gates publishing on a sample test', async () => {
test('opens desktop automation editing in a centered review dialog and publishes valid changes without requiring a sample test', async () => {
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
@@ -1100,7 +1117,7 @@ test('opens desktop automation editing in a centered review dialog and gates pub
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
expect(within(editor).getByLabelText('自动化配置摘要')).toBeInTheDocument();
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument();
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
expect(within(editor).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument();
expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
@@ -1110,21 +1127,58 @@ test('opens desktop automation editing in a centered review dialog and gates pub
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ }));
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
expect(within(editor).getByText('1 项变更')).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '下一步:测试规则' })).toBeEnabled();
expect(within(editor).getByRole('button', { name: '下一步:检查发布' })).toBeEnabled();
fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ }));
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
});
test('enables publishing a hydrogen pressure automation when only its recovery threshold changes', async () => {
const pressureRule: AlertRule = {
...alertRule(),
id: 'hydrogen-pressure-rule',
name: '氢系统压力过低',
description: '',
triggerType: 'metric',
metric: 'hydrogen_pressure_mpa',
operator: 'lt',
threshold: 2,
thresholdHigh: 100,
durationSec: 3,
recoveryOperator: 'gt',
recoveryThreshold: 3,
scopeProtocols: ['GB32960']
};
mocks.alertRulesV2.mockResolvedValue([pressureRule]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'hydrogen_pressure_mpa', label: '最高氢气压力', unit: 'MPa', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: { GB32960: 'gb32960.fuel_cell.max_hydrogen_pressure_mpa' }, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=hydrogen-pressure-rule']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: /^氢系统压力过低/ });
fireEvent.click(await screen.findByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
fireEvent.click(within(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).getByRole('button', { name: /执行动作/ }));
fireEvent.change(within(editor).getByDisplayValue('3'), { target: { value: '3.5' } });
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('恢复条件');
expect(within(editor).getByText('1 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
});
test('copies an automation into an audited disabled draft without changing the source rule', async () => {
const source = alertRule();
const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 };
@@ -1142,9 +1196,6 @@ test('copies an automation into an audited disabled draft without changing the s
expect(within(editor).getByText('2 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled();
fireEvent.click(within(editor).getByRole('button', { name: '创建副本' }));
@@ -1252,8 +1303,8 @@ test('creates auditable drafts from simple offline and hydrogen rule templates',
expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:测试规则' }));
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeDisabled();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
@@ -85,7 +85,7 @@ const AUTOMATION_EDITOR_STEPS: Array<{ step: AutomationEditorStep; label: string
{ step: 1, label: '事件与条件', description: '定义触发信号' },
{ step: 2, label: '车辆范围', description: '选择生效对象' },
{ step: 3, label: '执行动作', description: '通知、恢复与冷却' },
{ step: 4, label: '测试并发布', description: '验证后正式启用' }
{ step: 4, label: '检查并发布', description: '确认配置后发布' }
];
export function alertFiltersFromParams(params: URLSearchParams): Filters {
@@ -691,22 +691,31 @@ function ruleDraft(rule: AlertRule): AlertRuleInput {
export type AutomationReleaseChange = { key: string; label: string; before: string; after: string };
export function automationReleaseChanges(baseline: AlertRuleInput, draft: AlertRuleInput, catalogLabels: Record<string, string> = {}, catalogUnits: Record<string, string> = {}): AutomationReleaseChange[] {
const stableList = (values: string[]) => [...values].sort();
const stableTargets = (rule: AlertRuleInput) => [...(rule.notificationTargets ?? [])]
.map((target) => ({ channel: target.channel, recipientId: target.recipientId, label: target.label ?? '' }))
.sort((left, right) => `${left.channel}:${left.recipientId}`.localeCompare(`${right.channel}:${right.recipientId}`));
const changed = (select: (rule: AlertRuleInput) => unknown) => JSON.stringify(select(baseline)) !== JSON.stringify(select(draft));
const snapshot = (rule: AlertRuleInput) => ({
name: rule.name || '未命名自动化',
event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}`,
event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}${rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ' · 立即判断'}`,
scope: ruleScopeSummary(rule),
action: `${ruleActionSummary(rule)} · ${rule.repeatIntervalSec ? `${formatAlertDuration(rule.repeatIntervalSec)}内不重复` : '允许每次匹配执行'}`,
recovery: rule.recoveryOperator ? `${operatorLabels[rule.recoveryOperator]} ${rule.recoveryThreshold}${catalogUnits[rule.metric] ? ` ${catalogUnits[rule.metric]}` : ''}` : '状态恢复后自动关闭事件',
description: rule.description.trim() || '未填写',
status: rule.enabled ? '启用' : '停用'
});
const before = snapshot(baseline);
const after = snapshot(draft);
return [
{ key: 'name', label: '规则名称', before: before.name, after: after.name },
{ key: 'event', label: '事件与条件', before: before.event, after: after.event },
{ key: 'scope', label: '车辆范围', before: before.scope, after: after.scope },
{ key: 'action', label: '执行动作', before: before.action, after: after.action },
{ key: 'status', label: '发布状态', before: before.status, after: after.status }
].filter((item) => item.before !== item.after);
changed((rule) => rule.name) ? { key: 'name', label: '规则名称', before: before.name, after: after.name } : undefined,
changed((rule) => ({ triggerType: rule.triggerType, metric: rule.metric, valueType: rule.valueType, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold, durationSec: rule.durationSec, fenceName: rule.fenceName, fenceLongitude: rule.fenceLongitude, fenceLatitude: rule.fenceLatitude, fenceRadiusM: rule.fenceRadiusM })) ? { key: 'event', label: '事件与条件', before: before.event, after: after.event } : undefined,
changed((rule) => ({ scopeProtocols: stableList(rule.scopeProtocols), scopeVins: stableList(rule.scopeVins), scopeOems: stableList(rule.scopeOems), scopeModels: stableList(rule.scopeModels), scopeCompanies: stableList(rule.scopeCompanies) })) ? { key: 'scope', label: '车辆范围', before: before.scope, after: after.scope } : undefined,
changed((rule) => ({ severity: rule.severity, notificationChannels: stableList(rule.notificationChannels), notificationTargets: stableTargets(rule), repeatIntervalSec: rule.repeatIntervalSec })) ? { key: 'action', label: '执行动作', before: before.action, after: after.action } : undefined,
changed((rule) => ({ recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold })) ? { key: 'recovery', label: '恢复条件', before: before.recovery, after: after.recovery } : undefined,
changed((rule) => rule.description.trim()) ? { key: 'description', label: '规则说明', before: before.description, after: after.description } : undefined,
changed((rule) => rule.enabled) ? { key: 'status', label: '发布状态', before: before.status, after: after.status } : undefined
].filter((item): item is AutomationReleaseChange => Boolean(item));
}
function RuleFormSection({ id, title, description, children }: { id: string; title: string; description: string; children: ReactNode }) {
@@ -938,7 +947,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
moveToStep((editorStep + 1) as AutomationEditorStep);
return;
}
if (testRan) save.mutate(draft);
save.mutate(draft);
};
const openNewRule = () => {
const nextDraft = emptyRule();
@@ -1168,7 +1177,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
</div>
<div className="v2-automation-review-test">
<section className="v2-automation-sample-test" aria-labelledby="v2-automation-review-test-title">
<header><span><strong id="v2-automation-review-test-title"></strong><small>使</small></span><Button htmlType="button" theme="solid" disabled={Boolean(validationError)} onClick={() => setTestRan(true)}></Button></header>
<header><span><strong id="v2-automation-review-test-title"></strong><small>使</small></span><Button htmlType="button" theme="solid" disabled={Boolean(validationError)} onClick={() => setTestRan(true)}></Button></header>
<div className="v2-automation-review-fields"><span><small>event.type</small><code>{automationEventType(draft)}</code></span><span><small>source.protocol</small><code>{draft.scopeProtocols[0] || 'JT808'}</code></span><span><small>subject.vin</small><code>{draft.scopeVins[0] || 'LFP23A98V2P012345'}</code></span><span><small>occurred_at</small><code>2026-07-22 14:30:04</code></span><span><small>{draft.triggerType === 'geofence' ? 'payload.geofence' : `payload.${draft.metric}`}</small><code>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</code></span><span><small>planned.action</small><code>{ruleActionSummary(draft)}</code></span></div>
</section>
<section className="v2-automation-review-trace" aria-labelledby="v2-automation-review-trace-title"><header><strong id="v2-automation-review-trace-title"></strong><small></small></header>{testRan ? <ol className="v2-automation-test-trace"><li><IconTickCircle /><span><strong></strong><small></small></span></li><li><IconTickCircle /><span><strong></strong><small>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</small></span></li><li><IconTickCircle /><span><strong>{ruleActionSummary(draft)}</strong><small></small></span></li></ol> : <p className="v2-automation-test-empty"></p>}</section>
@@ -1176,16 +1185,18 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
</div>
</div>
</div>;
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:测试规则' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入样例测试,不会立即发送通知。' : '样例测试只验证契约与计划动作,不会产生真实通知。';
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:检查发布' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入发布检查,不会立即发送通知。' : '配置有效即可发布;样例测试为可选预演,不会产生真实通知。';
const editorFooterNote = currentStepError
? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em>
: mutationError
? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em>
: reviewMode && draft.version > 0 && !draftDirty
? <span>线</span>
: draftDirty
? <span className="v2-automation-editor-draft-status" role="status">稿 · </span>
? <span className="v2-automation-editor-draft-status" role="status">{reviewMode ? '配置已就绪,可直接发布更新' : '草稿未保存 · 关闭前会再次确认'}</span>
: editorGuidance;
const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && (!testRan || (draft.version > 0 && !releaseChanges.length)));
const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && draft.version > 0 && !draftDirty);
const closeEditor = () => {
setDiscardConfirmOpen(false);
setCloneSource(undefined);
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage, { mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage';
import StatisticsPage, { mileageExportDateWindows, mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false }));
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
@@ -29,17 +29,28 @@ function prepareData() {
mocks.mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4,
hydrogenMatchedMileageKm: 193.3, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 3.8,
hydrogenMatchedMileageKm: 128.4, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 5.7,
fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 3.5, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, source: 'GB32960' }
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.hydrogenDailyEvidence.mockResolvedValue({
vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', source: 'factory-a',
rawConsumptionKg: 3.1, batterySocDeltaPct: -2, batteryDischargeKWh: 0.421,
batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126,
mixedMileageKm: 56.2, pureElectricMileageKm: 32.5, consumptionKgPer100Km: 5.516,
socBalancedKgPer100Km: 5.562, sampleCount: 120, refuelCount: 1, chargeCount: 1,
validSegmentCount: 1, invalidSegmentCount: 0, qualityStatus: 'OK', qualityReason: '',
algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', calculatedAt: '2026-07-14 00:05:00.000',
parameters: { batteryCapacityKWh: 21.04, hydrogenEnergyKWhPerKg: 16, powerOnDelaySeconds: 60, powerOffLeadSeconds: 60, refuelRiseMpa: 3, refuelSustainSeconds: 300, pureElectricDropMpa: 5, pureElectricWindowSeconds: 1800, algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2' },
intervals: [{ index: 1, type: 'MIXED', startTime: '2026-07-13T08:01:00+08:00', endTime: '2026-07-13T10:30:00+08:00', startEventId: 'event-start', endEventId: 'event-end', source: 'factory-a', startPressureMpa: 30, endPressureMpa: 22, startTemperatureC: 30, endTemperatureC: 35, startMassKg: 11.3, endMassKg: 8.2, rawHydrogenConsumptionKg: 3.1, startSocPercent: 80, endSocPercent: 78, batteryDischargeKWh: 0.421, batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126, startMileageKm: 1000, endMileageKm: 1056.2, mileageKm: 56.2, consumptionKgPer100Km: 5.562, sampleCount: 120, qualityStatus: 'OK', qualityReason: '' }]
});
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }], total: 1, limit: 20, offset: 0 });
mocks.vehicleServiceOverviews.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 });
}
@@ -56,12 +67,23 @@ test('refreshes only ranges that include the current business date', () => {
expect(mileageRangeContainsDate({ dateFrom: '2026-07-20', dateTo: '2026-07-20' }, '2026-07-19')).toBe(false);
});
test('bounds full-fleet export queries by vehicle-day volume', () => {
const windows = mileageExportDateWindows('2026-07-01', '2026-07-30', 1024);
expect(windows).toEqual([
{ dateFrom: '2026-07-01', dateTo: '2026-07-07' },
{ dateFrom: '2026-07-08', dateTo: '2026-07-14' },
{ dateFrom: '2026-07-15', dateTo: '2026-07-21' },
{ dateFrom: '2026-07-22', dateTo: '2026-07-28' },
{ dateFrom: '2026-07-29', dateTo: '2026-07-30' }
]);
});
test('renders only the desktop matrix with dates as columns and a period total', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
expect(screen.getByText('区间合计')).toBeInTheDocument();
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
@@ -116,25 +138,58 @@ test('renders only the desktop matrix with dates as columns and a period total',
});
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
expect(matrixScroller!.scrollLeft).toBe(96);
expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 公里,来源 GB32960"]')).toBeInTheDocument();
expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 km,来源 GB32960"]')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument();
expect(screen.getByRole('note', { name: '氢耗矩阵读表说明' })).toHaveTextContent('每日耗氢量');
const hydrogenGuide = screen.getByRole('note', { name: '氢耗矩阵读表说明' });
expect(hydrogenGuide).toHaveTextContent('日期子列里程固定展示');
expect(screen.getByRole('group', { name: '日期子列' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '用氢量' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '百公里氢耗' })).toHaveAttribute('aria-pressed', 'true');
expect(hydrogenGuide).toHaveTextContent('里程 当日末累计里程-同源日基线');
expect(hydrogenGuide).toHaveTextContent('纯电里程 总里程-纯氢里程');
expect(hydrogenGuide).toHaveTextContent('纯氢里程 运行模式 0x02 的相邻里程差累计');
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 有效用氢量÷纯氢里程×100');
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配里程193.3 km按有氢耗数据的车辆日总里程计算');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg3.8 kg/100km · 2 个有效车辆日');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里氢耗3.8 kg/100km2 个有效车辆日');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配纯氢里程128.4 km纯氢里程=总里程-纯电模式里程');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg5.7 kg/100km · 2 个有效车辆日');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里氢耗5.7 kg/100km2 个有效车辆日');
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
expect(screen.getAllByText('4.2 kg').length).toBeGreaterThan(0);
expect(screen.getAllByText('7.3 kg').length).toBeGreaterThan(0);
expect(screen.getAllByText('5.5 kg/100km').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '纯电里程' }));
fireEvent.click(screen.getByRole('button', { name: '纯氢里程' }));
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getAllByText('32.5 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('56.2 km').length).toBeGreaterThan(0);
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']);
});
test('opens traceable hydrogen formula, parameters, intervals and raw event ids', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(await screen.findByRole('button', { name: '氢耗' }));
const traceButtons = await screen.findAllByTitle('查看计算公式、区间和原始报文标识');
fireEvent.click(traceButtons[0]);
expect(await screen.findByText('计算公式与口径')).toBeInTheDocument();
expect(screen.getByText(/m = P × 1000 × 0.00201588/)).toBeInTheDocument();
expect(screen.getByText(/SOC平衡氢耗 = 物理耗氢/)).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.getByText('event-start')).toBeInTheDocument();
expect(screen.getByText('event-end')).toBeInTheDocument();
expect(screen.getAllByText('3.126 kg').length).toBeGreaterThan(0);
expect(mocks.hydrogenDailyEvidence).toHaveBeenCalledWith('LTEST000000000001', '2026-07-13', expect.any(AbortSignal));
});
test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => {
prepareData();
mocks.mileageStatistics.mockResolvedValue({
@@ -163,6 +218,18 @@ test('does not expose the hydrogen consumption view to business customers', asyn
expect(screen.queryByText('纯氢里程 56.2 km')).not.toBeInTheDocument();
});
test('keeps the daily mileage matrix available when the fleet summary times out', async () => {
prepareData();
mocks.mileageStatistics.mockRejectedValue(new Error('请求处理超时'));
const view = renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary')).toHaveTextContent('汇总暂不可用');
expect(screen.queryByText('数据暂时无法加载')).not.toBeInTheDocument();
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
});
test('shows query failures inside the result panel without a misleading empty matrix', async () => {
prepareData();
mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时'));
@@ -273,7 +340,7 @@ test('uses one responsive mileage matrix without viewport listeners', async () =
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程');
expect(view.container.querySelector('.v2-mileage-table th.is-total-group')).toHaveTextContent('区间合计');
expect(view.container.querySelector('.v2-mileage-table th.is-vin')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
@@ -476,7 +543,7 @@ test('paginates all unique vehicles when no license plate is selected', async ()
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
test('exports the full fleet mileage with fleet-scoped time windows instead of VIN batches', async () => {
prepareData();
const exportRows = [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
@@ -499,12 +566,50 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
expect(exportMocks.appendRows).toHaveBeenCalledTimes(2);
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
expect(exportMocks.finish.mock.calls[0][0][0]).toMatchObject({ brandName: '现代', modelName: 'XCIENT' });
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
});
test('loads archive brand and model before exporting selected vehicles', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
const archiveCall = mocks.vehicleCoverage.mock.calls.find(([params]) => params.get('keywords') === 'LTEST000000000001');
expect(archiveCall?.[0].get('bindingStatus')).toBe('bound');
expect(archiveCall?.[1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.finish.mock.calls[0][0]).toEqual([
expect.objectContaining({ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' })
]);
});
test('splits a 1024-vehicle full-fleet export into bounded time windows', async () => {
prepareData();
const fleet = Array.from({ length: 1024 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve(params.get('limit') === String(2_000)
? { items: fleet, total: fleet.length, limit: fleet.length, offset: 0 }
: { items: fleet.slice(0, 20), total: fleet.length, limit: 20, offset: 0 }));
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
renderPage('/statistics?dateFrom=2026-07-01&dateTo=2026-07-30');
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
const exportCalls = mocks.dailyMileage.mock.calls
.map(([query]) => query as { vehicleScope?: string; vins?: string[]; dateFrom: string; dateTo: string })
.filter((query) => query.vehicleScope === 'bound');
expect(exportCalls.map(({ dateFrom, dateTo }) => ({ dateFrom, dateTo }))).toEqual(mileageExportDateWindows('2026-07-01', '2026-07-30', 1024));
expect(exportCalls.every((query) => query.vins === undefined)).toBe(true);
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1024);
});
test('shows export progress and lets the user cancel the active request', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
@@ -1,10 +1,10 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api, type MileageQuery } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
import type { DailyMileageRow, HydrogenDailyEvidence, MileageStatistics, Page, VehicleRow } from '../../api/types';
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
import { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
@@ -32,15 +32,34 @@ const BATCH_RESOLVE_SIZE = 200;
const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50;
const EXPORT_QUERY_TARGET_ROWS = 8_000;
const EXPORT_MAX_DAYS_PER_QUERY = 31;
const MAX_MILEAGE_RANGE_DAYS = 366;
const CURRENT_DAY_REFRESH_MS = 60_000;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'> & { brandName?: string; modelName?: string };
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
type ExportProgress = { label: string; completed?: number; total?: number };
type MetricView = 'mileage' | 'hydrogen';
type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'rate';
type MatrixMetric = 'mileage' | HydrogenMatrixMetric;
const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [
{ key: 'consumption', label: '用氢量' },
{ key: 'pureElectric', label: '纯电里程' },
{ key: 'pureHydrogen', label: '纯氢里程' },
{ key: 'rate', label: '百公里氢耗' }
];
const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate'];
const MATRIX_METRIC_META: Record<MatrixMetric, { label: string; unit: string; width: number }> = {
mileage: { label: '里程', unit: 'km', width: 92 },
consumption: { label: '用氢量', unit: 'kg', width: 88 },
pureElectric: { label: '纯电里程', unit: 'km', width: 96 },
pureHydrogen: { label: '纯氢里程', unit: 'km', width: 96 },
rate: { label: '百公里氢耗', unit: 'kg/100km', width: 112 }
};
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
const DEFAULT_SOURCES: MileageSourceOption[] = [
@@ -147,6 +166,20 @@ function mileageQuery(criteria: Criteria, offset = 0): MileageQuery {
return query;
}
export function mileageExportDateWindows(dateFrom: string, dateTo: string, vehicleCount: number) {
const start = new Date(`${dateFrom}T00:00:00Z`);
const end = new Date(`${dateTo}T00:00:00Z`);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || start > end) return [];
const daysPerQuery = Math.max(1, Math.min(EXPORT_MAX_DAYS_PER_QUERY, Math.floor(EXPORT_QUERY_TARGET_ROWS / Math.max(1, vehicleCount))));
const windows: Array<{ dateFrom: string; dateTo: string }> = [];
for (let cursor = start; cursor <= end;) {
const windowEnd = new Date(Math.min(end.getTime(), cursor.getTime() + (daysPerQuery - 1) * DAY));
windows.push({ dateFrom: cursor.toISOString().slice(0, 10), dateTo: windowEnd.toISOString().slice(0, 10) });
cursor = new Date(windowEnd.getTime() + DAY);
}
return windows;
}
function mileageRouteParams(criteria: Criteria) {
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
@@ -502,7 +535,7 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v
return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
<span>
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'}</small>
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'}</small>
</span>
<ButtonGroup aria-label="切换里程或氢耗视图">
<Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}></Button>
@@ -511,6 +544,26 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v
</div>;
}
function HydrogenMetricSelector({ value, onChange }: { value: HydrogenMatrixMetric[]; onChange: (value: HydrogenMatrixMetric[]) => void }) {
const selected = new Set(value);
return <div className="v2-mileage-metric-selector" role="group" aria-label="日期子列">
<span><strong></strong><small></small></span>
<Tag color="blue" type="light" size="small"></Tag>
{HYDROGEN_MATRIX_METRICS.map((metric) => {
const active = selected.has(metric.key);
return <Button
key={metric.key}
size="small"
theme={active ? 'solid' : 'light'}
aria-pressed={active}
onClick={() => onChange(active ? value.filter((item) => item !== metric.key) : [...value, metric.key])}
>
{metric.label}
</Button>;
})}
</div>;
}
function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = false }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean; view: MetricView; unavailable?: boolean }) {
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
@@ -542,9 +595,9 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
tone: 'success',
emphasis: 'primary'
}] : [{
label: '氢耗匹配里程',
label: '氢耗匹配纯氢里程',
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日里程' : '按有氢耗数据的车辆日总里程计算',
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程',
tone: 'success',
emphasis: 'primary'
}, {
@@ -560,7 +613,7 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
tone: 'warning',
emphasis: 'primary'
}, {
label: '百公里氢耗',
label: '百公里氢耗',
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
tone: 'warning',
@@ -579,11 +632,25 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
/>;
}
type HydrogenDayMetric = {
consumptionKg: number;
rateKgPer100Km?: number;
socBalancedKg?: number;
socBalancedRateKgPer100Km?: number;
evidenceAvailable: boolean;
qualityStatus?: string;
algorithmVersion?: string;
};
type VehicleMileageMatrix = VehicleOption & {
days: Map<string, number>;
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>;
pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
sources: Map<string, string>;
totalMileageKm?: number;
totalPureElectricMileageKm?: number;
totalPureHydrogenMileageKm?: number;
totalHydrogenConsumptionKg?: number;
totalHydrogenRateKgPer100Km?: number;
};
@@ -604,14 +671,31 @@ function dateLabel(date: string) {
return `${Number(month)}/${Number(day)}`;
}
function MileageMatrixGuide({ rows, view }: { rows: VehicleMileageMatrix[]; view: MetricView }) {
function MileageMatrixGuide({
rows,
view,
hydrogenMetrics,
onHydrogenMetricsChange
}: {
rows: VehicleMileageMatrix[];
view: MetricView;
hydrogenMetrics: HydrogenMatrixMetric[];
onHydrogenMetricsChange: (value: HydrogenMatrixMetric[]) => void;
}) {
const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length;
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen" role="note" aria-label="氢耗矩阵读表说明">
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen has-metric-selector" role="note" aria-label="氢耗矩阵读表说明">
<div className="v2-mileage-matrix-guide-toolbar">
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span>
<span><Tag color="orange" type="light" size="small">kg</Tag></span>
<span><Tag color="green" type="light" size="small">km</Tag></span>
<span><Tag color="amber" type="light" size="small">kg/100km</Tag></span>
<span className="is-mobile-hint"> · </span>
<HydrogenMetricSelector value={hydrogenMetrics} onChange={onHydrogenMetricsChange} />
</div>
<div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
<span><strong></strong> 线</span>
<span><strong></strong> </span>
<span><strong></strong> </span>
<span><strong></strong> 0x02 </span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> </span>
</div>
</aside>;
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
<span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span>
@@ -638,79 +722,112 @@ function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
event.preventDefault();
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView }) {
function matrixDailyMetricValue(row: VehicleMileageMatrix, date: string, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.days.get(date);
case 'consumption': return row.hydrogenDays.get(date)?.consumptionKg;
case 'pureElectric': return row.pureElectricDays.get(date);
case 'pureHydrogen': return row.pureHydrogenDays.get(date);
case 'rate': return row.hydrogenDays.get(date)?.rateKgPer100Km;
}
}
function matrixTotalMetricValue(row: VehicleMileageMatrix, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.totalMileageKm;
case 'consumption': return row.totalHydrogenConsumptionKg;
case 'pureElectric': return row.totalPureElectricMileageKm;
case 'pureHydrogen': return row.totalPureHydrogenMileageKm;
case 'rate': return row.totalHydrogenRateKgPer100Km;
}
}
function MatrixMetricHeader({ metric }: { metric: MatrixMetric }) {
const meta = MATRIX_METRIC_META[metric];
return <span className="v2-mileage-metric-header"><strong>{meta.label}</strong><small>{meta.unit}</small></span>;
}
function MatrixMetricValue({ value, metric, onEvidence }: { value?: number; metric: MatrixMetric; onEvidence?: () => void }) {
if (value == null || !Number.isFinite(value)) return <></>;
const content = <strong>{formatKm(value)} {MATRIX_METRIC_META[metric].unit}</strong>;
if (!onEvidence) return content;
return <button className="v2-hydrogen-evidence-trigger" type="button" title="查看计算公式、区间和原始报文标识" onClick={onEvidence}>{content}<small></small></button>;
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view, hydrogenMetrics, onOpenEvidence }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView; hydrogenMetrics: HydrogenMatrixMetric[]; onOpenEvidence: (row: VehicleMileageMatrix, date: string) => void }) {
const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
const dateColumnWidth = view === 'hydrogen' ? 128 : 96;
const totalColumnWidth = view === 'hydrogen' ? 148 : 128;
const metrics: MatrixMetric[] = view === 'hydrogen' ? ['mileage', ...hydrogenMetrics] : ['mileage'];
const metricsWidth = metrics.reduce((sum, metric) => sum + MATRIX_METRIC_META[metric].width, 0);
return {
columns: [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: dateColumnWidth,
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日${view === 'mileage' ? '里程' : '氢耗'}` }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const hydrogen = row?.hydrogenDays.get(date);
const source = row?.sources.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
if (view === 'hydrogen') return {
className: `is-number is-date is-hydrogen-daily${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null
? `耗氢 ${formatKm(hydrogen?.consumptionKg)} kg · 当日总里程 ${formatKm(mileage)} km · 百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} kg/100km · 来源:${source || '—'}`
: '无可用氢耗数据',
'aria-label': mileage != null
? `${date},耗氢 ${formatKm(hydrogen?.consumptionKg)} 千克,当日总里程 ${formatKm(mileage)} 公里,百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} 千克,来源 ${source || '未知'}`
: `${date},无可用氢耗数据`
};
title: dateLabel(date),
dataIndex: `date:${date}`,
className: 'is-date-group',
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 指标分组` }),
children: metrics.map((metric) => {
const meta = MATRIX_METRIC_META[metric];
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `里程 ${formatKm(mileage)} km · 来源:${source || '—'}` : '无可用里程',
'aria-label': mileage != null
? `${date},里程 ${formatKm(mileage)} 公里,来源 ${source || '未知'}`
: `${date},无可用里程`,
title: <MatrixMetricHeader metric={metric} />,
dataIndex: `${date}:${metric}`,
className: `is-number is-date is-metric-${metric}`,
width: meta.width,
onHeaderCell: () => ({ 'aria-label': `${date} ${meta.label} ${meta.unit}` }),
onCell: (row?: VehicleMileageMatrix) => {
const value = row ? matrixDailyMetricValue(row, date, metric) : undefined;
const mileage = row?.days.get(date);
const source = row?.sources.get(date);
const intensity = metric === 'mileage' && mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date is-metric-${metric}${value != null ? ' is-daily' : ' is-empty'}`,
title: value != null ? `${meta.label} ${formatKm(value)} ${meta.unit} · 来源:${source || '—'}` : `${meta.label}无可用数据`,
'aria-label': value != null
? `${date}${meta.label} ${formatKm(value)} ${meta.unit},来源 ${source || '未知'}`
: `${date}${meta.label}无可用数据`,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
const hydrogen = row.hydrogenDays.get(date);
if (mileage == null) return '—';
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
<strong>{formatKm(hydrogen?.consumptionKg)} kg</strong>
<small> {formatKm(mileage)} km</small>
<small> {formatKm(hydrogen?.rateKgPer100Km)} kg</small>
</span>;
return <strong>{formatKm(mileage)} km</strong>;
const traceable = (metric === 'consumption' || metric === 'rate') && row.hydrogenDays.get(date)?.evidenceAvailable;
return <MatrixMetricValue value={matrixDailyMetricValue(row, date, metric)} metric={metric} onEvidence={traceable ? () => onOpenEvidence(row, date) : undefined} />;
}
};
})
})),
{
title: view === 'mileage' ? '区间总里程' : '区间氢耗', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: totalColumnWidth,
onCell: (row?: VehicleMileageMatrix) => ({
className: `is-number is-period is-total${view === 'hydrogen' ? ' is-hydrogen-total' : ''}${row?.totalMileageKm == null ? ' is-empty' : ''}`,
'aria-label': row?.totalMileageKm == null
? view === 'mileage' ? '区间总里程,无可用里程' : '区间氢耗,无可用氢耗数据'
: view === 'mileage'
? `区间总里程,${formatKm(row.totalMileageKm)} 公里`
: `区间耗氢 ${formatKm(row.totalHydrogenConsumptionKg)} 千克,总里程 ${formatKm(row.totalMileageKm)} 公里,百公里氢耗 ${formatKm(row.totalHydrogenRateKgPer100Km)} 千克`
}),
render: (value: number | undefined, row: VehicleMileageMatrix) => {
if (value == null) return '—';
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
<strong>{formatKm(row.totalHydrogenConsumptionKg)} kg</strong>
<small> {formatKm(row.totalMileageKm)} km</small>
<small> {formatKm(row.totalHydrogenRateKgPer100Km)} kg</small>
</span>;
return <strong>{formatKm(value)} km</strong>;
}
title: '区间合计',
dataIndex: 'period',
className: 'is-total is-total-group',
children: metrics.map((metric) => {
const meta = MATRIX_METRIC_META[metric];
return {
title: <MatrixMetricHeader metric={metric} />,
dataIndex: `period:${metric}`,
className: `is-number is-total is-metric-${metric}`,
width: meta.width,
onHeaderCell: () => ({ 'aria-label': `区间${meta.label} ${meta.unit}` }),
onCell: (row?: VehicleMileageMatrix) => {
const value = row ? matrixTotalMetricValue(row, metric) : undefined;
return {
className: `is-number is-period is-total is-metric-${metric}${value == null ? ' is-empty' : ''}`,
'aria-label': value == null
? metric === 'mileage' ? '区间总里程,无可用里程' : `区间${meta.label},无可用数据`
: `区间${meta.label}${formatKm(value)} ${meta.unit}`
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => <MatrixMetricValue value={matrixTotalMetricValue(row, metric)} metric={metric} />
};
})
}
],
tableWidth: 120 + dates.length * dateColumnWidth + totalColumnWidth
tableWidth: 120 + (dates.length + 1) * metricsWidth
};
}, [dates, rows, view]);
}, [dates, hydrogenMetrics, onOpenEvidence, rows, view]);
return <div
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
ref={scrollRef}
@@ -719,12 +836,101 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }
aria-describedby="v2-mileage-matrix-guide"
tabIndex={0}
onKeyDown={scrollMileageMatrixFromKeyboard}
style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * (view === 'hydrogen' ? 112 : 78) + (view === 'hydrogen' ? 132 : 104)}px` } as CSSProperties}
style={{ '--v2-mileage-mobile-table-width': `${Math.max(556, tableWidth)}px` } as CSSProperties}
>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
});
type HydrogenEvidenceTarget = { vin: string; plate: string; date: string };
function evidenceNumber(value?: number | null, digits = 3) {
if (value == null || !Number.isFinite(value)) return '—';
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceTarget; onClose: () => void }) {
const evidence = useQuery<HydrogenDailyEvidence>({
queryKey: ['hydrogen-daily-evidence', target?.vin, target?.date],
queryFn: ({ signal }) => api.hydrogenDailyEvidence(target!.vin, target!.date, signal),
enabled: Boolean(target),
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.summaryGcTime
});
const data = evidence.data;
return <WorkspaceSideSheet
className="v2-hydrogen-evidence-sheet"
variant="detail"
visible={Boolean(target)}
ariaLabel="氢耗计算溯源"
closeLabel="关闭氢耗计算溯源"
dialogId="v2-hydrogen-evidence-sheet"
title={`${target?.plate || target?.vin || '车辆'} · ${target?.date || ''}`}
description="每日结果、计算参数、有效区间及原始报文标识"
icon={<IconInfoCircle />}
badge={data?.qualityStatus || '读取中'}
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
width="min(760px, 94vw)"
summaryItems={data ? [
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
] : []}
footerNote="物理耗氢与SOC修正值分开保存;点击区间中的报文ID可复制后到原始报文查询中检索。"
primaryAction={{ label: '关闭', onClick: onClose }}
onCancel={onClose}
>
{evidence.isLoading ? <PanelLoading title="正在读取计算证据" description="加载每日参数与区间明细。" /> : null}
{evidence.error ? <InlineError message={evidence.error instanceof Error ? evidence.error.message : '氢耗计算证据加载失败'} onRetry={() => evidence.refetch()} /> : null}
{data ? <div className="v2-hydrogen-evidence-content">
<section>
<header><strong></strong><Tag color="blue" type="light" size="small">{data.algorithmVersion}</Tag></header>
<code>m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Zₙᵢₛₜ)</code>
<code> = </code>
<code>SOC平衡氢耗 = + × (SOC SOC) ÷ 100 ÷ {evidenceNumber(data.parameters.hydrogenEnergyKWhPerKg, 1)}</code>
<p>{data.qualityReason || '数据通过完整性、运行状态、加氢、充电、异常跳变和端点中位数校验。'}</p>
</section>
<section>
<header><strong></strong></header>
<dl className="v2-hydrogen-evidence-parameters">
<div><dt></dt><dd>{data.parameters.powerOnDelaySeconds}s</dd></div>
<div><dt></dt><dd>{data.parameters.powerOffLeadSeconds}s</dd></div>
<div><dt></dt><dd>+{data.parameters.refuelRiseMpa}MPa / {data.parameters.refuelSustainSeconds}s</dd></div>
<div><dt></dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div>
<div><dt></dt><dd>{data.sampleCount}</dd></div>
<div><dt> / </dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div>
<div><dt></dt><dd>{data.invalidSegmentCount}</dd></div>
<div><dt></dt><dd>{data.calculatedAt}</dd></div>
</dl>
</section>
<section>
<header><strong></strong><small>{data.intervals.length} </small></header>
<div className="v2-hydrogen-evidence-intervals">
{data.intervals.map((interval) => <article key={`${interval.index}-${interval.startTime}`} className={`is-${interval.qualityStatus.toLowerCase()}`}>
<header><span><Tag color={interval.type === 'MIXED' ? 'green' : 'blue'} type="light" size="small">{interval.type === 'MIXED' ? '混动' : '纯电'}</Tag><strong> {interval.index}</strong></span><Tag color={interval.qualityStatus === 'OK' ? 'green' : 'orange'} type="light" size="small">{interval.qualityStatus}</Tag></header>
<p>{interval.startTime} {interval.endTime} · {interval.sampleCount} </p>
<div className="v2-hydrogen-evidence-metrics">
<span><small></small><strong>{evidenceNumber(interval.startPressureMpa)} {evidenceNumber(interval.endPressureMpa)} MPa</strong></span>
<span><small></small><strong>{evidenceNumber(interval.startTemperatureC, 1)} {evidenceNumber(interval.endTemperatureC, 1)} </strong></span>
<span><small></small><strong>{evidenceNumber(interval.startMassKg)} {evidenceNumber(interval.endMassKg)} kg</strong></span>
<span><small></small><strong>{evidenceNumber(interval.rawHydrogenConsumptionKg)} kg</strong></span>
<span><small>SOC</small><strong>{evidenceNumber(interval.startSocPercent, 1)} {evidenceNumber(interval.endSocPercent, 1)} %</strong></span>
<span><small></small><strong>{evidenceNumber(interval.mileageKm)} km</strong></span>
<span><small></small><strong>{evidenceNumber(interval.batteryEquivalentKg)} kg</strong></span>
<span><small>SOC平衡氢耗</small><strong>{evidenceNumber(interval.socBalancedConsumptionKg)} kg</strong></span>
</div>
<div className="v2-hydrogen-evidence-events">
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.startEventId)}><small></small><code>{interval.startEventId || '—'}</code></button>
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.endEventId)}><small></small><code>{interval.endEventId || '—'}</code></button>
</div>
{interval.qualityReason ? <p className="is-reason">{interval.qualityReason}</p> : null}
</article>)}
</div>
</section>
</div> : null}
</WorkspaceSideSheet>;
}
export default function StatisticsPage() {
const { session } = usePlatformSession();
const [searchParams, setSearchParams] = useSearchParams();
@@ -740,11 +946,16 @@ export default function StatisticsPage() {
const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [metricView, setMetricView] = useState<MetricView>('mileage');
const [hydrogenMatrixMetrics, setHydrogenMatrixMetrics] = useState<HydrogenMatrixMetric[]>(DEFAULT_HYDROGEN_MATRIX_METRICS);
const [hydrogenEvidenceTarget, setHydrogenEvidenceTarget] = useState<HydrogenEvidenceTarget>();
const hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer';
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const openHydrogenEvidence = useCallback((row: VehicleMileageMatrix, date: string) => {
setHydrogenEvidenceTarget({ vin: row.vin, plate: row.plate, date });
}, []);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
@@ -816,20 +1027,30 @@ export default function StatisticsPage() {
const index = new Map<string, {
plate: string;
days: Map<string, number>;
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>;
pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
sources: Map<string, string>;
}>();
for (const row of mileage.data?.items ?? []) {
let entry = index.get(row.vin);
if (!entry) {
entry = { plate: row.plate || '', days: new Map(), hydrogenDays: new Map(), sources: new Map() };
entry = { plate: row.plate || '', days: new Map(), pureElectricDays: new Map(), pureHydrogenDays: new Map(), hydrogenDays: new Map(), sources: new Map() };
index.set(row.vin, entry);
} else if (!entry.plate && row.plate) entry.plate = row.plate;
entry.days.set(row.date, row.dailyMileageKm);
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
if (row.hydrogenConsumptionKg != null) {
entry.hydrogenDays.set(row.date, {
consumptionKg: row.hydrogenConsumptionKg,
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
evidenceAvailable: row.hydrogenEvidenceAvailable === true,
qualityStatus: row.hydrogenQualityStatus,
algorithmVersion: row.hydrogenAlgorithmVersion
});
}
entry.sources.set(row.date, row.source);
@@ -841,23 +1062,35 @@ export default function StatisticsPage() {
const daily = mileageByVin.get(vehicle.vin);
const ranking = rankingByVin.get(vehicle.vin);
const days = daily?.days ?? new Map<string, number>();
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, { consumptionKg: number; rateKgPer100Km?: number }>();
const pureElectricDays = daily?.pureElectricDays ?? new Map<string, number>();
const pureHydrogenDays = daily?.pureHydrogenDays ?? new Map<string, number>();
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, HydrogenDayMetric>();
const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0;
let hydrogenTotal = 0;
let hydrogenRatedMileage = 0;
let pureElectricTotal = 0;
let pureHydrogenTotal = 0;
for (const value of days.values()) dailyTotal += value;
for (const value of pureElectricDays.values()) pureElectricTotal += value;
for (const value of pureHydrogenDays.values()) pureHydrogenTotal += value;
for (const [date, value] of hydrogenDays) {
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
if (matchedMileage <= 0) continue;
hydrogenTotal += value.consumptionKg;
hydrogenRatedMileage += days.get(date) ?? 0;
hydrogenRatedMileage += matchedMileage;
}
return {
...vehicle,
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
days,
pureElectricDays,
pureHydrogenDays,
hydrogenDays,
sources,
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
};
@@ -908,7 +1141,11 @@ export default function StatisticsPage() {
];
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
const resultsError = statistics.error ?? mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined);
// Fleet statistics are supplementary to the paged mileage matrix. A large
// authorized fleet may time out while the current page of vehicle rows is
// still available, so keep the summary-level unavailable state from hiding
// otherwise usable daily mileage data.
const resultsError = mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined);
const exportPercent = exportProgress?.total
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
: undefined;
@@ -924,14 +1161,39 @@ export default function StatisticsPage() {
setExportFeedback('');
let exportStream: MileageExportStream | undefined;
try {
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
if (!vehicles.length) {
let vehicles: VehicleOption[];
if (criteria.vehicles.length) {
const archiveByVIN = new Map<string, VehicleOption>();
reportProgress({ label: '正在读取车辆档案', completed: 0, total: criteria.vehicles.length });
for (let start = 0; start < criteria.vehicles.length; start += BATCH_RESOLVE_SIZE) {
const batch = criteria.vehicles.slice(start, start + BATCH_RESOLVE_SIZE);
const result = await api.vehicleCoverage(new URLSearchParams({
keywords: batch.map((vehicle) => vehicle.vin).join(','),
limit: String(batch.length),
offset: '0',
bindingStatus: 'bound'
}), controller.signal);
for (const vehicle of result.items) archiveByVIN.set(vehicle.vin, {
vin: vehicle.vin,
plate: vehicle.plate,
brandName: vehicle.brandName,
modelName: vehicle.modelName
});
reportProgress({ label: '正在读取车辆档案', completed: Math.min(start + batch.length, criteria.vehicles.length), total: criteria.vehicles.length });
}
vehicles = criteria.vehicles.map((vehicle) => ({ ...vehicle, ...archiveByVIN.get(vehicle.vin), vin: vehicle.vin }));
} else {
vehicles = [];
let offset = 0;
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
vehicles.push(...result.items.map((vehicle) => ({
vin: vehicle.vin,
plate: vehicle.plate,
brandName: vehicle.brandName,
modelName: vehicle.modelName
})));
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
if (!result.items.length) break;
offset += result.items.length;
@@ -949,21 +1211,27 @@ export default function StatisticsPage() {
const vehicleBatches = criteria.vehicles.length
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
: [[] as VehicleOption[]];
for (const [batchIndex, vehicleBatch] of vehicleBatches.entries()) {
const exportWindows = vehicleBatches.flatMap((vehicleBatch, batchIndex) => mileageExportDateWindows(
criteria.dateFrom,
criteria.dateTo,
vehicleBatch.length || vehicles.length
).map((window) => ({ vehicleBatch, batchIndex, window })));
let completedWindows = 0;
for (const { vehicleBatch, batchIndex, window } of exportWindows) {
let offset = 0;
const mileageLabel = vehicleBatches.length > 1
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length}`
: '正在读取全部车辆里程';
reportProgress({ label: mileageLabel });
const batchLabel = vehicleBatches.length > 1 ? ` · 车辆批次 ${batchIndex + 1}/${vehicleBatches.length}` : '';
const mileageLabel = `正在读取里程数据(${window.dateFrom}${window.dateTo}${batchLabel}`;
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
while (true) {
const query = mileageQuery({ ...criteria, vehicles: vehicleBatch }, offset);
const query = mileageQuery({ ...criteria, ...window, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(query, controller.signal);
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
exportStream.appendRows(result.items);
offset += result.items.length;
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
if (!result.items.length || offset >= result.total) break;
}
completedWindows++;
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
}
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
reportProgress({ label: '正在后台生成 Excel 文件' });
@@ -1086,11 +1354,12 @@ export default function StatisticsPage() {
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
</div> : null}
{resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
{!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} /> : null}
{!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} /> : null)}
{!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onHydrogenMetricsChange={setHydrogenMatrixMetrics} /> : null}
{!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onOpenEvidence={openHydrogenEvidence} /> : null)}
{!resultsError && !resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{totalVehicles > PAGE_SIZE ? <TablePagination page={page} totalPages={totalPages} info={`${hasVehicles ? '已选择' : '共'} ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={mileage.isFetching || (!hasVehicles && fleetVehicles.isFetching)} onPageChange={setPage} /> : <span className="v2-table-pagination-info">{hasVehicles ? '已选择' : '共'} {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card>
<HydrogenEvidenceSheet target={hydrogenEvidenceTarget} onClose={() => setHydrogenEvidenceTarget(undefined)} />
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>
</div>;
}
@@ -28523,6 +28523,300 @@
color: #8b6a3e;
}
.v2-mileage-matrix-guide.has-metric-selector {
display: grid;
min-height: 78px;
grid-template-columns: minmax(0, 1fr);
gap: 7px;
padding-block: 8px;
white-space: normal;
}
.v2-mileage-matrix-guide-toolbar,
.v2-mileage-metric-selector,
.v2-mileage-metric-logic {
display: flex;
min-width: 0;
align-items: center;
}
.v2-mileage-matrix-guide-toolbar {
justify-content: space-between;
gap: 16px;
}
.v2-mileage-metric-selector {
gap: 5px;
}
.v2-mileage-metric-selector > span {
display: inline-flex;
margin-right: 3px;
flex-direction: column;
line-height: 1.1;
}
.v2-mileage-metric-selector > span > strong {
color: #61461f;
font-size: 10px;
}
.v2-mileage-metric-selector > span > small {
color: #9a8568;
font-size: 8px;
}
.v2-mileage-metric-selector .semi-tag,
.v2-mileage-metric-selector .semi-button {
min-width: auto;
height: 24px;
border-radius: 6px;
padding-inline: 9px;
font-size: 10px;
font-weight: 680;
}
.v2-mileage-metric-logic {
overflow-x: auto;
gap: 7px 16px;
color: #76654f;
font-size: 9px;
scrollbar-width: none;
white-space: nowrap;
}
.v2-mileage-metric-logic::-webkit-scrollbar {
display: none;
}
.v2-mileage-metric-logic > span {
display: inline-flex;
flex: 0 0 auto;
align-items: baseline;
gap: 4px;
}
.v2-mileage-metric-logic strong {
color: #8b5718;
font-weight: 750;
}
.v2-mileage-table .semi-table-row-head.is-date-group,
.v2-mileage-table .semi-table-row-head.is-total-group {
border-left: 1px solid #e2e8f0;
text-align: center;
}
.v2-mileage-table .semi-table-row-head.is-date-group {
background: #eef4fb;
color: #344f70;
font-weight: 760;
}
.v2-mileage-table .v2-mileage-metric-header {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1px;
line-height: 1.05;
}
.v2-mileage-table .v2-mileage-metric-header > strong {
color: inherit;
font-size: 10px;
font-weight: 700;
}
.v2-mileage-table .v2-mileage-metric-header > small {
color: #8998aa;
font-size: 8px;
font-weight: 600;
}
.v2-mileage-table .semi-table-row-cell[class*="is-metric-"] > strong {
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.v2-mileage-table .semi-table-row-cell[class*="is-metric-"] > strong > small {
color: #8291a3;
font-size: 8px;
font-weight: 600;
}
.v2-mileage-table .semi-table-row-cell.is-metric-consumption > strong,
.v2-mileage-table .semi-table-row-cell.is-metric-rate > strong {
color: #a65f0b;
}
.v2-mileage-table .semi-table-row-cell.is-metric-pureElectric > strong {
color: #2767ae;
}
.v2-mileage-table .semi-table-row-cell.is-metric-pureHydrogen > strong {
color: #17815a;
}
.v2-hydrogen-evidence-trigger {
display: inline-flex;
appearance: none;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
flex-direction: column;
align-items: flex-end;
gap: 1px;
padding: 2px 3px;
}
.v2-hydrogen-evidence-trigger:hover,
.v2-hydrogen-evidence-trigger:focus-visible {
border-radius: 5px;
background: rgba(180, 106, 12, .09);
outline: 1px solid rgba(180, 106, 12, .28);
}
.v2-hydrogen-evidence-trigger > small {
color: #9a6a2d;
font-size: 8px;
font-weight: 700;
}
.v2-hydrogen-evidence-content {
display: grid;
gap: 14px;
}
.v2-hydrogen-evidence-content > section {
display: grid;
gap: 8px;
border: 1px solid #e6eaf0;
border-radius: 10px;
background: #fff;
padding: 12px;
}
.v2-hydrogen-evidence-content > section > header,
.v2-hydrogen-evidence-intervals article > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.v2-hydrogen-evidence-content > section > code {
overflow-x: auto;
border-radius: 6px;
background: #f7f8fa;
color: #374151;
padding: 7px 9px;
font-size: 11px;
white-space: nowrap;
}
.v2-hydrogen-evidence-content > section > p,
.v2-hydrogen-evidence-intervals article > p {
margin: 0;
color: #6b7280;
font-size: 11px;
}
.v2-hydrogen-evidence-parameters,
.v2-hydrogen-evidence-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.v2-hydrogen-evidence-parameters > div,
.v2-hydrogen-evidence-metrics > span {
display: grid;
gap: 2px;
border-radius: 7px;
background: #f8fafc;
padding: 8px;
}
.v2-hydrogen-evidence-parameters dt,
.v2-hydrogen-evidence-metrics small {
color: #7b8492;
font-size: 9px;
}
.v2-hydrogen-evidence-parameters dd,
.v2-hydrogen-evidence-metrics strong {
overflow-wrap: anywhere;
margin: 0;
color: #253247;
font-size: 11px;
}
.v2-hydrogen-evidence-intervals {
display: grid;
gap: 9px;
}
.v2-hydrogen-evidence-intervals article {
display: grid;
gap: 8px;
border-left: 3px solid #28a176;
border-radius: 8px;
background: #fbfcfd;
padding: 10px;
}
.v2-hydrogen-evidence-intervals article.is-suspect {
border-left-color: #d98a22;
}
.v2-hydrogen-evidence-intervals article > header > span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.v2-hydrogen-evidence-events {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.v2-hydrogen-evidence-events button {
display: grid;
min-width: 0;
appearance: none;
border: 1px solid #e4e8ee;
border-radius: 7px;
background: #fff;
cursor: pointer;
gap: 2px;
padding: 7px;
text-align: left;
}
.v2-hydrogen-evidence-events button:hover {
border-color: #9db5d2;
}
.v2-hydrogen-evidence-events small {
color: #778190;
font-size: 9px;
}
.v2-hydrogen-evidence-events code {
overflow: hidden;
color: #34506f;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-hydrogen-evidence-intervals .is-reason {
color: #9a651d;
}
@media (max-width: 700px) {
.v2-mileage-page {
gap: 7px;
@@ -28563,6 +28857,40 @@
padding: 5px 7px;
}
.v2-mileage-matrix-guide.has-metric-selector {
min-height: 104px;
gap: 6px;
padding-block: 7px;
}
.v2-mileage-matrix-guide-toolbar {
display: grid;
gap: 6px;
}
.v2-mileage-metric-selector {
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.v2-mileage-metric-selector::-webkit-scrollbar {
display: none;
}
.v2-mileage-metric-selector > span {
display: none;
}
.v2-mileage-metric-selector .semi-button,
.v2-mileage-metric-selector .semi-tag {
flex: 0 0 auto;
}
.v2-mileage-metric-logic {
font-size: 8px;
}
.v2-mileage-matrix-guide > .v2-mileage-matrix-guide-mobile {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 2px 5px;
@@ -59,6 +59,7 @@ test -x "$root/current/deploy/import-source-providers.py"
test -f "$root/current/deploy/migrations/017_reconciliation_center.sql"
test -f "$root/current/deploy/migrations/036_alert_rule_archive.sql"
test -f "$root/current/deploy/migrations/038_daily_pure_hydrogen_mileage.sql"
test -f "$root/current/deploy/migrations/044_hydrogen_v35_realtime.sql"
test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
test ! -e "$root/current/lingniu-vehicle-platform.service"
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
@@ -0,0 +1,29 @@
-- Persist the end-of-day cumulative odometer independently of the source that
-- wins the daily-distance projection. This prevents GPS-derived daily mileage
-- from being exposed as terminal odometer mileage.
ALTER TABLE vehicle_daily_mileage
ADD COLUMN day_end_total_mileage_km DECIMAL(18,3) NULL AFTER latest_total_mileage_km;
-- Backfill every historical day with the best valid terminal/source cumulative
-- reading. Non-GPS evidence always wins; GPS remains the fallback only where
-- it is the sole available cumulative source.
UPDATE vehicle_daily_mileage m
SET m.day_end_total_mileage_km = COALESCE((
SELECT total_candidate.latest_total_mileage_km
FROM vehicle_daily_mileage_source total_candidate
WHERE total_candidate.vin=m.vin
AND total_candidate.stat_date=m.stat_date
AND total_candidate.protocol=m.protocol
AND total_candidate.quality_status='OK'
AND total_candidate.latest_total_mileage_km IS NOT NULL
AND total_candidate.latest_total_mileage_km>=0
ORDER BY
CASE WHEN COALESCE(total_candidate.quality_reason,'')='gps_coordinate_accumulation' THEN 1 ELSE 0 END,
CASE WHEN total_candidate.latest_total_mileage_km>total_candidate.daily_mileage_km THEN 0 ELSE 1 END,
total_candidate.is_selected DESC,
total_candidate.latest_event_time DESC,
total_candidate.sample_count DESC,
total_candidate.source_key ASC
LIMIT 1
),m.latest_total_mileage_km)
WHERE m.day_end_total_mileage_km IS NULL;
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state (
vin VARCHAR(64) NOT NULL,
stat_date DATE NOT NULL,
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
finalized_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
projected_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
sample_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
refuel_count INT UNSIGNED NOT NULL DEFAULT 0,
abnormal_drop_count INT UNSIGNED NOT NULL DEFAULT 0,
eligible_interval_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
qualified_segment_count INT UNSIGNED NOT NULL DEFAULT 0,
last_mass_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
last_event_time DATETIME(3) NOT NULL,
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
state_json JSON NOT NULL,
calculation_method VARCHAR(48) NOT NULL DEFAULT 'PRESSURE_NIST_SEGMENT_MEDIAN_5',
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin, stat_date),
INDEX idx_hydrogen_segment_stream_date_quality (stat_date, quality_status, vin),
CONSTRAINT chk_hydrogen_segment_stream_quality CHECK (quality_status IN ('OK', 'NO_DATA', 'SUSPECT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS vehicle_hydrogen_energy_parameter (
vin VARCHAR(64) NOT NULL,
battery_capacity_kwh DECIMAL(12,3) NOT NULL,
hydrogen_energy_kwh_per_kg DECIMAL(12,3) NOT NULL DEFAULT 16.000,
confirmed_by VARCHAR(96) NOT NULL DEFAULT '',
source_note VARCHAR(255) NOT NULL DEFAULT '',
effective_from DATE NOT NULL,
effective_to DATE NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin, effective_from),
INDEX idx_hydrogen_energy_parameter_active (active, effective_from, effective_to, vin),
CONSTRAINT chk_hydrogen_energy_parameter_capacity CHECK (battery_capacity_kwh > 0 AND battery_capacity_kwh <= 500),
CONSTRAINT chk_hydrogen_energy_parameter_conversion CHECK (hydrogen_energy_kwh_per_kg > 0 AND hydrogen_energy_kwh_per_kg <= 50),
CONSTRAINT chk_hydrogen_energy_parameter_dates CHECK (effective_to IS NULL OR effective_to >= effective_from)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE vehicle_open_daily_energy
ADD COLUMN raw_consumption_kg DECIMAL(18,3) NULL AFTER consumption_kg,
ADD COLUMN battery_soc_delta_pct DECIMAL(10,3) NULL AFTER raw_consumption_kg,
ADD COLUMN battery_discharge_kwh DECIMAL(18,3) NULL AFTER battery_soc_delta_pct,
ADD COLUMN battery_equivalent_kg DECIMAL(18,3) NULL AFTER battery_discharge_kwh,
ADD COLUMN soc_balanced_consumption_kg DECIMAL(18,3) NULL AFTER battery_equivalent_kg,
ADD COLUMN mixed_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0 AFTER soc_balanced_consumption_kg,
ADD COLUMN pure_electric_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0 AFTER mixed_mileage_km,
ADD COLUMN consumption_kg_per_100km DECIMAL(18,3) NULL AFTER pure_electric_mileage_km,
ADD COLUMN soc_balanced_kg_per_100km DECIMAL(18,3) NULL AFTER consumption_kg_per_100km,
ADD COLUMN charge_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER refuel_count,
ADD COLUMN valid_segment_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER charge_count,
ADD COLUMN invalid_segment_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER valid_segment_count,
ADD COLUMN algorithm_version VARCHAR(64) NOT NULL DEFAULT 'PRESSURE_NIST_SEGMENT_MEDIAN_5' AFTER invalid_segment_count,
ADD COLUMN parameter_json JSON NULL AFTER algorithm_version,
ADD COLUMN evidence_json JSON NULL AFTER parameter_json;
@@ -0,0 +1,6 @@
ALTER TABLE vehicle_open_hydrogen_segment_stream_state
MODIFY COLUMN calculation_method VARCHAR(80) NOT NULL
DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5';
ALTER TABLE vehicle_open_daily_energy
ADD COLUMN calculation_phase VARCHAR(16) NOT NULL DEFAULT 'FINAL' AFTER algorithm_version;
+12 -2
View File
@@ -255,15 +255,25 @@ test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/033_open_mileage_range_snapshot.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/041_daily_mileage_day_end_total.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/042_hydrogen_segment_stream_state.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/043_hydrogen_energy_traceability.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/044_hydrogen_v35_realtime.sql
```
Migration `023` creates vehicle open-platform appKey records, per-vehicle grant intervals, precomputed daily energy, and immutable API/admin audits. Apply it before serving `/api/v1/vehicles/*` or enabling `lingniu-vehicle-open-stat.timer`.
Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. The stat writer synchronizes capacity from `ln_asset_management.vehicle_info.vehicle_model_id → vehicle_model.tank_capacity` at startup and every six hours, then serves frame-time lookups from process memory.
Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `043` creates the effective-dated VIN energy-parameter table. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. At startup and every six hours, the stat writer projects `vehicle_model.tank_capacity` and `vehicle_model.battery_capacity` through `vehicle_info.vehicle_model_id` to VIN-scoped calculation parameters. Rated battery energy is therefore model-specific rather than hard-coded by tonnage.
Migration `044` enables the V3.5 streaming calculation method and adds `calculation_phase`. Current-day streaming rows are marked `PRELIMINARY`; the completed-day `open-platform-stat` rebuild writes `FINAL`. Apply `043` and `044` before restarting `vehicle-stat-writer`.
Migration `038` adds daily pure-hydrogen mileage to the elected mileage table and its per-source evidence table. Deploy the migration before the updated stat writer and API so GB32960 `engine_work_state=2` and Yutong `TRIANGLE_STATE=4/11` intervals can be accumulated and returned as `pureHydrogenMileageKm`.
Migration `042` creates the per-VIN/per-day bounded state used by the GB32960 hydrogen segment stream. Apply it before switching `vehicle-stat-writer`; the previous writer ignores the additive table, so rolling back the binary does not require dropping it.
For the first switch to the segment stream, stop `lingniu-go-stat-writer.service`, run the current-day `open-platform-stat` rebuild once with `-seed-stream-state`, then switch and restart the writer. The seed transaction stores the rebuilt total and the exact last event-time watermark for every VIN, preventing already rebuilt Kafka backlog from being counted again. Do not pass this flag to the normal completed-day timer.
Migration `031` adds explicit metric, geofence, stationary and offline automation triggers. It must be applied before the API and both alert evaluators are restarted; geofence rules deliberately require one positioning protocol to avoid multi-source coordinate drift and duplicate boundary events.
Migration `035` adds rule recipient-group references plus notification leases and dispatch indexes. Apply it before publishing an API/evaluator that writes `notification_targets_json` or enabling `lingniu-vehicle-alert-notification-dispatcher`. The dispatcher must remain disabled until every configured gateway has passed a signed canary that returns a non-empty provider message ID.
@@ -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能量守恒。
+26 -14
View File
@@ -68,6 +68,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
"plateNumber": "粤A12345",
"date": "2026-07-01",
"hydrogenConsumptionKg": 12.315,
"calculationPhase": "FINAL",
"algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",
"status": "NORMAL"
},
{
@@ -81,6 +83,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
}
```
当天查询可能返回 `calculationPhase: PRELIMINARY`,表示结果由 V3.5 流式状态机生成,后续帧可能使结果回修;完成日终全量重算后变为 `FINAL`
## 单日里程
```http
@@ -343,35 +347,40 @@ PUT /portal-api/account/password
## 用氢量统计
当天数据由 `vehicle-stat-writer` 直接消费 GB32960 fields Kafka 流,统一采用压力法:
用氢量只接收 `GB32960` 帧,不读取 JT808、宇通 MQTT 或其他协议。统一采用压力法:
```text
剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000
```
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比或百公里氢耗推算质量;
- `vehicle-stat-writer` 每六小时从 `ln_asset_management.vehicle_info → vehicle_model.tank_capacity` 同步 VIN 容积到本地 `vehicle_hydrogen_tank_capacity`
- 同步完成后一次性加载到进程内存,高频帧计算不访问资产库、Redis或MySQL;缺少VIN容积时拒绝生成氢量
- 压力法使用 NIST 实氢气密度方程,允许 070 MPa、220–1000 K 的输入范围。
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比、宇通 MQTT 剩余氢量或百公里氢耗推算质量;
- `vehicle-stat-writer` 启动时及每六小时从 `ln_asset_management.vehicle_info → vehicle_model` 同步车型参数:`tank_capacity` VIN 投影到 `vehicle_hydrogen_tank_capacity``battery_capacity` 按 VIN 投影到 `vehicle_hydrogen_energy_parameter`
- 额定电量按车辆关联的具体车型读取,不按吨位写死;后续生效日期更晚的人工业务参数仍可覆盖自动同步基线
- 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量;
- 压力法使用 NIST 实氢气密度方程,允许 070 MPa、220–1000 K 的输入范围;
- GB32960 上报的是“最高氢压”和“最高氢温”,两者不保证来自同一探头,也不保证代表静置平衡后的储氢瓶状态。因此压力法结果是车载信号估算值,不能用加氢站计量值直接反标公式系数。
每个有效样本在同一个 MySQL 事务中更新
`vehicle_open_hydrogen_stream_state`,并投影到
当天准实时数据由 `vehicle-stat-writer` 消费 GB32960 fields Kafka 流。每个有效样本在同一个 MySQL 事务中更新
`vehicle_open_hydrogen_segment_stream_state`,并投影到
`vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按
`VIN + 日期 + source_endpoint` 保存,每条消息只做常数次数据库操作,不扫描当天历史数据。
`VIN + 日期` 保存并合并全部连接端点;每条消息只做常数次数据库操作,不扫描当天历史数据。
统计规则:
- 先按 VIN `source_endpoint` 分别统计,避免多个采集源的序列交错产生虚假变化
- 同一 VIN 优先采用质量正常、样本数量更多的数据源,并记录入库
- 每次加氢之间维护质量低水位,只累计新的有效最低质量,避免压力温度波动反复计量;
- 已结束日期先按 VIN 合并全部 `source_endpoint`,同一事件时间只保留一条可信样本;连接端口仅用于追踪,不再作为互斥统计来源
- 只有相邻两条样本都确认燃料电池处于工作状态时,才纳入工作段;优先采用广东扩展 `engine_work_state=2`,缺失时以燃料电池电流大于 `1 A` 作为回退;完全没有工作区间的日期标记为 `NO_DATA`
- 加氢、超过 5 分钟的数据断点、燃料电池停机和异常跳变会切分工作段;每段开始与结束各取 5 条样本的氢质量中位数做差,再累加为当日用氢量;
- 每个可计算工作段至少需要 10 条有效样本;段首尾差值未超过动态噪声阈值时不累计;
- 燃料电池未工作时,下降的压力质量只更新防重复计算的低水位,不降低对外剩余氢量;压力或质量恢复上升后再更新剩余量;
- 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`
- 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位;
- 相邻样本在 60 秒内无工作耗氢却恢复至少 `8 MPa` 时,按压力信号恢复处理,重置低水位但不增加加氢次数;
- 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`
- 至少两条有效样本才标记 `OK`
- 对外只返回 `quality_status=OK` 的结果。
当天接口结果是准实时的进行中累计值。乱序、重复和迟到样本不会覆盖更新的状态;跨日迟到数据由
`open-platform-stat` 夜间批处理兜底。批处理默认重算昨天和前天,以修正迟到上报,并作为已结束日期的权威结果。
分段计算由流式状态机直接执行:每车仅保留当前段的首 5 帧、尾 5 帧、低水位、上一帧和事件时间水位,状态不会随当天帧数增长。水位只覆盖当前自然日 `00:00:0024:00:00`,不向前后日期扩窗,也不借用次日帧;早于或等于当前事件时间水位的延迟帧直接忽略。流式结果采用与日终相同的工作段、中位数、加氢和异常跳变规则;已结束日期仍由 `open-platform-stat` 重算,作为最终对账结果。
历史重放只接收事件时间和接收时间同时落在统计日内的 GB32960 帧。每个统计日先读取有数据且配置了氢瓶容积的 VIN,再在同一进程内按单车单日并行查询;默认 4 个工作线程,全部车辆成功后才一次性事务替换当天结果。单车帧按 `event_time` 排序并单遍去重;JSON 只读取压力、温度和工作状态字段,不展开整帧,从而减少全车排序与反序列化开销。可通过 `-vin-workers``OPEN_STAT_VIN_WORKERS` 调整工作线程数。
```bash
/opt/lingniu-vehicle-platform/current/open-platform-stat \
@@ -380,3 +389,6 @@ PUT /portal-api/account/password
systemctl enable --now lingniu-vehicle-open-stat.timer
```
流式算法首次切换时,先停止 `lingniu-go-stat-writer.service`,再对当天执行一次
`-seed-stream-state`。该参数会在同一个 MySQL 事务中替换当天结果并写入每车最后事件时间水位;恢复 Kafka 消费后,已经包含在重算结果中的 backlog 不会再次累计。正常日终任务不使用此参数。
@@ -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
+29 -5
View File
@@ -22,7 +22,7 @@ const i18n = {
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailContactPerson: '联系人', detailContactPhone: '联系方式', detailUnitPrice: '单价', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
@@ -50,7 +50,7 @@ const i18n = {
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailContactPerson: 'Contact', detailContactPhone: 'Phone', detailUnitPrice: 'Unit price', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.',
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
@@ -1247,6 +1247,17 @@ function detailField(label, value, wide = false, extra = false) {
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
}
function detailPhoneField(label, phone, wide = false) {
const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, '');
if (!href) return detailField(label, i18n[currentLang].valueUnavailable, wide);
return `<div class="detail-field${wide ? ' is-wide' : ''}"><dt>${escapeHTML(label)}</dt><dd><a class="detail-phone-link" href="tel:${escapeHTML(href)}">${escapeHTML(value)}</a></dd></div>`;
}
function stationUnitPrice(station, fallback = i18n[currentLang].valueUnavailable) {
const value = Number(station?.unitPrice);
return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : fallback;
}
function vehicleStatusLabel(vehicle) {
const status = vehicleActiveToday(vehicle)
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
@@ -1307,8 +1318,15 @@ function navigateToSelectedEntity() {
}
button?.classList.add('is-launching');
if (button) button.disabled = true;
// Paint the acknowledgement before Safari hands control to the AMap URL scheme.
navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140);
// Open synchronously to avoid Safari's popup blocker, then let the launch
// animation acknowledge the action while the current map remains intact.
const target = window.open('', '_blank');
navigationLaunchTimer = window.setTimeout(() => {
if (target) {
target.opener = null;
target.location.href = url;
} else window.open(url, '_blank', 'noopener');
}, 140);
window.setTimeout(resetNavigationLaunch, 4000);
}
@@ -1337,6 +1355,11 @@ function showEntityDetails(mode, entity) {
const publicFields = [
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
detailField(dict.detailAdmin, detailValue(admin)),
...(entity.cooperative ? [
detailField(dict.detailUnitPrice, stationUnitPrice(entity)),
detailField(dict.detailContactPerson, detailValue(entity.contactPerson)),
detailPhoneField(dict.detailContactPhone, entity.contactPhone),
] : []),
detailField(dict.detailAddress, detailValue(entity.address), true),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
];
@@ -1483,6 +1506,7 @@ function renderRankingList(type) {
name: node.name,
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
price: level === 'station' && node.station?.cooperative ? stationUnitPrice(node.station, '') : '',
location: node.lnglat,
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
@@ -1496,7 +1520,7 @@ function renderRankingList(type) {
rows.forEach((item, index) => {
const row = document.createElement('div');
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-value-stack">${item.price ? `<span class="station-list-price">${escapeHTML(item.price)}</span>` : ''}<span class="r-val">${escapeHTML(item.value)}</span></span>`;
row.onclick = () => {
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
if (item.entity) showEntityDetails(item.mode, item.entity);
+133 -12
View File
@@ -42,11 +42,15 @@ AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERV
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
_station_geocode_cache_lock = threading.Lock()
_station_geocode_cache = None
_station_geocode_cache_dirty = False
_station_geocode_rate_lock = threading.Lock()
_station_geocode_next_request_at = 0.0
_station_geocode_refresh_lock = threading.Lock()
_station_geocode_refresh_pending = {}
_station_geocode_refresh_running = False
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
@@ -85,24 +89,54 @@ def _post_open_platform(path, body):
return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
thread = threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True)
thread.start()
def _cached(key, ttl_seconds, loader):
now = time.time()
refresh_in_background = False
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
# Startup prewarming and the first browser request can arrive together.
# Coalesce them so a cold geocode cache is filled only once.
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
# Cold starts still need one synchronous load. Coalesce startup prewarming
# and the first browser request so only one upstream request is issued.
with load_lock:
now = time.time()
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (now, value)
_cache[key] = (time.time(), value)
return value
@@ -181,7 +215,7 @@ def _reverse_geocode_station(longitude, latitude):
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
def _station_region_from_gps(station, now=None):
def _station_region_from_gps(station, now=None, refresh_queue=None):
global _station_geocode_cache_dirty
coordinate_key = _station_coordinate_key(station)
if not coordinate_key:
@@ -191,6 +225,11 @@ def _station_region_from_gps(station, now=None):
cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return cached.get("region") or {"province": "", "city": "", "district": ""}
cached_region = cached.get("region")
if isinstance(cached_region, dict):
if refresh_queue is not None:
refresh_queue.append(station)
return cached_region
try:
longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude)
@@ -203,22 +242,92 @@ def _station_region_from_gps(station, now=None):
return region
def _refresh_station_region(station):
coordinate_key = _station_coordinate_key(station)
if not coordinate_key:
return False
now = time.time()
cache = _load_station_geocode_cache()
with _station_geocode_cache_lock:
cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return False
try:
longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude)
except Exception as exc:
print("station reverse geocode refresh deferred for {}: {}".format(coordinate_key, exc))
return False
with _station_geocode_cache_lock:
cache[coordinate_key] = {"updatedAt": now, "region": region}
return True
def _refresh_station_regions(stations):
with ThreadPoolExecutor(max_workers=8) as executor:
changed = any(list(executor.map(_refresh_station_region, stations)))
if changed:
with _station_geocode_cache_lock:
_persist_station_geocode_cache()
def _drain_station_region_refreshes():
global _station_geocode_refresh_running
restart = False
try:
while True:
with _station_geocode_refresh_lock:
batch = list(_station_geocode_refresh_pending.values())
_station_geocode_refresh_pending.clear()
if not batch:
return
_refresh_station_regions(batch)
except Exception as exc:
print("station reverse geocode background refresh deferred: {}".format(exc))
finally:
with _station_geocode_refresh_lock:
_station_geocode_refresh_running = False
restart = bool(_station_geocode_refresh_pending)
if restart:
_schedule_station_region_refresh([])
def _schedule_station_region_refresh(stations):
global _station_geocode_refresh_running
start_worker = False
with _station_geocode_refresh_lock:
for station in stations:
coordinate_key = _station_coordinate_key(station)
if coordinate_key:
_station_geocode_refresh_pending[coordinate_key] = station
if _station_geocode_refresh_pending and not _station_geocode_refresh_running:
_station_geocode_refresh_running = True
start_worker = True
if start_worker:
threading.Thread(target=_drain_station_region_refreshes, daemon=True).start()
def _stations_with_gps_regions(stations):
global _station_geocode_cache_dirty
def enrich(station):
item = dict(station)
region = _station_region_from_gps(item)
refresh_queue = []
region = _station_region_from_gps(item, refresh_queue=refresh_queue)
# Never fall back to data-table administrative fields. The raw address
# remains untouched and is the only directly displayed location text.
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
return item
return item, refresh_queue[0] if refresh_queue else None
with ThreadPoolExecutor(max_workers=8) as executor:
enriched = list(executor.map(enrich, stations))
results = list(executor.map(enrich, stations))
enriched = [item for item, _ in results]
stale_stations = [station for _, station in results if station is not None]
with _station_geocode_cache_lock:
if _station_geocode_cache_dirty:
_persist_station_geocode_cache()
_station_geocode_cache_dirty = False
if stale_stations:
_schedule_station_region_refresh(stale_stations)
return enriched
@@ -233,7 +342,13 @@ def _load_stations():
def _public_station_directory():
stations = _load_stations()
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations]
partner_fields = ("contactPerson", "contactPhone", "unitPrice")
directory = []
for station in stations:
item = {field: station.get(field) for field in fields if station.get(field) is not None}
if item.get("cooperative"):
item.update({field: station.get(field) for field in partner_fields if station.get(field) not in (None, "", 0)})
directory.append(item)
return {
"status": "ok",
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
@@ -331,7 +446,6 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
if path == "/api/dashboard":
try:
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
self._write_json(200, payload)
except Exception as exc: # keep upstream details server-side only
self.log_error("dashboard refresh failed: %s", exc)
self._write_json(502, {
@@ -340,9 +454,11 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"message": "地图数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/api/stations":
try:
self._write_json(200, _public_station_directory())
payload = _public_station_directory()
except Exception as exc:
self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, {
@@ -351,6 +467,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"message": "加氢站数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
super().do_GET()
def _serve_index(self):
@@ -385,7 +503,10 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
+34
View File
@@ -919,6 +919,15 @@ body {
font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif;
}
.detail-phone-link {
color: var(--accent-primary);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent);
text-underline-offset: 2px;
}
.detail-field.is-extra { display: none; }
.map-detail-card.is-expanded .detail-field.is-extra { display: block; }
@@ -1216,6 +1225,21 @@ body {
color: var(--text-main);
}
.r-value-stack {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 7px;
min-width: 0;
}
.station-list-price {
color: var(--accent-primary);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
/* Custom Scrollbars */
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -1578,6 +1602,16 @@ body {
max-height: none;
overflow: visible;
}
.rank-glass-row {
align-items: flex-start;
}
.r-value-stack {
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
}
@media (max-width: 390px) {
+10 -2
View File
@@ -105,13 +105,15 @@ filterState.district = '';
dashboard = {
stations: [
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, contactPerson: '张工', contactPhone: '13800000000', unitPrice: 12.5, totalHydrogenKg: 20 },
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
]
};
currentMode = 'station';
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
assert.equal(stationUnitPrice(dashboard.stations[0]), '¥12.50 / kg');
assert.match(detailPhoneField('联系方式', dashboard.stations[0].contactPhone), /tel:13800000000/);
assert.equal(fuzzySearchScore('广州', 'gz'), 3);
assert.equal(fuzzySearchScore('广州市', '广州'), 1);
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
@@ -153,7 +155,9 @@ document.getElementById = id => id === 'navigationLaunchOverlay' ? launchOverlay
window.requestAnimationFrame = callback => callback();
window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
window.clearTimeout = () => {};
window.location = { assign() {} };
const navigationTarget = { opener: {}, location: {} };
const openedNavigation = [];
window.open = (...args) => { openedNavigation.push(args); return navigationTarget; };
selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
navigateToSelectedEntity();
assert.equal(launchOverlay.hidden, false);
@@ -161,6 +165,10 @@ assert.ok(launchClassSet.has('is-visible'));
assert.ok(launchClassSet.has('button:is-launching'));
assert.equal(launchButton.disabled, true);
assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
assert.deepEqual(openedNavigation[0], ['', '_blank']);
launchTimers[0].callback();
assert.equal(navigationTarget.opener, null);
assert.equal(navigationTarget.location.href, stationNavigationUrl);
const stationView = stationViewportSummary();
assert.equal(stationView.level, 'station');
assert.equal(stationView.visibleNodes.length, 2);
+55
View File
@@ -1,6 +1,8 @@
import importlib.util
from pathlib import Path
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
@@ -52,6 +54,20 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120)
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
def test_public_station_directory_limits_partner_contact_and_price_to_cooperative_stations(self):
stations = [
{"id": "partner", "cooperative": True, "contactPerson": "张工", "contactPhone": "13800000000", "unitPrice": 12.5},
{"id": "external", "cooperative": False, "contactPerson": "李工", "contactPhone": "13900000000", "unitPrice": 10.0},
]
with patch.object(server, "_load_stations", return_value=stations):
directory = server._public_station_directory()["stations"]
self.assertEqual(directory[0]["contactPerson"], "张工")
self.assertEqual(directory[0]["contactPhone"], "13800000000")
self.assertEqual(directory[0]["unitPrice"], 12.5)
self.assertNotIn("contactPerson", directory[1])
self.assertNotIn("contactPhone", directory[1])
self.assertNotIn("unitPrice", directory[1])
def test_cache_reuses_dashboard_snapshot_within_window(self):
calls = []
with patch.object(server, "_cache", {}):
@@ -60,6 +76,45 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(calls, ["load"])
self.assertEqual(first, second)
def test_expired_cache_returns_stale_value_while_refreshing_in_background(self):
started = threading.Event()
release = threading.Event()
def loader():
started.set()
release.wait(1)
return {"version": 2}
cache = {"dashboard": (time.time() - 121, {"version": 1})}
with patch.object(server, "_cache", cache), \
patch.object(server, "_cache_refreshing", set()), \
patch.object(server, "_cache_load_locks", {}):
result = server._cached("dashboard", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["dashboard"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["dashboard"][1], {"version": 2})
def test_expired_station_region_is_served_while_refresh_is_scheduled(self):
station = {"longitude": 113.2, "latitude": 23.1, "province": "错误省份"}
coordinate = server._station_coordinate_key(station)
stale_region = {"province": "广东省", "city": "广州市", "district": "黄埔区"}
cache = {coordinate: {"updatedAt": time.time() - server.STATION_GEOCODE_CACHE_SECONDS - 1, "region": stale_region}}
with patch.object(server, "_station_geocode_cache", cache), \
patch.object(server, "_station_geocode_cache_dirty", False), \
patch.object(server, "_schedule_station_region_refresh") as schedule, \
patch.object(server, "_reverse_geocode_station") as reverse:
result = server._stations_with_gps_regions([station])
self.assertEqual(result[0]["province"], "广东省")
self.assertEqual(result[0]["city"], "广州市")
reverse.assert_not_called()
schedule.assert_called_once()
self.assertEqual(len(schedule.call_args[0][0]), 1)
def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self):
station = {
"longitude": 113.200001,