Compare commits
23
Commits
go
..
9fa3d81777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa3d81777 | ||
|
|
e402eb5e60 | ||
|
|
a26179c8fe | ||
|
|
e7ee8583aa | ||
|
|
c6875fbb98 | ||
|
|
8759668d39 | ||
|
|
4201878c35 | ||
|
|
6fceb0f5b4 | ||
|
|
861e0bf207 | ||
|
|
c81cfcb377 | ||
|
|
5b565d57fc | ||
|
|
4c547757a7 | ||
|
|
f707e6ad68 | ||
|
|
1403b84b0b | ||
|
|
9452e30538 | ||
|
|
6ef98d03c2 | ||
|
|
357dd490e9 | ||
|
|
d1ba129e99 | ||
|
|
d4593c44cd | ||
|
|
f535b1570b | ||
|
|
c42570c0ee | ||
|
|
22f3904ad7 | ||
|
|
8539af13e6 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -0,0 +1,35 @@
|
||||
# 羚牛氢能 · 加氢站导航
|
||||
|
||||
面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`。
|
||||
|
||||
它复用运营地图已经按 GPS 逆地理解析、落盘缓存的站点目录,并只对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 API 返回。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:20804/>。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/stations`:公开站点目录,不包含加氢量字段;
|
||||
- `GET /api/health`:进程健康状态;
|
||||
- 其他 `/api/*` 路由均返回 404。
|
||||
|
||||
站点目录在服务端缓存 2 分钟,并在服务启动后后台预热;因此正常页面打开直接返回缓存数据,最多延迟 2 分钟更新一次。省、市、区由运营地图根据 GCJ-02 坐标调用高德逆地理服务生成,结果按坐标缓存 7 天;详细地址仍直接展示资产原始字段。
|
||||
|
||||
## ECS 部署
|
||||
|
||||
服务运行在 `20804`,与运营地图端口隔离。部署单元复用
|
||||
`/opt/lingniu-vehicle-map/env/vehicle-map.env` 中已有的 Open Platform 凭据,并通过
|
||||
`VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current` 复用一致的品牌样式。
|
||||
|
||||
```bash
|
||||
install -m 0644 deploy/lingniu-station-navigation.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lingniu-station-navigation.service
|
||||
deploy/install-release.sh <release-id> <release.tgz>
|
||||
```
|
||||
@@ -0,0 +1,279 @@
|
||||
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true, regionPicker: null };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => { initClock(); initMap(); loadStations(); });
|
||||
document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeStationSearchSuggestions(); });
|
||||
|
||||
async function loadStations() {
|
||||
setStatus('正在同步加氢站数据…');
|
||||
try {
|
||||
const response = await fetch('/api/stations', { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
state.stations = payload.stations.filter(hasCoordinates);
|
||||
updateSummary(payload.summary);
|
||||
setStatus(`站点数据已同步 · ${formatNumber(payload.summary.totalStations)} 座加氢站 · ${payload.asOf}`);
|
||||
render();
|
||||
} catch (error) {
|
||||
console.error('station directory refresh failed', error);
|
||||
setStatus('站点数据同步失败 · 将自动重试', true);
|
||||
}
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (typeof AMap === 'undefined') { setStatus('地图组件加载失败', true); return; }
|
||||
state.map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: 'amap://styles/light', showBuildingBlock: false, showLabel: true });
|
||||
state.map.on('complete', render);
|
||||
state.map.on('zoomend', render);
|
||||
state.map.on('moveend', render);
|
||||
}
|
||||
|
||||
function updateSummary(summary) {
|
||||
const total = Number(summary.totalStations || 0), partner = Number(summary.cooperativeStations || 0), external = Math.max(0, total - partner);
|
||||
setHTML('kpiTotal', `${formatNumber(total)} <small>座</small>`); setHTML('kpiPartner', `${formatNumber(partner)} <small>座</small>`);
|
||||
setHTML('statusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('statusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('statusExternal', `${formatNumber(external)} <small>座</small>`);
|
||||
setHTML('mobileStatusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('mobileStatusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('mobileStatusExternal', `${formatNumber(external)} <small>座</small>`);
|
||||
document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||||
document.getElementById('externalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||||
document.getElementById('mobilePartnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||||
document.getElementById('mobileExternalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||||
}
|
||||
|
||||
function stationMatchesQuery(station, query = state.query) { const needle = normalize(query); return !needle || [station.name, station.shortName, station.address].some(value => normalize(value).includes(needle)); }
|
||||
function locationFilterKey(filter) { return [filter.level || 'location', filter.province || '', filter.city || '', filter.district || ''].join('|'); }
|
||||
function stationMatchesLocationFilter(station, filter) { return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); }
|
||||
function stationMatchesLocation(station) { return !state.locationFilters.length || state.locationFilters.some(filter => stationMatchesLocationFilter(station, filter)); }
|
||||
function stationTypeScope() { return state.stations.filter(station => state.stationFilter !== 'partner' || station.cooperative); }
|
||||
function filteredStations() { return stationTypeScope().filter(station => stationMatchesLocation(station) && stationMatchesQuery(station)); }
|
||||
function stationSearchScore(station, query) {
|
||||
const fields = [station.name, station.shortName, station.address].filter(Boolean);
|
||||
for (const value of fields) { const text = normalize(value); if (text.includes(query)) return 3; const pinyin = toPinyin(value); if (pinyin.includes(query) || initials(pinyin).includes(query)) return 2; }
|
||||
return 0;
|
||||
}
|
||||
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] || ''; }
|
||||
|
||||
function render() { renderMarkers(); renderList(); }
|
||||
function renderMarkers() {
|
||||
if (!state.map || typeof AMap === 'undefined') return;
|
||||
state.markers.forEach(marker => marker.remove()); state.markers = [];
|
||||
const nodes = stationNodes(true);
|
||||
nodes.forEach(node => {
|
||||
const point = node.kind === 'station', content = document.createElement('div');
|
||||
content.className = 'province-info-badge-wrap';
|
||||
const label = point ? (node.cooperative ? 'H₂ · 合作' : '') : `${formatNumber(node.count)} 座`;
|
||||
const subline = point ? node.adminPath : '';
|
||||
content.innerHTML = `<div class="province-info-card${point ? ` is-station-point${node.cooperative ? '' : ' is-external'}${state.selected?.id === node.station.id ? ' is-selected' : ''}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span>${label ? `<span class="p-total">${escapeHTML(label)}</span>` : ''}</div>${subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||||
const marker = new AMap.Marker({ position: node.lnglat, content, offset: new AMap.Pixel(-30, -12), title: node.name, zIndex: point ? 20 : 10 });
|
||||
marker.on('click', () => { if (point) selectStation(node.station, true); else state.map.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }); marker.setMap(state.map); state.markers.push(marker);
|
||||
});
|
||||
renderUserMarker();
|
||||
}
|
||||
function stationHierarchyLevel(zoom = state.map?.getZoom?.() || 0) {
|
||||
if (normalize(state.query)) return 'station';
|
||||
if (zoom < 7) return 'province';
|
||||
if (zoom < 11) return 'city';
|
||||
return 'station';
|
||||
}
|
||||
function stationGroupName(station, level) {
|
||||
const raw = (level === 'province' ? station.province : station.city) || '未标注区域';
|
||||
return level === 'province' ? String(raw).replace(/(壮族|回族|维吾尔)?自治区$|特别行政区$|省$|市$/, '') : String(raw).replace(/自治州$|地区$|林区$|[盟区县市]$/, '');
|
||||
}
|
||||
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevel()) {
|
||||
if (level === 'station') return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', level, name: station.name || station.shortName || '未命名站点', lnglat: coords(station), station, count: 1, online: station.cooperative ? 1 : 0, adminPath: stationRegion(station), detail: [stationRegion(station), station.address].filter(Boolean).join(' · '), cooperative: Boolean(station.cooperative) }));
|
||||
const grouped = new Map();
|
||||
for (const station of stations) {
|
||||
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
|
||||
const group = grouped.get(key) || { kind: level === 'province' ? 'stationProvince' : 'stationCity', level, name: stationGroupName(station, level), lng: 0, lat: 0, count: 0, online: 0, stations: [] };
|
||||
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); grouped.set(key, group);
|
||||
}
|
||||
return [...grouped.values()].map((group, index) => ({ ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') }));
|
||||
}
|
||||
function currentMapBounds() { const bounds = state.map?.getBounds?.(), sw = bounds?.getSouthWest?.(), ne = bounds?.getNorthEast?.(); if (!sw || !ne) return null; return { west: sw.getLng(), south: sw.getLat(), east: ne.getLng(), north: ne.getLat() }; }
|
||||
function nodesInsideCurrentMapBounds(nodes) { const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); }
|
||||
function stationNodes(visibleOnly = false) { const nodes = buildStationNodes(); return visibleOnly && !normalize(state.query) ? nodesInsideCurrentMapBounds(nodes) : nodes; }
|
||||
function stationViewportSummary() { const level = stationHierarchyLevel(), allNodes = stationNodes(false), visibleNodes = normalize(state.query) ? allNodes : nodesInsideCurrentMapBounds(allNodes); return { level, allNodes, visibleNodes }; }
|
||||
function nextZoomForLevel(level) { return level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14; }
|
||||
|
||||
function renderList() {
|
||||
const box = document.getElementById('stationList'); if (!box) return;
|
||||
const { level, allNodes, visibleNodes } = stationViewportSummary();
|
||||
const display = [...visibleNodes].sort(compareNodes);
|
||||
document.getElementById('resultMeta').textContent = `视野 ${formatNumber(visibleNodes.reduce((sum, node) => sum + node.count, 0))} / ${formatNumber(filteredStations().length)}`;
|
||||
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 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'); }
|
||||
|
||||
function fuzzySearchScore(value, query) {
|
||||
const needle = normalize(query); if (!needle) return 4;
|
||||
const text = normalize(value); if (text === needle) return 0; if (text.startsWith(needle)) return 1; if (text.includes(needle)) return 2;
|
||||
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).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
|
||||
function stationLocationOptions() {
|
||||
const groups = new Map();
|
||||
const add = (level, station) => {
|
||||
const province = station.province || '', city = station.city || '', district = station.district || '';
|
||||
const key = level === 'province' ? `province|${province}` : level === 'city' ? `city|${province}|${city}` : `district|${province}|${city}|${district}`;
|
||||
const filter = level === 'province' ? { province, city: '', district: '' } : level === 'city' ? { province, city, district: '' } : { province, city, district };
|
||||
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 && !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;
|
||||
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) {
|
||||
return stationLocationOptions().map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, query), fuzzySearchScore(option.path, query)) }))
|
||||
.filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, normalize(query) ? 8 : 6);
|
||||
}
|
||||
function stationEntitySuggestions(query) {
|
||||
if (!normalize(query)) return [];
|
||||
return filteredStations().map(station => ({ type: 'station', station, score: stationSearchScore(station, query) }))
|
||||
.filter(item => item.score > 0).sort((left, right) => right.score - left.score || String(left.station.name || '').localeCompare(String(right.station.name || ''), 'zh-CN')).slice(0, 5);
|
||||
}
|
||||
function renderStationSearchSuggestions() {
|
||||
const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return;
|
||||
state.suggestions = stationEntitySuggestions(state.searchText); state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
|
||||
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的站点或详细地址</span>'; return; }
|
||||
box.innerHTML = `<span class="suggestion-section-label">加氢站</span>${state.suggestions.map((item, index) => { const station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(station.address || stationRegion(station) || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
|
||||
}
|
||||
function openStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (!box) return; box.hidden = false; renderStationSearchSuggestions(); }
|
||||
function closeStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (box) box.hidden = true; state.activeSuggestion = -1; }
|
||||
function locationFilterLabel(filter) { return filter.label || (filter.district ? stationGroupName({ city: filter.district }, 'city') : filter.city ? stationGroupName({ city: filter.city }, 'city') : stationGroupName({ province: filter.province }, 'province')); }
|
||||
function renderLocationFilterChips() {
|
||||
const rail = document.getElementById('selectedLocationFilters'); if (!rail) return;
|
||||
rail.hidden = !state.locationFilters.length;
|
||||
rail.innerHTML = state.locationFilters.map(filter => { const key = locationFilterKey(filter), label = locationFilterLabel(filter); return `<button class="selected-location-filter-chip" type="button" onclick="removeLocationFilter('${escapeAttribute(key)}')" aria-label="移除位置筛选 ${escapeAttribute(label)}"><span>${escapeHTML(label)}</span><b aria-hidden="true">×</b></button>`; }).join('');
|
||||
const count = document.getElementById('regionPickerTriggerCount'); if (count) { count.hidden = !state.locationFilters.length; count.textContent = state.locationFilters.length; }
|
||||
}
|
||||
function removeLocationFilter(key) {
|
||||
state.locationFilters = state.locationFilters.filter(filter => locationFilterKey(filter) !== key);
|
||||
state.selected = null; closeDetail(); renderLocationFilterChips(); render(); renderStationSearchSuggestions();
|
||||
document.getElementById('selectedRegionHint').textContent = state.locationFilters.length ? `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}` : '视角: 全国加氢站网络';
|
||||
}
|
||||
function addLocationFilter(option) {
|
||||
const filter = { level: option.level, province: option.province, city: option.city, district: option.district, label: option.label };
|
||||
if (!state.locationFilters.some(current => locationFilterKey(current) === locationFilterKey(filter))) state.locationFilters.push(filter);
|
||||
renderLocationFilterChips();
|
||||
}
|
||||
function cloneLocationFilter(filter) { return { level: filter.level, province: filter.province || '', city: filter.city || '', district: filter.district || '', label: filter.label || '' }; }
|
||||
function openRegionPicker() {
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: state.locationFilters.map(cloneLocationFilter), options: [] };
|
||||
const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = false;
|
||||
renderRegionPicker(); window.setTimeout(() => document.getElementById('regionPickerSearch')?.focus(), 0);
|
||||
}
|
||||
function closeRegionPicker() { state.regionPicker = null; const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = true; }
|
||||
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').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 && (!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];
|
||||
return `${compactPath(parts)} · ${formatNumber(option.stations.length)} 座站点`;
|
||||
}
|
||||
function regionPickerOptionSelected(option) { return state.regionPicker?.draft.some(filter => locationFilterKey(filter) === locationFilterKey(option)); }
|
||||
function renderRegionPicker() {
|
||||
const picker = state.regionPicker; if (!picker) return;
|
||||
const search = document.getElementById('regionPickerSearch'); if (search && search.value !== picker.search) search.value = picker.search;
|
||||
const clear = document.getElementById('regionPickerSearchClear'); if (clear) clear.hidden = !picker.search;
|
||||
const selected = document.getElementById('regionPickerSelected');
|
||||
if (selected) selected.innerHTML = picker.draft.length ? `<span class="region-picker-selected-label">已选</span>${picker.draft.map((filter, index) => `<button type="button" class="region-picker-selected-chip" onclick="removeRegionPickerDraft(${index})">${escapeHTML(locationFilterLabel(filter))}<b aria-hidden="true">×</b></button>`).join('')}` : '<span class="region-picker-selected-placeholder">可同时选择多个省 / 市 / 区县</span>';
|
||||
const path = document.getElementById('regionPickerPath');
|
||||
if (path) {
|
||||
const crumbs = [{ label: '全部省份', level: 'province' }];
|
||||
if (picker.province) crumbs.push({ label: stationGroupName({ province: picker.province }, 'province'), level: 'city' });
|
||||
if (picker.city) crumbs.push({ label: stationGroupName({ city: picker.city }, 'city'), level: 'district' });
|
||||
path.innerHTML = picker.search ? '<span>搜索结果</span>' : crumbs.map((crumb, index) => `<button type="button" class="${index === crumbs.length - 1 ? 'is-current' : ''}" onclick="navigateRegionPicker('${crumb.level}')">${escapeHTML(crumb.label)}</button>`).join('<i>›</i>');
|
||||
}
|
||||
const options = regionPickerOptions(); picker.options = options;
|
||||
const list = document.getElementById('regionPickerList');
|
||||
if (list) list.innerHTML = options.length ? options.map((option, index) => {
|
||||
const selectedOption = regionPickerOptionSelected(option), canDescend = !picker.search && option.level !== 'district';
|
||||
return `<article class="region-picker-row${selectedOption ? ' is-selected' : ''}"><button type="button" class="region-picker-check" onclick="toggleRegionPickerOption(${index})" aria-label="${selectedOption ? '取消选择' : '选择'} ${escapeAttribute(option.path)}"><span>${selectedOption ? '✓' : ''}</span></button><button type="button" class="region-picker-row-main" onclick="toggleRegionPickerOption(${index})"><strong>${escapeHTML(option.label)}</strong><small>${escapeHTML(regionPickerOptionPath(option))}</small></button>${canDescend ? `<button type="button" class="region-picker-next" onclick="descendRegionPicker(${index})" aria-label="浏览${escapeAttribute(option.label)}下级">›</button>` : '<span class="region-picker-next is-empty"></span>'}</article>`;
|
||||
}).join('') : '<div class="region-picker-empty">没有可选择的运营区域</div>';
|
||||
const summary = document.getElementById('regionPickerSummary'); if (summary) summary.textContent = `已选 ${picker.draft.length} 项`;
|
||||
const confirm = document.querySelector('.region-picker-confirm'); if (confirm) confirm.textContent = picker.draft.length ? `确认(${picker.draft.length})` : '确认';
|
||||
}
|
||||
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' && 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(); }
|
||||
function confirmRegionPicker() { if (!state.regionPicker) return; state.locationFilters = state.regionPicker.draft.map(cloneLocationFilter); state.query = ''; state.searchText = ''; const input = document.getElementById('stationSearch'); if (input) input.value = ''; const clear = document.getElementById('clearSearch'); if (clear) clear.hidden = true; state.selected = null; closeDetail(); closeStationSearchSuggestions(); renderLocationFilterChips(); closeRegionPicker(); render(); if (state.locationFilters.length) { focusSearchResults(); document.getElementById('selectedRegionHint').textContent = `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}`; } else resetMap(); }
|
||||
function focusSearchResults() {
|
||||
const stations = filteredStations(); if (!state.map || !stations.length) return;
|
||||
const longitudes = stations.map(station => Number(station.longitude)), latitudes = stations.map(station => Number(station.latitude));
|
||||
const center = [(Math.min(...longitudes) + Math.max(...longitudes)) / 2, (Math.min(...latitudes) + Math.max(...latitudes)) / 2];
|
||||
const span = Math.max(Math.max(...longitudes) - Math.min(...longitudes), Math.max(...latitudes) - Math.min(...latitudes));
|
||||
const zoom = stations.length === 1 ? 14 : span < .1 ? 13 : span < .35 ? 11.5 : span < 1 ? 9.5 : 7;
|
||||
state.map.setZoomAndCenter(zoom, center);
|
||||
}
|
||||
function handleStationSearchInput(value) { state.searchText = value; document.getElementById('clearSearch').hidden = !normalize(value); state.activeSuggestion = -1; openStationSearchSuggestions(); window.clearTimeout(state.searchDebounce); state.searchDebounce = window.setTimeout(() => { state.query = value; state.selected = null; closeDetail(); render(); if (normalize(value)) focusSearchResults(); }, 180); }
|
||||
function selectStationSearchSuggestion(index) {
|
||||
const item = state.suggestions[index]; if (!item) return;
|
||||
closeStationSearchSuggestions(); selectStation(item.station, true);
|
||||
}
|
||||
function setStationFilter(filter) { if (!['all', 'partner'].includes(filter) || state.stationFilter === filter) return; state.stationFilter = filter; state.selected = null; closeDetail(); document.querySelectorAll('[data-station-filter]').forEach(button => button.classList.toggle('is-active', button.dataset.stationFilter === filter)); render(); renderStationSearchSuggestions(); }
|
||||
function clearSearch() { window.clearTimeout(state.searchDebounce); document.getElementById('stationSearch').value = ''; state.query = ''; state.searchText = ''; state.locationFilters = []; renderLocationFilterChips(); closeStationSearchSuggestions(); closeDetail(); resetMap(); render(); }
|
||||
function handleSearchKeydown(event) { if (event.key === 'Escape') { closeStationSearchSuggestions(); event.currentTarget.blur(); return; } if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return; const box = document.getElementById('stationSearchSuggestions'); if (box?.hidden) openStationSearchSuggestions(); if (event.key === 'Enter' && state.activeSuggestion >= 0) { event.preventDefault(); selectStationSearchSuggestion(state.activeSuggestion); return; } if (event.key !== 'Enter') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; state.activeSuggestion = (state.activeSuggestion + direction + state.suggestions.length) % Math.max(state.suggestions.length, 1); renderStationSearchSuggestions(); } }
|
||||
|
||||
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), 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 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 }); }
|
||||
function locationFailed(label) { const button = document.getElementById('locateDeviceBtn'); button.disabled = false; button.classList.add('is-error'); document.getElementById('locateDeviceLabel').textContent = label; window.setTimeout(() => { button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = state.userLocation ? '已定位' : '定位'; }, 2600); }
|
||||
function renderUserMarker() { if (!state.map || !state.userLocation || typeof AMap === 'undefined') return; if (state.userMarker) state.userMarker.remove(); const content = document.createElement('div'); content.className = 'user-location-marker'; state.userMarker = new AMap.Marker({ position: state.userLocation, content, offset: new AMap.Pixel(-9, -9), zIndex: 50 }); state.userMarker.setMap(state.map); }
|
||||
function stationDistance(station) { return state.userLocation ? distanceKm(state.userLocation, coords(station)) : Number.POSITIVE_INFINITY; }
|
||||
function distanceKm(origin, target) { const radians = value => value * Math.PI / 180, dLat = radians(target[1] - origin[1]), dLng = radians(target[0] - origin[0]), a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(origin[1])) * Math.cos(radians(target[1])) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }
|
||||
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 || '加氢站'), 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 }); }
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '`'); }
|
||||
function initClock() { const clock = document.getElementById('clockTime'), update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); }
|
||||
function wgs84ToGcj02(lng, lat) { if (lng < 72.004 || lng > 137.8347 || lat < .8293 || lat > 55.8271) return [lng, lat]; const transformLat = (x, y) => -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(y*Math.PI)+40*Math.sin(y/3*Math.PI))*2/3 + (160*Math.sin(y/12*Math.PI)+320*Math.sin(y*Math.PI/30))*2/3; const transformLng = (x, y) => 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(x*Math.PI)+40*Math.sin(x/3*Math.PI))*2/3 + (150*Math.sin(x/12*Math.PI)+300*Math.sin(x/30*Math.PI))*2/3; const a = 6378245, ee = .00669342162296594323; let dLat = transformLat(lng-105, lat-35), dLng = transformLng(lng-105, lat-35); const radLat = lat / 180 * Math.PI, magic = 1 - ee * Math.sin(radLat) ** 2, sqrtMagic = Math.sqrt(magic); dLat = dLat * 180 / ((a * (1-ee)) / (magic * sqrtMagic) * Math.PI); dLng = dLng * 180 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; }
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
release_id=${1:?release id is required}
|
||||
archive=${2:?release archive is required}
|
||||
root=${STATION_NAVIGATION_ROOT:-/opt/lingniu-station-navigation}
|
||||
service=${STATION_NAVIGATION_SERVICE:-lingniu-station-navigation.service}
|
||||
base_url=${STATION_NAVIGATION_BASE_URL:-http://127.0.0.1:20804}
|
||||
|
||||
case "$release_id" in
|
||||
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$release_id" >&2; exit 1 ;;
|
||||
esac
|
||||
test -f "$archive"
|
||||
|
||||
if ! id station-navigation >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$root" --shell /sbin/nologin station-navigation
|
||||
fi
|
||||
mkdir -p "$root/releases"
|
||||
next="$root/releases/$release_id"
|
||||
test ! -e "$next"
|
||||
mkdir -p "$next"
|
||||
|
||||
while IFS= read -r member; do
|
||||
case "$member" in
|
||||
/*|../*|*/../*|*/..) printf 'archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(tar -tzf "$archive")
|
||||
tar --no-same-owner -xzf "$archive" -C "$next"
|
||||
test -f "$next/server.py"
|
||||
test -f "$next/index.html"
|
||||
chown -R root:station-navigation "$next"
|
||||
chmod -R u=rwX,g=rX,o= "$next"
|
||||
|
||||
ln -s "$next" "$root/current.next"
|
||||
python3 -c 'import os,sys; os.replace(sys.argv[1],sys.argv[2])' "$root/current.next" "$root/current"
|
||||
systemctl restart "$service"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" | grep -q '"service":"station-navigation"'; then
|
||||
curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"'
|
||||
curl -fsS "$base_url/api/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400; forbidden={"hydrogenVolume","fuelingVolume","dailyHydrogenVolume"}; assert not (forbidden & set().union(*(x.keys() for x in d["stations"])))'
|
||||
printf 'station_navigation_release_install=ok release=%s\n' "$release_id"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
systemctl status "$service" --no-pager >&2 || true
|
||||
exit 1
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Lingniu Public Hydrogen Station Navigation
|
||||
After=network-online.target lingniu-vehicle-open-platform.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=station-navigation
|
||||
Group=station-navigation
|
||||
# The public process needs read-only access to the operations map's shared
|
||||
# logo and stylesheet. Its own release remains owned by station-navigation.
|
||||
SupplementaryGroups=vehicle-map
|
||||
WorkingDirectory=/opt/lingniu-station-navigation/current
|
||||
# Reuse the operations map's existing Open Platform credential; the public
|
||||
# service still returns only the station-directory allowlist in server.py.
|
||||
EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env
|
||||
Environment=STATION_NAVIGATION_HOST=0.0.0.0
|
||||
Environment=STATION_NAVIGATION_PORT=20804
|
||||
Environment=STATION_NAVIGATION_CACHE_SECONDS=120
|
||||
Environment=VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current
|
||||
ExecStart=/usr/bin/python3 /opt/lingniu-station-navigation/current/server.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>羚牛氢能 - 加氢站导航</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/vehicle-map.css?v=__ASSET_VERSION__">
|
||||
<link rel="stylesheet" href="styles.css?v=__ASSET_VERSION__">
|
||||
<script>window._AMapSecurityConfig = { securityJsCode: '0b54a41143bec162788d01deba851340' };</script>
|
||||
<script src="https://webapi.amap.com/maps?v=2.0&key=1868920ac8ff6b6f88dbe9fa2609c183&plugin=AMap.Scale,AMap.ToolBar,AMap.Marker"></script>
|
||||
</head>
|
||||
<body class="theme-white">
|
||||
<div class="liquid-cockpit-wrapper station-navigation-shell">
|
||||
<header class="liquid-header floating-glass">
|
||||
<div class="header-left">
|
||||
<div class="brand-block"><img class="brand-logo-svg" src="/assets/logo_light.svg" width="150" height="36" alt="羚牛氢能"></div>
|
||||
<div class="brand-divider"></div>
|
||||
<div class="cockpit-title-wrap"><h1 class="cockpit-title">加氢站导航</h1></div>
|
||||
</div>
|
||||
<div class="header-kpi-group">
|
||||
<div class="kpi-glass-capsule"><span class="capsule-lbl">站点总数</span><span class="capsule-val" id="kpiTotal">-- <small>座</small></span></div>
|
||||
<div class="kpi-glass-capsule primary-capsule"><span class="capsule-lbl">合作站点</span><span class="capsule-val" id="kpiPartner">-- <small>座</small></span></div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="theme-switcher-bw">
|
||||
<button class="bw-btn theme-dark-btn" title="深色模式" onclick="setTheme('theme-dark')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>深色</span></button>
|
||||
<button class="bw-btn theme-white-btn active" title="浅色模式" onclick="setTheme('theme-white')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4m0-15.6-1.4 1.4M5.6 18.4l-1.4 1.4"></path></svg><span>浅色</span></button>
|
||||
</div>
|
||||
<div class="time-widget" id="clockTime"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="glass-panel station-summary-strip" aria-label="加氢站类型分布"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="mobileStatusTotal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusPartner">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusExternal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="mobilePartnerSegment"></div><div class="seg seg-stopped" id="mobileExternalSegment"></div></div></section>
|
||||
|
||||
<main class="cockpit-main-grid">
|
||||
<section class="map-spatial-container floating-glass">
|
||||
<div class="map-top-bar"><div class="status-glass-pill"><span class="pulse-ring"></span><span id="mapStatusText">正在同步加氢站数据…</span></div></div>
|
||||
<div class="map-explore-toolbar" aria-label="地图搜索与定位">
|
||||
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateMe()" aria-label="定位附近站点"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path><circle cx="12" cy="12" r="8"></circle></svg><span id="locateDeviceLabel">定位</span></button>
|
||||
<button class="region-picker-trigger" id="regionPickerTrigger" type="button" onclick="openRegionPicker()" aria-haspopup="dialog"><span class="region-picker-trigger-icon">⌖</span><span>运营区域</span><b id="regionPickerTriggerCount" hidden>0</b></button>
|
||||
<div class="explore-search-shell"><label class="entity-search-field" for="stationSearch"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg><input id="stationSearch" type="search" autocomplete="off" placeholder="搜索站点或详细地址" aria-label="搜索站点或详细地址" oninput="handleStationSearchInput(this.value)" onfocus="openStationSearchSuggestions()" onkeydown="handleSearchKeydown(event)"><button class="search-clear-btn" id="clearSearch" type="button" onclick="clearSearch()" aria-label="清除搜索" hidden>×</button></label><div class="explore-suggestions" id="stationSearchSuggestions" role="listbox" aria-label="站点搜索建议" hidden></div></div>
|
||||
<div class="selected-location-filter-rail" id="selectedLocationFilters" aria-label="已选位置" hidden></div>
|
||||
<div class="filter-chip-rail" id="filterChipRail" role="group" aria-label="站点类型筛选"><button class="map-filter-chip is-active" type="button" data-station-filter="all" onclick="setStationFilter('all')">全部站点</button><button class="map-filter-chip" type="button" data-station-filter="partner" onclick="setStationFilter('partner')">合作站</button></div>
|
||||
<span class="filter-result-meta" id="resultMeta" aria-live="polite">全部 0</span>
|
||||
</div>
|
||||
<div class="amap-canvas-box">
|
||||
<div id="amapContainer"></div>
|
||||
<div class="navigation-launch-overlay" id="navigationOverlay" role="status" aria-live="polite" hidden><div class="navigation-launch-panel"><span class="navigation-launch-spinner"></span><div><strong>正在打开高德地图</strong><span>正在准备导航路线,请稍候</span></div></div></div>
|
||||
<article class="map-detail-card" id="stationDetail" aria-live="polite" hidden>
|
||||
<div class="detail-card-accent is-station"></div><div class="detail-card-header"><div class="detail-title-wrap"><span class="detail-type-pill is-station" id="detailType">合作站</span><h2 id="detailName">—</h2><p id="detailRegion"></p></div><div class="detail-card-actions"><button class="detail-navigate-btn" id="navigateButton" type="button" onclick="navigateToStation()" aria-label="导航"><svg viewBox="0 0 24 24"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg></button><button class="detail-close-btn" type="button" onclick="closeDetail()" aria-label="关闭详情">×</button></div></div><dl class="detail-grid" id="detailFields"></dl>
|
||||
</article>
|
||||
<div class="map-action-controls"><button class="glass-btn" onclick="resetMap()">复位视角</button><button class="glass-btn" id="pitchButton" onclick="toggleMapPitch()">3D/2D 视角</button><button class="glass-btn" onclick="clearSearch()">全国视图</button></div>
|
||||
</div>
|
||||
<div class="map-bottom-info"><span>地图引擎: 羚牛氢能 GIS (AMap 3D Engine)</span><span>站点数据服务 · 公开目录</span><span id="selectedRegionHint">视角: 全国加氢站网络</span></div>
|
||||
</section>
|
||||
<aside class="sidebar-operations">
|
||||
<div class="glass-panel sidebar-card station-status-card"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="statusTotal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="statusPartner">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="statusExternal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="partnerSegment"></div><div class="seg seg-stopped" id="externalSegment"></div></div></div>
|
||||
<div class="glass-panel sidebar-card flex-fill-auto"><div class="panel-header"><div class="panel-heading-stack"><span class="panel-title" id="panelRankTitle">加氢站省份 TOP 排名</span><span class="viewport-meta" id="rankViewportMeta"></span></div><div class="glass-tab-control"><button class="gtab active" id="rankPrimaryTab">按站点数</button></div></div><div class="liquid-ranking-list" id="stationList"></div></div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<div class="region-picker-backdrop" id="regionPickerModal" role="dialog" aria-modal="true" aria-labelledby="regionPickerTitle" hidden>
|
||||
<section class="region-picker-dialog">
|
||||
<header class="region-picker-header"><div><span class="region-picker-eyebrow">加氢站导航</span><h2 id="regionPickerTitle">运营区域选择 <em>多选</em></h2></div><button type="button" class="region-picker-close" onclick="closeRegionPicker()" aria-label="关闭">×</button></header>
|
||||
<div class="region-picker-selected" id="regionPickerSelected"></div>
|
||||
<label class="region-picker-search" for="regionPickerSearch"><span>⌕</span><input id="regionPickerSearch" type="search" autocomplete="off" placeholder="搜索省 / 市 / 区县" aria-label="搜索省、市或区县" oninput="setRegionPickerSearch(this.value)"><button type="button" id="regionPickerSearchClear" onclick="setRegionPickerSearch('')" aria-label="清除搜索" hidden>×</button></label>
|
||||
<nav class="region-picker-path" id="regionPickerPath" aria-label="行政区域路径"></nav>
|
||||
<div class="region-picker-list" id="regionPickerList" aria-live="polite"></div>
|
||||
<footer class="region-picker-footer"><span id="regionPickerSummary">已选 0 项</span><div><button type="button" class="region-picker-text-button" onclick="clearRegionPickerDraft()">清空</button><button type="button" class="region-picker-confirm" onclick="confirmRegionPicker()">确认</button></div></footer>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
|
||||
<script src="app.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Public hydrogen station navigation service.
|
||||
|
||||
This process deliberately has a smaller API boundary than the operations map:
|
||||
it retrieves only station directory fields and never exposes vehicle records or
|
||||
hydrogen-volume fields.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from socketserver import ThreadingMixIn
|
||||
import threading
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
# Production deliberately reuses the operations map's visual assets. Keeping
|
||||
# this configurable lets the standalone release live under its own directory.
|
||||
VEHICLE_MAP_ROOT = Path(os.getenv("VEHICLE_MAP_ASSET_ROOT", str(ROOT.parent / "vehicle-map")))
|
||||
HOST = os.getenv("STATION_NAVIGATION_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("STATION_NAVIGATION_PORT", "20804"))
|
||||
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
|
||||
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||
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():
|
||||
digest = hashlib.sha256()
|
||||
for filename in ("app.js", "styles.css"):
|
||||
digest.update((ROOT / filename).read_bytes())
|
||||
digest.update((VEHICLE_MAP_ROOT / "styles.css").read_bytes())
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
|
||||
STATIC_ASSET_VERSION = _static_asset_version()
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def _json_bytes(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _post_open_platform(path, body):
|
||||
if not OPEN_PLATFORM_APP_KEY:
|
||||
raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
|
||||
request = Request(
|
||||
OPEN_PLATFORM_BASE_URL + path,
|
||||
data=_json_bytes(body),
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "lingniu-station-navigation/1.0",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except HTTPError as exc:
|
||||
detail = exc.read(2048).decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc
|
||||
if payload.get("code") != "SUCCESS":
|
||||
raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}")
|
||||
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] = (time.time(), value)
|
||||
return value
|
||||
|
||||
|
||||
def _load_station_directory():
|
||||
"""Use the operations map's GPS-resolved public station directory.
|
||||
|
||||
The map service owns reverse-geocoding and its persistent cache, keeping
|
||||
the public navigation view and the operations view on identical boundaries.
|
||||
"""
|
||||
request = Request(
|
||||
VEHICLE_MAP_INTERNAL_BASE_URL + "/api/stations",
|
||||
headers={"Accept": "application/json", "User-Agent": "lingniu-station-navigation/1.0"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except (HTTPError, URLError, ValueError) as exc:
|
||||
raise RuntimeError("vehicle map station directory unavailable: {}".format(exc)) from exc
|
||||
if payload.get("status") != "ok" or not isinstance(payload.get("stations"), list):
|
||||
raise RuntimeError("vehicle map returned an invalid station directory")
|
||||
return payload
|
||||
|
||||
|
||||
def _prewarm_station_directory_cache():
|
||||
"""Fetch the public directory after startup so first navigation is warm."""
|
||||
try:
|
||||
_cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
|
||||
except Exception as exc: # The request path will retry without blocking startup.
|
||||
print(f"station navigation cache warmup deferred: {exc}")
|
||||
|
||||
|
||||
class StationNavigationHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "LingniuStationNavigation/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path in {"/", "/index.html"}:
|
||||
self._serve_index()
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"service": "station-navigation",
|
||||
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
||||
})
|
||||
return
|
||||
if path == "/api/stations":
|
||||
try:
|
||||
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, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
self._write_json(200, payload)
|
||||
return
|
||||
if path == "/assets/logo_light.svg":
|
||||
self._serve_logo()
|
||||
return
|
||||
if path == "/assets/vehicle-map.css":
|
||||
self._serve_vehicle_map_styles()
|
||||
return
|
||||
if path.startswith("/api/"):
|
||||
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def _serve_index(self):
|
||||
try:
|
||||
template = (ROOT / "index.html").read_text(encoding="utf-8")
|
||||
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
|
||||
except OSError as exc:
|
||||
self.log_error("index template unavailable: %s", exc)
|
||||
self.send_error(500, "Station navigation is temporarily unavailable")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_logo(self):
|
||||
try:
|
||||
body = (VEHICLE_MAP_ROOT / "logo_light.svg").read_bytes()
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/svg+xml")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_vehicle_map_styles(self):
|
||||
try:
|
||||
body = (VEHICLE_MAP_ROOT / "styles.css").read_bytes()
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/css; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def end_headers(self):
|
||||
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||||
super().end_headers()
|
||||
|
||||
def _write_json(self, status, payload):
|
||||
body = _json_bytes(payload)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
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__":
|
||||
# Python 3.6 on ECS predates SimpleHTTPRequestHandler(directory=...).
|
||||
# Resolve static assets from this release directory before serving.
|
||||
os.chdir(str(ROOT))
|
||||
server = ThreadingHTTPServer((HOST, PORT), StationNavigationHandler)
|
||||
threading.Thread(target=_prewarm_station_directory_cache, daemon=True).start()
|
||||
print(f"Lingniu Station Navigation listening on http://{HOST}:{PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export STATION_NAVIGATION_HOST="${STATION_NAVIGATION_HOST:-127.0.0.1}"
|
||||
export STATION_NAVIGATION_PORT="${STATION_NAVIGATION_PORT:-20804}"
|
||||
exec python3 "$(dirname "$0")/server.py"
|
||||
@@ -0,0 +1,225 @@
|
||||
/* Keep the public service visually identical to the main map shell. */
|
||||
.station-navigation-shell .station-summary-strip { display: none; }
|
||||
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 8px; }
|
||||
.station-navigation-shell .station-summary-strip .status-glass-grid3 { margin-bottom: 7px; }
|
||||
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px 9px; }
|
||||
.station-navigation-shell .map-filter-chip { min-height: 27px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 8px; background: var(--pill-bg); color: var(--text-muted); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.station-navigation-shell .map-filter-chip.is-active { border-color: rgba(0,113,67,.24); background: rgba(0,113,67,.1); color: var(--accent-primary); }
|
||||
.station-navigation-shell .filter-chip-rail { display: flex; gap: 6px; }
|
||||
.station-navigation-shell .region-picker-trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-main); font-size: 10px; font-weight: 700; white-space: nowrap; cursor: pointer; }
|
||||
.station-navigation-shell .region-picker-trigger:hover { border-color: rgba(0,113,67,.32); color: var(--accent-primary); }
|
||||
.station-navigation-shell .region-picker-trigger-icon { color: var(--accent-primary); font-size: 14px; line-height: 1; }
|
||||
.station-navigation-shell .region-picker-trigger b { display: grid; min-width: 15px; height: 15px; place-items: center; border-radius: 99px; background: var(--accent-primary); color: #fff; font-size: 9px; }
|
||||
.region-picker-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(15,23,42,.38); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); }
|
||||
.region-picker-backdrop[hidden] { display: none; }
|
||||
.region-picker-dialog { display: flex; width: min(500px, 100%); max-height: min(720px, calc(100dvh - 40px)); flex-direction: column; overflow: hidden; border: 1px solid var(--glass-border); border-radius: 18px; background: var(--glass-bg); box-shadow: 0 28px 70px rgba(15,23,42,.28); }
|
||||
.region-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 17px 12px; border-bottom: 1px solid var(--glass-border); }
|
||||
.region-picker-eyebrow { display: block; margin-bottom: 3px; color: var(--text-muted); font-size: 9px; font-weight: 700; letter-spacing: .07em; }
|
||||
.region-picker-header h2 { margin: 0; color: var(--text-main); font-size: 16px; letter-spacing: -.02em; }
|
||||
.region-picker-header h2 em { margin-left: 5px; color: var(--accent-primary); font-size: 11px; font-style: normal; font-weight: 700; }
|
||||
.region-picker-close { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-muted); font: 400 20px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
|
||||
.region-picker-selected { display: flex; min-height: 38px; align-items: center; gap: 6px; overflow-x: auto; padding: 8px 17px; border-bottom: 1px solid var(--glass-border); scrollbar-width: none; }
|
||||
.region-picker-selected::-webkit-scrollbar { display: none; }
|
||||
.region-picker-selected-label { flex: 0 0 auto; color: var(--text-muted); font-size: 10px; font-weight: 700; }
|
||||
.region-picker-selected-placeholder { color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-selected-chip { display: inline-flex; min-height: 25px; flex: 0 0 auto; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid rgba(0,113,67,.2); border-radius: 99px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.region-picker-selected-chip b { font: 700 14px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.region-picker-search { display: flex; height: 37px; align-items: center; gap: 8px; margin: 12px 17px 9px; padding: 0 10px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--pill-bg); color: var(--text-muted); }
|
||||
.region-picker-search span { font-size: 17px; line-height: 1; }
|
||||
.region-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text-main); font: 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
.region-picker-search button { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 50%; background: var(--segmented-bg); color: var(--text-muted); font-size: 14px; cursor: pointer; }
|
||||
.region-picker-search button[hidden] { display: none; }
|
||||
.region-picker-path { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 0 17px 7px; color: var(--text-muted); font-size: 10px; }
|
||||
.region-picker-path button { padding: 0; border: 0; background: transparent; color: var(--text-muted); font-size: inherit; cursor: pointer; }
|
||||
.region-picker-path button.is-current { color: var(--text-main); font-weight: 700; cursor: default; }
|
||||
.region-picker-path i { color: var(--text-sub); font-style: normal; }
|
||||
.region-picker-list { min-height: 240px; flex: 1 1 auto; overflow-y: auto; padding: 2px 9px 10px; }
|
||||
.region-picker-row { display: grid; grid-template-columns: 31px minmax(0, 1fr) 32px; min-height: 54px; align-items: center; border-bottom: 1px solid color-mix(in srgb, var(--glass-border) 72%, transparent); }
|
||||
.region-picker-row.is-selected { background: rgba(0,113,67,.045); }
|
||||
.region-picker-check { display: grid; width: 19px; height: 19px; margin-left: 6px; place-items: center; border: 1px solid var(--glass-border); border-radius: 5px; background: var(--pill-bg); color: #fff; font-size: 12px; cursor: pointer; }
|
||||
.region-picker-row.is-selected .region-picker-check { border-color: var(--accent-primary); background: var(--accent-primary); }
|
||||
.region-picker-row-main { min-width: 0; padding: 8px 3px; border: 0; background: transparent; color: var(--text-main); text-align: left; cursor: pointer; }
|
||||
.region-picker-row-main strong { display: block; overflow: hidden; font-size: 12px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-picker-row-main small { display: block; overflow: hidden; margin-top: 2px; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-picker-next { display: grid; width: 30px; height: 30px; place-items: center; border: 0; background: transparent; color: var(--text-muted); font: 400 22px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
|
||||
.region-picker-next:hover { color: var(--accent-primary); }
|
||||
.region-picker-next.is-empty { visibility: hidden; }
|
||||
.region-picker-empty { display: grid; min-height: 170px; place-items: center; color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-footer { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 17px; border-top: 1px solid var(--glass-border); }
|
||||
.region-picker-footer > span { color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-footer > div { display: flex; align-items: center; gap: 8px; }
|
||||
.region-picker-text-button, .region-picker-confirm { min-height: 32px; padding: 0 12px; border-radius: 8px; font-size: 11px; font-weight: 700; cursor: pointer; }
|
||||
.region-picker-text-button { border: 1px solid var(--glass-border); background: var(--pill-bg); color: var(--text-muted); }
|
||||
.region-picker-confirm { border: 1px solid var(--accent-primary); background: var(--accent-primary); color: #fff; }
|
||||
.station-navigation-shell .selected-location-filter-rail { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: min(360px, 30vw); overflow-x: auto; scrollbar-width: none; }
|
||||
.station-navigation-shell .selected-location-filter-rail::-webkit-scrollbar { display: none; }
|
||||
.station-navigation-shell .selected-location-filter-rail[hidden] { display: none; }
|
||||
.station-navigation-shell .selected-location-filter-chip { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; min-height: 29px; max-width: 132px; padding: 0 8px 0 10px; border: 1px solid rgba(0,113,67,.22); border-radius: 999px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.station-navigation-shell .selected-location-filter-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.station-navigation-shell .selected-location-filter-chip b { font: 700 15px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.station-navigation-shell .province-info-card.is-station-point { min-width: 92px; }
|
||||
.station-navigation-shell .province-info-card.is-station-point .p-total { color: var(--accent-primary); }
|
||||
.station-navigation-shell .province-info-card.is-external .p-total { color: var(--text-muted); }
|
||||
.station-navigation-shell .rank-glass-row { cursor: pointer; }
|
||||
.station-navigation-shell .rank-glass-row.is-selected { background: rgba(0,113,67,.09); border-color: rgba(0,113,67,.2); }
|
||||
.station-navigation-shell .r-name { display: flex; min-width: 0; align-items: flex-start; gap: 5px; line-height: 1.35; }
|
||||
.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) {
|
||||
/* Mobile is navigation-first: keep the first screen focused on map,
|
||||
search and the next action, rather than dashboard chrome. */
|
||||
.station-navigation-shell .liquid-header {
|
||||
position: relative;
|
||||
padding: 9px 10px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .header-left {
|
||||
width: calc(100% - 78px);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .brand-block { flex-basis: 118px; min-width: 96px; }
|
||||
.station-navigation-shell .brand-logo-svg { max-width: 118px; }
|
||||
.station-navigation-shell .brand-divider { height: 18px; }
|
||||
.station-navigation-shell .cockpit-title { font-size: 14px; }
|
||||
|
||||
.station-navigation-shell .header-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
order: initial;
|
||||
}
|
||||
|
||||
.station-navigation-shell .theme-switcher-bw { padding: 2px; border-radius: 9px; }
|
||||
.station-navigation-shell .theme-switcher-bw .bw-btn { display: grid; width: 31px; min-height: 28px; padding: 4px; place-items: center; }
|
||||
.station-navigation-shell .theme-switcher-bw .bw-btn span { display: none; }
|
||||
.station-navigation-shell .header-kpi-group,
|
||||
.station-navigation-shell .station-status-card { display: none; }
|
||||
.station-navigation-shell .station-summary-strip { display: block; padding: 9px 11px; border-radius: 14px; flex: 0 0 auto; }
|
||||
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 7px; padding-bottom: 5px; }
|
||||
.station-navigation-shell .station-summary-strip .status-glass-grid3 { gap: 5px; margin-bottom: 6px; }
|
||||
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px; }
|
||||
|
||||
.station-navigation-shell .map-spatial-container {
|
||||
height: clamp(430px, 58dvh, 540px);
|
||||
min-height: clamp(430px, 58dvh, 540px);
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-top-bar { display: none; }
|
||||
|
||||
.station-navigation-shell .map-explore-toolbar {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px max-content minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .region-picker-trigger { grid-column: 1 / 3; grid-row: 2; min-height: 30px; padding: 0 9px; border-radius: 9px; }
|
||||
.station-navigation-shell .explore-search-shell { grid-column: 2 / -1; grid-row: 1; min-width: 0; }
|
||||
|
||||
.station-navigation-shell .locate-btn {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
flex-basis: 36px;
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .entity-search-field {
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .entity-search-field input { font-size: 12px; }
|
||||
.station-navigation-shell .selected-location-filter-rail { grid-column: 1 / -1; grid-row: 3; max-width: none; padding: 1px 0 0; }
|
||||
.station-navigation-shell .filter-chip-rail { grid-column: 3; grid-row: 2; min-width: 0; max-width: none; overflow-x: auto; }
|
||||
.station-navigation-shell .map-filter-chip { min-height: 30px; padding: 0 8px; font-size: 10px; }
|
||||
.station-navigation-shell .filter-result-meta { display: none; }
|
||||
|
||||
.region-picker-backdrop { align-items: end; padding: 0; }
|
||||
.region-picker-dialog { width: 100%; max-height: min(82dvh, 700px); border-radius: 20px 20px 0 0; }
|
||||
.region-picker-header { padding: 14px 15px 10px; }
|
||||
.region-picker-selected { padding: 8px 15px; }
|
||||
.region-picker-search { margin: 10px 15px 7px; }
|
||||
.region-picker-path { padding: 0 15px 6px; }
|
||||
.region-picker-footer { padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); }
|
||||
|
||||
.station-navigation-shell .explore-suggestions {
|
||||
top: calc(100% + 43px);
|
||||
left: -42px;
|
||||
width: calc(100vw - 32px);
|
||||
max-height: min(280px, calc(100dvh - 250px));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-explore-toolbar:has(.selected-location-filter-rail:not([hidden])) .explore-suggestions {
|
||||
top: calc(100% + 79px);
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-action-controls {
|
||||
right: 8px;
|
||||
bottom: 31px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-action-controls .glass-btn:nth-child(1),
|
||||
.station-navigation-shell .map-action-controls .glass-btn:nth-child(2) { display: none; }
|
||||
|
||||
.station-navigation-shell .map-action-controls .glass-btn {
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-detail-card {
|
||||
bottom: 46px;
|
||||
max-height: min(42dvh, 300px);
|
||||
padding: 12px;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-title-wrap h2 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-navigate-btn,
|
||||
.station-navigation-shell .detail-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-navigate-btn svg { width: 15px; height: 15px; }
|
||||
.station-navigation-shell .sidebar-operations { gap: 8px; }
|
||||
.station-navigation-shell .sidebar-card { padding: 10px 11px; border-radius: 14px; }
|
||||
.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) {
|
||||
.station-navigation-shell .header-left { width: calc(100% - 76px); }
|
||||
.station-navigation-shell .brand-block { flex-basis: 104px; min-width: 88px; }
|
||||
.station-navigation-shell .brand-logo-svg { max-width: 104px; }
|
||||
.station-navigation-shell .cockpit-title { font-size: 13px; }
|
||||
.station-navigation-shell .brand-divider { margin-left: -2px; }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const styles = fs.readFileSync(new URL('../styles.css', import.meta.url), 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
document: { addEventListener() {}, getElementById() { return null; } },
|
||||
window: {},
|
||||
pinyinPro: { pinyin(value) { return value === '广州合作站' ? 'guang zhou he zuo zhan' : value; } },
|
||||
navigator: {},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.runInNewContext(source, sandbox, { filename: 'app.js' });
|
||||
|
||||
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, 'gz'), 2);
|
||||
assert.equal(sandbox.stationSearchScore({ address: '广东省广州市黄埔区' }, '黄埔'), 3);
|
||||
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, '北京'), 0);
|
||||
assert.equal(sandbox.hasCoordinates({ longitude: 113.2, latitude: 23.1 }), true);
|
||||
assert.equal(sandbox.hasCoordinates({ longitude: 'bad', latitude: 23.1 }), false);
|
||||
vm.runInNewContext(`
|
||||
state.stations = [
|
||||
{ id: 'partner', name: '广州合作站', cooperative: true },
|
||||
{ id: 'external', name: '广州外部站', cooperative: false }
|
||||
];
|
||||
globalThis.allStationIds = filteredStations().map(station => station.id);
|
||||
state.stationFilter = 'partner';
|
||||
globalThis.partnerStationIds = filteredStations().map(station => station.id);
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.allStationIds.join(','), 'partner,external');
|
||||
assert.equal(sandbox.partnerStationIds.join(','), 'partner');
|
||||
vm.runInNewContext(`
|
||||
state.stations = [
|
||||
{ id: 'gd-hp', name: '黄埔合作站', province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, cooperative: true },
|
||||
{ id: 'gd-nh', name: '南海合作站', province: '广东省', city: '佛山市', district: '南海区', longitude: 113.1, latitude: 23.0, cooperative: true },
|
||||
{ id: 'zj-jx', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', district: '秀洲区', longitude: 120.7, latitude: 30.7, cooperative: true }
|
||||
];
|
||||
globalThis.hierarchy = [stationHierarchyLevel(4.8), stationHierarchyLevel(8), stationHierarchyLevel(13)];
|
||||
globalThis.provinceNodeCount = buildStationNodes(filteredStations(), 'province').length;
|
||||
globalThis.cityNodeCount = buildStationNodes(filteredStations(), 'city').length;
|
||||
globalThis.stationNodeCount = buildStationNodes(filteredStations(), 'station').length;
|
||||
globalThis.locationOptionCount = stationLocationOptions().length;
|
||||
globalThis.provinceOptionScope = stationLocationOptions().find(option => option.level === 'province' && option.province === '广东省');
|
||||
globalThis.cityOptionScope = stationLocationOptions().find(option => option.level === 'city' && option.city === '广州市');
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.hierarchy.join(','), 'province,city,station');
|
||||
assert.equal(sandbox.provinceNodeCount, 2);
|
||||
assert.equal(sandbox.cityNodeCount, 3);
|
||||
assert.equal(sandbox.stationNodeCount, 3);
|
||||
assert.equal(sandbox.locationOptionCount, 8);
|
||||
assert.equal(sandbox.provinceOptionScope.city, '');
|
||||
assert.equal(sandbox.provinceOptionScope.district, '');
|
||||
assert.equal(sandbox.cityOptionScope.district, '');
|
||||
vm.runInNewContext(`
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
|
||||
globalThis.regionProvinceLabels = regionPickerOptions().map(option => option.label).sort().join(',');
|
||||
state.regionPicker = { level: 'city', province: '广东省', city: '', search: '', draft: [], options: [] };
|
||||
globalThis.regionCityLabels = regionPickerOptions().map(option => option.label).sort().join(',');
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '嘉兴', draft: [], options: [] };
|
||||
globalThis.regionSearchLabels = regionPickerOptions().map(option => option.label).join(',');
|
||||
`, sandbox);
|
||||
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 = [];
|
||||
state.query = '广州';
|
||||
globalThis.queryMatches = stationMatchesQuery({ name: '广州合作站' });
|
||||
globalThis.queryMisses = stationMatchesQuery({ name: '杭州合作站' });
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.inferredDistrict, '海盐县');
|
||||
assert.equal(sandbox.queryMatches, true);
|
||||
assert.equal(sandbox.queryMisses, false);
|
||||
vm.runInNewContext(`
|
||||
state.query = '';
|
||||
state.locationFilters = [
|
||||
{ level: 'city', province: '广东省', city: '广州市', district: '', label: '广州' },
|
||||
{ level: 'city', province: '浙江省', city: '嘉兴市', district: '', label: '嘉兴' }
|
||||
];
|
||||
globalThis.multiLocationMatches = [
|
||||
stationMatchesLocation({ province: '广东省', city: '广州市', district: '白云区' }),
|
||||
stationMatchesLocation({ province: '浙江省', city: '嘉兴市', district: '海盐县' }),
|
||||
stationMatchesLocation({ province: '广东省', city: '深圳市', district: '南山区' })
|
||||
];
|
||||
`, 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\(/);
|
||||
assert.match(source, /stationSearchSuggestions/);
|
||||
assert.match(source, /handleStationSearchInput/);
|
||||
assert.match(source, /locationFilters/);
|
||||
assert.match(source, /renderLocationFilterChips/);
|
||||
assert.match(source, /openRegionPicker/);
|
||||
assert.match(source, /kpiTotal|kpiPartner/);
|
||||
const index = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
assert.match(index, /station-summary-strip/);
|
||||
assert.match(index, /station-status-card/);
|
||||
assert.equal((index.match(/id="statusTotal"/g) || []).length, 1);
|
||||
assert.equal((index.match(/id="mobileStatusTotal"/g) || []).length, 1);
|
||||
assert.match(source, /window\.setTimeout\(\(\) => \{ state\.query = value/);
|
||||
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, /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; \}/);
|
||||
assert.match(styles, /height: clamp\(430px, 58dvh, 540px\)/);
|
||||
assert.match(styles, /map-action-controls \.glass-btn:nth-child\(2\)/);
|
||||
console.log('station navigation app tests: ok');
|
||||
@@ -0,0 +1,79 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("station_navigation_server", Path(__file__).parents[1] / "server.py")
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
class StationNavigationTest(unittest.TestCase):
|
||||
def test_directory_is_a_positive_allowlist_without_hydrogen_data(self):
|
||||
source = {
|
||||
"status": "ok",
|
||||
"asOf": "2026-08-12 12:00:00",
|
||||
"summary": {"totalStations": 1, "cooperativeStations": 1},
|
||||
"stations": [{
|
||||
"id": "GD-1", "name": "广州合作站", "province": "广东省", "city": "广州市",
|
||||
"district": "黄埔区", "address": "开源大道1号", "longitude": 113.2, "latitude": 23.1,
|
||||
"cooperative": True,
|
||||
}],
|
||||
}
|
||||
with patch.object(server, "urlopen", return_value=io.BytesIO(json.dumps(source).encode("utf-8"))):
|
||||
result = server._load_station_directory()
|
||||
|
||||
self.assertEqual(result["summary"], {"totalStations": 1, "cooperativeStations": 1})
|
||||
station = result["stations"][0]
|
||||
self.assertEqual(station["name"], "广州合作站")
|
||||
self.assertEqual(station["province"], "广东省")
|
||||
self.assertEqual(server.VEHICLE_MAP_INTERNAL_BASE_URL, "http://127.0.0.1:20800")
|
||||
|
||||
def test_static_assets_are_fingerprinted(self):
|
||||
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
|
||||
|
||||
def test_static_assets_resolve_from_the_release_directory(self):
|
||||
self.assertIn('os.chdir(str(ROOT))', Path(__file__).parents[1].joinpath('server.py').read_text(encoding='utf-8'))
|
||||
|
||||
def test_default_cache_window_is_two_minutes(self):
|
||||
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
|
||||
|
||||
def test_cache_reuses_directory_snapshot_within_window(self):
|
||||
calls = []
|
||||
with patch.object(server, "_cache", {}):
|
||||
first = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 1})
|
||||
second = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 2})
|
||||
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
@@ -1,11 +1,11 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.5.0
|
||||
version: 1.7.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位。
|
||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||
servers:
|
||||
- url: /
|
||||
@@ -20,7 +20,7 @@ paths:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询车辆单日用氢量
|
||||
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。SUSPECT 计算结果保留用氢量并以 DATA_ANOMALY 输出,同时返回质量原因。
|
||||
operationId: queryDailyHydrogenConsumption
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
@@ -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,110 @@ 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: [合作方数据接口]
|
||||
summary: 查询授权车辆实时位置与状态
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
||||
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
|
||||
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
|
||||
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
|
||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||
operationId: queryRealtimeVehicles
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;没有实时记录的授权车辆以 NO_DATA 返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/hydrogen-stations/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询加氢站地图点位
|
||||
description: 只读返回资产管理数据库内具有有效经纬度的加氢站,可按省、市和合作状态筛选;无需车辆授权但要求有效 appKey。
|
||||
operationId: queryHydrogenStations
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v2/open-platform/apps:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
@@ -430,6 +534,78 @@ 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
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 2000
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||
HydrogenStationQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
province:
|
||||
type: string
|
||||
maxLength: 32
|
||||
city:
|
||||
type: string
|
||||
maxLength: 32
|
||||
cooperateOnly:
|
||||
type: boolean
|
||||
description: true仅合作站,false仅外部站,省略则返回全部
|
||||
HydrogenResult:
|
||||
type: object
|
||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||
@@ -443,7 +619,21 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;无数据时为 null
|
||||
description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null
|
||||
calculationPhase:
|
||||
type: string
|
||||
enum: [PRELIMINARY, FINAL]
|
||||
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
||||
algorithmVersion:
|
||||
type: string
|
||||
description: 氢耗计算算法版本
|
||||
qualityStatus:
|
||||
type: string
|
||||
enum: [OK, SUSPECT, NO_DATA]
|
||||
description: 氢耗计算质量状态
|
||||
qualityReason:
|
||||
type: string
|
||||
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
@@ -466,7 +656,7 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -482,6 +672,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:
|
||||
@@ -506,7 +701,7 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
|
||||
description: 当日所选协议终端累计总里程;GPS 日里程估算不会作为累计总里程,缺日时沿用此前最近有效值
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -521,11 +716,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'
|
||||
@@ -606,6 +807,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'
|
||||
@@ -614,6 +834,70 @@ 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, 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秒内上报 }
|
||||
activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 }
|
||||
motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/h;idle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 }
|
||||
locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 }
|
||||
status: { $ref: '#/components/schemas/DataStatus' }
|
||||
RealtimeVehicleQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/RealtimeVehicleResult' }
|
||||
HydrogenStation:
|
||||
type: object
|
||||
required: [id, name, longitude, latitude, cooperative]
|
||||
properties:
|
||||
id: { type: string, description: 站点ID使用字符串避免JavaScript整数精度损失 }
|
||||
name: { type: string }
|
||||
shortName: { type: string }
|
||||
address: { type: string }
|
||||
longitude: { type: number, format: double }
|
||||
latitude: { type: number, format: double }
|
||||
province: { type: string }
|
||||
city: { type: string }
|
||||
district: { type: string }
|
||||
cooperative: { type: boolean }
|
||||
HydrogenStationQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/HydrogenStation' }
|
||||
SuccessEnvelope:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
|
||||
@@ -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,9 @@ 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"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -59,6 +62,9 @@ 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)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminAppRoutes() {
|
||||
@@ -106,6 +112,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -114,7 +123,49 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
|
||||
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) {
|
||||
traceID := externalTraceID(r)
|
||||
var request RealtimeVehicleRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryRealtimeVehicles(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) hydrogenStations(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request HydrogenStationRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHydrogenStations(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) hydrogen(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -650,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 {
|
||||
@@ -725,5 +778,23 @@ 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: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: RealtimeVehicleQueryPath, Unit: "",
|
||||
},
|
||||
{
|
||||
Code: "hydrogen_station", Name: "加氢站地图点位",
|
||||
Description: "只读查询资产管理库中的加氢站名称、经纬度、行政区划和合作状态。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: HydrogenStationQueryPath, Unit: "座",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ 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"`
|
||||
QualityStatus string `json:"qualityStatus,omitempty"`
|
||||
QualityReason string `json:"qualityReason,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -60,6 +65,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 +78,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 +108,105 @@ 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"`
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
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"`
|
||||
}
|
||||
|
||||
type HydrogenStationRequest struct {
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
CooperateOnly *bool `json:"cooperateOnly,omitempty"`
|
||||
}
|
||||
|
||||
type HydrogenStation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"shortName,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Cooperative bool `json:"cooperative"`
|
||||
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 {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -184,6 +290,9 @@ type DailyHydrogen struct {
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
CalculationPhase string
|
||||
AlgorithmVersion string
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
@@ -210,6 +319,7 @@ type MileageSnapshot struct {
|
||||
type HydrogenObservation struct {
|
||||
VIN string
|
||||
Source string
|
||||
EventID string
|
||||
ObservedAt time.Time
|
||||
MassKg float64
|
||||
TankCapacityLiter float64
|
||||
@@ -217,6 +327,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 {
|
||||
@@ -244,8 +421,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,12 +138,182 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) {
|
||||
if r.tdengine == nil || r.tdDatabase == "" {
|
||||
return nil, errors.New("TDengine is not configured for stationary vehicle query")
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
return []StationaryLocationPoint{}, nil
|
||||
}
|
||||
// Use a small bounding box as TDengine's prefilter, then apply the exact
|
||||
// 5-metre great-circle calculation in the service before returning a match.
|
||||
latitudeDelta := radiusMeters / 111320.0
|
||||
longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180))
|
||||
quotedVINs := make([]string, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'")
|
||||
}
|
||||
startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''")
|
||||
endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''")
|
||||
query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` +
|
||||
` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` +
|
||||
` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` +
|
||||
` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) +
|
||||
` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) +
|
||||
` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) +
|
||||
` ORDER BY vin,ts ASC,protocol ASC`
|
||||
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
points := make([]StationaryLocationPoint, 0)
|
||||
for rows.Next() {
|
||||
var point StationaryLocationPoint
|
||||
var timestampMS int64
|
||||
if err := rows.Scan(×tampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
points = append(points, point)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
||||
if len(vins) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
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),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,
|
||||
CASE WHEN l.updated_at>=? THEN 0 ELSE 1 END,
|
||||
CASE l.protocol WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END,
|
||||
l.updated_at DESC,l.protocol ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
onlineThreshold := now.Add(-time.Minute)
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
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
|
||||
} else if point.Online && !selected.Online {
|
||||
// The selected source still follows the documented protocol priority, but
|
||||
// online means that any source for this VIN reported in the last minute.
|
||||
selected.Online = true
|
||||
out[point.VIN] = selected
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
where := []string{
|
||||
"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, 4)
|
||||
if request.Province != "" {
|
||||
where = append(where, "(n.province=? OR province_region.NAME=?)")
|
||||
args = append(args, request.Province, request.Province)
|
||||
}
|
||||
if request.City != "" {
|
||||
where = append(where, "(n.city=? OR city_region.NAME=?)")
|
||||
args = append(args, request.City, request.City)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
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 province_region.NAME,city_region.NAME,n.site_name,n.id
|
||||
LIMIT 2000`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
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, &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()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
||||
if len(vins) == 0 {
|
||||
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,quality_reason,
|
||||
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...)
|
||||
@@ -153,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.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
@@ -173,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
|
||||
@@ -184,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
|
||||
@@ -198,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)
|
||||
@@ -254,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
|
||||
@@ -271,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,58 @@ 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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(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.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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -34,10 +35,14 @@ 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
|
||||
|
||||
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
||||
@@ -48,6 +53,298 @@ 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
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"
|
||||
} else if item.Online {
|
||||
item.MotionStatus = "idle"
|
||||
}
|
||||
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
|
||||
item.LocationAvailable = true
|
||||
}
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
now := s.now().In(s.location)
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
request.Province = strings.TrimSpace(request.Province)
|
||||
request.City = strings.TrimSpace(request.City)
|
||||
if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 {
|
||||
return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest)
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error())
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
stations, err := s.repository.HydrogenStations(ctx, request)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "")
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
func validCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
@@ -94,10 +391,21 @@ 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
|
||||
item.QualityStatus = value.QualityStatus
|
||||
item.QualityReason = value.QualityReason
|
||||
switch {
|
||||
case strings.EqualFold(value.QualityStatus, "OK"):
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
case strings.EqualFold(value.QualityStatus, "SUSPECT"):
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusDataAnomaly
|
||||
}
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
@@ -128,7 +436,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)
|
||||
@@ -143,6 +456,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)
|
||||
}
|
||||
@@ -238,6 +553,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 {
|
||||
@@ -249,7 +565,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 {
|
||||
@@ -272,6 +593,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)
|
||||
}
|
||||
@@ -632,9 +956,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)
|
||||
}
|
||||
@@ -642,7 +969,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 {
|
||||
@@ -651,6 +978,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)
|
||||
@@ -686,6 +1016,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,8 +21,12 @@ 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
|
||||
createdPrefix string
|
||||
@@ -31,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) {
|
||||
@@ -62,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
|
||||
@@ -78,10 +88,153 @@ 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
|
||||
}
|
||||
func (f *fakeRepository) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
return f.stations, nil
|
||||
}
|
||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||
f.audits = append(f.audits, endpoint+":"+result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
"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}},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), key, "trace-v", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("missing realtime row should remain explicit: %#v", vehicles[1])
|
||||
}
|
||||
stations, err := service.QueryHydrogenStations(context.Background(), key, "trace-s", HydrogenStationRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stations) != 1 || stations[0].Name != "测试加氢站" {
|
||||
t.Fatalf("unexpected stations: %#v", stations)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
// The selected GB32960 point can be older while JT808/MQTT keeps the VIN online.
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, ObservedAt: now.Add(-2 * time.Minute), Online: true},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-any-source", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 1 || !vehicles[0].Online || vehicles[0].MotionStatus != "idle" {
|
||||
t.Fatalf("fresh alternate protocol should keep vehicle online: %#v", vehicles)
|
||||
}
|
||||
if vehicles[0].TimeDifferenceSeconds == nil || *vehicles[0].TimeDifferenceSeconds != 120 {
|
||||
t.Fatalf("selected source record time must remain auditable: %#v", vehicles[0])
|
||||
}
|
||||
}
|
||||
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
f.createdHash, f.createdPrefix = hash, prefix
|
||||
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
||||
@@ -108,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": {
|
||||
@@ -129,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)
|
||||
@@ -138,6 +294,32 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalHydrogenQueryPublishesSuspectCalculationAsDataAnomaly(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {
|
||||
VIN: "LTEST32960VIN0001", ConsumptionKg: 4.491, QualityStatus: "SUSPECT",
|
||||
QualityReason: "燃料电池未工作期间持续压降", CalculationPhase: "FINAL", AlgorithmVersion: trustedHydrogenAlgorithmVersion,
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 8, 26, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
||||
result, err := service.QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace-suspect", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345"}, Date: "2026-08-26",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].HydrogenConsumptionKg == nil || *result[0].HydrogenConsumptionKg != 4.491 || result[0].Status != StatusDataAnomaly || result[0].QualityStatus != "SUSPECT" || result[0].QualityReason == "" {
|
||||
t.Fatalf("suspect hydrogen result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
@@ -266,6 +448,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", "",
|
||||
0.0, 8.691, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.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, "", 0.0, stat.ConsumptionKg,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.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", "",
|
||||
0.0, 2.6, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.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, 0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.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", "refuel_amount_kg", "charge_count", "charge_energy_kwh", "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, 8.976, 1, 12.5, 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 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || 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,93 @@ 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"`
|
||||
PureElectricMileageKm *float64 `json:"pureElectricMileageKm,omitempty"`
|
||||
MixedMileageKm *float64 `json:"mixedMileageKm,omitempty"`
|
||||
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||
ChargeCount *int `json:"chargeCount,omitempty"`
|
||||
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
||||
RefuelCount *int `json:"refuelCount,omitempty"`
|
||||
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
||||
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
|
||||
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
|
||||
HydrogenQualityReason string `json:"hydrogenQualityReason,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"`
|
||||
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
||||
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, ` +
|
||||
@@ -556,7 +559,7 @@ LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK'
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
@@ -575,6 +578,20 @@ 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(h.pure_electric_mileage_km) AS pure_electric_mileage_km, ` +
|
||||
`MAX(h.mixed_mileage_km) AS mixed_mileage_km, ` +
|
||||
`MAX(h.battery_soc_delta_pct) AS battery_soc_delta_pct, ` +
|
||||
`MAX(h.charge_count) AS charge_count, ` +
|
||||
`MAX(h.charge_energy_kwh) AS charge_energy_kwh, ` +
|
||||
`MAX(h.refuel_count) AS refuel_count, ` +
|
||||
`MAX(h.refuel_amount_kg) AS refuel_amount_kg, ` +
|
||||
`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.quality_reason), '') AS hydrogen_quality_reason, ` +
|
||||
`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 +608,10 @@ 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, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
|
||||
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
||||
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), 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 +707,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 +723,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 +733,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,46 @@ 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, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount sql.NullFloat64
|
||||
var chargeCount, refuelCount sql.NullInt64
|
||||
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, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
|
||||
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &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.PureElectricMileageKm = nullableFloatPointer(pureElectric)
|
||||
row.MixedMileageKm = nullableFloatPointer(mixed)
|
||||
row.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
|
||||
if chargeCount.Valid {
|
||||
value := int(chargeCount.Int64)
|
||||
row.ChargeCount = &value
|
||||
}
|
||||
row.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
|
||||
if refuelCount.Valid {
|
||||
value := int(refuelCount.Int64)
|
||||
row.RefuelCount = &value
|
||||
}
|
||||
row.RefuelAmountKg = nullableFloatPointer(refuelAmount)
|
||||
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1075,11 +1205,69 @@ 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.refuel_amount_kg,h.charge_count,h.charge_energy_kwh,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 COLLATE utf8mb4_unicode_ci = h.vin COLLATE utf8mb4_unicode_ci
|
||||
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, refuelAmount, chargeEnergy 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, &refuelAmount, &result.ChargeCount, &chargeEnergy,
|
||||
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
|
||||
&result.AlgorithmVersion, ¶meterJSON, &evidenceJSON, &result.CalculatedAt,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return HydrogenDailyEvidence{}, false, nil
|
||||
}
|
||||
return HydrogenDailyEvidence{}, false, err
|
||||
}
|
||||
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
|
||||
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
|
||||
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
|
||||
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(
|
||||
|
||||
@@ -6,10 +6,52 @@ import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
columns := []string{
|
||||
"vin", "plate", "date", "start_mileage_km", "end_mileage_km", "daily_mileage_km", "pure_hydrogen_mileage_km",
|
||||
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
|
||||
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
|
||||
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
|
||||
"hydrogen_algorithm_version", "protocol",
|
||||
}
|
||||
mock.ExpectQuery("SELECT m.vin").
|
||||
WithArgs(20, 0).
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
|
||||
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
|
||||
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
|
||||
))
|
||||
|
||||
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("items=%#v", page.Items)
|
||||
}
|
||||
row := page.Items[0]
|
||||
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
|
||||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
|
||||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
|
||||
t.Fatalf("row=%#v", row)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
sql.Register("tdengine_missing_table_test", missingTableDriver{})
|
||||
sql.Register("ops_health_test", opsHealthDriver{})
|
||||
@@ -141,7 +183,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 +259,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)
|
||||
}
|
||||
@@ -485,9 +485,9 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
|
||||
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text)
|
||||
}
|
||||
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} {
|
||||
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status IN ('OK','SUSPECT')", "h.consumption_kg", "h.charge_energy_kwh", "h.refuel_amount_kg", "h.quality_reason"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text)
|
||||
t.Fatalf("daily mileage must return calculated hydrogen details, missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5404,11 +5586,58 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
result.Items[index].PureHydrogenMileageKm = 0
|
||||
result.Items[index].HydrogenConsumptionKg = nil
|
||||
result.Items[index].HydrogenConsumptionKgPer100Km = nil
|
||||
result.Items[index].HydrogenSOCBalancedKg = nil
|
||||
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
|
||||
result.Items[index].PureElectricMileageKm = nil
|
||||
result.Items[index].MixedMileageKm = nil
|
||||
result.Items[index].BatterySOCDeltaPct = nil
|
||||
result.Items[index].ChargeCount = nil
|
||||
result.Items[index].ChargeEnergyKWh = nil
|
||||
result.Items[index].RefuelCount = nil
|
||||
result.Items[index].RefuelAmountKg = nil
|
||||
result.Items[index].HydrogenEvidenceAvailable = false
|
||||
result.Items[index].HydrogenQualityStatus = ""
|
||||
result.Items[index].HydrogenQualityReason = ""
|
||||
result.Items[index].HydrogenAlgorithmVersion = ""
|
||||
}
|
||||
}
|
||||
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,13 +557,29 @@ 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) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
consumption, rate, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount := 3.1, 5.5, 32.5, 56.2, -2.0, 12.5, 8.976
|
||||
chargeCount, refuelCount := 1, 1
|
||||
return Page[DailyMileageRow]{Items: []DailyMileageRow{{
|
||||
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
|
||||
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
HydrogenSOCBalancedKg: &consumption, PureElectricMileageKm: &pureElectric, MixedMileageKm: &mixed,
|
||||
BatterySOCDeltaPct: &socDelta, ChargeCount: &chargeCount, ChargeEnergyKWh: &chargeEnergy,
|
||||
RefuelCount: &refuelCount, RefuelAmountKg: &refuelAmount, HydrogenEvidenceAvailable: true,
|
||||
HydrogenQualityStatus: "SUSPECT", HydrogenQualityReason: "疑似管路泄压", HydrogenAlgorithmVersion: "V3_5",
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
@@ -572,15 +590,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()}
|
||||
}
|
||||
@@ -599,7 +628,8 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil {
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
|
||||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
|
||||
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
summary, err := service.MileageStatistics(customer, query)
|
||||
@@ -622,6 +652,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 +694,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 +745,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 +1017,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 +1054,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",\n "qualityStatus": "OK",\n "qualityReason": ""` : `"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>实际选中的 GB32960、MQTT 或 JT808;NO_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;SUSPECT 结果也会返回数值" : "当日行驶里程,单位 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><tr><td><code>qualityStatus</code></td><td>string</td><td>计算质量:OK、SUSPECT 或 NO_DATA</td></tr><tr><td><code>qualityReason</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>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL、DATA_ANOMALY 或 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>核验半径,单位米;1–100,默认 5</td></tr><tr><td><code>plateNumbers</code></td><td>否</td><td>待核验车牌;省略或空数组时核验全部有效授权车辆</td></tr></tbody></table>
|
||||
<h2 id="请求示例">请求示例</h2><CodeBlock language="json" value={`{
|
||||
"startTime": "2026-08-06 10:00:00",
|
||||
"endTime": "2026-08-06 12:00:00",
|
||||
"longitude": 120.752312,
|
||||
"latitude": 30.746281,
|
||||
"radiusMeters": 8
|
||||
}`} />
|
||||
<h2 id="核验口径">核验口径</h2><p>使用 WGS-84/GPS 坐标,<code>radiusMeters</code> 可设为 1–100 米,省略时为 5 米;速度不超过 3 km/h。至少需要 2 条样本且停留不少于 60 秒。相邻样本间隔超过 10 分钟时视为不同停留。结果按 <code>matchScore</code> 降序,分数综合距离、速度、停留时长和样本数量,仅作为辅助核验,不替代加氢交易凭证。</p>
|
||||
<h2 id="响应字段">响应字段</h2><table className="docs-table"><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><code>stayStartTime</code> / <code>stayEndTime</code></td><td>定位证据覆盖的停留开始、结束时间</td></tr><tr><td><code>stayDurationSeconds</code> / <code>stayDurationMinutes</code></td><td>停留长度</td></tr><tr><td><code>matchScore</code></td><td>0–100,越高表示匹配越充分</td></tr><tr><td><code>averageDistanceMeters</code> / <code>maxDistanceMeters</code></td><td>与站点的距离证据</td></tr><tr><td><code>averageSpeedKmh</code> / <code>maxSpeedKmh</code></td><td>静止速度证据</td></tr><tr><td><code>matchedSamples</code> / <code>sourceProtocols</code></td><td>定位样本数量与来源协议</td></tr></tbody></table>
|
||||
</>;
|
||||
}
|
||||
|
||||
function VehicleAuthGuide() {
|
||||
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: ["状态定义", "统计口径"],
|
||||
|
||||
@@ -40,10 +40,50 @@ export const products: Product[] = [
|
||||
method: "POST",
|
||||
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: "车辆实时位置与状态",
|
||||
description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/realtime/query",
|
||||
unit: ""
|
||||
},
|
||||
{
|
||||
code: "hydrogen_station",
|
||||
name: "加氢站地图点位",
|
||||
description: "只读查询资产管理库加氢站的名称、经纬度、行政区划和合作状态。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/hydrogen-stations/query",
|
||||
unit: "座"
|
||||
}
|
||||
];
|
||||
|
||||
export const curlExample = (product: Product) => {
|
||||
if (product.code === "realtime_vehicle") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{}'`;
|
||||
if (product.code === "hydrogen_station") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{"province":"浙江省"}'`;
|
||||
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
@@ -53,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,48 @@ export interface DailyMileageRow {
|
||||
pureHydrogenMileageKm?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
hydrogenSocBalancedKg?: number | null;
|
||||
hydrogenSocBalancedKgPer100Km?: number | null;
|
||||
pureElectricMileageKm?: number | null;
|
||||
mixedMileageKm?: number | null;
|
||||
batterySocDeltaPct?: number | null;
|
||||
chargeCount?: number | null;
|
||||
chargeEnergyKWh?: number | null;
|
||||
refuelCount?: number | null;
|
||||
refuelAmountKg?: number | null;
|
||||
hydrogenEvidenceAvailable?: boolean;
|
||||
hydrogenQualityStatus?: string;
|
||||
hydrogenQualityReason?: 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; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: 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,11 +10,11 @@ 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' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
|
||||
],
|
||||
sources: [
|
||||
@@ -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', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
||||
]);
|
||||
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', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, '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: 19 } });
|
||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
@@ -102,6 +106,16 @@ test('terminates the workbook worker when an active export is cancelled', async
|
||||
pureHydrogenMileageKm: undefined,
|
||||
hydrogenConsumptionKg: undefined,
|
||||
hydrogenConsumptionKgPer100Km: undefined,
|
||||
hydrogenSocBalancedKg: undefined,
|
||||
pureElectricMileageKm: undefined,
|
||||
mixedMileageKm: undefined,
|
||||
batterySocDeltaPct: undefined,
|
||||
chargeCount: undefined,
|
||||
chargeEnergyKWh: undefined,
|
||||
refuelCount: undefined,
|
||||
refuelAmountKg: undefined,
|
||||
hydrogenQualityStatus: undefined,
|
||||
hydrogenQualityReason: undefined,
|
||||
source: 'GB32960'
|
||||
}]);
|
||||
controller.abort();
|
||||
|
||||
@@ -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'>>;
|
||||
|
||||
@@ -90,6 +90,16 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
||||
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
|
||||
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
|
||||
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
|
||||
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
|
||||
pureElectricMileageKm: row.pureElectricMileageKm,
|
||||
mixedMileageKm: row.mixedMileageKm,
|
||||
batterySocDeltaPct: row.batterySocDeltaPct,
|
||||
chargeCount: row.chargeCount,
|
||||
chargeEnergyKWh: row.chargeEnergyKWh,
|
||||
refuelCount: row.refuelCount,
|
||||
refuelAmountKg: row.refuelAmountKg,
|
||||
hydrogenQualityStatus: row.hydrogenQualityStatus,
|
||||
hydrogenQualityReason: row.hydrogenQualityReason,
|
||||
source: row.source
|
||||
}))
|
||||
});
|
||||
|
||||
@@ -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,12 @@ 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)', '来源']);
|
||||
const hydrogenHeaders = [
|
||||
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
|
||||
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
|
||||
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
||||
];
|
||||
hydrogenSheet.addRow(hydrogenHeaders);
|
||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||
hydrogenHeader.height = 30;
|
||||
hydrogenHeader.eachCell((cell) => {
|
||||
@@ -144,33 +151,46 @@ 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,
|
||||
value.dailyMileageKm,
|
||||
value.pureHydrogenMileageKm ?? null,
|
||||
value.hydrogenConsumptionKg ?? null,
|
||||
vehicle?.brandName || '待维护',
|
||||
vehicle?.modelName || '待维护',
|
||||
value.hydrogenConsumptionKgPer100Km ?? null,
|
||||
value.hydrogenQualityStatus || 'NO_DATA',
|
||||
value.dailyMileageKm,
|
||||
value.pureElectricMileageKm ?? null,
|
||||
value.mixedMileageKm ?? null,
|
||||
value.batterySocDeltaPct ?? null,
|
||||
value.chargeCount ?? null,
|
||||
value.chargeEnergyKWh ?? null,
|
||||
value.refuelCount ?? null,
|
||||
value.refuelAmountKg ?? null,
|
||||
value.hydrogenSocBalancedKg ?? null,
|
||||
value.hydrogenQualityReason || '',
|
||||
value.hydrogenConsumptionKg ?? null,
|
||||
value.source ?? ''
|
||||
]);
|
||||
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 <= 16 ? '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 ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
|
||||
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
|
||||
});
|
||||
});
|
||||
[13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
||||
[13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 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: hydrogenHeaders.length } };
|
||||
}
|
||||
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,103 @@ 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.refuelAmountKg == null ? '—' : `${evidenceNumber(data.refuelAmountKg)} kg`}</dd></div>
|
||||
<div><dt>充电量</dt><dd>{data.chargeEnergyKWh == null ? '—' : `${evidenceNumber(data.chargeEnergyKWh)} kWh`}</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 +948,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 +1029,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 +1064,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 +1143,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 +1163,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 +1213,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 +1356,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;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE vehicle_open_daily_energy
|
||||
ADD COLUMN refuel_amount_kg DECIMAL(18,3) NULL AFTER refuel_count,
|
||||
ADD COLUMN charge_energy_kwh DECIMAL(18,3) NULL AFTER charge_count;
|
||||
@@ -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能量守恒。
|
||||
@@ -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 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围。
|
||||
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比、宇通 MQTT 剩余氢量或百公里氢耗推算质量;
|
||||
- `vehicle-stat-writer` 启动时及每六小时从 `ln_asset_management.vehicle_info → vehicle_model` 同步车型参数:`tank_capacity` 按 VIN 投影到 `vehicle_hydrogen_tank_capacity`,`battery_capacity` 按 VIN 投影到 `vehicle_hydrogen_energy_parameter`;
|
||||
- 额定电量按车辆关联的具体车型读取,不按吨位写死;后续生效日期更晚的人工业务参数仍可覆盖自动同步基线;
|
||||
- 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量;
|
||||
- 压力法使用 NIST 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围;
|
||||
- GB32960 上报的是“最高氢压”和“最高氢温”,两者不保证来自同一探头,也不保证代表静置平衡后的储氢瓶状态。因此压力法结果是车载信号估算值,不能用加氢站计量值直接反标公式系数。
|
||||
|
||||
每个有效样本在同一个 MySQL 事务中更新
|
||||
`vehicle_open_hydrogen_stream_state`,并投影到
|
||||
当天准实时数据由 `vehicle-stat-writer` 消费 GB32960 fields Kafka 流。每个有效样本在同一个 MySQL 事务中更新
|
||||
`vehicle_open_hydrogen_segment_stream_state`,并投影到
|
||||
`vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按
|
||||
`VIN + 日期 + source_endpoint` 保存,每条消息只做常数次数据库操作,不扫描当天历史数据。
|
||||
`VIN + 日期` 保存并合并全部连接端点;每条消息只做常数次数据库操作,不扫描当天历史数据。
|
||||
|
||||
统计规则:
|
||||
|
||||
- 先按 VIN 和 `source_endpoint` 分别统计,避免多个采集源的序列交错产生虚假变化;
|
||||
- 同一 VIN 优先采用质量正常、样本数量更多的数据源,并记录入库;
|
||||
- 每次加氢之间维护质量低水位,只累计新的有效最低质量,避免压力温度波动反复计量;
|
||||
- 已结束日期先按 VIN 合并全部 `source_endpoint`,同一事件时间只保留一条可信样本;连接端口仅用于追踪,不再作为互斥统计来源;
|
||||
- 只有相邻两条样本都确认燃料电池处于工作状态时,才纳入工作段;优先采用广东扩展 `engine_work_state=2`,缺失时以燃料电池电流大于 `1 A` 作为回退;完全没有工作区间的日期标记为 `NO_DATA`;
|
||||
- 加氢、超过 5 分钟的数据断点、燃料电池停机和异常跳变会切分工作段;每段开始与结束各取 5 条样本的氢质量中位数做差,再累加为当日用氢量;
|
||||
- 每个可计算工作段至少需要 10 条有效样本;段首尾差值未超过动态噪声阈值时不累计;
|
||||
- 燃料电池未工作时,下降的压力质量只更新防重复计算的低水位,不降低对外剩余氢量;压力或质量恢复上升后再更新剩余量;
|
||||
- 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`;
|
||||
- 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位;
|
||||
- 相邻样本在 60 秒内无工作耗氢却恢复至少 `8 MPa` 时,按压力信号恢复处理,重置低水位但不增加加氢次数;
|
||||
- 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`;
|
||||
- 至少两条有效样本才标记 `OK`;
|
||||
- 对外只返回 `quality_status=OK` 的结果。
|
||||
|
||||
当天接口结果是准实时的进行中累计值。乱序、重复和迟到样本不会覆盖更新的状态;跨日迟到数据由
|
||||
`open-platform-stat` 夜间批处理兜底。批处理默认重算昨天和前天,以修正迟到上报,并作为已结束日期的权威结果。
|
||||
分段计算由流式状态机直接执行:每车仅保留当前段的首 5 帧、尾 5 帧、低水位、上一帧和事件时间水位,状态不会随当天帧数增长。水位只覆盖当前自然日 `00:00:00–24:00:00`,不向前后日期扩窗,也不借用次日帧;早于或等于当前事件时间水位的延迟帧直接忽略。流式结果采用与日终相同的工作段、中位数、加氢和异常跳变规则;已结束日期仍由 `open-platform-stat` 重算,作为最终对账结果。
|
||||
|
||||
历史重放只接收事件时间和接收时间同时落在统计日内的 GB32960 帧。每个统计日先读取有数据且配置了氢瓶容积的 VIN,再在同一进程内按单车单日并行查询;默认 4 个工作线程,全部车辆成功后才一次性事务替换当天结果。单车帧按 `event_time` 排序并单遍去重;JSON 只读取压力、温度和工作状态字段,不展开整帧,从而减少全车排序与反序列化开销。可通过 `-vin-workers` 或 `OPEN_STAT_VIN_WORKERS` 调整工作线程数。
|
||||
|
||||
```bash
|
||||
/opt/lingniu-vehicle-platform/current/open-platform-stat \
|
||||
@@ -380,3 +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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user