Compare commits
7
Commits
861e0bf207
...
e402eb5e60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e402eb5e60 | ||
|
|
a26179c8fe | ||
|
|
e7ee8583aa | ||
|
|
c6875fbb98 | ||
|
|
8759668d39 | ||
|
|
4201878c35 | ||
|
|
6fceb0f5b4 |
@@ -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` 继续兼容。
|
三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时,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` 或混入分母。
|
内部里程统计会关联 `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` 对指定日期窗口执行正式回算;扫描按事件时间顺序读取原始报文,并使用与实时统计完全相同的模式判定和相邻里程规则。
|
存量日期需要用原始报文扫描方式回算纯氢里程,因为默认 `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 天,覆盖上游晚到或断传后恢复的总里程字段:
|
生产定时补算使用 systemd timer,默认每天 01:30 补算昨天往前 3 天,覆盖上游晚到或断传后恢复的总里程字段:
|
||||||
|
|
||||||
```bash
|
```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 {
|
} else {
|
||||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
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 {
|
if cfg.NormalizePlatformSourcesOnStart {
|
||||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||||
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
|
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)
|
logger.Warn("hydrogen tank capacity sync failed; retaining current cache", "source_schema", cfg.HydrogenCapacitySourceSchema, "error", err)
|
||||||
return
|
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)
|
loaded, err := writer.ReloadHydrogenTankCapacities(syncCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "cache_error"})
|
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.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
|
||||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
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)
|
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)
|
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
|
DateFrom string
|
||||||
DateTo string
|
DateTo string
|
||||||
Protocols []envelope.Protocol
|
Protocols []envelope.Protocol
|
||||||
|
VINs []string
|
||||||
Method string
|
Method string
|
||||||
Limit int
|
Limit int
|
||||||
DryRun bool
|
DryRun bool
|
||||||
@@ -39,6 +40,8 @@ type config struct {
|
|||||||
GPSFallback bool
|
GPSFallback bool
|
||||||
ProgressEvery int64
|
ProgressEvery int64
|
||||||
BaselineLookback int
|
BaselineLookback int
|
||||||
|
ListHydrogenVINs bool
|
||||||
|
ListActiveVINs bool
|
||||||
Location *time.Location
|
Location *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +120,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
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)
|
td, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("open tdengine", err)
|
fail("open tdengine", err)
|
||||||
@@ -125,14 +143,11 @@ func main() {
|
|||||||
if err := td.PingContext(ctx); err != nil {
|
if err := td.PingContext(ctx); err != nil {
|
||||||
fail("ping tdengine", err)
|
fail("ping tdengine", err)
|
||||||
}
|
}
|
||||||
|
if cfg.ListActiveVINs {
|
||||||
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
|
if err := printActiveFrameVINs(ctx, td, cfg); err != nil {
|
||||||
if err != nil {
|
fail("list active frame vins", err)
|
||||||
fail("open mysql", err)
|
}
|
||||||
}
|
return
|
||||||
defer mysqlDB.Close()
|
|
||||||
if err := mysqlDB.PingContext(ctx); err != nil {
|
|
||||||
fail("ping mysql", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
|
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
|
||||||
@@ -516,10 +531,11 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
|
|||||||
currentRow.TotalKM = previousRow.TotalKM
|
currentRow.TotalKM = previousRow.TotalKM
|
||||||
currentSources[i] = currentRow
|
currentSources[i] = currentRow
|
||||||
}
|
}
|
||||||
|
usesPreviousDayBoundary := hasPrevious && stats.IsUsableDailyMileageBoundary(date, previousRow.TS, currentRow.FirstTS.Location())
|
||||||
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
||||||
if cfg.StationaryCarry {
|
if cfg.StationaryCarry {
|
||||||
agg.QualityReason = stats.QualityReasonStationaryCarry
|
agg.QualityReason = stats.QualityReasonStationaryCarry
|
||||||
} else if hasPrevious {
|
} else if usesPreviousDayBoundary {
|
||||||
agg.QualityReason = "realtime_location_fallback_historical_baseline"
|
agg.QualityReason = "realtime_location_fallback_historical_baseline"
|
||||||
} else {
|
} else {
|
||||||
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
|
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
|
firstEventTime := current.FirstTS
|
||||||
qualityStatus := stats.QualityOK
|
qualityStatus := stats.QualityOK
|
||||||
qualityReason := stats.QualityReasonCurrentDayFirst
|
qualityReason := stats.QualityReasonCurrentDayFirst
|
||||||
if hasPrevious {
|
if hasPrevious && stats.IsUsableDailyMileageBoundary(date, previous.TS, current.FirstTS.Location()) {
|
||||||
firstKM = previous.TotalKM
|
firstKM = previous.TotalKM
|
||||||
firstEventTime = previous.TS
|
firstEventTime = previous.TS
|
||||||
qualityStatus = stats.QualityOK
|
qualityStatus = stats.QualityOK
|
||||||
@@ -1295,10 +1311,32 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
|
|||||||
}
|
}
|
||||||
fields[key] = value
|
fields[key] = value
|
||||||
}
|
}
|
||||||
|
promoteVehicleRunningModeForStats(protocol, fields, text)
|
||||||
promoteFuelCellWorkModeForStats(protocol, fields, text)
|
promoteFuelCellWorkModeForStats(protocol, fields, text)
|
||||||
return fields
|
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) {
|
func promoteFuelCellWorkModeForStats(protocol envelope.Protocol, fields map[string]any, text string) {
|
||||||
if fields == nil {
|
if fields == nil {
|
||||||
return
|
return
|
||||||
@@ -1383,6 +1421,7 @@ func loadConfig() (config, error) {
|
|||||||
DateFrom: dateFrom,
|
DateFrom: dateFrom,
|
||||||
DateTo: dateTo,
|
DateTo: dateTo,
|
||||||
Protocols: protocols,
|
Protocols: protocols,
|
||||||
|
VINs: parseVINs(env("BACKFILL_VINS", "")),
|
||||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
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)),
|
GPSFallback: envBool("BACKFILL_GPS_FALLBACK", envBool("BACKFILL_JT808_GPS_FALLBACK", true)),
|
||||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||||
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
|
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
|
||||||
|
ListHydrogenVINs: envBool("BACKFILL_LIST_HYDROGEN_VINS", false),
|
||||||
|
ListActiveVINs: envBool("BACKFILL_LIST_ACTIVE_VINS", false),
|
||||||
Location: loc,
|
Location: loc,
|
||||||
}, nil
|
}, 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) {
|
func resolveBackfillDateRange(now time.Time, loc *time.Location) (string, string) {
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
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, ",")+")")
|
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())
|
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
|
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
|
FROM %s.raw_frames
|
||||||
@@ -1449,10 +1514,61 @@ WHERE %s
|
|||||||
if cfg.Limit > 0 {
|
if cfg.Limit > 0 {
|
||||||
sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit)
|
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)
|
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 {
|
func backfillTimePredicates(cfg config, eventDateFrom string, eventDateToExclusive string) []string {
|
||||||
where := make([]string, 0, 4)
|
where := make([]string, 0, 4)
|
||||||
if !cfg.EventTimeFullScan {
|
if !cfg.EventTimeFullScan {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -189,7 +190,7 @@ func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
func TestAggregateFromDailySourceUsesCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
|
||||||
current := dailySourceLast{
|
current := dailySourceLast{
|
||||||
VIN: "LMRKH9AC2R1004087",
|
VIN: "LMRKH9AC2R1004087",
|
||||||
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
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)
|
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)
|
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)
|
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)
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||||
}
|
}
|
||||||
if agg.Count != 15 {
|
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)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
current := dailySourceLast{
|
current := dailySourceLast{
|
||||||
VIN: "LNXNEGRR6SR319464",
|
VIN: "LNXNEGRR6SR319464",
|
||||||
@@ -248,10 +249,10 @@ func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
|
|||||||
|
|
||||||
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
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)
|
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)
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||||
}
|
}
|
||||||
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
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()
|
tdDB, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -333,11 +334,11 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.
|
|||||||
if agg == nil {
|
if agg == nil {
|
||||||
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
||||||
}
|
}
|
||||||
if agg.FirstKM != 100 || agg.LatestKM != 120 {
|
if agg.FirstKM != 120 || agg.LatestKM != 120 {
|
||||||
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
|
t.Fatalf("km range = %v -> %v, want 120 -> 120", agg.FirstKM, agg.LatestKM)
|
||||||
}
|
}
|
||||||
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
|
if !agg.FirstEventTime.Equal(dayThreeTS) || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
||||||
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
|
t.Fatalf("baseline = %v reason=%q, want day-three current-day baseline", agg.FirstEventTime, agg.QualityReason)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
t.Fatalf("sql expectations: %v", err)
|
||||||
@@ -601,10 +602,10 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes
|
|||||||
if agg == nil {
|
if agg == nil {
|
||||||
t.Fatal("missing realtime-location fallback aggregate")
|
t.Fatal("missing realtime-location fallback aggregate")
|
||||||
}
|
}
|
||||||
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
|
if agg.FirstKM != 120 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayThreeTS) {
|
||||||
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
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)
|
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||||
}
|
}
|
||||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
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) {
|
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
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)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
base := stats.MetricSample{
|
base := stats.MetricSample{
|
||||||
VIN: "LTEST000000000001",
|
VIN: "LTEST000000000001",
|
||||||
@@ -1061,8 +1080,8 @@ func TestAddSamplesBackfillsOnlyContinuousPureHydrogenIntervals(t *testing.T) {
|
|||||||
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
|
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
|
||||||
}
|
}
|
||||||
for _, agg := range aggregates {
|
for _, agg := range aggregates {
|
||||||
if agg.PureHydrogenMileageKM != 5 {
|
if agg.PureHydrogenMileageKM != 7 {
|
||||||
t.Fatalf("pure-hydrogen mileage = %v, want 5", agg.PureHydrogenMileageKM)
|
t.Fatalf("pure-hydrogen mileage = %v, want 7", agg.PureHydrogenMileageKM)
|
||||||
}
|
}
|
||||||
if agg.PureHydrogenSampleCount != 2 {
|
if agg.PureHydrogenSampleCount != 2 {
|
||||||
t.Fatalf("pure-hydrogen sample count = %d, want 2", agg.PureHydrogenSampleCount)
|
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) {
|
func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
|
||||||
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
|
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
|||||||
@@ -2786,7 +2786,7 @@ func findingsForTopicFlow(parsedByService map[string]parsedMetricSet, thresholds
|
|||||||
var findings []string
|
var findings []string
|
||||||
findings = append(findings, findingsForGatewayFieldFlow(parsedByService["gateway"], thresholds)...)
|
findings = append(findings, findingsForGatewayFieldFlow(parsedByService["gateway"], thresholds)...)
|
||||||
findings = append(findings, findingsForGatewayFieldCounts(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, findingsForGatewayToFastWriterFlow(parsedByService["gateway"], parsedByService["fast-writer"], thresholds)...)
|
||||||
findings = append(findings, findingsForBridgeRawFanout(parsedByService["bridge"], parsedByService["history"], parsedByService["realtime"], thresholds)...)
|
findings = append(findings, findingsForBridgeRawFanout(parsedByService["bridge"], parsedByService["history"], parsedByService["realtime"], thresholds)...)
|
||||||
findings = append(findings, findingsForBridgeToStatFieldFlow(parsedByService["bridge"], parsedByService["stat"], thresholds)...)
|
findings = append(findings, findingsForBridgeToStatFieldFlow(parsedByService["bridge"], parsedByService["stat"], thresholds)...)
|
||||||
@@ -3045,12 +3045,13 @@ type gatewayPublishGroup struct {
|
|||||||
count float64
|
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 {
|
if thresholds.GatewayBridgeMinPublish <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
gatewayPublishes := gatewayPublishGroups(gateway.samples)
|
gatewayPublishes := gatewayPublishGroups(gateway.samples)
|
||||||
bridgeWrites := metricValuesByTopic(bridge.samples, "vehicle_bridge_kafka_writes_total", "ok", "vehicle.")
|
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
|
var findings []string
|
||||||
for _, group := range gatewayPublishes {
|
for _, group := range gatewayPublishes {
|
||||||
if group.count < thresholds.GatewayBridgeMinPublish {
|
if group.count < thresholds.GatewayBridgeMinPublish {
|
||||||
@@ -3066,7 +3067,7 @@ func findingsForGatewayToBridgeFlow(gateway parsedMetricSet, bridge parsedMetric
|
|||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if bridgeWrites[topic] <= 0 {
|
if bridgeWrites[topic] <= 0 && (group.kind != "fields" || projectorWrites[topic] <= 0) {
|
||||||
findings = append(findings, fmt.Sprintf(
|
findings = append(findings, fmt.Sprintf(
|
||||||
"bridge writes missing for gateway %s publish protocol %s: gateway ok %.0f, expected topic %s, bridge writes ok %.0f",
|
"bridge writes missing for gateway %s publish protocol %s: gateway ok %.0f, expected topic %s, bridge writes ok %.0f",
|
||||||
group.kind,
|
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) {
|
func TestEvaluateSkipsGatewayBridgeFlowWhenSamplesAreLow(t *testing.T) {
|
||||||
report := EvaluateWithThresholds(map[string]string{
|
report := EvaluateWithThresholds(map[string]string{
|
||||||
"gateway": `vehicle_service_info{service="vehicle-gateway"} 1
|
"gateway": `vehicle_service_info{service="vehicle-gateway"} 1
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FieldSpeedKMH = "speed_kmh"
|
FieldSpeedKMH = "speed_kmh"
|
||||||
FieldTotalMileageKM = "total_mileage_km"
|
FieldTotalMileageKM = "total_mileage_km"
|
||||||
FieldLongitude = "longitude"
|
FieldLongitude = "longitude"
|
||||||
FieldLatitude = "latitude"
|
FieldLatitude = "latitude"
|
||||||
FieldSOCPercent = "soc_percent"
|
FieldSOCPercent = "soc_percent"
|
||||||
FieldFuelCellWorkMode = "fuel_cell_work_mode"
|
FieldVehicleRunningMode = "running_mode"
|
||||||
|
FieldFuelCellWorkMode = "fuel_cell_work_mode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FrameEnvelope struct {
|
type FrameEnvelope struct {
|
||||||
|
|||||||
@@ -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) {
|
func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, cacheStatus string, err error) {
|
||||||
phone = normalizePhone(phone)
|
phone = normalizePhone(phone)
|
||||||
now := time.Now()
|
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
|
var stale registrationCacheEntry
|
||||||
hasStale := false
|
hasStale := false
|
||||||
r.lookupMu.Lock()
|
r.lookupMu.Lock()
|
||||||
@@ -659,6 +666,11 @@ func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone strin
|
|||||||
return entry.vin, entry.deviceID, entry.plate, "", nil
|
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) {
|
func addIdentityMetadata(parsed map[string]any, source string, value string) {
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
return
|
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) {
|
func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) {
|
||||||
db, mock := newMockDB(t)
|
db, mock := newMockDB(t)
|
||||||
defer db.Close()
|
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})
|
units = append(units, map[string]any{"type": "0x01", "name": "vehicle", "value": unit})
|
||||||
fields["vehicle_status"] = unit["vehicle_status"]
|
fields["vehicle_status"] = unit["vehicle_status"]
|
||||||
fields["charge_status"] = unit["charge_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.FieldSpeedKMH] = unit["speed_kmh"]
|
||||||
fields[envelope.FieldTotalMileageKM] = unit["total_mileage_km"]
|
fields[envelope.FieldTotalMileageKM] = unit["total_mileage_km"]
|
||||||
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
|
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
|
|||||||
t.Fatalf("vin = %q", env.VIN)
|
t.Fatalf("vin = %q", env.VIN)
|
||||||
}
|
}
|
||||||
assertFloatField(t, env, envelope.FieldTotalMileageKM, 53490.9)
|
assertFloatField(t, env, envelope.FieldTotalMileageKM, 53490.9)
|
||||||
|
assertIntField(t, env, envelope.FieldVehicleRunningMode, 1)
|
||||||
assertIntField(t, env, envelope.FieldFuelCellWorkMode, 2)
|
assertIntField(t, env, envelope.FieldFuelCellWorkMode, 2)
|
||||||
assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8)
|
assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8)
|
||||||
assertFloatField(t, env, envelope.FieldLongitude, 120.800326)
|
assertFloatField(t, env, envelope.FieldLongitude, 120.800326)
|
||||||
|
|||||||
@@ -30,31 +30,32 @@ type Execer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
exec Execer
|
exec Execer
|
||||||
query Queryer
|
query Queryer
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
sourceTouchInterval time.Duration
|
sourceTouchInterval time.Duration
|
||||||
projectionInterval time.Duration
|
projectionInterval time.Duration
|
||||||
cacheRetention time.Duration
|
cacheRetention time.Duration
|
||||||
cacheCleanupInterval time.Duration
|
cacheCleanupInterval time.Duration
|
||||||
baselineMissTTL time.Duration
|
baselineMissTTL time.Duration
|
||||||
baselineHitTTL time.Duration
|
baselineHitTTL time.Duration
|
||||||
maxCacheEntries int
|
maxCacheEntries int
|
||||||
lastCacheCleanup time.Time
|
lastCacheCleanup time.Time
|
||||||
lastCacheCleanupStats cacheCleanupStats
|
lastCacheCleanupStats cacheCleanupStats
|
||||||
cacheEvictions cacheCleanupStats
|
cacheEvictions cacheCleanupStats
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastTotalMileage map[string]float64
|
lastTotalMileage map[string]float64
|
||||||
lastSourceSeen map[string]time.Time
|
lastSourceSeen map[string]time.Time
|
||||||
lastProjection map[string]projectionCacheEntry
|
lastProjection map[string]projectionCacheEntry
|
||||||
pendingProjection map[string]pendingProjectionEntry
|
pendingProjection map[string]pendingProjectionEntry
|
||||||
baselineCache map[string]sourceBaselineCacheEntry
|
baselineCache map[string]sourceBaselineCacheEntry
|
||||||
mileageKeysByPrefix map[string]map[string]struct{}
|
mileageKeysByPrefix map[string]map[string]struct{}
|
||||||
projectionKeysByPrefix map[string]map[string]struct{}
|
projectionKeysByPrefix map[string]map[string]struct{}
|
||||||
baselineKeysByPrefix map[string]map[string]struct{}
|
baselineKeysByPrefix map[string]map[string]struct{}
|
||||||
lastGPSAccumulation map[string]time.Time
|
lastGPSAccumulation map[string]time.Time
|
||||||
hydrogenTankCapacities map[string]float64
|
hydrogenTankCapacities map[string]float64
|
||||||
gpsAccumulationInterval time.Duration
|
hydrogenEnergyParameters map[string]HydrogenRealtimeParameters
|
||||||
|
gpsAccumulationInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetricSample struct {
|
type MetricSample struct {
|
||||||
@@ -151,26 +152,27 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
|||||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
}
|
}
|
||||||
writer := &Writer{
|
writer := &Writer{
|
||||||
exec: exec,
|
exec: exec,
|
||||||
loc: loc,
|
loc: loc,
|
||||||
sourceTouchInterval: time.Minute,
|
sourceTouchInterval: time.Minute,
|
||||||
projectionInterval: 15 * time.Second,
|
projectionInterval: 15 * time.Second,
|
||||||
cacheRetention: defaultCacheRetention,
|
cacheRetention: defaultCacheRetention,
|
||||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||||
baselineMissTTL: defaultBaselineMissTTL,
|
baselineMissTTL: defaultBaselineMissTTL,
|
||||||
baselineHitTTL: defaultBaselineHitTTL,
|
baselineHitTTL: defaultBaselineHitTTL,
|
||||||
maxCacheEntries: defaultMaxCacheEntries,
|
maxCacheEntries: defaultMaxCacheEntries,
|
||||||
lastTotalMileage: map[string]float64{},
|
lastTotalMileage: map[string]float64{},
|
||||||
lastSourceSeen: map[string]time.Time{},
|
lastSourceSeen: map[string]time.Time{},
|
||||||
lastProjection: map[string]projectionCacheEntry{},
|
lastProjection: map[string]projectionCacheEntry{},
|
||||||
pendingProjection: map[string]pendingProjectionEntry{},
|
pendingProjection: map[string]pendingProjectionEntry{},
|
||||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||||
lastGPSAccumulation: map[string]time.Time{},
|
lastGPSAccumulation: map[string]time.Time{},
|
||||||
hydrogenTankCapacities: map[string]float64{},
|
hydrogenTankCapacities: map[string]float64{},
|
||||||
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{},
|
||||||
|
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
||||||
}
|
}
|
||||||
if query, ok := exec.(Queryer); ok {
|
if query, ok := exec.(Queryer); ok {
|
||||||
writer.query = query
|
writer.query = query
|
||||||
@@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
|
|||||||
return capacity, ok
|
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) {
|
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -302,7 +323,9 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
|
|||||||
DailyMileageSourceTableSQL,
|
DailyMileageSourceTableSQL,
|
||||||
DailyMileageTableSQL,
|
DailyMileageTableSQL,
|
||||||
HydrogenTankCapacityTableSQL,
|
HydrogenTankCapacityTableSQL,
|
||||||
|
HydrogenEnergyParameterTableSQL,
|
||||||
HydrogenStreamStateTableSQL,
|
HydrogenStreamStateTableSQL,
|
||||||
|
HydrogenSegmentStreamStateTableSQL,
|
||||||
} {
|
} {
|
||||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -313,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, statement := range HydrogenRealtimeAlterSQL {
|
||||||
|
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
|||||||
w.maybeCleanupCaches(seenAt)
|
w.maybeCleanupCaches(seenAt)
|
||||||
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
||||||
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN)
|
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 {
|
if err != nil {
|
||||||
return result, err
|
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 {
|
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
|
||||||
return result, err
|
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)
|
projectDaily := w.shouldProjectDailyMileage(sample)
|
||||||
if projectDaily {
|
if projectDaily {
|
||||||
@@ -868,6 +906,16 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !found {
|
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.
|
// If no earlier odometer exists at all, use the first current-day sample.
|
||||||
// Later samples retain that boundary through the in-memory baseline cache.
|
// Later samples retain that boundary through the in-memory baseline cache.
|
||||||
candidate.DailyKM = 0
|
candidate.DailyKM = 0
|
||||||
@@ -875,9 +923,25 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
|
|||||||
candidate.QualityReason = QualityReasonCurrentDayFirst
|
candidate.QualityReason = QualityReasonCurrentDayFirst
|
||||||
return nil
|
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.FirstTotalKM = baseline.LatestTotalKM
|
||||||
candidate.FirstEventTime = baseline.LatestEventTime
|
candidate.FirstEventTime = baseline.LatestEventTime
|
||||||
candidate.DailyKM = DailyMileageFromDayBoundary(baseline.LatestTotalKM, candidate.LatestTotalKM)
|
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.QualityStatus = QualityOK
|
||||||
candidate.QualityReason = baseline.QualityReason
|
candidate.QualityReason = baseline.QualityReason
|
||||||
if candidate.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`).
|
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").
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
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.ExpectBegin()
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||||
WithArgs(
|
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`).
|
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").
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
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.ExpectBegin()
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||||
WithArgs(
|
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()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -1488,18 +1494,18 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
"LMRKH9AC2R1004087",
|
"LMRKH9AC2R1004087",
|
||||||
"",
|
"",
|
||||||
float64(120672.0),
|
|
||||||
float64(120788.0),
|
float64(120788.0),
|
||||||
approxFloat64{want: 116.0, tolerance: 0.000001},
|
float64(120788.0),
|
||||||
|
float64(0),
|
||||||
float64(0),
|
float64(0),
|
||||||
int64(1),
|
int64(1),
|
||||||
int64(0),
|
int64(0),
|
||||||
historicalTime,
|
currentTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
QualityOK,
|
QualityOK,
|
||||||
QualityReasonHistorical,
|
QualityReasonCurrentDayFirst,
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||||
@@ -1608,7 +1614,7 @@ func TestWriterApplyRealtimeBaselineKeepsNegativeHistoricalDeltaInvalid(t *testi
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
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)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
||||||
@@ -1651,7 +1657,7 @@ func TestWriterApplyRealtimeBaselineKeepsInvalidWhenCurrentDayBaselineIsStillHig
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
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)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
||||||
@@ -1691,7 +1697,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
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)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
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()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -1734,18 +1740,65 @@ func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *tes
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
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)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM < 7833.4 || candidate.DailyKM > 7833.6 {
|
if candidate.DailyKM != 0 {
|
||||||
t.Fatalf("daily km = %v, want multi-day gap delta", candidate.DailyKM)
|
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 {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
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()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
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 {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
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)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM < 29999.9 || candidate.DailyKM > 30000.1 {
|
if candidate.DailyKM != 0 {
|
||||||
t.Fatalf("daily km = %v, want historical jump delta", candidate.DailyKM)
|
t.Fatalf("daily km = %v, want current-day first baseline", candidate.DailyKM)
|
||||||
}
|
}
|
||||||
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
|
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
|
||||||
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
|
t.Fatalf("current-day boundary not retained: %#v", candidate)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
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`).
|
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").
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
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.ExpectBegin()
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
@@ -1894,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
|
|||||||
t.Fatalf("Append() error = %v", err)
|
t.Fatalf("Append() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaCalls := 6 + len(DailyMileageAlterSQL)
|
schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL)
|
||||||
if len(exec.calls) != schemaCalls+5 {
|
if len(exec.calls) != schemaCalls+5 {
|
||||||
t.Fatalf("exec calls = %d", len(exec.calls))
|
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_source",
|
||||||
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
|
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
|
||||||
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_tank_capacity",
|
"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_stream_state",
|
||||||
|
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(exec.calls[i].query, want) {
|
if !strings.Contains(exec.calls[i].query, want) {
|
||||||
t.Fatalf("schema call %d = %s, want %s", i, 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`).
|
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").
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
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.ExpectBegin()
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||||
WithArgs(
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,8 +17,14 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultHydrogenNoiseKG = 0.05
|
defaultHydrogenNoiseKG = 0.05
|
||||||
defaultHydrogenMaxDropKG = 20.0
|
defaultHydrogenMaxDropKG = 20.0
|
||||||
|
hydrogenActiveCurrentA = 1.0
|
||||||
hydrogenMolarMassKGPerMol = 0.00201588
|
hydrogenMolarMassKGPerMol = 0.00201588
|
||||||
universalGasConstant = 8.314472
|
universalGasConstant = 8.314472
|
||||||
|
hydrogenSegmentWindowSize = 5
|
||||||
|
hydrogenSegmentMinSamples = hydrogenSegmentWindowSize * 2
|
||||||
|
hydrogenSegmentMaxGap = 5 * time.Minute
|
||||||
|
hydrogenRecoveryWindow = time.Minute
|
||||||
|
hydrogenRecoveryPressure = 8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
var hydrogenPressureFieldKeys = []string{
|
var hydrogenPressureFieldKeys = []string{
|
||||||
@@ -28,6 +37,11 @@ var hydrogenTemperatureFieldKeys = []string{
|
|||||||
"fuel_cell_max_hydrogen_temperature_c",
|
"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 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 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}
|
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,
|
first_event_time DATETIME(3) NOT NULL,
|
||||||
last_event_time DATETIME(3) NOT NULL,
|
last_event_time DATETIME(3) NOT NULL,
|
||||||
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
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_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
|
||||||
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效压力质量样本不足2条',
|
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效压力质量样本不足2条',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
@@ -60,18 +76,58 @@ const HydrogenStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hyd
|
|||||||
KEY idx_hydrogen_stream_date_quality (stat_date, quality_status, vin)
|
KEY idx_hydrogen_stream_date_quality (stat_date, quality_status, vin)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
) 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 {
|
type HydrogenStreamSample struct {
|
||||||
VIN string
|
VIN string
|
||||||
Date string
|
Date string
|
||||||
SourceEndpoint string
|
SourceEndpoint string
|
||||||
EventID string
|
EventID string
|
||||||
EventTime time.Time
|
EventTime time.Time
|
||||||
MassKG float64
|
MassKG float64
|
||||||
TankCapacityLiters float64
|
TankCapacityLiters float64
|
||||||
PressureMPa float64
|
PressureMPa float64
|
||||||
TemperatureC float64
|
TemperatureC float64
|
||||||
NoiseKG float64
|
NoiseKG float64
|
||||||
RefuelThresholdKG 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 {
|
type HydrogenStreamResult struct {
|
||||||
@@ -82,6 +138,268 @@ type HydrogenStreamResult struct {
|
|||||||
NotCurrent int
|
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) {
|
func HydrogenDensityKGPerM3(pressureMPa, temperatureC float64) (float64, bool) {
|
||||||
temperatureK := temperatureC + 273.15
|
temperatureK := temperatureC + 273.15
|
||||||
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 {
|
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 {
|
||||||
@@ -126,6 +444,9 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
|
|||||||
if !temperatureOK {
|
if !temperatureOK {
|
||||||
return HydrogenStreamSample{}, "missing_temperature", false
|
return HydrogenStreamSample{}, "missing_temperature", false
|
||||||
}
|
}
|
||||||
|
if !validHydrogenPressureTemperature(pressure, temperature) {
|
||||||
|
return HydrogenStreamSample{}, "invalid_pressure_temperature", false
|
||||||
|
}
|
||||||
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
|
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
|
||||||
return HydrogenStreamSample{}, "missing_tank_capacity", false
|
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.Max(defaultHydrogenNoiseKG, mass-pressureStepMass)
|
||||||
noiseKG = math.Min(noiseKG, 1.0)
|
noiseKG = math.Min(noiseKG, 1.0)
|
||||||
refuelThresholdKG := math.Max(1.0, mass*0.05)
|
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 {
|
if loc == nil {
|
||||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
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,
|
EventID: env.StableEventID(), EventTime: eventTime, MassKG: mass,
|
||||||
TankCapacityLiters: tankCapacityLiters, PressureMPa: pressure, TemperatureC: temperature,
|
TankCapacityLiters: tankCapacityLiters, PressureMPa: pressure, TemperatureC: temperature,
|
||||||
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
|
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
|
}, "", 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) {
|
func firstHydrogenNumber(fields map[string]any, keys []string) (float64, bool) {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value, ok := telemetry.Number(fields, key); ok {
|
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
|
return result, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
write, err := tx.ExecContext(ctx, upsertHydrogenStreamStateSQL,
|
state, found, err := selectHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
affected, _ := write.RowsAffected()
|
if found {
|
||||||
if affected == 0 {
|
if !state.add(sample) {
|
||||||
result.Duplicate = 1
|
result.Duplicate = 1
|
||||||
return result, tx.Commit()
|
return result, tx.Commit()
|
||||||
|
}
|
||||||
|
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 := tx.ExecContext(ctx, projectHydrogenStreamDailySQL, sample.VIN, sample.Date); err != nil {
|
if err := projectHydrogenSegmentStreamDaily(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -219,50 +590,114 @@ func AppendHydrogenStream(ctx context.Context, exec Execer, env envelope.FrameEn
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertHydrogenStreamStateSQL = `
|
func selectHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenSegmentStreamState, bool, error) {
|
||||||
INSERT INTO vehicle_open_hydrogen_stream_state(
|
var encoded []byte
|
||||||
vin,stat_date,source_endpoint,first_mass_kg,last_mass_kg,cycle_min_mass_kg,consumption_kg,sample_count,
|
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
|
||||||
refuel_count,abnormal_drop_count,tank_capacity_l,first_pressure_mpa,last_pressure_mpa,
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
first_temperature_c,last_temperature_c,calculation_method,
|
return hydrogenSegmentStreamState{}, false, nil
|
||||||
first_event_time,last_event_time,last_event_id,quality_status,quality_reason
|
}
|
||||||
) VALUES(?,?,?,?,?,?,0,1,0,0,?,?,?,?,?,'PRESSURE_NIST',?,?,?,'NO_DATA','有效压力质量样本不足2条')
|
if err != nil {
|
||||||
ON DUPLICATE KEY UPDATE
|
return hydrogenSegmentStreamState{}, false, err
|
||||||
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)<=?,
|
var state hydrogenSegmentStreamState
|
||||||
cycle_min_mass_kg-VALUES(last_mass_kg),0),
|
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||||
refuel_count = refuel_count + IF(VALUES(last_event_time)>last_event_time
|
return hydrogenSegmentStreamState{}, false, err
|
||||||
AND VALUES(last_mass_kg)-cycle_min_mass_kg>?,1,0),
|
}
|
||||||
quality_status = IF(VALUES(last_event_time)<=last_event_time,quality_status,
|
return state, true, nil
|
||||||
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))`
|
|
||||||
|
|
||||||
const projectHydrogenStreamDailySQL = `
|
func selectHydrogenStreamBaseline(ctx context.Context, tx *sql.Tx, vin, date string) (*hydrogenStreamBaseline, bool, error) {
|
||||||
INSERT INTO vehicle_open_daily_energy(
|
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,
|
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
|
sample_count,refuel_count,quality_status,quality_reason,calculated_at
|
||||||
)
|
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))
|
||||||
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
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),unit='kg',
|
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),
|
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package stats
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
|
||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
|
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) {
|
func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
|
||||||
density, ok := HydrogenDensityKGPerM3(10, 26.85)
|
density, ok := HydrogenDensityKGPerM3(10, 26.85)
|
||||||
if !ok || math.Abs(density-7.625) > 0.01 {
|
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) {
|
func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,14 +272,20 @@ func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
|
|||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
|
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
|
||||||
mock.ExpectBegin()
|
mock.ExpectBegin()
|
||||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).
|
||||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
|
||||||
2050.0, 6.0, 6.0, 44.0, 44.0, eventTime, eventTime, "event-1",
|
WillReturnError(sql.ErrNoRows)
|
||||||
sqlmock.AnyArg(), 20.0, sqlmock.AnyArg(), 20.0, 20.0, 20.0,
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).
|
||||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 20.0).
|
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))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).
|
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))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
||||||
@@ -104,12 +310,14 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
|
|||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
|
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
|
||||||
mock.ExpectBegin()
|
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.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
||||||
writer := NewWriter(db, loc)
|
writer := NewWriter(db, loc)
|
||||||
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
|
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
|
||||||
|
writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}
|
||||||
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
|
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AppendWithResult() error = %v", err)
|
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{
|
for _, want := range []string{
|
||||||
"VALUES(last_event_time)>last_event_time",
|
"PRIMARY KEY (vin, stat_date)",
|
||||||
"cycle_min_mass_kg-VALUES(last_mass_kg)>?",
|
"state_json JSON NOT NULL",
|
||||||
"tank_capacity_l",
|
HydrogenV35RealtimeAlgorithmVersion,
|
||||||
"last_pressure_mpa",
|
|
||||||
"last_temperature_c",
|
|
||||||
"PRESSURE_NIST",
|
|
||||||
} {
|
} {
|
||||||
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(upsertHydrogenStreamStateSQL) {
|
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
|
||||||
t.Fatalf("stream upsert missing %q", 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
|
yutongFuelCellDrivingMode = 11
|
||||||
)
|
)
|
||||||
|
|
||||||
// PureHydrogenModeFromEnvelope returns whether the reported vehicle is in a
|
// PureHydrogenModeFromEnvelope returns whether hydrogen power participates in
|
||||||
// fuel-cell-only work mode and whether the source value is understood.
|
// vehicle propulsion and whether the source value is understood.
|
||||||
//
|
//
|
||||||
// The 32960 fuel-cell extension reports engine_work_state; production frames
|
// GB32960's standard vehicle running mode is authoritative when present:
|
||||||
// use mode 2 for normal fuel-cell operation. Yutong reports the equivalent
|
// mode 1 is pure electric, mode 2 is hybrid, and mode 3 is fuel powered. Mode
|
||||||
// state through TRIANGLE_STATE; production frames use 1 for inactive and 4/11
|
// 2 is counted as hydrogen-participating mileage for the hydrogen fleet; mode
|
||||||
// for active fuel-cell operating modes.
|
// 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) {
|
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]
|
value, exists := env.Fields[envelope.FieldFuelCellWorkMode]
|
||||||
if !exists {
|
if !exists {
|
||||||
return false, false
|
return false, false
|
||||||
@@ -56,8 +82,9 @@ func PureHydrogenModeFromEnvelope(env envelope.FrameEnvelope) (active bool, know
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PureHydrogenMileageDelta returns the odometer delta that can be attributed
|
// PureHydrogenMileageDelta returns the odometer delta that can be attributed
|
||||||
// to pure-hydrogen operation. Both endpoints must report a known active mode;
|
// to hydrogen-participating operation. The current endpoint owns the interval,
|
||||||
// this deliberately leaves transitions and missing mode data unclassified.
|
// matching the daily rule: total mileage minus intervals ending in pure-electric
|
||||||
|
// mode. Missing or invalid current modes remain unclassified.
|
||||||
func PureHydrogenMileageDelta(
|
func PureHydrogenMileageDelta(
|
||||||
previousKM float64,
|
previousKM float64,
|
||||||
currentKM float64,
|
currentKM float64,
|
||||||
@@ -67,7 +94,7 @@ func PureHydrogenMileageDelta(
|
|||||||
currentKnown bool,
|
currentKnown bool,
|
||||||
) float64 {
|
) float64 {
|
||||||
delta := currentKM - previousKM
|
delta := currentKM - previousKM
|
||||||
if !previousKnown || !previousActive || !currentKnown || !currentActive {
|
if !currentKnown || !currentActive {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if delta < 0 || delta > maxSelectedDailyMileageKM {
|
if delta < 0 || delta > maxSelectedDailyMileageKM {
|
||||||
|
|||||||
@@ -15,16 +15,26 @@ func TestPureHydrogenModeFromEnvelope(t *testing.T) {
|
|||||||
known bool
|
known bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "gb32960 fuel cell mode",
|
name: "gb32960 hybrid running mode",
|
||||||
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 2}},
|
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldVehicleRunningMode: 2}},
|
||||||
active: true,
|
active: true,
|
||||||
known: true,
|
known: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gb32960 electric mode",
|
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,
|
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,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "yutong active mode",
|
name: "yutong active mode",
|
||||||
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: json.Number("4")}},
|
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: json.Number("4")}},
|
||||||
@@ -62,7 +72,7 @@ func TestPureHydrogenModeFromEnvelope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPureHydrogenMileageDeltaRequiresContinuousKnownActiveMode(t *testing.T) {
|
func TestPureHydrogenMileageDeltaUsesCurrentKnownActiveMode(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
previousKM, currentKM float64
|
previousKM, currentKM float64
|
||||||
@@ -81,15 +91,25 @@ func TestPureHydrogenMileageDeltaRequiresContinuousKnownActiveMode(t *testing.T)
|
|||||||
want: 6.5,
|
want: 6.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "transition into active is unclassified",
|
name: "transition into active belongs to active interval",
|
||||||
previousKM: 100,
|
previousKM: 100,
|
||||||
currentKM: 106.5,
|
currentKM: 106.5,
|
||||||
previousKnown: true,
|
previousKnown: true,
|
||||||
currentActive: true,
|
currentActive: true,
|
||||||
currentKnown: 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,
|
previousKM: 100,
|
||||||
currentKM: 106.5,
|
currentKM: 106.5,
|
||||||
previousActive: true,
|
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,
|
daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
|
||||||
pure_hydrogen_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,
|
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,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (vin, stat_date, protocol),
|
PRIMARY KEY (vin, stat_date, protocol),
|
||||||
KEY idx_stat_date (stat_date),
|
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_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 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 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 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 first_total_mileage_km",
|
||||||
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_key",
|
"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 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_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_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 (
|
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
QualityOK = "OK"
|
QualityOK = "OK"
|
||||||
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
|
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
|
||||||
QualityInvalidDelta = "INVALID_DELTA"
|
QualityInvalidDelta = "INVALID_DELTA"
|
||||||
|
QualityReasonTotalRollback = "TOTAL_MILEAGE_ROLLBACK"
|
||||||
QualityReasonHistorical = "historical_source_baseline"
|
QualityReasonHistorical = "historical_source_baseline"
|
||||||
QualityReasonCurrentDayFirst = "current_day_first_baseline"
|
QualityReasonCurrentDayFirst = "current_day_first_baseline"
|
||||||
QualityReasonStationaryCarry = "stationary_carry_forward"
|
QualityReasonStationaryCarry = "stationary_carry_forward"
|
||||||
@@ -136,6 +137,26 @@ func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM
|
|||||||
return currentDayLatestKM - previousBaselineKM
|
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) {
|
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
|
||||||
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
|
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
|
||||||
return 0, true, "negative_jitter_clamped"
|
return 0, true, "negative_jitter_clamped"
|
||||||
@@ -444,8 +465,6 @@ const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL +
|
|||||||
const upsertSourcePureHydrogenIncrementSQL = `CASE
|
const upsertSourcePureHydrogenIncrementSQL = `CASE
|
||||||
WHEN latest_event_time IS NOT NULL
|
WHEN latest_event_time IS NOT NULL
|
||||||
AND VALUES(latest_event_time) > latest_event_time
|
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_mode_known) = 1
|
||||||
AND VALUES(latest_pure_hydrogen_active) = 1
|
AND VALUES(latest_pure_hydrogen_active) = 1
|
||||||
AND VALUES(latest_total_mileage_km) >= latest_total_mileage_km
|
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)
|
= 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 = `
|
const projectDailyMileageSQL = `
|
||||||
INSERT INTO vehicle_daily_mileage
|
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
|
SELECT
|
||||||
s.vin,
|
s.vin,
|
||||||
s.stat_date,
|
s.stat_date,
|
||||||
@@ -741,7 +783,8 @@ SELECT
|
|||||||
ds.id,
|
ds.id,
|
||||||
s.daily_mileage_km,
|
s.daily_mileage_km,
|
||||||
LEAST(s.pure_hydrogen_mileage_km, 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
|
FROM vehicle_daily_mileage_source s
|
||||||
LEFT JOIN vehicle_data_source ds
|
LEFT JOIN vehicle_data_source ds
|
||||||
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
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),
|
daily_mileage_km = VALUES(daily_mileage_km),
|
||||||
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
|
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
|
||||||
latest_total_mileage_km = VALUES(latest_total_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
|
updated_at = CURRENT_TIMESTAMP
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -866,6 +910,35 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
|
|||||||
}, latestTotal.Valid, nil
|
}, 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
|
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
|
||||||
// same VIN, protocol and source before statDate. Missing calendar days are
|
// same VIN, protocol and source before statDate. Missing calendar days are
|
||||||
// skipped automatically.
|
// skipped automatically.
|
||||||
@@ -889,3 +962,14 @@ WHERE vin = ?
|
|||||||
ORDER BY stat_date DESC, latest_event_time DESC
|
ORDER BY stat_date DESC, latest_event_time DESC
|
||||||
LIMIT 1
|
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{
|
for _, want := range []string{
|
||||||
"pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE",
|
"pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE",
|
||||||
"VALUES(latest_event_time) > latest_event_time",
|
"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_mode_known) = 1",
|
||||||
"VALUES(latest_pure_hydrogen_active) = 1",
|
"VALUES(latest_pure_hydrogen_active) = 1",
|
||||||
"VALUES(latest_total_mileage_km) - latest_total_mileage_km",
|
"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) {
|
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
|
||||||
exec := &recordingExec{}
|
exec := &recordingExec{}
|
||||||
sample := SourceMileageSample{
|
sample := SourceMileageSample{
|
||||||
@@ -612,6 +630,15 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
|||||||
"project": projectFinal,
|
"project": projectFinal,
|
||||||
"mark": markSelected,
|
"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)")
|
identityScore := strings.Index(query, "SUM(identity_source.sample_count)")
|
||||||
freshness := strings.Index(query, "latest_event_time DESC")
|
freshness := strings.Index(query, "latest_event_time DESC")
|
||||||
rowSamples := strings.LastIndex(query, "sample_count 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)).
|
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
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 {
|
func containsStatement(statements []string, fragment string) bool {
|
||||||
for _, statement := range statements {
|
for _, statement := range statements {
|
||||||
if strings.Contains(statement, fragment) {
|
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"
|
||||||
+25
-10
@@ -52,6 +52,9 @@ function stationSearchScore(station, query) {
|
|||||||
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
|
function 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 initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
|
||||||
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
|
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 hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
|
||||||
function coords(station) { return [Number(station.longitude), 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 stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
|
||||||
@@ -106,7 +109,14 @@ function renderList() {
|
|||||||
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
|
document.getElementById('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 排名';
|
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; }
|
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
|
||||||
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)} 座`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join('');
|
box.innerHTML = display.slice(0, 100).map((node, index) => {
|
||||||
|
const station = node.kind === 'station' ? node.station : null, distance = station ? stationDistance(station) : Number.POSITIVE_INFINITY;
|
||||||
|
const partnerTag = station ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '';
|
||||||
|
const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)} 座`;
|
||||||
|
const price = station?.cooperative ? stationUnitPrice(station) : '';
|
||||||
|
const valueStack = price || rowValue ? `<span class="r-value-stack">${price ? `<span class="station-list-price">${escapeHTML(price)}</span>` : ''}${rowValue ? `<span class="r-val">${escapeHTML(rowValue)}</span>` : ''}</span>` : '';
|
||||||
|
return `<button type="button" class="rank-glass-row${station && state.selected?.id === station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${valueStack}</button>`;
|
||||||
|
}).join('');
|
||||||
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
|
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 compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
|
||||||
@@ -117,7 +127,7 @@ function fuzzySearchScore(value, query) {
|
|||||||
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
|
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;
|
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
|
||||||
}
|
}
|
||||||
function compactPath(parts) { return parts.filter(Boolean).join(' · '); }
|
function compactPath(parts) { return parts.filter(Boolean).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
|
||||||
function stationLocationOptions() {
|
function stationLocationOptions() {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
const add = (level, station) => {
|
const add = (level, station) => {
|
||||||
@@ -127,12 +137,13 @@ function stationLocationOptions() {
|
|||||||
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
|
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
|
||||||
groups.get(key).stations.push(station);
|
groups.get(key).stations.push(station);
|
||||||
};
|
};
|
||||||
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
|
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city && !isMunicipalityCity(station.province, station.city)) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
|
||||||
return [...groups.values()].map(option => {
|
return [...groups.values()].map(option => {
|
||||||
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
|
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 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 latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
|
||||||
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
|
const label = option.level === 'district' ? option.district : stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city');
|
||||||
|
return { ...option, label, path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function stationLocationSuggestions(query) {
|
function stationLocationSuggestions(query) {
|
||||||
@@ -180,9 +191,10 @@ function regionPickerOptions() {
|
|||||||
const picker = state.regionPicker; if (!picker) return [];
|
const picker = state.regionPicker; if (!picker) return [];
|
||||||
const all = stationLocationOptions(); const needle = normalize(picker.search);
|
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 (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
|
||||||
if (picker.level === 'province') return all.filter(option => option.level === 'province');
|
if (picker.level === 'province') return all.filter(option => option.level === 'province').sort((left, right) => right.stations.length - left.stations.length || left.label.localeCompare(right.label, 'zh-CN'));
|
||||||
|
if (picker.level === 'city' && isMunicipalityProvince(picker.province)) return all.filter(option => option.level === 'district' && option.province === picker.province);
|
||||||
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
|
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
|
||||||
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city);
|
return all.filter(option => option.level === 'district' && option.province === picker.province && (!picker.city || option.city === picker.city));
|
||||||
}
|
}
|
||||||
function regionPickerOptionPath(option) {
|
function regionPickerOptionPath(option) {
|
||||||
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
|
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
|
||||||
@@ -213,7 +225,7 @@ function renderRegionPicker() {
|
|||||||
}
|
}
|
||||||
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
|
function 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 navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
|
||||||
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); }
|
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' && isMunicipalityProvince(option.province) ? 'district' : option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.level === 'province' && isMunicipalityProvince(option.province) ? '' : option.city || ''; renderRegionPicker(); }
|
||||||
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
|
function 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 removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
|
||||||
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
|
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
|
||||||
@@ -239,11 +251,14 @@ function selectStation(station, fit = false) {
|
|||||||
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
|
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);
|
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 cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
|
||||||
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
|
const distance = stationDistance(station), partnerFields = cooperative ? `${detailField('单价', stationUnitPrice(station) || '暂无')}${detailField('联系人', station.contactPerson || '暂无')}${detailPhoneField('联系方式', station.contactPhone)}` : '';
|
||||||
|
document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${partnerFields}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
|
||||||
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
|
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 detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
|
||||||
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); }
|
function detailPhoneField(label, phone) { const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, ''); return `<div class="detail-field"><dt>${escapeHTML(label)}</dt><dd>${href ? `<a class="detail-phone-link" href="tel:${escapeAttribute(href)}">${escapeHTML(value)}</a>` : '暂无'}</dd></div>`; }
|
||||||
|
function stationUnitPrice(station) { const value = Number(station?.unitPrice); return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : ''; }
|
||||||
|
function stationRegion(station) { return compactPath([station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]')); }
|
||||||
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
|
function 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 locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
|
||||||
@@ -254,7 +269,7 @@ function distanceKm(origin, target) { const radians = value => value * Math.PI /
|
|||||||
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
|
function 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 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 setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
|
||||||
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); }
|
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'), target = window.open('', '_blank'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => { const url = `https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`; if (target) { target.opener = null; target.location.href = url; } else window.open(url, '_blank', 'noopener'); }, 140); }
|
||||||
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
|
function 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 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 formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120")
|
|||||||
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
|
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
|
||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
_cache = {}
|
_cache = {}
|
||||||
|
_cache_load_locks = {}
|
||||||
|
_cache_refreshing = set()
|
||||||
|
|
||||||
|
|
||||||
def _static_asset_version():
|
def _static_asset_version():
|
||||||
@@ -80,16 +82,51 @@ def _post_open_platform(path, body):
|
|||||||
return payload.get("data") or []
|
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):
|
def _cached(key, ttl_seconds, loader):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
refresh_in_background = False
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
cached = _cache.get(key)
|
cached = _cache.get(key)
|
||||||
if cached and now - cached[0] < ttl_seconds:
|
if cached and now - cached[0] < ttl_seconds:
|
||||||
return cached[1]
|
return cached[1]
|
||||||
value = loader()
|
if cached:
|
||||||
with _cache_lock:
|
if key not in _cache_refreshing:
|
||||||
_cache[key] = (now, value)
|
_cache_refreshing.add(key)
|
||||||
return value
|
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():
|
def _load_station_directory():
|
||||||
@@ -137,7 +174,7 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if path == "/api/stations":
|
if path == "/api/stations":
|
||||||
try:
|
try:
|
||||||
self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory))
|
payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log_error("station directory refresh failed: %s", exc)
|
self.log_error("station directory refresh failed: %s", exc)
|
||||||
self._write_json(502, {
|
self._write_json(502, {
|
||||||
@@ -145,6 +182,8 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
|
|||||||
"code": "UPSTREAM_UNAVAILABLE",
|
"code": "UPSTREAM_UNAVAILABLE",
|
||||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
self._write_json(200, payload)
|
||||||
return
|
return
|
||||||
if path == "/assets/logo_light.svg":
|
if path == "/assets/logo_light.svg":
|
||||||
self._serve_logo()
|
self._serve_logo()
|
||||||
@@ -213,7 +252,10 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
|
|||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
try:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
self.log_message("client disconnected before JSON response completed")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
.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 { 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 .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 .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; }
|
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -209,6 +212,8 @@
|
|||||||
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
|
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
|
||||||
.station-navigation-shell .glass-cell { padding: 6px; }
|
.station-navigation-shell .glass-cell { padding: 6px; }
|
||||||
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
|
.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) {
|
@media (max-width: 390px) {
|
||||||
|
|||||||
@@ -63,6 +63,30 @@ vm.runInNewContext(`
|
|||||||
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
|
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
|
||||||
assert.equal(sandbox.regionCityLabels, '佛山,广州');
|
assert.equal(sandbox.regionCityLabels, '佛山,广州');
|
||||||
assert.match(sandbox.regionSearchLabels, /嘉兴/);
|
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(`
|
vm.runInNewContext(`
|
||||||
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
|
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
|
||||||
state.locationFilters = [];
|
state.locationFilters = [];
|
||||||
@@ -87,6 +111,26 @@ vm.runInNewContext(`
|
|||||||
`, sandbox);
|
`, sandbox);
|
||||||
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
|
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
|
||||||
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
|
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, /zoom < 11\) return 'city'/);
|
||||||
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
|
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
|
||||||
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
|
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
|
||||||
@@ -106,12 +150,15 @@ assert.match(source, /focusSearchResults\(\)/);
|
|||||||
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
|
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
|
||||||
assert.match(source, /station-list-partner-tag/);
|
assert.match(source, /station-list-partner-tag/);
|
||||||
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
|
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
|
||||||
assert.match(source, /node\.kind === 'station' \? '' : `\$\{formatNumber\(node\.count\)\} 座`/);
|
assert.match(source, /const rowValue = Number\.isFinite\(distance\)/);
|
||||||
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
|
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
|
||||||
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
|
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
|
||||||
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
|
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
|
||||||
assert.match(source, /uri\.amap\.com\/navigation/);
|
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.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
|
||||||
|
assert.match(source, /station-list-price/);
|
||||||
assert.match(styles, /station-summary-strip/);
|
assert.match(styles, /station-summary-strip/);
|
||||||
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
|
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, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import importlib.util
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -49,6 +51,29 @@ class StationNavigationTest(unittest.TestCase):
|
|||||||
self.assertEqual(calls, ["load"])
|
self.assertEqual(calls, ["load"])
|
||||||
self.assertEqual(first, second)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
@@ -22,6 +25,12 @@ func main() {
|
|||||||
lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date")
|
lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date")
|
||||||
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
|
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
|
||||||
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
|
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
|
||||||
|
vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild")
|
||||||
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
@@ -50,24 +59,224 @@ func main() {
|
|||||||
if *lookback < 1 || *lookback > 31 {
|
if *lookback < 1 || *lookback > 31 {
|
||||||
log.Fatal("-lookback-days must be between 1 and 31")
|
log.Fatal("-lookback-days must be between 1 and 31")
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
if *vinWorkers < 1 || *vinWorkers > 16 {
|
||||||
|
log.Fatal("-vin-workers must be between 1 and 16")
|
||||||
|
}
|
||||||
|
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
|
||||||
|
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()
|
defer cancel()
|
||||||
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
|
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load hydrogen tank capacities: %v", err)
|
log.Fatalf("load hydrogen tank capacities: %v", err)
|
||||||
}
|
}
|
||||||
for offset := *lookback - 1; offset >= 0; offset-- {
|
if normalizedVIN != "" {
|
||||||
start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset)
|
if _, ok := capacities[normalizedVIN]; !ok {
|
||||||
observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities)
|
log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err)
|
|
||||||
}
|
}
|
||||||
stats := openplatform.BuildHydrogenDailyStats(observations, start.Format("2006-01-02"), *noise, *maxDrop)
|
|
||||||
if err := openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, start.Format("2006-01-02"), stats); err != nil {
|
|
||||||
log.Fatalf("persist hydrogen statistics for %s: %v", start.Format("2006-01-02"), err)
|
|
||||||
}
|
|
||||||
fmt.Printf("date=%s observations=%d vehicles=%d\n", start.Format("2006-01-02"), len(observations), len(stats))
|
|
||||||
}
|
}
|
||||||
|
var brandVINs []string
|
||||||
|
if normalizedBrand != "" {
|
||||||
|
brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load VINs for brand %q: %v", normalizedBrand, err)
|
||||||
|
}
|
||||||
|
if len(brandVINs) == 0 {
|
||||||
|
log.Fatalf("no vehicles found for brand %q", normalizedBrand)
|
||||||
|
}
|
||||||
|
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 {
|
func envInt(name string, fallback int) int {
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
|||||||
return
|
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)
|
required := requiredRole(r)
|
||||||
if roleRank(principal.Role) < roleRank(required) {
|
if roleRank(principal.Role) < roleRank(required) {
|
||||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||||
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||||
return "monitor"
|
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"
|
return "shared"
|
||||||
case path == "/api/v2/tracks":
|
case path == "/api/v2/tracks":
|
||||||
return "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 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 {
|
type authUser struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Username string `json:"username"`
|
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) {
|
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||||
menus := append([]string(nil), adminMenus...)
|
menus := []string{}
|
||||||
vehicles := []string{}
|
vehicles := []string{}
|
||||||
vehicleGrants := []platform.VehicleGrant{}
|
vehicleGrants := []platform.VehicleGrant{}
|
||||||
businessScopeLevel := ""
|
businessScopeLevel := ""
|
||||||
departmentIDs := []string{}
|
departmentIDs := []string{}
|
||||||
responsibleUserID := ""
|
responsibleUserID := ""
|
||||||
if user.UserType == "customer" {
|
if user.UserType == "customer" || user.UserType == "admin" {
|
||||||
menus = []string{}
|
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
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 {
|
if err != nil {
|
||||||
return platform.Principal{}, err
|
return platform.Principal{}, err
|
||||||
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
|||||||
rows.Close()
|
rows.Close()
|
||||||
return platform.Principal{}, err
|
return platform.Principal{}, err
|
||||||
}
|
}
|
||||||
if customerMenuSet[value] {
|
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
|
||||||
menus = append(menus, value)
|
menus = append(menus, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return platform.Principal{}, err
|
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 {
|
if err != nil {
|
||||||
return platform.Principal{}, err
|
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) {
|
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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) {
|
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
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) {
|
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||||
cases := []config.Config{
|
cases := []config.Config{
|
||||||
{AuthMode: "enforce"},
|
{AuthMode: "enforce"},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
description: |
|
description: |
|
||||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。
|
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位。
|
||||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||||
servers:
|
servers:
|
||||||
- url: /
|
- url: /
|
||||||
@@ -54,7 +54,7 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||||
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||||
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。
|
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。
|
||||||
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
||||||
operationId: queryDailyMileage
|
operationId: queryDailyMileage
|
||||||
security:
|
security:
|
||||||
@@ -92,7 +92,7 @@ paths:
|
|||||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。
|
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。
|
||||||
operationId: queryDailyMileageRange
|
operationId: queryDailyMileageRange
|
||||||
security:
|
security:
|
||||||
- AppKeyAuth: []
|
- AppKeyAuth: []
|
||||||
@@ -160,6 +160,46 @@ paths:
|
|||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalError'
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/api/v1/vehicles/stationary/query:
|
||||||
|
post:
|
||||||
|
tags: [合作方数据接口]
|
||||||
|
summary: 加氢站车辆停留核验
|
||||||
|
description: |
|
||||||
|
用于加氢车牌核验。传入加氢站经度、纬度、坐标系、半径和北京时间区间,返回范围内速度不大于 3 km/h 的授权车辆停留区间。
|
||||||
|
coordinateSystem 支持 WGS84(默认)和 GCJ02(高德坐标);服务端会将 GCJ02 转换为 WGS84 后进行核验。
|
||||||
|
同一车辆相邻静止点间隔超过 10 分钟会拆分为不同停留;仅返回至少 2 个定位样本且持续不少于 60 秒的停留。
|
||||||
|
最大查询区间为 24 小时。结果按 matchScore 从高到低排序;分数综合平均距离、最高速度、停留时长和采样数量,供人工核验使用,不等同于加氢交易凭证。
|
||||||
|
operationId: queryStationaryVehicles
|
||||||
|
security:
|
||||||
|
- AppKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StationaryVehicleQuery'
|
||||||
|
example:
|
||||||
|
startTime: '2026-08-06 10:00:00'
|
||||||
|
endTime: '2026-08-06 12:00:00'
|
||||||
|
longitude: 120.752312
|
||||||
|
latitude: 30.746281
|
||||||
|
coordinateSystem: GCJ02
|
||||||
|
radiusMeters: 5
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 查询成功;无匹配车辆时 data 为空数组
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StationaryVehicleQueryResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
/api/v1/vehicles/realtime/query:
|
/api/v1/vehicles/realtime/query:
|
||||||
post:
|
post:
|
||||||
tags: [合作方数据接口]
|
tags: [合作方数据接口]
|
||||||
@@ -167,7 +207,9 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
||||||
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
||||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。
|
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
|
||||||
|
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
|
||||||
|
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
|
||||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||||
operationId: queryRealtimeVehicles
|
operationId: queryRealtimeVehicles
|
||||||
security:
|
security:
|
||||||
@@ -492,6 +534,52 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||||
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
||||||
|
StationaryVehicleQuery:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [startTime, endTime, longitude, latitude]
|
||||||
|
properties:
|
||||||
|
startTime:
|
||||||
|
type: string
|
||||||
|
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
|
||||||
|
description: 开始时间,北京时间,yyyy-MM-dd HH:mm:ss
|
||||||
|
endTime:
|
||||||
|
type: string
|
||||||
|
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
|
||||||
|
description: 结束时间,北京时间;须晚于开始时间,最长 24 小时
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
minimum: -180
|
||||||
|
maximum: 180
|
||||||
|
description: 加氢站经度;坐标系由 coordinateSystem 指定
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
minimum: -90
|
||||||
|
maximum: 90
|
||||||
|
description: 加氢站纬度;坐标系由 coordinateSystem 指定
|
||||||
|
coordinateSystem:
|
||||||
|
type: string
|
||||||
|
enum: [WGS84, GCJ02]
|
||||||
|
default: WGS84
|
||||||
|
description: 坐标系;WGS84 为默认值,GCJ02 为高德地图坐标。GCJ02 会在服务端转换为 WGS84 后核验
|
||||||
|
radiusMeters:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 5
|
||||||
|
description: 核验半径,单位米;省略时为 5 米
|
||||||
|
plateNumbers:
|
||||||
|
type: array
|
||||||
|
maxItems: 2000
|
||||||
|
uniqueItems: true
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 32
|
||||||
|
description: 可选;省略或传空数组时核验整个时间区间均有效授权的全部车辆
|
||||||
RealtimeVehicleQuery:
|
RealtimeVehicleQuery:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -532,6 +620,13 @@ components:
|
|||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 单日用氢量,kg;无数据时为 null
|
description: 单日用氢量,kg;无数据时为 null
|
||||||
|
calculationPhase:
|
||||||
|
type: string
|
||||||
|
enum: [PRELIMINARY, FINAL]
|
||||||
|
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
||||||
|
algorithmVersion:
|
||||||
|
type: string
|
||||||
|
description: 氢耗计算算法版本
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
MileageResult:
|
MileageResult:
|
||||||
@@ -554,7 +649,7 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null
|
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||||
dataTime:
|
dataTime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -570,6 +665,11 @@ components:
|
|||||||
enum: [GB32960, MQTT, JT808]
|
enum: [GB32960, MQTT, JT808]
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||||
|
dataQuality:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [TOTAL_MILEAGE_ROLLBACK]
|
||||||
|
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
MileageRangeResult:
|
MileageRangeResult:
|
||||||
@@ -594,7 +694,7 @@ components:
|
|||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
minimum: 0
|
minimum: 0
|
||||||
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
|
description: 当日所选协议终端累计总里程;GPS 日里程估算不会作为累计总里程,缺日时沿用此前最近有效值
|
||||||
dataTime:
|
dataTime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -609,11 +709,17 @@ components:
|
|||||||
enum: [GB32960, MQTT, JT808]
|
enum: [GB32960, MQTT, JT808]
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||||
|
dataQuality:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [TOTAL_MILEAGE_ROLLBACK]
|
||||||
|
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
DataStatus:
|
DataStatus:
|
||||||
type: string
|
type: string
|
||||||
enum: [NORMAL, NO_DATA]
|
enum: [NORMAL, NO_DATA, DATA_ANOMALY]
|
||||||
|
description: NORMAL=存在可用数据;NO_DATA=授权范围内无可用数据;DATA_ANOMALY=检测到数据异常,详情见 dataQuality
|
||||||
HydrogenQueryResponse:
|
HydrogenQueryResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
@@ -694,6 +800,25 @@ components:
|
|||||||
example: GB32960 > YUTONG_MQTT > JT808
|
example: GB32960 > YUTONG_MQTT > JT808
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
|
StationaryVehicleResult:
|
||||||
|
type: object
|
||||||
|
required: [vin, plateNumber, stayStartTime, stayEndTime, stayDurationSeconds, stayDurationMinutes, matchScore, averageDistanceMeters, maxDistanceMeters, averageSpeedKmh, maxSpeedKmh, matchedSamples, sourceProtocols]
|
||||||
|
properties:
|
||||||
|
vin: { type: string, description: 车辆唯一 VIN }
|
||||||
|
plateNumber: { type: string, description: 车辆车牌号 }
|
||||||
|
stayStartTime: { type: string, description: 停留开始定位时间,北京时间 }
|
||||||
|
stayEndTime: { type: string, description: 停留结束定位时间,北京时间 }
|
||||||
|
stayDurationSeconds: { type: integer, description: 停留长度,秒 }
|
||||||
|
stayDurationMinutes: { type: number, format: double, description: 停留长度,分钟 }
|
||||||
|
matchScore: { type: number, format: double, minimum: 0, maximum: 100, description: 匹配度,越高表示位置更接近、速度更低、停留更久且样本更充分 }
|
||||||
|
averageDistanceMeters: { type: number, format: double }
|
||||||
|
maxDistanceMeters: { type: number, format: double }
|
||||||
|
averageSpeedKmh: { type: number, format: double }
|
||||||
|
maxSpeedKmh: { type: number, format: double, maximum: 3 }
|
||||||
|
matchedSamples: { type: integer, minimum: 2 }
|
||||||
|
sourceProtocols:
|
||||||
|
type: array
|
||||||
|
items: { type: string, enum: [GB32960, MQTT, JT808] }
|
||||||
TotalMileageQueryResponse:
|
TotalMileageQueryResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
@@ -702,22 +827,37 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/TotalMileageResult'
|
$ref: '#/components/schemas/TotalMileageResult'
|
||||||
|
StationaryVehicleQueryResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
|
- type: object
|
||||||
|
required: [data]
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/StationaryVehicleResult'
|
||||||
RealtimeVehicleResult:
|
RealtimeVehicleResult:
|
||||||
type: object
|
type: object
|
||||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
||||||
properties:
|
properties:
|
||||||
vin: { type: string }
|
vin: { type: string }
|
||||||
plateNumber: { type: string }
|
plateNumber: { type: string }
|
||||||
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] }
|
protocol:
|
||||||
longitude: { type: number, format: double, nullable: true }
|
type: string
|
||||||
latitude: { type: number, format: double, nullable: true }
|
enum: [GB32960, MQTT, JT808]
|
||||||
speedKmh: { type: number, format: double, nullable: true }
|
description: 本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源
|
||||||
totalMileageKm: { type: number, format: double, nullable: true }
|
longitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效经度;无位置时为 null }
|
||||||
recordTime: { type: string }
|
latitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效纬度;无位置时为 null }
|
||||||
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 }
|
speedKmh: { type: number, format: double, nullable: true, description: 所选来源瞬时速度,单位 km/h }
|
||||||
|
socPercent: { type: number, format: double, minimum: 0, maximum: 100, description: 所选来源的动力电池荷电状态,单位 %;无有效采集值时字段省略 }
|
||||||
|
totalMileageKm: { type: number, format: double, nullable: true, description: 所选来源累计总里程,单位 km }
|
||||||
|
recordTime: { type: string, description: 所选来源实际记录时间,北京时间 }
|
||||||
|
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0, description: 当前查询时间减 recordTime,单位秒 }
|
||||||
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
|
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
|
||||||
motionStatus: { type: string, enum: [driving, idle, offline] }
|
activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 }
|
||||||
locationAvailable: { type: boolean }
|
motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/h;idle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 }
|
||||||
|
locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 }
|
||||||
status: { $ref: '#/components/schemas/DataStatus' }
|
status: { $ref: '#/components/schemas/DataStatus' }
|
||||||
RealtimeVehicleQueryResponse:
|
RealtimeVehicleQueryResponse:
|
||||||
allOf:
|
allOf:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func WithDocs(next http.Handler) http.Handler {
|
|||||||
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
|
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
|
||||||
case SimpleDocsPath:
|
case SimpleDocsPath:
|
||||||
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
|
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
|
||||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||||
default:
|
default:
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
|
|||||||
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
|
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||||
t.Fatal("documentation asset must set nosniff")
|
t.Fatal("documentation asset must set nosniff")
|
||||||
}
|
}
|
||||||
|
if test.path == SimpleDocsPath && !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'") {
|
||||||
|
t.Fatal("simple documentation must allow its bundled interactive field renderer")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +64,14 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
|
|||||||
HydrogenQueryPath + ":",
|
HydrogenQueryPath + ":",
|
||||||
MileageQueryPath + ":",
|
MileageQueryPath + ":",
|
||||||
MileageRangeQueryPath + ":",
|
MileageRangeQueryPath + ":",
|
||||||
|
StationaryVehicleQueryPath + ":",
|
||||||
"/api/v2/open-platform/apps:",
|
"/api/v2/open-platform/apps:",
|
||||||
"AppKeyAuth:",
|
"AppKeyAuth:",
|
||||||
"AdminBearer:",
|
"AdminBearer:",
|
||||||
"省略或传空数组时",
|
"省略或传空数组时",
|
||||||
"protocolPriority:",
|
"protocolPriority:",
|
||||||
"sourceProtocol:",
|
"sourceProtocol:",
|
||||||
|
StationaryVehicleQueryPath,
|
||||||
"enum: [GB32960, MQTT, JT808]",
|
"enum: [GB32960, MQTT, JT808]",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(spec, want) {
|
if !strings.Contains(spec, want) {
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
||||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||||
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
|
StationaryVehicleQueryPath = "/api/v1/vehicles/stationary/query"
|
||||||
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
|
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
|
||||||
|
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -61,6 +62,7 @@ func (h *Handler) registerExternalDataRoutes() {
|
|||||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||||
|
h.mux.HandleFunc("POST "+StationaryVehicleQueryPath, h.stationaryVehicles)
|
||||||
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
|
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
|
||||||
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
|
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,7 @@ func NewDataHandler(service *Service) *Handler {
|
|||||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||||
|
handler.mux.HandleFunc("POST "+StationaryVehicleQueryPath, handler.stationaryVehicles)
|
||||||
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
|
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
|
||||||
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
|
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
|
||||||
return handler
|
return handler
|
||||||
@@ -120,7 +123,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsPublicPath(path string) bool {
|
func IsPublicPath(path string) bool {
|
||||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
traceID := externalTraceID(r)
|
||||||
|
var request StationaryVehicleQueryRequest
|
||||||
|
if !decodeExternalBody(w, r, traceID, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.QueryStationaryVehicles(r.Context(), externalBearer(r), traceID, request)
|
||||||
|
if err != nil {
|
||||||
|
writeExternalError(w, traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -684,7 +701,9 @@ func writeExternal(w http.ResponseWriter, status int, response any) {
|
|||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
encoder := json.NewEncoder(w)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
_ = encoder.Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalBearer(r *http.Request) string {
|
func externalBearer(r *http.Request) string {
|
||||||
@@ -759,6 +778,12 @@ func dataProducts() []DataProduct {
|
|||||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||||
Path: TotalMileageQueryPath, Unit: "km",
|
Path: TotalMileageQueryPath, Unit: "km",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Code: "stationary_vehicle_verification", Name: "加氢车辆停留核验",
|
||||||
|
Description: "按加氢站坐标与时间区间,核验授权车辆是否在 5 米内低速停留,并按匹配度排序。",
|
||||||
|
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||||
|
Path: StationaryVehicleQueryPath, Unit: "辆",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
|
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
|
||||||
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||||
|
|||||||
@@ -0,0 +1,616 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hydrogenRefuelObservationWindow = 10 * time.Minute
|
||||||
|
hydrogenRefuelThermalGapWindow = 30 * time.Minute
|
||||||
|
hydrogenRefuelConfirmSamples = 3
|
||||||
|
hydrogenRefuelMileageEpsilonKm = 0.2
|
||||||
|
hydrogenThermalMileageEpsilonKm = 5.0
|
||||||
|
hydrogenRefuelTemperatureRiseC = 3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
const trustedHydrogenAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"
|
||||||
|
|
||||||
|
func defaultHydrogenCalculationParameters() HydrogenCalculationParameters {
|
||||||
|
return HydrogenCalculationParameters{
|
||||||
|
HydrogenEnergyKWhKg: 16,
|
||||||
|
PowerOnDelaySeconds: 0,
|
||||||
|
PowerOffLeadSeconds: 0,
|
||||||
|
RefuelRiseMPa: 3,
|
||||||
|
RefuelSustainSeconds: 60,
|
||||||
|
PureElectricDropMPA: 5,
|
||||||
|
PureElectricWindowSeconds: 1800,
|
||||||
|
AlgorithmVersion: trustedHydrogenAlgorithmVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHydrogenCalculationParameters(input HydrogenCalculationParameters) HydrogenCalculationParameters {
|
||||||
|
result := defaultHydrogenCalculationParameters()
|
||||||
|
if input.BatteryCapacityKWh > 0 {
|
||||||
|
result.BatteryCapacityKWh = input.BatteryCapacityKWh
|
||||||
|
}
|
||||||
|
if input.HydrogenEnergyKWhKg > 0 {
|
||||||
|
result.HydrogenEnergyKWhKg = input.HydrogenEnergyKWhKg
|
||||||
|
}
|
||||||
|
if input.PowerOnDelaySeconds > 0 {
|
||||||
|
result.PowerOnDelaySeconds = input.PowerOnDelaySeconds
|
||||||
|
}
|
||||||
|
if input.PowerOffLeadSeconds > 0 {
|
||||||
|
result.PowerOffLeadSeconds = input.PowerOffLeadSeconds
|
||||||
|
}
|
||||||
|
if input.RefuelRiseMPa > 0 {
|
||||||
|
result.RefuelRiseMPa = input.RefuelRiseMPa
|
||||||
|
}
|
||||||
|
if input.RefuelSustainSeconds > 0 {
|
||||||
|
result.RefuelSustainSeconds = input.RefuelSustainSeconds
|
||||||
|
}
|
||||||
|
if input.PureElectricDropMPA > 0 {
|
||||||
|
result.PureElectricDropMPA = input.PureElectricDropMPA
|
||||||
|
}
|
||||||
|
if input.PureElectricWindowSeconds > 0 {
|
||||||
|
result.PureElectricWindowSeconds = input.PureElectricWindowSeconds
|
||||||
|
}
|
||||||
|
result.InitialChargeCycleKnown = input.InitialChargeCycleKnown
|
||||||
|
result.InitialMixedLocked = input.InitialMixedLocked
|
||||||
|
if input.AlgorithmVersion != "" {
|
||||||
|
result.AlgorithmVersion = input.AlgorithmVersion
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildHydrogenDailyStatsWithParameters calculates auditable daily hydrogen
|
||||||
|
// results. Pressure/temperature mass remains the physical source of truth;
|
||||||
|
// battery SOC only produces a separate energy-balanced derivative.
|
||||||
|
func BuildHydrogenDailyStatsWithParameters(
|
||||||
|
observations []HydrogenObservation,
|
||||||
|
date string,
|
||||||
|
noiseKg float64,
|
||||||
|
maxDropKg float64,
|
||||||
|
parameters map[string]HydrogenCalculationParameters,
|
||||||
|
) []HydrogenDailyStat {
|
||||||
|
return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, false, parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildHydrogenDailyStatsOrderedWithParameters(
|
||||||
|
observations []HydrogenObservation,
|
||||||
|
date string,
|
||||||
|
noiseKg float64,
|
||||||
|
maxDropKg float64,
|
||||||
|
parameters map[string]HydrogenCalculationParameters,
|
||||||
|
) []HydrogenDailyStat {
|
||||||
|
return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, true, parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTrustedHydrogenDailyStat(
|
||||||
|
vin string,
|
||||||
|
source string,
|
||||||
|
date string,
|
||||||
|
values []HydrogenObservation,
|
||||||
|
noiseKg float64,
|
||||||
|
maxDropKg float64,
|
||||||
|
input HydrogenCalculationParameters,
|
||||||
|
) HydrogenDailyStat {
|
||||||
|
return buildHydrogenDailyStatV3(vin, source, date, values, noiseKg, maxDropKg, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareHydrogenObservations(values []HydrogenObservation, params HydrogenCalculationParameters) ([]HydrogenObservation, int) {
|
||||||
|
prepared := append([]HydrogenObservation(nil), values...)
|
||||||
|
for index := range prepared {
|
||||||
|
prepared[index].BoundaryEligible = true
|
||||||
|
}
|
||||||
|
var powerOnAt time.Time
|
||||||
|
previousVehicleKnown := false
|
||||||
|
previousVehicleState := 0
|
||||||
|
charging := false
|
||||||
|
chargeCount := 0
|
||||||
|
for index := range prepared {
|
||||||
|
value := &prepared[index]
|
||||||
|
if value.VehicleStateKnown && value.VehicleState == 1 && (!previousVehicleKnown || previousVehicleState != 1) {
|
||||||
|
powerOnAt = value.ObservedAt
|
||||||
|
}
|
||||||
|
if !powerOnAt.IsZero() && value.ObservedAt.Before(powerOnAt.Add(time.Duration(params.PowerOnDelaySeconds)*time.Second)) {
|
||||||
|
value.BoundaryEligible = false
|
||||||
|
value.BoundaryReason = "上电稳定等待窗口"
|
||||||
|
}
|
||||||
|
isCharging := value.ChargeStateKnown && value.ChargeState == 1
|
||||||
|
if isCharging && !charging {
|
||||||
|
chargeCount++
|
||||||
|
}
|
||||||
|
charging = isCharging
|
||||||
|
if isCharging {
|
||||||
|
value.BoundaryEligible = false
|
||||||
|
value.BoundaryReason = "停车充电区间"
|
||||||
|
}
|
||||||
|
if value.VehicleStateKnown {
|
||||||
|
previousVehicleKnown = true
|
||||||
|
previousVehicleState = value.VehicleState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 1; index < len(prepared); index++ {
|
||||||
|
value := prepared[index]
|
||||||
|
previous := prepared[index-1]
|
||||||
|
if !value.VehicleStateKnown || value.VehicleState != 2 || !previous.VehicleStateKnown || previous.VehicleState == 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cutoff := value.ObservedAt.Add(-time.Duration(params.PowerOffLeadSeconds) * time.Second)
|
||||||
|
for cursor := index; cursor >= 0 && prepared[cursor].ObservedAt.After(cutoff); cursor-- {
|
||||||
|
prepared[cursor].BoundaryEligible = false
|
||||||
|
prepared[cursor].BoundaryReason = "下电前稳定窗口"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markPersistentRefuels(prepared, params)
|
||||||
|
markPureElectricPressureInvalid(prepared, params)
|
||||||
|
return prepared, chargeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPersistentRefuels(values []HydrogenObservation, params HydrogenCalculationParameters) {
|
||||||
|
if len(values) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minimumPressure := values[0].PressureMPa
|
||||||
|
minimumMassKg := values[0].MassKg
|
||||||
|
minimumObservedAt := values[0].ObservedAt
|
||||||
|
minimumFloorIndex := 0
|
||||||
|
candidateIndex := -1
|
||||||
|
candidateBaseline := 0.0
|
||||||
|
candidateBaselineMassKg := 0.0
|
||||||
|
candidateHighSamples := 0
|
||||||
|
for index := 1; index < len(values); index++ {
|
||||||
|
value := values[index]
|
||||||
|
previous := values[index-1]
|
||||||
|
// Zero/negative pressure is an invalid sensor placeholder and must not
|
||||||
|
// become the low-pressure baseline of a refuel candidate.
|
||||||
|
if value.PressureMPa <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if minimumPressure <= 0 {
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
minimumFloorIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidateIndex >= 0 {
|
||||||
|
candidateAge := value.ObservedAt.Sub(values[candidateIndex].ObservedAt)
|
||||||
|
switch {
|
||||||
|
case candidateAge > hydrogenRefuelObservationWindow:
|
||||||
|
// A fast fill must be confirmed within the observation window. Do
|
||||||
|
// not let an old pressure rise split a much later driving segment.
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
minimumFloorIndex = index
|
||||||
|
candidateIndex = -1
|
||||||
|
candidateHighSamples = 0
|
||||||
|
case value.PressureMPa-candidateBaseline < params.RefuelRiseMPa:
|
||||||
|
if value.PressureMPa < minimumPressure {
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
}
|
||||||
|
candidateIndex = -1
|
||||||
|
candidateHighSamples = 0
|
||||||
|
default:
|
||||||
|
candidateHighSamples++
|
||||||
|
if candidateAge >= time.Duration(params.RefuelSustainSeconds)*time.Second || candidateHighSamples >= hydrogenRefuelConfirmSamples {
|
||||||
|
values[candidateIndex].RefuelBoundary = true
|
||||||
|
values[candidateIndex].RefuelAmountKg = math.Max(0, value.MassKg-candidateBaselineMassKg)
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
minimumFloorIndex = index
|
||||||
|
candidateIndex = -1
|
||||||
|
candidateHighSamples = 0
|
||||||
|
}
|
||||||
|
// Keep a pending fast-refuel candidate across an immediate
|
||||||
|
// fuel-cell restart. The post-fill high pressure is confirmation,
|
||||||
|
// not ordinary bottle-valve pressure recovery.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if previous.FuelCellStateKnown && !previous.FuelCellActive && value.FuelCellStateKnown && value.FuelCellActive {
|
||||||
|
// Compare the recovered pressure/mass with the highest stable value
|
||||||
|
// immediately before or during the inactive period. A recovery that does
|
||||||
|
// not exceed that baseline by at least 1 kg is pipe pressure restoration,
|
||||||
|
// not refuelling. A larger increase still needs thermal refill evidence.
|
||||||
|
reference, referenceOK := hydrogenValveRecoveryReference(values, index)
|
||||||
|
thresholdKg := math.Max(1, value.RefuelThresholdKg)
|
||||||
|
if referenceOK && value.MassKg-reference.MassKg >= thresholdKg &&
|
||||||
|
hydrogenRefuelThermalJump(previous, value, params) {
|
||||||
|
candidateIndex = index
|
||||||
|
candidateBaseline = previous.PressureMPa
|
||||||
|
candidateBaselineMassKg = reference.MassKg
|
||||||
|
candidateHighSamples = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
minimumFloorIndex = index
|
||||||
|
candidateIndex = -1
|
||||||
|
candidateHighSamples = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidateIndex < 0 && hydrogenRefuelThermalJump(previous, value, params) {
|
||||||
|
// A short fill can be hidden behind invalid temperature frames or a
|
||||||
|
// reporting gap. A near-stationary pressure jump accompanied by hydrogen
|
||||||
|
// temperature rise is refill evidence even when the ordinary 10-minute
|
||||||
|
// pressure window would miss it.
|
||||||
|
candidateIndex = index
|
||||||
|
candidateBaseline = previous.PressureMPa
|
||||||
|
candidateBaselineMassKg = previous.MassKg
|
||||||
|
candidateHighSamples = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidateIndex < 0 {
|
||||||
|
if value.ObservedAt.Sub(minimumObservedAt) > hydrogenRefuelObservationWindow {
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
for cursor := index - 1; cursor >= minimumFloorIndex; cursor-- {
|
||||||
|
candidate := values[cursor]
|
||||||
|
if value.ObservedAt.Sub(candidate.ObservedAt) > hydrogenRefuelObservationWindow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if candidate.PressureMPa < minimumPressure {
|
||||||
|
minimumPressure = candidate.PressureMPa
|
||||||
|
minimumMassKg = candidate.MassKg
|
||||||
|
minimumObservedAt = candidate.ObservedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.PressureMPa < minimumPressure {
|
||||||
|
minimumPressure = value.PressureMPa
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
minimumObservedAt = value.ObservedAt
|
||||||
|
}
|
||||||
|
if value.PressureMPa-minimumPressure >= params.RefuelRiseMPa && hydrogenRefuelCandidateContext(previous, value) {
|
||||||
|
candidateIndex = index
|
||||||
|
candidateBaseline = minimumPressure
|
||||||
|
candidateBaselineMassKg = minimumMassKg
|
||||||
|
candidateHighSamples = 1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenRefuelThermalJump(previous, value HydrogenObservation, params HydrogenCalculationParameters) bool {
|
||||||
|
gap := value.ObservedAt.Sub(previous.ObservedAt)
|
||||||
|
if gap <= 0 || gap > hydrogenRefuelThermalGapWindow {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if previous.PressureMPa <= 0 || value.PressureMPa-previous.PressureMPa < params.RefuelRiseMPa {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if value.TemperatureC-previous.TemperatureC < hydrogenRefuelTemperatureRiseC {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !previous.MileageKnown || !value.MileageKnown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return math.Abs(value.MileageKm-previous.MileageKm) <= hydrogenThermalMileageEpsilonKm
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenValveRecoveryReference(values []HydrogenObservation, restartIndex int) (HydrogenObservation, bool) {
|
||||||
|
if restartIndex <= 0 || restartIndex >= len(values) {
|
||||||
|
return HydrogenObservation{}, false
|
||||||
|
}
|
||||||
|
restartTime := values[restartIndex].ObservedAt
|
||||||
|
var reference HydrogenObservation
|
||||||
|
found := false
|
||||||
|
for index := restartIndex - 1; index >= 0; index-- {
|
||||||
|
value := values[index]
|
||||||
|
if restartTime.Sub(value.ObservedAt) > hydrogenRefuelThermalGapWindow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if value.PressureMPa > 0 && value.MassKg > 0 && (!found || value.MassKg > reference.MassKg) {
|
||||||
|
reference = value
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if value.FuelCellStateKnown && value.FuelCellActive {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !value.FuelCellStateKnown {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reference, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNetHydrogenIncreaseRefuelFallback(values, detectionRuns []HydrogenObservation) {
|
||||||
|
if len(values) < hydrogenRefuelConfirmSamples || len(detectionRuns) < hydrogenRefuelConfirmSamples {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
firstRun := detectionRuns[0]
|
||||||
|
lastRun := detectionRuns[len(detectionRuns)-1]
|
||||||
|
if firstRun.PressureMPa <= 0 || lastRun.PressureMPa <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thresholdKg := math.Max(1, firstRun.RefuelThresholdKg)
|
||||||
|
if lastRun.MassKg-firstRun.MassKg < thresholdKg {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bestRunIndex := -1
|
||||||
|
bestRiseKg := 0.0
|
||||||
|
minimumMassKg := firstRun.MassKg
|
||||||
|
for index := 1; index < len(detectionRuns); index++ {
|
||||||
|
value := detectionRuns[index]
|
||||||
|
if value.MassKg < minimumMassKg {
|
||||||
|
minimumMassKg = value.MassKg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
riseKg := value.MassKg - minimumMassKg
|
||||||
|
if riseKg < thresholdKg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Split at the first high point after the low-mass baseline. The daily
|
||||||
|
// start-to-end net increase already confirms that the rise persisted.
|
||||||
|
// The largest mass rise can occur much later and would otherwise place the
|
||||||
|
// boundary at day end, where it cannot separate pre/post-refuel usage.
|
||||||
|
if bestRunIndex < 0 {
|
||||||
|
bestRunIndex = index
|
||||||
|
}
|
||||||
|
bestRiseKg = math.Max(bestRiseKg, riseKg)
|
||||||
|
}
|
||||||
|
if bestRunIndex < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boundary := detectionRuns[bestRunIndex]
|
||||||
|
for index := range values {
|
||||||
|
if values[index].EventID == boundary.EventID || values[index].ObservedAt.Equal(boundary.ObservedAt) {
|
||||||
|
values[index].RefuelBoundary = true
|
||||||
|
values[index].RefuelAmountKg = bestRiseKg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenRefuelCandidateContext(previous, value HydrogenObservation) bool {
|
||||||
|
if previous.MileageKnown && value.MileageKnown {
|
||||||
|
// Mileage is the primary discriminator because vehicle/fuel-cell states
|
||||||
|
// can remain stale for the first frame after a short fill.
|
||||||
|
return math.Abs(value.MileageKm-previous.MileageKm) <= hydrogenRefuelMileageEpsilonKm
|
||||||
|
}
|
||||||
|
if value.VehicleStateKnown && value.VehicleState == 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if value.FuelCellStateKnown && !value.FuelCellActive {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Preserve pressure-only compatibility when neither operating-state signal
|
||||||
|
// is present; downstream persistence and valve-reopen checks still apply.
|
||||||
|
return !value.VehicleStateKnown && !value.FuelCellStateKnown
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPureElectricPressureInvalid(values []HydrogenObservation, params HydrogenCalculationParameters) {
|
||||||
|
var peak HydrogenObservation
|
||||||
|
hasPeak := false
|
||||||
|
window := time.Duration(params.PureElectricWindowSeconds) * time.Second
|
||||||
|
for index := range values {
|
||||||
|
value := &values[index]
|
||||||
|
pureElectric := value.RunningModeKnown && value.RunningMode == 1
|
||||||
|
if !pureElectric {
|
||||||
|
hasPeak = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasPeak || value.ObservedAt.Sub(peak.ObservedAt) > window {
|
||||||
|
peak, hasPeak = *value, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if peak.PressureMPa-value.PressureMPa > params.PureElectricDropMPA {
|
||||||
|
value.PressureInvalid = true
|
||||||
|
value.BoundaryEligible = false
|
||||||
|
value.BoundaryReason = "纯电状态30分钟内氢压下降超过阈值"
|
||||||
|
}
|
||||||
|
if value.PressureMPa > peak.PressureMPa {
|
||||||
|
peak = *value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trustedHydrogenAccumulator struct {
|
||||||
|
noiseKg float64
|
||||||
|
maxDropKg float64
|
||||||
|
params HydrogenCalculationParameters
|
||||||
|
previous HydrogenObservation
|
||||||
|
hasPrevious bool
|
||||||
|
segmentType string
|
||||||
|
segment []HydrogenObservation
|
||||||
|
intervals []HydrogenIntervalEvidence
|
||||||
|
minimumMassKg float64
|
||||||
|
refuelCount int
|
||||||
|
abnormalDrops int
|
||||||
|
invalidSegments int
|
||||||
|
eligibleIntervals int
|
||||||
|
mixedSegments int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrustedHydrogenAccumulator(noiseKg, maxDropKg float64, params HydrogenCalculationParameters) *trustedHydrogenAccumulator {
|
||||||
|
if noiseKg <= 0 {
|
||||||
|
noiseKg = 0.05
|
||||||
|
}
|
||||||
|
if maxDropKg <= noiseKg {
|
||||||
|
maxDropKg = 20
|
||||||
|
}
|
||||||
|
return &trustedHydrogenAccumulator{noiseKg: noiseKg, maxDropKg: maxDropKg, params: params, minimumMassKg: math.Inf(1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (accumulator *trustedHydrogenAccumulator) Add(value HydrogenObservation) {
|
||||||
|
segmentType := hydrogenObservationSegmentType(value)
|
||||||
|
eligible := value.BoundaryEligible && !value.PressureInvalid && segmentType != ""
|
||||||
|
if accumulator.hasPrevious {
|
||||||
|
gap := value.ObservedAt.Sub(accumulator.previous.ObservedAt)
|
||||||
|
massDrop := accumulator.previous.MassKg - value.MassKg
|
||||||
|
massRise := value.MassKg - accumulator.previous.MassKg
|
||||||
|
switch {
|
||||||
|
case value.RefuelBoundary:
|
||||||
|
accumulator.flush("加氢前结束区间")
|
||||||
|
accumulator.refuelCount++
|
||||||
|
eligible = false
|
||||||
|
case massRise > hydrogenObservationRefuelThreshold(value, accumulator.previous):
|
||||||
|
accumulator.flush("加氢前结束区间")
|
||||||
|
if !rapidHydrogenPressureRecovery(accumulator.previous, value) {
|
||||||
|
accumulator.refuelCount++
|
||||||
|
}
|
||||||
|
case gap > hydrogenSegmentMaxGap:
|
||||||
|
accumulator.flush("数据中断超过5分钟")
|
||||||
|
case massDrop > accumulator.maxDropKg:
|
||||||
|
accumulator.flush("压力质量异常下降")
|
||||||
|
accumulator.abnormalDrops++
|
||||||
|
accumulator.invalidSegments++
|
||||||
|
eligible = false
|
||||||
|
case segmentType != accumulator.segmentType:
|
||||||
|
accumulator.flush("运行模式切换")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.MassKg < accumulator.minimumMassKg {
|
||||||
|
accumulator.minimumMassKg = value.MassKg
|
||||||
|
}
|
||||||
|
if !eligible {
|
||||||
|
accumulator.flush(value.BoundaryReason)
|
||||||
|
accumulator.previous, accumulator.hasPrevious = value, true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accumulator.eligibleIntervals++
|
||||||
|
if len(accumulator.segment) == 0 {
|
||||||
|
accumulator.segmentType = segmentType
|
||||||
|
}
|
||||||
|
accumulator.segment = append(accumulator.segment, value)
|
||||||
|
accumulator.previous, accumulator.hasPrevious = value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (accumulator *trustedHydrogenAccumulator) Finalize() []HydrogenIntervalEvidence {
|
||||||
|
accumulator.flush("")
|
||||||
|
return accumulator.intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (accumulator *trustedHydrogenAccumulator) flush(reason string) {
|
||||||
|
if len(accumulator.segment) < hydrogenSegmentEndpointWindow*2 {
|
||||||
|
accumulator.segment = nil
|
||||||
|
accumulator.segmentType = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := hydrogenMedianObservation(accumulator.segment[:hydrogenSegmentEndpointWindow])
|
||||||
|
end := hydrogenMedianObservation(accumulator.segment[len(accumulator.segment)-hydrogenSegmentEndpointWindow:])
|
||||||
|
interval := HydrogenIntervalEvidence{
|
||||||
|
Index: len(accumulator.intervals) + 1, Type: accumulator.segmentType,
|
||||||
|
StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano),
|
||||||
|
StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source),
|
||||||
|
StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa),
|
||||||
|
StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC),
|
||||||
|
StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg), SampleCount: len(accumulator.segment),
|
||||||
|
QualityStatus: "OK", QualityReason: reason,
|
||||||
|
}
|
||||||
|
if accumulator.segmentType == "MIXED" {
|
||||||
|
drop := start.MassKg - end.MassKg
|
||||||
|
segmentNoise := math.Max(accumulator.noiseKg, math.Max(start.NoiseKg, end.NoiseKg))
|
||||||
|
if drop > segmentNoise {
|
||||||
|
interval.RawHydrogenConsumptionKg = round3(drop)
|
||||||
|
}
|
||||||
|
accumulator.mixedSegments++
|
||||||
|
}
|
||||||
|
if start.SOCKnown && end.SOCKnown {
|
||||||
|
startSOC, endSOC := round3(start.SOCPercent), round3(end.SOCPercent)
|
||||||
|
interval.StartSOCPercent, interval.EndSOCPercent = &startSOC, &endSOC
|
||||||
|
if accumulator.params.BatteryCapacityKWh > 0 {
|
||||||
|
discharge := accumulator.params.BatteryCapacityKWh * (start.SOCPercent - end.SOCPercent) / 100
|
||||||
|
equivalent := discharge / accumulator.params.HydrogenEnergyKWhKg
|
||||||
|
discharge, equivalent = round3(discharge), round3(equivalent)
|
||||||
|
interval.BatteryDischargeKWh, interval.BatteryEquivalentKg = &discharge, &equivalent
|
||||||
|
if accumulator.segmentType == "MIXED" {
|
||||||
|
balanced := round3(interval.RawHydrogenConsumptionKg + equivalent)
|
||||||
|
interval.SOCBalancedConsumptionKg = &balanced
|
||||||
|
if balanced < 0 {
|
||||||
|
interval.QualityStatus = "SUSPECT"
|
||||||
|
interval.QualityReason = "SOC修正后结果为负,需复核SOC或车型参数"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start.MileageKnown && end.MileageKnown {
|
||||||
|
startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm)
|
||||||
|
interval.StartMileageKm, interval.EndMileageKm = &startMileage, &endMileage
|
||||||
|
delta := end.MileageKm - start.MileageKm
|
||||||
|
if delta >= 0 && delta <= 1500 {
|
||||||
|
delta = round3(delta)
|
||||||
|
interval.MileageKm = &delta
|
||||||
|
if accumulator.segmentType == "MIXED" && delta > 0 {
|
||||||
|
consumption := interval.RawHydrogenConsumptionKg
|
||||||
|
if interval.SOCBalancedConsumptionKg != nil {
|
||||||
|
consumption = *interval.SOCBalancedConsumptionKg
|
||||||
|
}
|
||||||
|
rate := round3(consumption * 100 / delta)
|
||||||
|
interval.ConsumptionKgPer100Km = &rate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interval.QualityStatus = "SUSPECT"
|
||||||
|
interval.QualityReason = "区间仪表里程倒退或异常跳变"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interval.QualityStatus != "OK" {
|
||||||
|
accumulator.invalidSegments++
|
||||||
|
}
|
||||||
|
accumulator.intervals = append(accumulator.intervals, interval)
|
||||||
|
accumulator.segment = nil
|
||||||
|
accumulator.segmentType = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenObservationSegmentType(value HydrogenObservation) string {
|
||||||
|
if value.ChargeStateKnown && value.ChargeState == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.VehicleStateKnown && value.VehicleState != 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.RunningModeKnown {
|
||||||
|
switch value.RunningMode {
|
||||||
|
case 1:
|
||||||
|
if value.FuelCellStateKnown && value.FuelCellActive {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "PURE_ELECTRIC"
|
||||||
|
case 2:
|
||||||
|
if value.FuelCellStateKnown && !value.FuelCellActive {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "MIXED"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.FuelCellStateKnown {
|
||||||
|
if value.FuelCellActive {
|
||||||
|
return "MIXED"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "MIXED"
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenMedianObservation(values []HydrogenObservation) HydrogenObservation {
|
||||||
|
ordered := append([]HydrogenObservation(nil), values...)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if ordered[i].MassKg != ordered[j].MassKg {
|
||||||
|
return ordered[i].MassKg < ordered[j].MassKg
|
||||||
|
}
|
||||||
|
return ordered[i].ObservedAt.Before(ordered[j].ObservedAt)
|
||||||
|
})
|
||||||
|
return ordered[len(ordered)/2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyHydrogen(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,953 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPressureHydrogenMassNISTValidationGridHasAtLeast100Samples(t *testing.T) {
|
||||||
|
pressures := []float64{1, 3, 5, 8, 10, 12, 15, 18, 21, 25, 30, 35}
|
||||||
|
temperatures := []float64{-20, -10, 0, 10, 20, 30, 40, 50, 60, 70}
|
||||||
|
const volumeLiter = 520.0
|
||||||
|
validated := 0
|
||||||
|
for _, pressure := range pressures {
|
||||||
|
for _, temperature := range temperatures {
|
||||||
|
got, ok := PressureHydrogenMassKg(pressure, temperature, volumeLiter)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("pressure=%vMPa temperature=%vC unexpectedly rejected", pressure, temperature)
|
||||||
|
}
|
||||||
|
want := independentNISTHydrogenMass(pressure, temperature, volumeLiter)
|
||||||
|
if math.Abs(got-want) > 1e-10 {
|
||||||
|
t.Fatalf("sample=%d pressure=%v temperature=%v got=%.12f want=%.12f", validated, pressure, temperature, got, want)
|
||||||
|
}
|
||||||
|
validated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validated < 100 {
|
||||||
|
t.Fatalf("validated only %d pressure/temperature samples", validated)
|
||||||
|
}
|
||||||
|
t.Logf("validated %d independent NIST pressure/temperature/volume samples", validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func independentNISTHydrogenMass(pressureMPa, temperatureC, volumeLiter float64) float64 {
|
||||||
|
a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
|
||||||
|
b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
|
||||||
|
c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
|
||||||
|
temperatureK := temperatureC + 273.15
|
||||||
|
z := 1.0
|
||||||
|
for index := range a {
|
||||||
|
z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index])
|
||||||
|
}
|
||||||
|
return pressureMPa * 1000 * 0.00201588 * volumeLiter / (8.314472 * temperatureK * z)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedHydrogenCalculatorProducesTraceableSOCBalancedResultFrom120Samples(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
values := make([]HydrogenObservation, 0, 120)
|
||||||
|
for index := 0; index < 120; index++ {
|
||||||
|
pressure := 30 - float64(index)*0.05
|
||||||
|
temperature := 30 + math.Sin(float64(index)/10)
|
||||||
|
mass, ok := PressureHydrogenMassKg(pressure, temperature, 520)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("sample %d mass conversion failed", index)
|
||||||
|
}
|
||||||
|
vehicleState := 1
|
||||||
|
if index == 119 {
|
||||||
|
vehicleState = 2
|
||||||
|
}
|
||||||
|
values = append(values, HydrogenObservation{
|
||||||
|
VIN: vin, Source: "factory-a", EventID: fmt.Sprintf("event-%03d", index),
|
||||||
|
ObservedAt: base.Add(time.Duration(index) * 10 * time.Second),
|
||||||
|
MassKg: mass, TankCapacityLiter: 520, PressureMPa: pressure, TemperatureC: temperature,
|
||||||
|
NoiseKg: 0.05, RefuelThresholdKg: 1,
|
||||||
|
FuelCellActive: true, FuelCellStateKnown: true,
|
||||||
|
SOCPercent: 80 - float64(index)*0.02, SOCKnown: true,
|
||||||
|
MileageKm: 1000 + float64(index)*0.1, MileageKnown: true,
|
||||||
|
VehicleState: vehicleState, VehicleStateKnown: true,
|
||||||
|
ChargeState: 3, ChargeStateKnown: true,
|
||||||
|
RunningMode: 2, RunningModeKnown: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.SampleCount != 120 || len(stat.Intervals) != 1 || stat.QualityStatus != "OK" {
|
||||||
|
t.Fatalf("stat=%#v", stat)
|
||||||
|
}
|
||||||
|
interval := stat.Intervals[0]
|
||||||
|
if interval.StartEventID == "" || interval.EndEventID == "" || interval.SampleCount < 100 {
|
||||||
|
t.Fatalf("trace evidence incomplete: %#v", interval)
|
||||||
|
}
|
||||||
|
if interval.StartSOCPercent == nil || interval.EndSOCPercent == nil || interval.BatteryEquivalentKg == nil || interval.SOCBalancedConsumptionKg == nil {
|
||||||
|
t.Fatalf("SOC evidence incomplete: %#v", interval)
|
||||||
|
}
|
||||||
|
wantEnergyChange := 21.04 * (*interval.EndSOCPercent - *interval.StartSOCPercent) / 100
|
||||||
|
wantEquivalent := wantEnergyChange / 16
|
||||||
|
wantBalanced := interval.RawHydrogenConsumptionKg - wantEquivalent
|
||||||
|
if math.Abs(*interval.BatteryDischargeKWh-round3(wantEnergyChange)) > 0.001 ||
|
||||||
|
math.Abs(*interval.BatteryEquivalentKg-round3(wantEquivalent)) > 0.001 ||
|
||||||
|
math.Abs(*interval.SOCBalancedConsumptionKg-round3(wantBalanced)) > 0.001 {
|
||||||
|
t.Fatalf("energy balance mismatch: interval=%#v", interval)
|
||||||
|
}
|
||||||
|
if stat.SOCBalancedConsumptionKg == nil || stat.SOCBalancedKgPer100Km == nil || stat.MixedMileageKm <= 0 {
|
||||||
|
t.Fatalf("daily SOC-balanced result incomplete: %#v", stat)
|
||||||
|
}
|
||||||
|
t.Logf("validated traceable daily calculation with %d raw samples and %d interval samples", stat.SampleCount, interval.SampleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSOCBalanceUsesEnergyConservationForProvided45TExample(t *testing.T) {
|
||||||
|
rawHydrogenKg := 8.33
|
||||||
|
// SOC下降10个百分点:SOC变化和电等效氢均为负;
|
||||||
|
// 修正耗氢=用氢量-电等效氢,因此等价于补回电池放出的能量。
|
||||||
|
batteryEnergyChangeKWh := 21.04 * (-10) / 100
|
||||||
|
batteryEquivalentKg := batteryEnergyChangeKWh / 16
|
||||||
|
balanced := rawHydrogenKg - batteryEquivalentKg
|
||||||
|
if math.Abs(batteryEquivalentKg-(-0.1315)) > 1e-9 || math.Abs(balanced-8.4615) > 1e-9 {
|
||||||
|
t.Fatalf("battery equivalent=%.6f balanced=%.6f", batteryEquivalentKg, balanced)
|
||||||
|
}
|
||||||
|
if math.Abs(balanced/300*100-2.8205) > 1e-9 {
|
||||||
|
t.Fatalf("balanced rate=%.6f", balanced/300*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedHydrogenCalculatorExcludesExternalChargingAndRebaselines(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := make([]HydrogenObservation, 0, 48)
|
||||||
|
for index := 0; index < 48; index++ {
|
||||||
|
chargeState := 3
|
||||||
|
if index >= 12 && index < 24 {
|
||||||
|
chargeState = 1
|
||||||
|
} else if index == 24 {
|
||||||
|
chargeState = 4
|
||||||
|
}
|
||||||
|
values = append(values, HydrogenObservation{
|
||||||
|
VIN: vin, ObservedAt: base.Add(time.Duration(index) * time.Minute), EventID: fmt.Sprintf("charge-%02d", index),
|
||||||
|
MassKg: 10 - float64(index)*0.02, PressureMPa: 25 - float64(index)*0.05, TemperatureC: 30,
|
||||||
|
NoiseKg: 0.01, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true,
|
||||||
|
SOCPercent: 60 + float64(index), SOCKnown: true, MileageKm: 100 + float64(index), MileageKnown: true,
|
||||||
|
VehicleState: func() int {
|
||||||
|
if chargeState == 1 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}(), VehicleStateKnown: true, ChargeState: chargeState, ChargeStateKnown: true,
|
||||||
|
RunningMode: 2, RunningModeKnown: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.01, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].ChargeCount != 1 || len(stats[0].Intervals) != 2 {
|
||||||
|
t.Fatalf("charging was not isolated: %#v", stats)
|
||||||
|
}
|
||||||
|
for _, interval := range stats[0].Intervals {
|
||||||
|
if interval.StartTime <= values[12].ObservedAt.Format(time.RFC3339Nano) && interval.EndTime >= values[23].ObservedAt.Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("external charging leaked into calculation interval: %#v", interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3CountsOnlyInitialPurePrefixAndLocksAfterMixed(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10.0, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 5, 9.9, 70, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 10, 9.8, 60, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 11, 9.7, 58, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(40*time.Second), 12, 9.6, 56, 2),
|
||||||
|
// Returning to pure mode does not reopen pure mileage before another charge.
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(50*time.Second), 15, 9.5, 53, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(60*time.Second), 20, 9.3, 50, 1),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.PureElectricMileageKm != 10 || stat.MixedMileageKm != 10 {
|
||||||
|
t.Fatalf("pure=%.3f mixed=%.3f intervals=%#v", stat.PureElectricMileageKm, stat.MixedMileageKm, stat.Intervals)
|
||||||
|
}
|
||||||
|
if stat.BatterySOCDeltaPct == nil || *stat.BatterySOCDeltaPct != -10 || stat.ElectricEquivalentKg == nil || math.Abs(*stat.ElectricEquivalentKg-(-0.132)) > 0.0011 {
|
||||||
|
t.Fatalf("SOC delta=%v equivalent=%v stat=%#v", stat.BatterySOCDeltaPct, stat.ElectricEquivalentKg, stat)
|
||||||
|
}
|
||||||
|
if stat.ConsumptionKg != 0.2 || stat.CorrectedConsumptionKg == nil || math.Abs(*stat.CorrectedConsumptionKg-0.332) > 0.0001 {
|
||||||
|
if stat.CorrectedConsumptionKg == nil {
|
||||||
|
t.Fatal("corrected consumption is nil")
|
||||||
|
}
|
||||||
|
t.Fatalf("corrected consumption=%.6f", *stat.CorrectedConsumptionKg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3UsesVehicleRunningModeWhenFuelCellExtensionDisagrees(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 24, 14, 57, 46, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 35, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(50*time.Second), 101, 9.9, 34, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(80*time.Second), 102, 9.8, 34, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(90*time.Second), 103, 9.7, 34, 2),
|
||||||
|
// The extension reports state 1, but the authoritative vehicle running
|
||||||
|
// mode remains 2 and must therefore start and lock the mixed interval.
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(100*time.Second), 104, 9.6, 33, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(110*time.Second), 110, 9.5, 30, 1),
|
||||||
|
}
|
||||||
|
for index := 1; index <= 3; index++ {
|
||||||
|
values[index].FuelCellActive = false
|
||||||
|
values[index].FuelCellStateKnown = true
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-24", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.PureElectricMileageKm != 1 || stat.MixedMileageKm != 9 || len(stat.Intervals) != 2 || stat.Intervals[1].Type != "MIXED" {
|
||||||
|
t.Fatalf("running mode was not authoritative: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3IgnoresSingleMixedModeJitter(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 5, 9.9, 75, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 6, 9.8, 74, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 10, 9.7, 70, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(40*time.Second), 20, 9.6, 65, 1),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].QualityStatus != "NO_DATA" || stats[0].PureElectricMileageKm != 20 || stats[0].MixedMileageKm != 0 {
|
||||||
|
t.Fatalf("single-frame mixed jitter must not lock cycle: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3CarriesMixedLockAcrossDayBoundary(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 0, 0, 10, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10, 60, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Minute), 120, 9, 50, 1),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {
|
||||||
|
BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16,
|
||||||
|
InitialChargeCycleKnown: true, InitialMixedLocked: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 20 {
|
||||||
|
t.Fatalf("mixed lock was reset at midnight: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3LargeBatteryTreatsNoMixedDayAsAllElectricWithoutSameDayCharge(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 160, 9.5, 40, 1),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.PureElectricMileageKm != 60 || stat.MixedMileageKm != 0 {
|
||||||
|
t.Fatalf("large-battery no-mixed day must be entirely pure electric: %#v", stat)
|
||||||
|
}
|
||||||
|
if stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil {
|
||||||
|
t.Fatalf("all-electric day must not expose a mixed-mode hydrogen rate: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "全部里程计入纯电") {
|
||||||
|
t.Fatalf("all-electric classification reason missing: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3SmallBatteryKeepsExistingNoChargeClassification(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 160, 9.5, 40, 1),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 60 {
|
||||||
|
t.Fatalf("small-battery vehicle must retain the existing classification: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3LargeBatteryWithConfirmedMixedKeepsExistingChargeBranch(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 105, 9.9, 90, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 110, 9.8, 85, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 120, 9.5, 80, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 58.73, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].PureElectricMileageKm != 0 || stats[0].MixedMileageKm != 20 {
|
||||||
|
t.Fatalf("confirmed mixed day must retain the existing charge-cycle branch: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotPublishZeroRateWhenPhysicalHydrogenIsNegative(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 80, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 120, 10.1, 70, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.ConsumptionKg >= 0 || stat.CorrectedConsumptionKg == nil || *stat.CorrectedConsumptionKg != 0 || stat.SOCBalancedKgPer100Km != nil || stat.QualityStatus != "NO_DATA" {
|
||||||
|
t.Fatalf("negative physical hydrogen must not publish a zero rate: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "物理用氢量为负") {
|
||||||
|
t.Fatalf("negative physical hydrogen reason missing: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotPublishZeroRateWhenSOCAdjustedHydrogenIsNegative(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 50, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 120, 9.9, 80, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.ConsumptionKg <= 0 || stat.CorrectedConsumptionKg == nil || *stat.CorrectedConsumptionKg != 0 || stat.SOCBalancedKgPer100Km != nil || stat.QualityStatus != "NO_DATA" {
|
||||||
|
t.Fatalf("negative SOC-adjusted hydrogen must not publish a zero rate: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "SOC修正后用氢量为负") {
|
||||||
|
t.Fatalf("negative corrected hydrogen reason missing: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ExcludesDaysBelowOneKilometre(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 60, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 100.9, 9.9, 60, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil {
|
||||||
|
t.Fatalf("day below one kilometre must not participate in rate calculation: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "总里程不足1km") {
|
||||||
|
t.Fatalf("sub-kilometre exclusion reason missing: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotCalculateRateBelowTenMixedKilometres(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 60, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 109.9, 9.5, 58, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.MixedMileageKm != 9.9 || stat.QualityStatus != "NO_DATA" || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil {
|
||||||
|
t.Fatalf("mixed mileage below 10km must not expose a daily rate: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "混动里程不足10km") {
|
||||||
|
t.Fatalf("short mixed mileage reason missing: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ExcludesInactiveFuelCellPipePressureLoss(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10.0, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(4*time.Minute), 120, 9.5, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(8*time.Minute), 130, 9.5, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(12*time.Minute), 140, 9.0, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(16*time.Minute), 150, 8.5, 70, 2),
|
||||||
|
}
|
||||||
|
for index := 2; index < len(values); index++ {
|
||||||
|
values[index].FuelCellActive = false
|
||||||
|
values[index].FuelCellStateKnown = true
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.ConsumptionKg != 0.5 {
|
||||||
|
t.Fatalf("inactive pipe pressure loss must not be counted as driving hydrogen: %#v", stat)
|
||||||
|
}
|
||||||
|
if stat.SuspectedLeakCount != 1 || stat.SuspectedLeakMaxPressureDropMPa != 1 {
|
||||||
|
t.Fatalf("inactive pressure loss evidence mismatch: %#v", stat)
|
||||||
|
}
|
||||||
|
if stat.SOCBalancedKgPer100Km == nil || *stat.SOCBalancedKgPer100Km != 1 {
|
||||||
|
t.Fatalf("rate must use active-stack hydrogen boundary over 50km mixed mileage: %#v", stat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stat.QualityReason, "疑似管路泄压/漏氢") || stat.QualityStatus != "SUSPECT" {
|
||||||
|
t.Fatalf("suspected leak must be explicit and require review: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ResetsSOCAndPurePrefixAtEachExternalCharge(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10.0, 50, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 20, 9.5, 55, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 20, 9.5, 60, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 20, 9.5, 90, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(40*time.Second), 20, 9.5, 90, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(50*time.Second), 30, 9.3, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(60*time.Second), 35, 9.1, 68, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(70*time.Second), 40, 8.9, 66, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(80*time.Second), 60, 8.5, 60, 1),
|
||||||
|
}
|
||||||
|
// Joint charging condition: both charging frames are stopped + chargeState=1.
|
||||||
|
for index := 2; index <= 3; index++ {
|
||||||
|
values[index].VehicleState = 2
|
||||||
|
values[index].ChargeState = 1
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.ChargeCount != 1 || stat.PureElectricMileageKm != 10 || stat.MixedMileageKm != 50 {
|
||||||
|
t.Fatalf("charge-cycle mileage mismatch: %#v", stat)
|
||||||
|
}
|
||||||
|
if math.Abs(stat.ChargeEnergyKWh-6.312) > 0.0011 {
|
||||||
|
t.Fatalf("charge energy mismatch: %#v", stat)
|
||||||
|
}
|
||||||
|
// First mixed cycle SOC +5; second mixed cycle SOC -10. Charging SOC +35 is excluded.
|
||||||
|
if stat.BatterySOCDeltaPct == nil || *stat.BatterySOCDeltaPct != -5 || stat.ElectricEquivalentKg == nil || math.Abs(*stat.ElectricEquivalentKg-(-0.066)) > 0.0011 {
|
||||||
|
t.Fatalf("charge SOC leaked into correction: %#v", stat)
|
||||||
|
}
|
||||||
|
if stat.ConsumptionKg != 1.1 || stat.CorrectedConsumptionKg == nil || math.Abs(*stat.CorrectedConsumptionKg-1.166) > 0.0011 {
|
||||||
|
t.Fatalf("hydrogen correction mismatch: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3SplitsHydrogenAtPersistentRefuelButNotCharge(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
times := []time.Duration{0, time.Minute, 2 * time.Minute, 2*time.Minute + 10*time.Second, 2*time.Minute + 20*time.Second, 3 * time.Minute}
|
||||||
|
masses := []float64{10, 9, 13, 13, 13, 12}
|
||||||
|
mileages := []float64{0, 10, 10, 10, 10, 20}
|
||||||
|
values := make([]HydrogenObservation, 0, len(times))
|
||||||
|
for index := range times {
|
||||||
|
values = append(values, hydrogenV3TestFrame(vin, base.Add(times[index]), mileages[index], masses[index], 50, 2))
|
||||||
|
values[index].PressureMPa = masses[index]
|
||||||
|
}
|
||||||
|
// The fill itself is short and stationary. The third high-pressure sample is
|
||||||
|
// already a restarted driving frame and must still confirm the refuel.
|
||||||
|
for _, index := range []int{2, 3} {
|
||||||
|
values[index].VehicleState = 2
|
||||||
|
values[index].FuelCellActive = false
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].RefuelCount != 1 || stats[0].RefuelAmountKg != 4 || stats[0].ConsumptionKg != 2 || len(stats[0].HydrogenIntervals) != 2 {
|
||||||
|
t.Fatalf("refuel split mismatch: %#v", stats)
|
||||||
|
}
|
||||||
|
if stats[0].HydrogenIntervals[0].RawHydrogenConsumptionKg != 1 || stats[0].HydrogenIntervals[1].RawHydrogenConsumptionKg != 1 {
|
||||||
|
t.Fatalf("refuel evidence mismatch: %#v", stats[0].HydrogenIntervals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DetectsThermalRefuelAcrossInvalidFrameGapAndFuelCellRestart(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 15, 33, 7, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 45241.9, 4.2, 62, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(4*time.Minute+24*time.Second), 45242.0, 10.8, 63, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(4*time.Minute+34*time.Second), 45242.0, 10.7, 64, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(4*time.Minute+44*time.Second), 45242.0, 10.6, 64, 2),
|
||||||
|
}
|
||||||
|
values[0].PressureMPa, values[0].TemperatureC = 12.5, 34
|
||||||
|
values[1].PressureMPa, values[1].TemperatureC = 32.9, 55
|
||||||
|
values[2].PressureMPa, values[2].TemperatureC = 32.8, 54
|
||||||
|
values[3].PressureMPa, values[3].TemperatureC = 32.7, 54
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 0 {
|
||||||
|
t.Fatalf("thermal refill after invalid frames/restart was not detected: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DetectsThermalRefuelAcrossReportingGap(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 10, 26, 38, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 57402.2, 3.9, 77, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(21*time.Minute+50*time.Second), 57402.2, 11.1, 79, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(22*time.Minute), 57402.2, 11.0, 79, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(22*time.Minute+10*time.Second), 57402.2, 10.9, 79, 1),
|
||||||
|
}
|
||||||
|
values[0].PressureMPa, values[0].TemperatureC = 9.8, 28
|
||||||
|
values[1].PressureMPa, values[1].TemperatureC = 34.6, 52
|
||||||
|
values[2].PressureMPa, values[2].TemperatureC = 34.5, 52
|
||||||
|
values[3].PressureMPa, values[3].TemperatureC = 34.4, 51
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 0 {
|
||||||
|
t.Fatalf("thermal refill across reporting gap was not detected: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DetectsThermalRefuelAfterShortReposition(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 24, 18, 31, 44, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 55648.4, 4.267, 72, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(12*time.Minute+6*time.Second), 55648.7, 10.728, 76, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(12*time.Minute+16*time.Second), 55648.7, 10.755, 76, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(12*time.Minute+26*time.Second), 55648.7, 10.72, 76, 1),
|
||||||
|
}
|
||||||
|
values[0].PressureMPa, values[0].TemperatureC = 11.0, 32
|
||||||
|
values[1].PressureMPa, values[1].TemperatureC = 33.1, 53
|
||||||
|
values[2].PressureMPa, values[2].TemperatureC = 33.2, 53
|
||||||
|
values[3].PressureMPa, values[3].TemperatureC = 33.1, 52
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !values[1].RefuelBoundary || values[1].RefuelAmountKg <= 6 {
|
||||||
|
t.Fatalf("thermal refill after 0.3km reposition was not detected: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3TreatsLeakedPipeRecoveryAsValveReopen(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 10, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Minute), 100, 5, 70, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100, 9.8, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute+10*time.Second), 100, 9.8, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute+20*time.Second), 100, 9.8, 70, 2),
|
||||||
|
}
|
||||||
|
values[0].PressureMPa, values[0].TemperatureC = 30, 30
|
||||||
|
values[1].PressureMPa, values[1].TemperatureC = 10, 25
|
||||||
|
values[2].PressureMPa, values[2].TemperatureC = 29, 35
|
||||||
|
values[3].PressureMPa, values[3].TemperatureC = 29, 35
|
||||||
|
values[4].PressureMPa, values[4].TemperatureC = 29, 35
|
||||||
|
for index := range values {
|
||||||
|
values[index].RefuelThresholdKg = 1
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
for _, value := range values {
|
||||||
|
if value.RefuelBoundary {
|
||||||
|
t.Fatalf("pipe pressure restoration must not be marked as refuel: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3AllowsRefuelAbovePreShutdownBaseline(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 10, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Minute), 100, 5, 70, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100, 12, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute+10*time.Second), 100, 12, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute+20*time.Second), 100, 12, 70, 2),
|
||||||
|
}
|
||||||
|
values[0].PressureMPa, values[0].TemperatureC = 30, 30
|
||||||
|
values[1].PressureMPa, values[1].TemperatureC = 10, 25
|
||||||
|
values[2].PressureMPa, values[2].TemperatureC = 36, 50
|
||||||
|
values[3].PressureMPa, values[3].TemperatureC = 36, 50
|
||||||
|
values[4].PressureMPa, values[4].TemperatureC = 36, 50
|
||||||
|
for index := range values {
|
||||||
|
values[index].RefuelThresholdKg = 1
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !values[2].RefuelBoundary || math.Abs(values[2].RefuelAmountKg-2) > 0.001 {
|
||||||
|
t.Fatalf("refuel above pre-shutdown baseline was not detected correctly: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3InfersPersistentRefuelWhenDailyActiveMassEndsHigher(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 4.0, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 2.5, 65, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(3*time.Hour), 155, 9.5, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(3*time.Hour+10*time.Second), 155, 9.4, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(3*time.Hour+20*time.Second), 155, 9.3, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(8*time.Hour), 300, 7.0, 60, 2),
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].PressureMPa = values[index].MassKg * 3
|
||||||
|
values[index].TemperatureC = 30
|
||||||
|
values[index].BoundaryEligible = true
|
||||||
|
values[index].RefuelThresholdKg = 1
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
markNetHydrogenIncreaseRefuelFallback(values, values)
|
||||||
|
if !values[2].RefuelBoundary || values[2].RefuelAmountKg != 7 {
|
||||||
|
t.Fatalf("daily net-mass refill fallback was not detected: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DailyCalculationUsesNetMassRefuelFallback(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 4.0, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Hour), 150, 2.5, 65, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(3*time.Hour), 155, 9.5, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(3*time.Hour+10*time.Second), 155, 9.4, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(3*time.Hour+20*time.Second), 155, 9.3, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(8*time.Hour), 300, 7.0, 60, 2),
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].PressureMPa = values[index].MassKg * 3
|
||||||
|
values[index].TemperatureC = 30
|
||||||
|
values[index].RefuelThresholdKg = 1
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-28", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].RefuelCount != 1 || stats[0].ConsumptionKg <= 0 || stats[0].SOCBalancedKgPer100Km == nil || *stats[0].SOCBalancedKgPer100Km <= 0 {
|
||||||
|
t.Fatalf("V3.5 daily calculation did not apply net-mass refill fallback: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotInferRefuelForValveRecoveryWithoutDailyMassIncrease(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 10, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 5, 65, 1),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour+10*time.Second), 150, 10, 65, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour+20*time.Second), 150, 10, 65, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour+30*time.Second), 150, 10, 65, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(8*time.Hour), 300, 9, 60, 2),
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].PressureMPa = values[index].MassKg * 3
|
||||||
|
values[index].TemperatureC = 30
|
||||||
|
values[index].BoundaryEligible = true
|
||||||
|
values[index].RefuelThresholdKg = 1
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
markNetHydrogenIncreaseRefuelFallback(values, values)
|
||||||
|
for _, value := range values {
|
||||||
|
if value.RefuelBoundary {
|
||||||
|
t.Fatalf("valve recovery without daily net mass increase must not be inferred as refuel: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3CountsRefuelAtLastEffectiveHydrogenBoundary(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 22, 8, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 10, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Hour), 150, 5, 65, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Hour), 150, 15, 65, 2),
|
||||||
|
}
|
||||||
|
values[2].RefuelBoundary = true
|
||||||
|
values[2].RefuelAmountKg = 10
|
||||||
|
consumption, refuelCount, refuelAmount, _, intervals := calculateHydrogenV3Consumption(values, values)
|
||||||
|
if refuelCount != 1 || refuelAmount != 10 || consumption != 5 {
|
||||||
|
t.Fatalf("last-boundary refuel was not included: consumption=%v count=%d amount=%v intervals=%#v", consumption, refuelCount, refuelAmount, intervals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3IgnoresSubNoiseMassRiseBeforeRefuel(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 28, 10, 0, 0, 0, time.Local)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 100, 3.876, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(time.Minute), 100.2, 3.889, 70, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(2*time.Minute), 100.2, 11.135, 70, 2),
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].NoiseKg = 0.05
|
||||||
|
}
|
||||||
|
values[2].RefuelBoundary = true
|
||||||
|
values[2].RefuelAmountKg = 7.259
|
||||||
|
consumption, refuelCount, _, reason, intervals := calculateHydrogenV3Consumption(values, values)
|
||||||
|
if refuelCount != 1 || consumption != 0 || reason != "" || len(intervals) != 2 || intervals[0].QualityStatus != "OK" {
|
||||||
|
t.Fatalf("sub-noise mass rise should be treated as zero: consumption=%v count=%d reason=%q intervals=%#v", consumption, refuelCount, reason, intervals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3RejectsPressureRiseWhileVehicleIsMovingAsRefuel(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame("vin", base, 0, 10, 50, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(10*time.Second), 1, 14, 50, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(20*time.Second), 2, 14, 50, 2),
|
||||||
|
hydrogenV3TestFrame("vin", base.Add(30*time.Second), 3, 14, 50, 2),
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
for _, value := range values {
|
||||||
|
if value.RefuelBoundary {
|
||||||
|
t.Fatalf("moving pressure rise must not be marked as refuel: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3AllowsDailyMileageAbove1500Km(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 1000, 20, 70, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(23*time.Hour), 3000, 10, 60, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].QualityStatus == "NO_DATA" || stats[0].MixedMileageKm != 2000 {
|
||||||
|
t.Fatalf("daily mileage above 1500km must remain calculable: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ToleratesFloatingPointPureMileageEquality(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 0.1, 9.9, 79, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 0.1, 9.9, 90, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 0.1, 9.9, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(40*time.Second), 0.1, 9.9, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(50*time.Second), 0.3, 9.8, 90, 1),
|
||||||
|
}
|
||||||
|
for index := 2; index <= 3; index++ {
|
||||||
|
values[index].VehicleState = 2
|
||||||
|
values[index].ChargeState = 1
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.QualityReason == "纯电里程超过当日总里程" || stat.PureElectricMileageKm != 0.3 || stat.MixedMileageKm != 0 {
|
||||||
|
t.Fatalf("floating-point equality must not be treated as pure mileage overage: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ClearsRatesWhenNoMixedCycleExists(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Second), 10, 9.9, 70, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(20*time.Second), 10, 9.9, 90, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(30*time.Second), 10, 9.9, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(40*time.Second), 10.1, 9.9, 95, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(50*time.Second), 20, 9.8, 90, 1),
|
||||||
|
}
|
||||||
|
for index := 2; index <= 3; index++ {
|
||||||
|
values[index].VehicleState = 2
|
||||||
|
values[index].ChargeState = 1
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats=%#v", stats)
|
||||||
|
}
|
||||||
|
stat := stats[0]
|
||||||
|
if stat.QualityStatus != "NO_DATA" || stat.MixedMileageKm != 0.1 || stat.ConsumptionKgPer100Km != nil || stat.SOCBalancedKgPer100Km != nil {
|
||||||
|
t.Fatalf("NO_DATA without a mixed cycle must not retain rate fields: %#v", stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3ExplainsMissingRunBoundaries(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
parked := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 0, 10, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Minute), 0, 10, 80, 1),
|
||||||
|
}
|
||||||
|
for index := range parked {
|
||||||
|
parked[index].VehicleState = 2
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(parked, "2026-08-26", 0.05, 20, nil)
|
||||||
|
if len(stats) != 1 || stats[0].QualityReason != "当日无车辆启动运行帧,无法形成起止区间" {
|
||||||
|
t.Fatalf("parked vehicle reason is not explicit: %#v", stats)
|
||||||
|
}
|
||||||
|
oneRun := []HydrogenObservation{hydrogenV3TestFrame(vin, base, 0, 10, 80, 2)}
|
||||||
|
stats = BuildHydrogenDailyStatsOrderedWithParameters(oneRun, "2026-08-26", 0.05, 20, nil)
|
||||||
|
if len(stats) != 1 || stats[0].QualityReason != "有效运行分界点仅1条,无法形成起止区间" {
|
||||||
|
t.Fatalf("single run reason is not explicit: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DetectsRefuelAcrossPowerOffTBOXGapWithReposition(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
{ObservedAt: base, PressureMPa: 10, TemperatureC: 30, MassKg: 10, MileageKm: 100, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1},
|
||||||
|
{ObservedAt: base.Add(10 * time.Minute), PressureMPa: 5, TemperatureC: 25, MassKg: 5, MileageKm: 101, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: false, RefuelThresholdKg: 1},
|
||||||
|
{ObservedAt: base.Add(25 * time.Minute), PressureMPa: 12, TemperatureC: 40, MassKg: 12, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1},
|
||||||
|
{ObservedAt: base.Add(25*time.Minute + 10*time.Second), PressureMPa: 12.1, TemperatureC: 40, MassKg: 12.1, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1},
|
||||||
|
{ObservedAt: base.Add(25*time.Minute + 20*time.Second), PressureMPa: 12.1, TemperatureC: 40, MassKg: 12.1, MileageKm: 104, MileageKnown: true, FuelCellStateKnown: true, FuelCellActive: true, RefuelThresholdKg: 1},
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !values[2].RefuelBoundary || values[2].RefuelAmountKg < 2 {
|
||||||
|
t.Fatalf("power-off refuel with TBOX gap and reposition must be detected: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3RecognizesChargeStateOneWhileVehicleIsOn(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10, 20, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(time.Minute), 100, 10, 20, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(2*time.Minute), 100, 10, 80, 1),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(3*time.Minute), 100, 10, 80, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(4*time.Minute), 120, 9.5, 70, 2),
|
||||||
|
}
|
||||||
|
for index := 1; index <= 2; index++ {
|
||||||
|
values[index].ChargeState = 1
|
||||||
|
values[index].VehicleState = 1
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].ChargeCount != 1 || stats[0].BatterySOCDeltaPct == nil || *stats[0].BatterySOCDeltaPct != -10 {
|
||||||
|
t.Fatalf("charge-state=1 must reset the SOC baseline even while vehicle status is on: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotApplySOCChangeAcrossTBOXGap(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
hydrogenV3TestFrame(vin, base, 100, 10, 20, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Minute), 110, 9.8, 90, 2),
|
||||||
|
hydrogenV3TestFrame(vin, base.Add(10*time.Minute+10*time.Second), 120, 9.5, 80, 2),
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].BatterySOCDeltaPct == nil || *stats[0].BatterySOCDeltaPct != -10 {
|
||||||
|
t.Fatalf("SOC change across a TBOX gap must not enter energy correction: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3UsesFuelCellEnergyWhenPressureBoundaryWouldPublishZero(t *testing.T) {
|
||||||
|
const vin = "LTEST32960VIN0001"
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := make([]HydrogenObservation, 0, 61)
|
||||||
|
for index := 0; index <= 60; index++ {
|
||||||
|
frame := hydrogenV3TestFrame(vin, base.Add(time.Duration(index)*10*time.Second), 100+float64(index)/3, 10+float64(index)/600, 70, 2)
|
||||||
|
frame.FuelCellVoltageV = 300
|
||||||
|
frame.FuelCellCurrentA = 100
|
||||||
|
frame.FuelCellPowerKnown = true
|
||||||
|
values = append(values, frame)
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrderedWithParameters(values, "2026-08-26", 0.05, 20, map[string]HydrogenCalculationParameters{
|
||||||
|
vin: {BatteryCapacityKWh: 21.04, HydrogenEnergyKWhKg: 16, InitialChargeCycleKnown: true},
|
||||||
|
})
|
||||||
|
if len(stats) != 1 || stats[0].SOCBalancedKgPer100Km == nil || *stats[0].SOCBalancedKgPer100Km <= 0 || !strings.Contains(stats[0].QualityReason, "电压电流积分") {
|
||||||
|
t.Fatalf("fuel-cell energy must provide a positive auditable fallback: %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenV3TestFrame(vin string, observedAt time.Time, mileage, mass, soc float64, mode int) HydrogenObservation {
|
||||||
|
active := mode == 2
|
||||||
|
return HydrogenObservation{
|
||||||
|
VIN: vin, EventID: fmt.Sprintf("frame-%d", observedAt.UnixNano()), ObservedAt: observedAt,
|
||||||
|
MassKg: mass, PressureMPa: mass, TemperatureC: 30,
|
||||||
|
FuelCellActive: active, FuelCellStateKnown: true,
|
||||||
|
SOCPercent: soc, SOCKnown: true, MileageKm: mileage, MileageKnown: true,
|
||||||
|
VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true,
|
||||||
|
RunningMode: mode, RunningModeKnown: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedHydrogenCalculatorDetectsGradualPersistentRefuelRise(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := make([]HydrogenObservation, 0, 12)
|
||||||
|
pressures := []float64{10, 10.5, 11.2, 12, 13.2, 13.5, 13.6, 13.7, 13.8, 13.9, 14, 14.1}
|
||||||
|
for index, pressure := range pressures {
|
||||||
|
values = append(values, HydrogenObservation{ObservedAt: base.Add(time.Duration(index) * time.Minute), PressureMPa: pressure})
|
||||||
|
}
|
||||||
|
prepared, _ := prepareHydrogenObservations(values, defaultHydrogenCalculationParameters())
|
||||||
|
detected := 0
|
||||||
|
for _, value := range prepared {
|
||||||
|
if value.RefuelBoundary {
|
||||||
|
detected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if detected != 1 {
|
||||||
|
t.Fatalf("persistent gradual pressure rise detections=%d prepared=%#v", detected, prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenV3DoesNotTreatValveReopenPressureRecoveryAsRefuel(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
pressures := []float64{10, 8, 6, 20, 20.1, 20.1, 20.0, 20.1, 20.0, 20.1}
|
||||||
|
values := make([]HydrogenObservation, 0, len(pressures))
|
||||||
|
for index, pressure := range pressures {
|
||||||
|
active := index >= 3
|
||||||
|
values = append(values, HydrogenObservation{
|
||||||
|
ObservedAt: base.Add(time.Duration(index) * time.Minute),
|
||||||
|
PressureMPa: pressure, MassKg: pressure, TemperatureC: 30,
|
||||||
|
FuelCellStateKnown: true, FuelCellActive: active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markPersistentRefuels(values, defaultHydrogenCalculationParameters())
|
||||||
|
for _, value := range values {
|
||||||
|
if value.RefuelBoundary {
|
||||||
|
t.Fatalf("valve reopen pressure recovery must not be marked as refuel: %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedHydrogenCalculatorRejectsPureElectricPressureFall(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
values := []HydrogenObservation{
|
||||||
|
{ObservedAt: base, PressureMPa: 28, RunningMode: 1, RunningModeKnown: true},
|
||||||
|
{ObservedAt: base.Add(20 * time.Minute), PressureMPa: 22.5, RunningMode: 1, RunningModeKnown: true},
|
||||||
|
}
|
||||||
|
prepared, _ := prepareHydrogenObservations(values, defaultHydrogenCalculationParameters())
|
||||||
|
if !prepared[1].PressureInvalid || prepared[1].BoundaryEligible || prepared[1].BoundaryReason == "" {
|
||||||
|
t.Fatalf("pure-electric pressure anomaly not rejected: %#v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hydrogenV3MixedConfirmSamples = 3
|
||||||
|
hydrogenV3MixedConfirmWindow = 30 * time.Second
|
||||||
|
hydrogenV3MinimumMixedKm = 10.0
|
||||||
|
hydrogenV3MileageEpsilonKm = 0.001
|
||||||
|
hydrogenV3LargeBatteryKWh = 30.0
|
||||||
|
hydrogenV3LeakMinimumDuration = 5 * time.Minute
|
||||||
|
hydrogenV3LeakMaximumGap = 5 * time.Minute
|
||||||
|
hydrogenV3LeakMinimumPressure = 0.5
|
||||||
|
hydrogenV3LeakMinimumMassKg = 0.05
|
||||||
|
hydrogenV3SOCContinuityGap = 5 * time.Minute
|
||||||
|
hydrogenV3PowerIntegrationGap = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type hydrogenV3LeakSummary struct {
|
||||||
|
count int
|
||||||
|
maxPressureDropMPa float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type hydrogenV3Cycle struct {
|
||||||
|
frames []HydrogenObservation
|
||||||
|
initialStateKnown bool
|
||||||
|
initialMixedLocked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHydrogenDailyStatV3(
|
||||||
|
vin string,
|
||||||
|
source string,
|
||||||
|
date string,
|
||||||
|
values []HydrogenObservation,
|
||||||
|
noiseKg float64,
|
||||||
|
maxDropKg float64,
|
||||||
|
input HydrogenCalculationParameters,
|
||||||
|
) HydrogenDailyStat {
|
||||||
|
params := normalizeHydrogenCalculationParameters(input)
|
||||||
|
params.PowerOnDelaySeconds = 0
|
||||||
|
params.PowerOffLeadSeconds = 0
|
||||||
|
params.AlgorithmVersion = trustedHydrogenAlgorithmVersion
|
||||||
|
|
||||||
|
stat := HydrogenDailyStat{
|
||||||
|
VIN: vin, Source: source, Date: date, SampleCount: len(values),
|
||||||
|
CalculationParameters: params, QualityStatus: "OK",
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
stat.QualityStatus, stat.QualityReason = "NO_DATA", "无有效压力温度样本"
|
||||||
|
return stat
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := append([]HydrogenObservation(nil), values...)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if !ordered[i].ObservedAt.Equal(ordered[j].ObservedAt) {
|
||||||
|
return ordered[i].ObservedAt.Before(ordered[j].ObservedAt)
|
||||||
|
}
|
||||||
|
return ordered[i].EventID < ordered[j].EventID
|
||||||
|
})
|
||||||
|
|
||||||
|
prepared := append([]HydrogenObservation(nil), ordered...)
|
||||||
|
markPureElectricPressureInvalid(prepared, params)
|
||||||
|
for index := range prepared {
|
||||||
|
annotateHydrogenV3Eligibility(&prepared[index])
|
||||||
|
value := prepared[index]
|
||||||
|
if value.PressureInvalid {
|
||||||
|
stat.InvalidSegmentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cycles, runs, chargeCount, chargeEnergyKWh := buildHydrogenV3Cycles(prepared, params)
|
||||||
|
stat.ChargeCount = chargeCount
|
||||||
|
stat.ChargeEnergyKWh = hydrogenV3Round3(chargeEnergyKWh)
|
||||||
|
stat.EligibleIntervalCount = len(runs)
|
||||||
|
if len(runs) < 2 {
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
if len(runs) == 1 {
|
||||||
|
stat.QualityReason = "有效运行分界点仅1条,无法形成起止区间"
|
||||||
|
} else if countHydrogenV3StartedFrames(prepared) == 0 {
|
||||||
|
stat.QualityReason = "当日无车辆启动运行帧,无法形成起止区间"
|
||||||
|
} else {
|
||||||
|
stat.QualityReason = "当日无满足计算条件的有效运行分界点"
|
||||||
|
}
|
||||||
|
return stat
|
||||||
|
}
|
||||||
|
|
||||||
|
firstRun, lastRun := runs[0], runs[len(runs)-1]
|
||||||
|
stat.FirstObservation, stat.LastObservation = firstRun, lastRun
|
||||||
|
stat.FirstMassKg, stat.LastMassKg = hydrogenV3Round3(firstRun.MassKg), hydrogenV3Round3(lastRun.MassKg)
|
||||||
|
stat.CycleMinimumMassKg = hydrogenV3Round3(minimumHydrogenV3Mass(runs))
|
||||||
|
|
||||||
|
rawTotalMileage := lastRun.MileageKm - firstRun.MileageKm
|
||||||
|
if !firstRun.MileageKnown || !lastRun.MileageKnown || rawTotalMileage < -hydrogenV3MileageEpsilonKm {
|
||||||
|
stat.QualityStatus, stat.QualityReason = "NO_DATA", "当日有效仪表里程倒退或异常跳变"
|
||||||
|
return stat
|
||||||
|
}
|
||||||
|
totalMileage := hydrogenV3Round3(math.Max(0, rawTotalMileage))
|
||||||
|
largeBatteryPureDay := params.BatteryCapacityKWh > hydrogenV3LargeBatteryKWh && !hydrogenV3HasConfirmedMixed(cycles)
|
||||||
|
|
||||||
|
intervals := make([]HydrogenIntervalEvidence, 0, len(cycles)*2)
|
||||||
|
var pureMileage, socDeltaPct, batteryEnergyKWh, electricEquivalentKg float64
|
||||||
|
mixedCycleCount := 0
|
||||||
|
initialStateUnknown := false
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
if largeBatteryPureDay {
|
||||||
|
// For large-battery vehicles, a day with no confirmed mixed-mode
|
||||||
|
// operation is an all-electric day even when the external charge
|
||||||
|
// happened on an earlier date and is absent from today's frames.
|
||||||
|
cycle.initialStateKnown = true
|
||||||
|
cycle.initialMixedLocked = false
|
||||||
|
}
|
||||||
|
if !cycle.initialStateKnown && len(cycle.frames) > 0 && isHydrogenV3Pure(cycle.frames[0]) {
|
||||||
|
initialStateUnknown = true
|
||||||
|
}
|
||||||
|
cycleIntervals, cyclePureKm, cycleSOCDelta, cycleEnergyKWh, cycleEquivalentKg, hasMixed := buildHydrogenV3CycleIntervals(cycle, len(intervals)+1, params)
|
||||||
|
intervals = append(intervals, cycleIntervals...)
|
||||||
|
pureMileage += cyclePureKm
|
||||||
|
if hasMixed {
|
||||||
|
mixedCycleCount++
|
||||||
|
socDeltaPct += cycleSOCDelta
|
||||||
|
batteryEnergyKWh += cycleEnergyKWh
|
||||||
|
electricEquivalentKg += cycleEquivalentKg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fuelCellEnergyHydrogenKg := calculateHydrogenV3FuelCellEnergyHydrogen(cycles, largeBatteryPureDay, params)
|
||||||
|
if largeBatteryPureDay {
|
||||||
|
// Charging/parking gaps can leave small mileage residuals outside the
|
||||||
|
// individual run intervals. The business rule classifies the complete
|
||||||
|
// first-to-last valid mileage delta as pure electric on such days.
|
||||||
|
pureMileage = totalMileage
|
||||||
|
}
|
||||||
|
|
||||||
|
pureMileage = hydrogenV3Round3(math.Max(0, pureMileage))
|
||||||
|
mixedMileage := hydrogenV3Round3(totalMileage - pureMileage)
|
||||||
|
if mixedMileage < -hydrogenV3MileageEpsilonKm {
|
||||||
|
stat.QualityStatus, stat.QualityReason = "NO_DATA", "纯电里程超过当日总里程"
|
||||||
|
return stat
|
||||||
|
}
|
||||||
|
if mixedMileage < 0 {
|
||||||
|
// Telemetry mileage is reported at finite precision. When separately
|
||||||
|
// rounded charge-cycle spans add up a few ulps above the whole-day
|
||||||
|
// boundary delta, treat the sub-metre difference as zero instead of
|
||||||
|
// rejecting a physically valid pure-electric day.
|
||||||
|
mixedMileage = 0
|
||||||
|
pureMileage = totalMileage
|
||||||
|
}
|
||||||
|
|
||||||
|
markPersistentRefuels(prepared, params)
|
||||||
|
// Use all valid running boundaries for the day-level net-increase fallback.
|
||||||
|
// A short fill may occur while the fuel-cell state is inactive; limiting the
|
||||||
|
// fallback to active-stack frames would miss exactly that refill event.
|
||||||
|
hydrogenRuns := hydrogenV3FuelCellConsumptionRuns(runs)
|
||||||
|
_, preliminaryRefuelCount, _, _, _ := calculateHydrogenV3Consumption(hydrogenRuns, prepared)
|
||||||
|
if preliminaryRefuelCount == 0 {
|
||||||
|
markNetHydrogenIncreaseRefuelFallback(prepared, runs)
|
||||||
|
}
|
||||||
|
stat.AbnormalDropCount = countHydrogenV3AbnormalDrops(prepared, maxDropKg)
|
||||||
|
leakSummary := detectHydrogenV3InactivePressureDrops(prepared)
|
||||||
|
stat.SuspectedLeakCount = leakSummary.count
|
||||||
|
stat.SuspectedLeakMaxPressureDropMPa = hydrogenV3Round3(leakSummary.maxPressureDropMPa)
|
||||||
|
consumptionKg, refuelCount, refuelAmountKg, hydrogenReason, hydrogenIntervals := calculateHydrogenV3Consumption(hydrogenRuns, prepared)
|
||||||
|
stat.RefuelCount = refuelCount
|
||||||
|
stat.RefuelAmountKg = hydrogenV3Round3(refuelAmountKg)
|
||||||
|
stat.ConsumptionKg = hydrogenV3Round3(consumptionKg)
|
||||||
|
stat.HydrogenIntervals = hydrogenIntervals
|
||||||
|
stat.PureElectricMileageKm = hydrogenV3Round3(pureMileage)
|
||||||
|
stat.MixedMileageKm = hydrogenV3Round3(mixedMileage)
|
||||||
|
stat.Intervals = intervals
|
||||||
|
stat.QualifiedSegmentCount = len(intervals)
|
||||||
|
|
||||||
|
rawElectricEquivalentKg := electricEquivalentKg
|
||||||
|
usedEnergyFallback := false
|
||||||
|
if mixedMileage >= hydrogenV3MinimumMixedKm && fuelCellEnergyHydrogenKg > 0 &&
|
||||||
|
(consumptionKg <= 0 || consumptionKg-rawElectricEquivalentKg <= 0) {
|
||||||
|
consumptionKg = math.Max(consumptionKg, fuelCellEnergyHydrogenKg)
|
||||||
|
stat.ConsumptionKg = hydrogenV3Round3(consumptionKg)
|
||||||
|
usedEnergyFallback = true
|
||||||
|
}
|
||||||
|
socDeltaPct = hydrogenV3Round3(socDeltaPct)
|
||||||
|
batteryEnergyKWh = hydrogenV3Round3(batteryEnergyKWh)
|
||||||
|
electricEquivalentKg = hydrogenV3Round3(electricEquivalentKg)
|
||||||
|
unclampedCorrectedKg := consumptionKg - rawElectricEquivalentKg
|
||||||
|
correctedRawKg := unclampedCorrectedKg
|
||||||
|
correctedClampedToZero := consumptionKg < 0 || correctedRawKg < 0
|
||||||
|
if correctedClampedToZero {
|
||||||
|
correctedRawKg = 0
|
||||||
|
}
|
||||||
|
correctedKg := hydrogenV3Round3(correctedRawKg)
|
||||||
|
stat.BatterySOCDeltaPct = hydrogenV3FloatPointer(socDeltaPct)
|
||||||
|
stat.BatteryEnergyChangeKWh = hydrogenV3FloatPointer(batteryEnergyKWh)
|
||||||
|
stat.ElectricEquivalentKg = hydrogenV3FloatPointer(electricEquivalentKg)
|
||||||
|
stat.CorrectedConsumptionKg = hydrogenV3FloatPointer(correctedKg)
|
||||||
|
// Compatibility fields retain their storage locations but now follow the
|
||||||
|
// business-facing sign: SOC rise is positive and is subtracted from hydrogen.
|
||||||
|
stat.BatteryDischargeKWh = hydrogenV3FloatPointer(batteryEnergyKWh)
|
||||||
|
stat.BatteryEquivalentKg = hydrogenV3FloatPointer(electricEquivalentKg)
|
||||||
|
stat.SOCBalancedConsumptionKg = hydrogenV3FloatPointer(correctedKg)
|
||||||
|
|
||||||
|
reasons := make([]string, 0, 4)
|
||||||
|
if hydrogenReason != "" {
|
||||||
|
reasons = append(reasons, hydrogenReason)
|
||||||
|
}
|
||||||
|
if usedEnergyFallback {
|
||||||
|
reasons = append(reasons, "压力质量首末边界无法形成有效正耗氢,采用燃料电池电压电流积分折算氢量")
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if largeBatteryPureDay {
|
||||||
|
reasons = append(reasons, "电池容量大于30kWh且全天未出现确认混动,全部里程计入纯电")
|
||||||
|
}
|
||||||
|
if correctedClampedToZero {
|
||||||
|
if consumptionKg < 0 {
|
||||||
|
reasons = append(reasons, "当日物理用氢量为负,修正用氢量已按0处理")
|
||||||
|
} else {
|
||||||
|
reasons = append(reasons, "SOC修正后用氢量为负,修正用氢量已按0处理")
|
||||||
|
}
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if initialStateUnknown {
|
||||||
|
reasons = append(reasons, "日初充电周期状态缺失,按已进入混动处理,未累计日初纯电里程")
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stat.InvalidSegmentCount > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("已排除%d条纯电状态氢压异常记录", stat.InvalidSegmentCount))
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stat.AbnormalDropCount > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("检测到%d次氢量异常大幅下降", stat.AbnormalDropCount))
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stat.SuspectedLeakCount > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("检测到%d段燃料电池未工作期间持续压降,疑似管路泄压/漏氢,最大压降%.3fMPa;停机段不作为行驶耗氢边界", stat.SuspectedLeakCount, stat.SuspectedLeakMaxPressureDropMPa))
|
||||||
|
if stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case totalMileage < 1:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, "当日有效总里程不足1km,不参与氢耗计算")
|
||||||
|
case mixedCycleCount == 0:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, "当日未形成用氢混动周期")
|
||||||
|
case mixedMileage == 0:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, "当日用氢混动里程为0")
|
||||||
|
case len(hydrogenIntervals) == 0:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, "燃料电池有效工作氢量边界不足,无法计算当日耗氢")
|
||||||
|
case params.BatteryCapacityKWh <= 0:
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
reasons = append(reasons, "车型动力电池容量未确认")
|
||||||
|
case mixedMileage < hydrogenV3MinimumMixedKm:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, fmt.Sprintf("混动里程不足%.0fkm,不计算当日百公里氢耗", hydrogenV3MinimumMixedKm))
|
||||||
|
case correctedRawKg <= 0:
|
||||||
|
stat.QualityStatus = "NO_DATA"
|
||||||
|
reasons = append(reasons, "存在混动里程及燃料电池工作记录,但现有报文不足以形成可信的正耗氢结果,不输出0值")
|
||||||
|
}
|
||||||
|
if hydrogenReason != "" && stat.QualityStatus == "OK" {
|
||||||
|
stat.QualityStatus = "SUSPECT"
|
||||||
|
}
|
||||||
|
if stat.QualityStatus != "NO_DATA" && mixedCycleCount > 0 && mixedMileage > 0 {
|
||||||
|
physicalRate := hydrogenV3Round3(consumptionKg * 100 / mixedMileage)
|
||||||
|
correctedRate := hydrogenV3Round3(correctedRawKg * 100 / mixedMileage)
|
||||||
|
stat.ConsumptionKgPer100Km = &physicalRate
|
||||||
|
stat.SOCBalancedKgPer100Km = &correctedRate
|
||||||
|
}
|
||||||
|
stat.QualityReason = strings.Join(reasons, ";")
|
||||||
|
return stat
|
||||||
|
}
|
||||||
|
|
||||||
|
func countHydrogenV3StartedFrames(values []HydrogenObservation) int {
|
||||||
|
count := 0
|
||||||
|
for _, value := range values {
|
||||||
|
if value.VehicleStateKnown && value.VehicleState == 1 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHydrogenV3Cycles(values []HydrogenObservation, params HydrogenCalculationParameters) ([]hydrogenV3Cycle, []HydrogenObservation, int, float64) {
|
||||||
|
cycles := make([]hydrogenV3Cycle, 0, 4)
|
||||||
|
runs := make([]HydrogenObservation, 0, len(values))
|
||||||
|
var current *hydrogenV3Cycle
|
||||||
|
charging := false
|
||||||
|
chargeCount := 0
|
||||||
|
chargeStartSOC := 0.0
|
||||||
|
chargeLastSOC := 0.0
|
||||||
|
chargeSOCKnown := false
|
||||||
|
chargeEnergyKWh := 0.0
|
||||||
|
initialStateApplied := false
|
||||||
|
finishCharge := func() {
|
||||||
|
if chargeSOCKnown && params.BatteryCapacityKWh > 0 && chargeLastSOC > chargeStartSOC {
|
||||||
|
chargeEnergyKWh += params.BatteryCapacityKWh * (chargeLastSOC - chargeStartSOC) / 100
|
||||||
|
}
|
||||||
|
chargeSOCKnown = false
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
if current != nil && len(current.frames) > 0 && value.ObservedAt.Sub(current.frames[len(current.frames)-1].ObservedAt) > hydrogenV3SOCContinuityGap {
|
||||||
|
cycles = append(cycles, *current)
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
externalCharging := isHydrogenV3ExternalCharging(value)
|
||||||
|
if externalCharging {
|
||||||
|
if !charging {
|
||||||
|
chargeCount++
|
||||||
|
if value.SOCKnown {
|
||||||
|
chargeStartSOC = value.SOCPercent
|
||||||
|
chargeLastSOC = value.SOCPercent
|
||||||
|
chargeSOCKnown = true
|
||||||
|
}
|
||||||
|
if current != nil && len(current.frames) > 0 {
|
||||||
|
cycles = append(cycles, *current)
|
||||||
|
}
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
if value.SOCKnown {
|
||||||
|
if !chargeSOCKnown {
|
||||||
|
chargeStartSOC = value.SOCPercent
|
||||||
|
chargeSOCKnown = true
|
||||||
|
}
|
||||||
|
chargeLastSOC = value.SOCPercent
|
||||||
|
}
|
||||||
|
charging = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if charging {
|
||||||
|
finishCharge()
|
||||||
|
charging = false
|
||||||
|
current = nil
|
||||||
|
initialStateApplied = true
|
||||||
|
}
|
||||||
|
if !isHydrogenV3EffectiveRun(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
known := true
|
||||||
|
locked := false
|
||||||
|
if !initialStateApplied && params.InitialChargeCycleKnown {
|
||||||
|
locked = params.InitialMixedLocked
|
||||||
|
} else if !initialStateApplied {
|
||||||
|
// Without persisted state, do not assume that a day beginning in
|
||||||
|
// pure mode follows an external charge. This prevents midnight from
|
||||||
|
// incorrectly resetting the charge cycle.
|
||||||
|
known = false
|
||||||
|
locked = true
|
||||||
|
}
|
||||||
|
current = &hydrogenV3Cycle{initialStateKnown: known, initialMixedLocked: locked}
|
||||||
|
initialStateApplied = true
|
||||||
|
}
|
||||||
|
current.frames = append(current.frames, value)
|
||||||
|
runs = append(runs, value)
|
||||||
|
}
|
||||||
|
if current != nil && len(current.frames) > 0 {
|
||||||
|
cycles = append(cycles, *current)
|
||||||
|
}
|
||||||
|
if charging {
|
||||||
|
finishCharge()
|
||||||
|
}
|
||||||
|
return cycles, runs, chargeCount, chargeEnergyKWh
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHydrogenV3CycleIntervals(cycle hydrogenV3Cycle, firstIndex int, params HydrogenCalculationParameters) ([]HydrogenIntervalEvidence, float64, float64, float64, float64, bool) {
|
||||||
|
frames := cycle.frames
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return nil, 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
first, last := frames[0], frames[len(frames)-1]
|
||||||
|
startsPure := isHydrogenV3Pure(first) && !cycle.initialMixedLocked
|
||||||
|
mixedStart := -1
|
||||||
|
if cycle.initialMixedLocked || !startsPure {
|
||||||
|
mixedStart = 0
|
||||||
|
} else {
|
||||||
|
mixedStart = findHydrogenV3ConfirmedMixed(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals := make([]HydrogenIntervalEvidence, 0, 2)
|
||||||
|
pureMileage := 0.0
|
||||||
|
if startsPure {
|
||||||
|
pureEndIndex := len(frames) - 1
|
||||||
|
if mixedStart >= 0 {
|
||||||
|
// The first confirmed mixed frame is the state boundary. Its mileage is
|
||||||
|
// shared by the end of the pure prefix and the start of the mixed span,
|
||||||
|
// so no mileage is lost between two adjacent telemetry frames.
|
||||||
|
pureEndIndex = mixedStart
|
||||||
|
}
|
||||||
|
if pureEndIndex >= 0 {
|
||||||
|
pureEnd := frames[pureEndIndex]
|
||||||
|
pureMileage = math.Max(0, pureEnd.MileageKm-first.MileageKm)
|
||||||
|
intervals = append(intervals, newHydrogenV3Interval(firstIndex+len(intervals), "PURE_ELECTRIC", first, pureEnd, pureEndIndex+1, params, "充电后首次连续纯电区间(终点为首次确认混动分界点)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mixedStart < 0 {
|
||||||
|
return intervals, round3(pureMileage), 0, 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
mixedStartFrame := frames[mixedStart]
|
||||||
|
mixedInterval := newHydrogenV3Interval(firstIndex+len(intervals), "MIXED", mixedStartFrame, last, len(frames)-mixedStart, params, "首次进入混动后锁定至下一次充电")
|
||||||
|
intervals = append(intervals, mixedInterval)
|
||||||
|
socDelta := last.SOCPercent - mixedStartFrame.SOCPercent
|
||||||
|
energyKWh := 0.0
|
||||||
|
equivalentKg := 0.0
|
||||||
|
if params.BatteryCapacityKWh > 0 && params.HydrogenEnergyKWhKg > 0 {
|
||||||
|
energyKWh = params.BatteryCapacityKWh * socDelta / 100
|
||||||
|
equivalentKg = energyKWh / params.HydrogenEnergyKWhKg
|
||||||
|
}
|
||||||
|
return intervals, pureMileage, socDelta, energyKWh, equivalentKg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHydrogenV3Interval(index int, intervalType string, start, end HydrogenObservation, sampleCount int, params HydrogenCalculationParameters, reason string) HydrogenIntervalEvidence {
|
||||||
|
startSOC, endSOC := round3(start.SOCPercent), round3(end.SOCPercent)
|
||||||
|
socDelta := round3(end.SOCPercent - start.SOCPercent)
|
||||||
|
energyKWh := round3(params.BatteryCapacityKWh * (end.SOCPercent - start.SOCPercent) / 100)
|
||||||
|
equivalentKg := 0.0
|
||||||
|
if params.HydrogenEnergyKWhKg > 0 {
|
||||||
|
equivalentKg = round3(energyKWh / params.HydrogenEnergyKWhKg)
|
||||||
|
}
|
||||||
|
rawHydrogenKg := round3(start.MassKg - end.MassKg)
|
||||||
|
correctedKg := round3(rawHydrogenKg - equivalentKg)
|
||||||
|
startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm)
|
||||||
|
mileage := round3(math.Max(0, end.MileageKm-start.MileageKm))
|
||||||
|
interval := HydrogenIntervalEvidence{
|
||||||
|
Index: index, Type: intervalType,
|
||||||
|
StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano),
|
||||||
|
StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source),
|
||||||
|
StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa),
|
||||||
|
StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC),
|
||||||
|
StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg),
|
||||||
|
StartSOCPercent: &startSOC, EndSOCPercent: &endSOC, BatterySOCDeltaPct: &socDelta,
|
||||||
|
BatteryEnergyChangeKWh: &energyKWh, ElectricEquivalentKg: &equivalentKg, CorrectedConsumptionKg: &correctedKg,
|
||||||
|
BatteryDischargeKWh: &energyKWh, BatteryEquivalentKg: &equivalentKg,
|
||||||
|
StartMileageKm: &startMileage, EndMileageKm: &endMileage, MileageKm: &mileage,
|
||||||
|
SampleCount: sampleCount, QualityStatus: "OK", QualityReason: reason,
|
||||||
|
}
|
||||||
|
if intervalType == "MIXED" {
|
||||||
|
interval.RawHydrogenConsumptionKg = rawHydrogenKg
|
||||||
|
interval.SOCBalancedConsumptionKg = &correctedKg
|
||||||
|
if mileage > 0 {
|
||||||
|
rate := round3(correctedKg * 100 / mileage)
|
||||||
|
interval.ConsumptionKgPer100Km = &rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrogenV3FuelCellConsumptionRuns limits pressure/temperature mass boundaries
|
||||||
|
// to moments when the fuel-cell stack is actually working. When the stack is
|
||||||
|
// explicitly inactive, the bottle valve may be closed while residual pressure
|
||||||
|
// in the downstream pipe slowly decays; treating that pressure as tank mass
|
||||||
|
// would incorrectly inflate driving hydrogen consumption. Unknown stack state
|
||||||
|
// remains eligible for backward compatibility, but is still subject to the
|
||||||
|
// existing quality rules.
|
||||||
|
func hydrogenV3FuelCellConsumptionRuns(runs []HydrogenObservation) []HydrogenObservation {
|
||||||
|
filtered := make([]HydrogenObservation, 0, len(runs))
|
||||||
|
for _, value := range runs {
|
||||||
|
if value.FuelCellStateKnown && !value.FuelCellActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, value)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectHydrogenV3InactivePressureDrops identifies sustained pressure loss while
|
||||||
|
// the fuel cell is explicitly inactive. Requiring at least three samples, five
|
||||||
|
// minutes of continuity, and simultaneous pressure and temperature-corrected
|
||||||
|
// mass loss avoids classifying isolated sensor noise or simple gas cooling as a
|
||||||
|
// leak. The estimated loss is audit evidence only and is not driving hydrogen.
|
||||||
|
func detectHydrogenV3InactivePressureDrops(values []HydrogenObservation) hydrogenV3LeakSummary {
|
||||||
|
var result hydrogenV3LeakSummary
|
||||||
|
segment := make([]HydrogenObservation, 0, 16)
|
||||||
|
flush := func() {
|
||||||
|
if len(segment) < 3 {
|
||||||
|
segment = segment[:0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, end := segment[0], segment[len(segment)-1]
|
||||||
|
if end.ObservedAt.Sub(start.ObservedAt) < hydrogenV3LeakMinimumDuration {
|
||||||
|
segment = segment[:0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pressureDrop := start.PressureMPa - end.PressureMPa
|
||||||
|
massDrop := start.MassKg - end.MassKg
|
||||||
|
massThreshold := math.Max(hydrogenV3LeakMinimumMassKg, math.Max(start.NoiseKg, end.NoiseKg))
|
||||||
|
if pressureDrop >= hydrogenV3LeakMinimumPressure && massDrop >= massThreshold {
|
||||||
|
result.count++
|
||||||
|
result.maxPressureDropMPa = math.Max(result.maxPressureDropMPa, pressureDrop)
|
||||||
|
}
|
||||||
|
segment = segment[:0]
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
eligible := value.FuelCellStateKnown && !value.FuelCellActive &&
|
||||||
|
!value.RefuelBoundary && validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) && value.MassKg > 0
|
||||||
|
if !eligible {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(segment) > 0 && value.ObservedAt.Sub(segment[len(segment)-1].ObservedAt) > hydrogenV3LeakMaximumGap {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
segment = append(segment, value)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateHydrogenV3Consumption(runs, prepared []HydrogenObservation) (float64, int, float64, string, []HydrogenIntervalEvidence) {
|
||||||
|
if len(runs) < 2 {
|
||||||
|
return 0, 0, 0, "燃料电池有效工作氢量边界不足", nil
|
||||||
|
}
|
||||||
|
boundaries := make([]time.Time, 0, 2)
|
||||||
|
refuelAmountKg := 0.0
|
||||||
|
for _, value := range prepared {
|
||||||
|
if value.RefuelBoundary && value.ObservedAt.After(runs[0].ObservedAt) && !value.ObservedAt.After(runs[len(runs)-1].ObservedAt) {
|
||||||
|
boundaries = append(boundaries, value.ObservedAt)
|
||||||
|
refuelAmountKg += math.Max(0, value.RefuelAmountKg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(boundaries) == 0 {
|
||||||
|
_, drop, _ := hydrogenV3MassDropForCalculation(runs[0], runs[len(runs)-1])
|
||||||
|
interval := newHydrogenV3HydrogenInterval(1, runs[0], runs[len(runs)-1], len(runs), "当日无加氢,取首末有效运行分界点")
|
||||||
|
if drop < 0 {
|
||||||
|
return drop, 0, 0, "无加氢但终点剩余氢量高于起点", []HydrogenIntervalEvidence{interval}
|
||||||
|
}
|
||||||
|
return drop, 0, 0, "", []HydrogenIntervalEvidence{interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex := 0
|
||||||
|
consumption := 0.0
|
||||||
|
usedBoundaries := 0
|
||||||
|
negativeSegment := false
|
||||||
|
intervals := make([]HydrogenIntervalEvidence, 0, len(boundaries)+1)
|
||||||
|
for _, boundary := range boundaries {
|
||||||
|
endIndex := -1
|
||||||
|
for index := startIndex; index < len(runs) && runs[index].ObservedAt.Before(boundary); index++ {
|
||||||
|
endIndex = index
|
||||||
|
}
|
||||||
|
if endIndex >= startIndex {
|
||||||
|
_, drop, _ := hydrogenV3MassDropForCalculation(runs[startIndex], runs[endIndex])
|
||||||
|
consumption += drop
|
||||||
|
negativeSegment = negativeSegment || drop < 0
|
||||||
|
intervals = append(intervals, newHydrogenV3HydrogenInterval(len(intervals)+1, runs[startIndex], runs[endIndex], endIndex-startIndex+1, "加氢前氢量消耗分段"))
|
||||||
|
}
|
||||||
|
nextStart := endIndex + 1
|
||||||
|
for nextStart < len(runs) && runs[nextStart].ObservedAt.Before(boundary) {
|
||||||
|
nextStart++
|
||||||
|
}
|
||||||
|
if nextStart < len(runs) {
|
||||||
|
startIndex = nextStart
|
||||||
|
usedBoundaries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startIndex < len(runs) {
|
||||||
|
_, drop, _ := hydrogenV3MassDropForCalculation(runs[startIndex], runs[len(runs)-1])
|
||||||
|
consumption += drop
|
||||||
|
negativeSegment = negativeSegment || drop < 0
|
||||||
|
intervals = append(intervals, newHydrogenV3HydrogenInterval(len(intervals)+1, runs[startIndex], runs[len(runs)-1], len(runs)-startIndex, "加氢后至下一加氢或日终分段"))
|
||||||
|
}
|
||||||
|
if negativeSegment {
|
||||||
|
return consumption, usedBoundaries, refuelAmountKg, "加氢分段存在终点氢量高于起点", intervals
|
||||||
|
}
|
||||||
|
return consumption, usedBoundaries, refuelAmountKg, "", intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHydrogenV3HydrogenInterval(index int, start, end HydrogenObservation, sampleCount int, reason string) HydrogenIntervalEvidence {
|
||||||
|
startMileage, endMileage := round3(start.MileageKm), round3(end.MileageKm)
|
||||||
|
mileage := round3(math.Max(0, end.MileageKm-start.MileageKm))
|
||||||
|
rawDrop, adjustedDrop, withinNoise := hydrogenV3MassDropForCalculation(start, end)
|
||||||
|
drop := round3(rawDrop)
|
||||||
|
quality := "OK"
|
||||||
|
if adjustedDrop < 0 {
|
||||||
|
quality = "SUSPECT"
|
||||||
|
} else if withinNoise {
|
||||||
|
reason += ";氢量上升在噪声阈值内,按0计"
|
||||||
|
}
|
||||||
|
return HydrogenIntervalEvidence{
|
||||||
|
Index: index, Type: "HYDROGEN",
|
||||||
|
StartTime: start.ObservedAt.Format(time.RFC3339Nano), EndTime: end.ObservedAt.Format(time.RFC3339Nano),
|
||||||
|
StartEventID: start.EventID, EndEventID: end.EventID, Source: firstNonEmptyHydrogen(end.Source, start.Source),
|
||||||
|
StartPressureMPa: round3(start.PressureMPa), EndPressureMPa: round3(end.PressureMPa),
|
||||||
|
StartTemperatureC: round3(start.TemperatureC), EndTemperatureC: round3(end.TemperatureC),
|
||||||
|
StartMassKg: round3(start.MassKg), EndMassKg: round3(end.MassKg), RawHydrogenConsumptionKg: drop,
|
||||||
|
StartMileageKm: &startMileage, EndMileageKm: &endMileage, MileageKm: &mileage,
|
||||||
|
SampleCount: sampleCount, QualityStatus: quality, QualityReason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenV3MassDropForCalculation(start, end HydrogenObservation) (raw, adjusted float64, withinNoise bool) {
|
||||||
|
raw = start.MassKg - end.MassKg
|
||||||
|
adjusted = raw
|
||||||
|
noise := math.Max(0.05, math.Max(start.NoiseKg, end.NoiseKg))
|
||||||
|
if raw < 0 && math.Abs(raw) <= noise {
|
||||||
|
adjusted = 0
|
||||||
|
withinNoise = true
|
||||||
|
}
|
||||||
|
return raw, adjusted, withinNoise
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHydrogenV3ConfirmedMixed(frames []HydrogenObservation) int {
|
||||||
|
runStart, count := -1, 0
|
||||||
|
var previous time.Time
|
||||||
|
for index, value := range frames {
|
||||||
|
if !isHydrogenV3Mixed(value) || (count > 0 && value.ObservedAt.Sub(previous) > hydrogenV3MixedConfirmWindow) {
|
||||||
|
runStart, count = -1, 0
|
||||||
|
}
|
||||||
|
if isHydrogenV3Mixed(value) {
|
||||||
|
if count == 0 {
|
||||||
|
runStart = index
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
previous = value.ObservedAt
|
||||||
|
if count >= hydrogenV3MixedConfirmSamples {
|
||||||
|
return runStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenV3HasConfirmedMixed(cycles []hydrogenV3Cycle) bool {
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
if findHydrogenV3ConfirmedMixed(cycle.frames) >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHydrogenV3ExternalCharging(value HydrogenObservation) bool {
|
||||||
|
return value.ChargeStateKnown && value.ChargeState == 1 && value.VehicleStateKnown &&
|
||||||
|
(value.VehicleState == 1 || value.VehicleState == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateHydrogenV3FuelCellEnergyHydrogen(cycles []hydrogenV3Cycle, allElectric bool, params HydrogenCalculationParameters) float64 {
|
||||||
|
if allElectric || params.HydrogenEnergyKWhKg <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
energyKWh := 0.0
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
frames := cycle.frames
|
||||||
|
if len(frames) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mixedStart := 0
|
||||||
|
if isHydrogenV3Pure(frames[0]) && !cycle.initialMixedLocked {
|
||||||
|
mixedStart = findHydrogenV3ConfirmedMixed(frames)
|
||||||
|
if mixedStart < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := mixedStart + 1; index < len(frames); index++ {
|
||||||
|
previous, current := frames[index-1], frames[index]
|
||||||
|
gap := current.ObservedAt.Sub(previous.ObservedAt)
|
||||||
|
if gap <= 0 || gap > hydrogenV3PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
previousKW := math.Max(0, previous.FuelCellVoltageV*previous.FuelCellCurrentA/1000)
|
||||||
|
currentKW := math.Max(0, current.FuelCellVoltageV*current.FuelCellCurrentA/1000)
|
||||||
|
energyKWh += (previousKW + currentKW) / 2 * gap.Hours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return energyKWh / params.HydrogenEnergyKWhKg
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHydrogenV3EffectiveRun(value HydrogenObservation) bool {
|
||||||
|
return value.VehicleStateKnown && value.VehicleState == 1 &&
|
||||||
|
!isHydrogenV3ExternalCharging(value) && !value.PressureInvalid &&
|
||||||
|
validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) && value.MassKg > 0 &&
|
||||||
|
value.MileageKnown && value.SOCKnown && value.RunningModeKnown
|
||||||
|
}
|
||||||
|
|
||||||
|
func annotateHydrogenV3Eligibility(value *HydrogenObservation) {
|
||||||
|
value.BoundaryEligible = false
|
||||||
|
if value.PressureInvalid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isHydrogenV3ExternalCharging(*value):
|
||||||
|
value.BoundaryReason = "外部充电区间(充电状态=1且车辆状态为启动或熄火)"
|
||||||
|
case !value.VehicleStateKnown || value.VehicleState != 1:
|
||||||
|
value.BoundaryReason = "非车辆启动状态"
|
||||||
|
case !validHydrogenPressureTemperature(value.PressureMPa, value.TemperatureC) || value.MassKg <= 0:
|
||||||
|
value.BoundaryReason = "压力、温度或换算氢量无效"
|
||||||
|
case !value.MileageKnown || !value.SOCKnown || !value.RunningModeKnown:
|
||||||
|
value.BoundaryReason = "SOC、仪表里程或运行模式缺失"
|
||||||
|
default:
|
||||||
|
value.BoundaryEligible = true
|
||||||
|
value.BoundaryReason = "有效运行候选点"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countHydrogenV3AbnormalDrops(values []HydrogenObservation, maxDropKg float64) int {
|
||||||
|
if maxDropKg <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for index := 1; index < len(values); index++ {
|
||||||
|
previous, current := values[index-1], values[index]
|
||||||
|
if !previous.BoundaryEligible || !current.BoundaryEligible || current.RefuelBoundary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previous.MassKg-current.MassKg > maxDropKg {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHydrogenV3Pure(value HydrogenObservation) bool {
|
||||||
|
// GB/T 32960整车运行模式是纯电/混动里程划分的主判断字段。
|
||||||
|
// 燃料电池扩展状态仅作为核验信号,不能覆盖整车运行模式,
|
||||||
|
// 否则会把“运行模式=2、发动机工作状态=1”的真实混动区间误判为纯电。
|
||||||
|
return value.RunningModeKnown && value.RunningMode == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHydrogenV3Mixed(value HydrogenObservation) bool {
|
||||||
|
return value.RunningModeKnown && value.RunningMode == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimumHydrogenV3Mass(values []HydrogenObservation) float64 {
|
||||||
|
minimum := math.Inf(1)
|
||||||
|
for _, value := range values {
|
||||||
|
if value.MassKg < minimum {
|
||||||
|
minimum = value.MassKg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if math.IsInf(minimum, 1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenV3FloatPointer(value float64) *float64 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenV3Round3(value float64) float64 {
|
||||||
|
// Telemetry arithmetic often lands a few ulps below an exact x.xxx5 tie.
|
||||||
|
// Apply a scale-relative epsilon before half-away-from-zero rounding so the
|
||||||
|
// displayed three-decimal result is stable and reproducible in Excel.
|
||||||
|
scaled := value * 1000
|
||||||
|
return math.Round(scaled+math.Copysign(1e-9, scaled)) / 1000
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusNormal = "NORMAL"
|
StatusNormal = "NORMAL"
|
||||||
StatusNoData = "NO_DATA"
|
StatusNoData = "NO_DATA"
|
||||||
|
StatusDataAnomaly = "DATA_ANOMALY"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryRequest struct {
|
type QueryRequest struct {
|
||||||
@@ -48,6 +49,8 @@ type HydrogenResult struct {
|
|||||||
PlateNumber string `json:"plateNumber"`
|
PlateNumber string `json:"plateNumber"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||||
|
CalculationPhase string `json:"calculationPhase,omitempty"`
|
||||||
|
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ type MileageResult struct {
|
|||||||
DataTime *string `json:"dataTime"`
|
DataTime *string `json:"dataTime"`
|
||||||
UpdatedAt *string `json:"updatedAt"`
|
UpdatedAt *string `json:"updatedAt"`
|
||||||
SourceProtocol *string `json:"sourceProtocol"`
|
SourceProtocol *string `json:"sourceProtocol"`
|
||||||
|
DataQuality *string `json:"dataQuality,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ type MileageRangeResult struct {
|
|||||||
DataTime *string `json:"dataTime"`
|
DataTime *string `json:"dataTime"`
|
||||||
UpdatedAt *string `json:"updatedAt"`
|
UpdatedAt *string `json:"updatedAt"`
|
||||||
SourceProtocol *string `json:"sourceProtocol"`
|
SourceProtocol *string `json:"sourceProtocol"`
|
||||||
|
DataQuality *string `json:"dataQuality,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +106,44 @@ type TotalMileagePoint struct {
|
|||||||
TotalMileageKm float64
|
TotalMileageKm float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StationaryVehicleQueryRequest verifies whether an authorized vehicle stayed
|
||||||
|
// at a refuelling-site coordinate during a supplied time window. Coordinates
|
||||||
|
// may be supplied in WGS84 or GCJ02 (Amap) longitude/latitude.
|
||||||
|
type StationaryVehicleQueryRequest struct {
|
||||||
|
StartTime string `json:"startTime"`
|
||||||
|
EndTime string `json:"endTime"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
CoordinateSystem string `json:"coordinateSystem,omitempty"`
|
||||||
|
RadiusMeters *float64 `json:"radiusMeters,omitempty"`
|
||||||
|
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StationaryVehicleResult struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
PlateNumber string `json:"plateNumber"`
|
||||||
|
StayStartTime string `json:"stayStartTime"`
|
||||||
|
StayEndTime string `json:"stayEndTime"`
|
||||||
|
StayDurationSeconds int64 `json:"stayDurationSeconds"`
|
||||||
|
StayDurationMinutes float64 `json:"stayDurationMinutes"`
|
||||||
|
MatchScore float64 `json:"matchScore"`
|
||||||
|
AverageDistanceM float64 `json:"averageDistanceMeters"`
|
||||||
|
MaxDistanceM float64 `json:"maxDistanceMeters"`
|
||||||
|
AverageSpeedKmh float64 `json:"averageSpeedKmh"`
|
||||||
|
MaxSpeedKmh float64 `json:"maxSpeedKmh"`
|
||||||
|
MatchedSamples int `json:"matchedSamples"`
|
||||||
|
SourceProtocols []string `json:"sourceProtocols"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StationaryLocationPoint struct {
|
||||||
|
VIN string
|
||||||
|
Protocol string
|
||||||
|
ObservedAt time.Time
|
||||||
|
Longitude float64
|
||||||
|
Latitude float64
|
||||||
|
SpeedKmh float64
|
||||||
|
}
|
||||||
|
|
||||||
type RealtimeVehicleRequest struct {
|
type RealtimeVehicleRequest struct {
|
||||||
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -111,22 +154,28 @@ type RealtimeVehiclePoint struct {
|
|||||||
Longitude float64
|
Longitude float64
|
||||||
Latitude float64
|
Latitude float64
|
||||||
SpeedKmh float64
|
SpeedKmh float64
|
||||||
|
SOCPercent *float64
|
||||||
TotalMileageKm float64
|
TotalMileageKm float64
|
||||||
ObservedAt time.Time
|
ObservedAt time.Time
|
||||||
Online bool
|
Online bool
|
||||||
|
ActiveToday bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type RealtimeVehicleResult struct {
|
type RealtimeVehicleResult struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
PlateNumber string `json:"plateNumber"`
|
PlateNumber string `json:"plateNumber"`
|
||||||
|
// Protocol is the single, canonical source protocol for this realtime record.
|
||||||
|
// It is normalized to GB32960, MQTT, or JT808.
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
Longitude *float64 `json:"longitude"`
|
Longitude *float64 `json:"longitude"`
|
||||||
Latitude *float64 `json:"latitude"`
|
Latitude *float64 `json:"latitude"`
|
||||||
SpeedKmh *float64 `json:"speedKmh"`
|
SpeedKmh *float64 `json:"speedKmh"`
|
||||||
|
SOCPercent *float64 `json:"socPercent,omitempty"`
|
||||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||||
RecordTime string `json:"recordTime,omitempty"`
|
RecordTime string `json:"recordTime,omitempty"`
|
||||||
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
ActiveToday bool `json:"activeToday"`
|
||||||
MotionStatus string `json:"motionStatus"`
|
MotionStatus string `json:"motionStatus"`
|
||||||
LocationAvailable bool `json:"locationAvailable"`
|
LocationAvailable bool `json:"locationAvailable"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -139,16 +188,21 @@ type HydrogenStationRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenStation struct {
|
type HydrogenStation struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ShortName string `json:"shortName,omitempty"`
|
ShortName string `json:"shortName,omitempty"`
|
||||||
Address string `json:"address,omitempty"`
|
Address string `json:"address,omitempty"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Latitude float64 `json:"latitude"`
|
Latitude float64 `json:"latitude"`
|
||||||
Province string `json:"province,omitempty"`
|
Province string `json:"province,omitempty"`
|
||||||
City string `json:"city,omitempty"`
|
City string `json:"city,omitempty"`
|
||||||
District string `json:"district,omitempty"`
|
District string `json:"district,omitempty"`
|
||||||
Cooperative bool `json:"cooperative"`
|
Cooperative bool `json:"cooperative"`
|
||||||
|
ContactPerson string `json:"contactPerson,omitempty"`
|
||||||
|
ContactPhone string `json:"contactPhone,omitempty"`
|
||||||
|
UnitPrice float64 `json:"unitPrice,omitempty"`
|
||||||
|
MonthlyHydrogenKg float64 `json:"monthlyHydrogenKg"`
|
||||||
|
TotalHydrogenKg float64 `json:"totalHydrogenKg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalResponse struct {
|
type ExternalResponse struct {
|
||||||
@@ -229,11 +283,13 @@ type AppCredential struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DailyHydrogen struct {
|
type DailyHydrogen struct {
|
||||||
VIN string
|
VIN string
|
||||||
Date string
|
Date string
|
||||||
ConsumptionKg float64
|
ConsumptionKg float64
|
||||||
SampleCount int
|
SampleCount int
|
||||||
QualityStatus string
|
QualityStatus string
|
||||||
|
CalculationPhase string
|
||||||
|
AlgorithmVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyMileage struct {
|
type DailyMileage struct {
|
||||||
@@ -258,15 +314,83 @@ type MileageSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenObservation struct {
|
type HydrogenObservation struct {
|
||||||
VIN string
|
VIN string
|
||||||
Source string
|
Source string
|
||||||
ObservedAt time.Time
|
EventID string
|
||||||
MassKg float64
|
ObservedAt time.Time
|
||||||
TankCapacityLiter float64
|
MassKg float64
|
||||||
PressureMPa float64
|
TankCapacityLiter float64
|
||||||
TemperatureC float64
|
PressureMPa float64
|
||||||
NoiseKg float64
|
TemperatureC float64
|
||||||
RefuelThresholdKg float64
|
NoiseKg float64
|
||||||
|
RefuelThresholdKg float64
|
||||||
|
FuelCellActive bool
|
||||||
|
FuelCellStateKnown bool
|
||||||
|
FuelCellVoltageV float64
|
||||||
|
FuelCellCurrentA float64
|
||||||
|
FuelCellPowerKnown bool
|
||||||
|
SOCPercent float64
|
||||||
|
SOCKnown bool
|
||||||
|
MileageKm float64
|
||||||
|
MileageKnown bool
|
||||||
|
VehicleState int
|
||||||
|
VehicleStateKnown bool
|
||||||
|
ChargeState int
|
||||||
|
ChargeStateKnown bool
|
||||||
|
RunningMode int
|
||||||
|
RunningModeKnown bool
|
||||||
|
BoundaryEligible bool
|
||||||
|
BoundaryReason string
|
||||||
|
RefuelBoundary bool
|
||||||
|
RefuelAmountKg float64
|
||||||
|
PressureInvalid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenCalculationParameters struct {
|
||||||
|
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
|
||||||
|
HydrogenEnergyKWhKg float64 `json:"hydrogenEnergyKWhPerKg"`
|
||||||
|
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
|
||||||
|
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
|
||||||
|
RefuelRiseMPa float64 `json:"refuelRiseMpa"`
|
||||||
|
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
|
||||||
|
PureElectricDropMPA float64 `json:"pureElectricDropMpa"`
|
||||||
|
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
|
||||||
|
InitialChargeCycleKnown bool `json:"initialChargeCycleKnown,omitempty"`
|
||||||
|
InitialMixedLocked bool `json:"initialMixedLocked,omitempty"`
|
||||||
|
AlgorithmVersion string `json:"algorithmVersion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenIntervalEvidence struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
StartTime string `json:"startTime"`
|
||||||
|
EndTime string `json:"endTime"`
|
||||||
|
StartEventID string `json:"startEventId"`
|
||||||
|
EndEventID string `json:"endEventId"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
StartPressureMPa float64 `json:"startPressureMpa"`
|
||||||
|
EndPressureMPa float64 `json:"endPressureMpa"`
|
||||||
|
StartTemperatureC float64 `json:"startTemperatureC"`
|
||||||
|
EndTemperatureC float64 `json:"endTemperatureC"`
|
||||||
|
StartMassKg float64 `json:"startMassKg"`
|
||||||
|
EndMassKg float64 `json:"endMassKg"`
|
||||||
|
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
|
||||||
|
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
|
||||||
|
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
|
||||||
|
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||||
|
BatteryEnergyChangeKWh *float64 `json:"batteryEnergyChangeKWh,omitempty"`
|
||||||
|
ElectricEquivalentKg *float64 `json:"electricEquivalentHydrogenKg,omitempty"`
|
||||||
|
CorrectedConsumptionKg *float64 `json:"correctedHydrogenConsumptionKg,omitempty"`
|
||||||
|
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||||
|
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||||
|
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||||
|
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
|
||||||
|
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
|
||||||
|
MileageKm *float64 `json:"mileageKm,omitempty"`
|
||||||
|
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||||
|
SampleCount int `json:"sampleCount"`
|
||||||
|
QualityStatus string `json:"qualityStatus"`
|
||||||
|
QualityReason string `json:"qualityReason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenRateObservation struct {
|
type HydrogenRateObservation struct {
|
||||||
@@ -288,16 +412,42 @@ type HydrogenRateDailyStat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenDailyStat struct {
|
type HydrogenDailyStat struct {
|
||||||
VIN string
|
VIN string
|
||||||
Source string
|
Source string
|
||||||
Date string
|
Date string
|
||||||
ConsumptionKg float64
|
ConsumptionKg float64
|
||||||
FirstMassKg float64
|
FirstMassKg float64
|
||||||
LastMassKg float64
|
LastMassKg float64
|
||||||
SampleCount int
|
CycleMinimumMassKg float64
|
||||||
RefuelCount int
|
SampleCount int
|
||||||
QualityStatus string
|
RefuelCount int
|
||||||
QualityReason string
|
RefuelAmountKg float64
|
||||||
|
AbnormalDropCount int
|
||||||
|
SuspectedLeakCount int
|
||||||
|
SuspectedLeakMaxPressureDropMPa float64
|
||||||
|
EligibleIntervalCount int
|
||||||
|
QualifiedSegmentCount int
|
||||||
|
ChargeCount int
|
||||||
|
ChargeEnergyKWh float64
|
||||||
|
InvalidSegmentCount int
|
||||||
|
BatterySOCDeltaPct *float64
|
||||||
|
BatteryEnergyChangeKWh *float64
|
||||||
|
ElectricEquivalentKg *float64
|
||||||
|
CorrectedConsumptionKg *float64
|
||||||
|
BatteryDischargeKWh *float64
|
||||||
|
BatteryEquivalentKg *float64
|
||||||
|
SOCBalancedConsumptionKg *float64
|
||||||
|
MixedMileageKm float64
|
||||||
|
PureElectricMileageKm float64
|
||||||
|
ConsumptionKgPer100Km *float64
|
||||||
|
SOCBalancedKgPer100Km *float64
|
||||||
|
CalculationParameters HydrogenCalculationParameters
|
||||||
|
Intervals []HydrogenIntervalEvidence
|
||||||
|
HydrogenIntervals []HydrogenIntervalEvidence
|
||||||
|
FirstObservation HydrogenObservation
|
||||||
|
LastObservation HydrogenObservation
|
||||||
|
QualityStatus string
|
||||||
|
QualityReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortalUser struct {
|
type PortalUser struct {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -137,20 +138,65 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) {
|
||||||
|
if r.tdengine == nil || r.tdDatabase == "" {
|
||||||
|
return nil, errors.New("TDengine is not configured for stationary vehicle query")
|
||||||
|
}
|
||||||
|
if len(vins) == 0 {
|
||||||
|
return []StationaryLocationPoint{}, nil
|
||||||
|
}
|
||||||
|
// Use a small bounding box as TDengine's prefilter, then apply the exact
|
||||||
|
// 5-metre great-circle calculation in the service before returning a match.
|
||||||
|
latitudeDelta := radiusMeters / 111320.0
|
||||||
|
longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180))
|
||||||
|
quotedVINs := make([]string, 0, len(vins))
|
||||||
|
for _, vin := range vins {
|
||||||
|
quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'")
|
||||||
|
}
|
||||||
|
startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''")
|
||||||
|
endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''")
|
||||||
|
query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` +
|
||||||
|
` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` +
|
||||||
|
` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` +
|
||||||
|
` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) +
|
||||||
|
` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) +
|
||||||
|
` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) +
|
||||||
|
` ORDER BY vin,ts ASC,protocol ASC`
|
||||||
|
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
points := make([]StationaryLocationPoint, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var point StationaryLocationPoint
|
||||||
|
var timestampMS int64
|
||||||
|
if err := rows.Scan(×tampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
points = append(points, point)
|
||||||
|
}
|
||||||
|
return points, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||||
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
||||||
if len(vins) == 0 {
|
if len(vins) == 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||||
args := make([]any, 0, len(vins)+1)
|
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
args := make([]any, 0, len(vins)+2)
|
||||||
|
args = append(args, dayStart)
|
||||||
for _, vin := range vins {
|
for _, vin := range vins {
|
||||||
args = append(args, vin)
|
args = append(args, vin)
|
||||||
}
|
}
|
||||||
args = append(args, now.Add(-10*time.Minute))
|
args = append(args, now.Add(-10*time.Minute))
|
||||||
query := `
|
query := `
|
||||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||||
COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at
|
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
||||||
|
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today
|
||||||
FROM vehicle_realtime_location l
|
FROM vehicle_realtime_location l
|
||||||
WHERE BINARY l.vin IN (` + placeholders + `)
|
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||||
ORDER BY l.vin,
|
ORDER BY l.vin,
|
||||||
@@ -165,9 +211,14 @@ ORDER BY l.vin,
|
|||||||
onlineThreshold := now.Add(-time.Minute)
|
onlineThreshold := now.Add(-time.Minute)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var point RealtimeVehiclePoint
|
var point RealtimeVehiclePoint
|
||||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
|
var soc sql.NullFloat64
|
||||||
|
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
||||||
|
value := round3(soc.Float64)
|
||||||
|
point.SOCPercent = &value
|
||||||
|
}
|
||||||
point.Online = !point.ObservedAt.Before(onlineThreshold)
|
point.Online = !point.ObservedAt.Before(onlineThreshold)
|
||||||
if selected, exists := out[point.VIN]; !exists {
|
if selected, exists := out[point.VIN]; !exists {
|
||||||
out[point.VIN] = point
|
out[point.VIN] = point
|
||||||
@@ -183,35 +234,57 @@ ORDER BY l.vin,
|
|||||||
|
|
||||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||||
where := []string{
|
where := []string{
|
||||||
"s.longitude BETWEEN -180 AND 180",
|
"n.del_flag='0'",
|
||||||
"s.latitude BETWEEN -90 AND 90",
|
"n.longitude BETWEEN -180 AND 180",
|
||||||
"NOT (s.longitude=0 AND s.latitude=0)",
|
"n.latitude BETWEEN -90 AND 90",
|
||||||
|
"NOT (n.longitude=0 AND n.latitude=0)",
|
||||||
}
|
}
|
||||||
args := make([]any, 0, 3)
|
args := make([]any, 0, 4)
|
||||||
if request.Province != "" {
|
if request.Province != "" {
|
||||||
where = append(where, "s.province=?")
|
where = append(where, "(n.province=? OR province_region.NAME=?)")
|
||||||
args = append(args, request.Province)
|
args = append(args, request.Province, request.Province)
|
||||||
}
|
}
|
||||||
if request.City != "" {
|
if request.City != "" {
|
||||||
where = append(where, "s.city=?")
|
where = append(where, "(n.city=? OR city_region.NAME=?)")
|
||||||
args = append(args, request.City)
|
args = append(args, request.City, request.City)
|
||||||
}
|
|
||||||
if request.CooperateOnly != nil {
|
|
||||||
if *request.CooperateOnly {
|
|
||||||
where = append(where, "s.inner_site_id IS NOT NULL")
|
|
||||||
} else {
|
|
||||||
where = append(where, "s.inner_site_id IS NULL")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
|
SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''),
|
||||||
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
|
n.longitude,n.latitude,
|
||||||
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
|
COALESCE(NULLIF(province_region.NAME,''),n.province,''),
|
||||||
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
|
COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''),
|
||||||
FROM ln_asset_management.tab_outside_hydrogen_site s
|
CASE WHEN s.inner_site_id IS NOT NULL OR (
|
||||||
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
|
n.cooperation_start_date IS NOT NULL
|
||||||
|
AND n.cooperation_start_date<=CURDATE()
|
||||||
|
AND (n.cooperation_end_date IS NULL OR n.cooperation_end_date>=CURDATE())
|
||||||
|
) THEN 1 ELSE 0 END,
|
||||||
|
COALESCE(n.contact_person,''),COALESCE(NULLIF(n.mobile_phone,''),n.fixed_phone,''),COALESCE(n.cost_price,0),
|
||||||
|
COALESCE(l.monthly_hydrogen_kg,0),COALESCE(l.total_hydrogen_kg,n.fill_weight,0)
|
||||||
|
FROM ln_asset_management.new_hydrogen_site n
|
||||||
|
LEFT JOIN ln_asset_management.common_district province_region
|
||||||
|
ON province_region.CODE COLLATE utf8mb4_general_ci=n.province COLLATE utf8mb4_general_ci
|
||||||
|
LEFT JOIN ln_asset_management.common_district city_region
|
||||||
|
ON city_region.CODE COLLATE utf8mb4_general_ci=n.city COLLATE utf8mb4_general_ci
|
||||||
|
LEFT JOIN ln_asset_management.tab_outside_hydrogen_site s ON s.id=(
|
||||||
|
SELECT MIN(matched.id)
|
||||||
|
FROM ln_asset_management.tab_outside_hydrogen_site matched
|
||||||
|
WHERE matched.fixed_station_name=n.site_name COLLATE utf8mb4_general_ci
|
||||||
|
OR matched.station_name=n.site_name COLLATE utf8mb4_general_ci
|
||||||
|
OR matched.fixed_station_name=n.site_short_name COLLATE utf8mb4_general_ci
|
||||||
|
OR matched.station_name=n.site_short_name COLLATE utf8mb4_general_ci
|
||||||
|
)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT station_id,
|
||||||
|
SUM(amount_kg) AS total_hydrogen_kg,
|
||||||
|
SUM(CASE WHEN refuel_date >= DATE_FORMAT(CURDATE(),'%Y-%m-01')
|
||||||
|
AND refuel_date < DATE_ADD(DATE_FORMAT(CURDATE(),'%Y-%m-01'),INTERVAL 1 MONTH)
|
||||||
|
THEN amount_kg ELSE 0 END) AS monthly_hydrogen_kg
|
||||||
|
FROM ln_asset_management.hydrogen_fuel_ledger
|
||||||
|
WHERE del_flag='0'
|
||||||
|
GROUP BY station_id
|
||||||
|
) l ON l.station_id=s.inner_site_id
|
||||||
WHERE `+strings.Join(where, " AND ")+`
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
ORDER BY s.province,s.city,s.fixed_station_name,s.id
|
ORDER BY province_region.NAME,city_region.NAME,n.site_name,n.id
|
||||||
LIMIT 2000`, args...)
|
LIMIT 2000`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -220,9 +293,15 @@ LIMIT 2000`, args...)
|
|||||||
stations := make([]HydrogenStation, 0, 512)
|
stations := make([]HydrogenStation, 0, 512)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var station HydrogenStation
|
var station HydrogenStation
|
||||||
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
|
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative, &station.ContactPerson, &station.ContactPhone, &station.UnitPrice, &station.MonthlyHydrogenKg, &station.TotalHydrogenKg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if isExcelSinopecStation(station.Name, station.ShortName) {
|
||||||
|
station.Cooperative = true
|
||||||
|
}
|
||||||
|
if request.CooperateOnly != nil && station.Cooperative != *request.CooperateOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
stations = append(stations, station)
|
stations = append(stations, station)
|
||||||
}
|
}
|
||||||
return stations, rows.Err()
|
return stations, rows.Err()
|
||||||
@@ -233,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
|
|||||||
return map[string]DailyHydrogen{}, nil
|
return map[string]DailyHydrogen{}, nil
|
||||||
}
|
}
|
||||||
query, args := inQuery(`
|
query, args := inQuery(`
|
||||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
|
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
|
||||||
|
calculation_phase,algorithm_version
|
||||||
FROM vehicle_open_daily_energy
|
FROM vehicle_open_daily_energy
|
||||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
@@ -244,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
|||||||
out := make(map[string]DailyHydrogen, len(vins))
|
out := make(map[string]DailyHydrogen, len(vins))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var value DailyHydrogen
|
var value DailyHydrogen
|
||||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
|
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[value.VIN] = value
|
out[value.VIN] = value
|
||||||
@@ -264,6 +344,48 @@ func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MileageRollbacks returns source days that were rejected because the odometer
|
||||||
|
// moved backwards. They must not silently fall back to an earlier total mileage
|
||||||
|
// and be exposed as normal data.
|
||||||
|
func (r *MySQLRepository) MileageRollbacks(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]bool, error) {
|
||||||
|
out := make(map[string]bool)
|
||||||
|
if len(vins) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT vin,DATE_FORMAT(stat_date,'%Y-%m-%d')
|
||||||
|
FROM vehicle_daily_mileage_source
|
||||||
|
WHERE stat_date BETWEEN ? AND ?
|
||||||
|
AND vin IN (` + vinPlaceholders + `)
|
||||||
|
AND quality_reason='TOTAL_MILEAGE_ROLLBACK'`
|
||||||
|
args := make([]any, 0, len(vins)+2+len(protocols))
|
||||||
|
args = append(args, startDate, endDate)
|
||||||
|
for _, vin := range vins {
|
||||||
|
args = append(args, vin)
|
||||||
|
}
|
||||||
|
if len(protocols) > 0 {
|
||||||
|
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
|
||||||
|
query += "\n AND protocol IN (" + protocolPlaceholders + ")"
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
args = append(args, protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var vin, date string
|
||||||
|
if err := rows.Scan(&vin, &date); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[dailyMileageKey(vin, date)] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
|
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
|
||||||
if len(vins) == 0 {
|
if len(vins) == 0 {
|
||||||
return map[string]DailyMileage{}, nil
|
return map[string]DailyMileage{}, nil
|
||||||
@@ -275,7 +397,7 @@ SELECT
|
|||||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||||
m.protocol,
|
m.protocol,
|
||||||
m.daily_mileage_km,
|
m.daily_mileage_km,
|
||||||
m.latest_total_mileage_km,
|
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
|
||||||
COALESCE(DATE_FORMAT((
|
COALESCE(DATE_FORMAT((
|
||||||
SELECT MAX(selected.latest_event_time)
|
SELECT MAX(selected.latest_event_time)
|
||||||
FROM vehicle_daily_mileage_source selected
|
FROM vehicle_daily_mileage_source selected
|
||||||
@@ -289,8 +411,8 @@ SELECT
|
|||||||
FROM vehicle_daily_mileage m
|
FROM vehicle_daily_mileage m
|
||||||
WHERE m.stat_date BETWEEN ? AND ?
|
WHERE m.stat_date BETWEEN ? AND ?
|
||||||
AND m.vin IN (` + placeholders + `)
|
AND m.vin IN (` + placeholders + `)
|
||||||
AND m.latest_total_mileage_km IS NOT NULL
|
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km) IS NOT NULL
|
||||||
AND m.latest_total_mileage_km>=0
|
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km)>=0
|
||||||
AND m.daily_mileage_km>=0`
|
AND m.daily_mileage_km>=0`
|
||||||
args := make([]any, 0, len(vins)+2+len(protocols)*2)
|
args := make([]any, 0, len(vins)+2+len(protocols)*2)
|
||||||
args = append(args, startDate, endDate)
|
args = append(args, startDate, endDate)
|
||||||
@@ -345,7 +467,7 @@ SELECT
|
|||||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||||
m.protocol,
|
m.protocol,
|
||||||
m.daily_mileage_km,
|
m.daily_mileage_km,
|
||||||
m.latest_total_mileage_km,
|
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
|
||||||
COALESCE(DATE_FORMAT((
|
COALESCE(DATE_FORMAT((
|
||||||
SELECT MAX(selected.latest_event_time)
|
SELECT MAX(selected.latest_event_time)
|
||||||
FROM vehicle_daily_mileage_source selected
|
FROM vehicle_daily_mileage_source selected
|
||||||
@@ -362,8 +484,8 @@ JOIN (
|
|||||||
FROM vehicle_daily_mileage prior
|
FROM vehicle_daily_mileage prior
|
||||||
WHERE prior.stat_date<?
|
WHERE prior.stat_date<?
|
||||||
AND prior.vin IN (` + vinPlaceholders + `)
|
AND prior.vin IN (` + vinPlaceholders + `)
|
||||||
AND prior.latest_total_mileage_km IS NOT NULL
|
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km) IS NOT NULL
|
||||||
AND prior.latest_total_mileage_km>=0
|
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=0
|
||||||
AND prior.daily_mileage_km>=0`
|
AND prior.daily_mileage_km>=0`
|
||||||
args := make([]any, 0, len(vins)+1+len(protocols)*2)
|
args := make([]any, 0, len(vins)+1+len(protocols)*2)
|
||||||
args = append(args, beforeDate)
|
args = append(args, beforeDate)
|
||||||
|
|||||||
@@ -75,13 +75,62 @@ func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) {
|
func TestHydrogenStationsUsesNewMasterAndExcelSinopecRoster(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0").
|
|
||||||
|
mock.ExpectQuery("cooperation_start_date.*FROM ln_asset_management\\.new_hydrogen_site n.*common_district province_region.*common_district city_region.*ORDER BY province_region\\.NAME,city_region\\.NAME,n\\.site_name,n\\.id").
|
||||||
|
WithArgs("湖北省", "湖北省", "武汉市", "武汉市").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "name", "short_name", "address", "longitude", "latitude", "province", "city", "district", "cooperative", "contact_person", "contact_phone", "unit_price", "monthly_hydrogen_kg", "total_hydrogen_kg",
|
||||||
|
}).
|
||||||
|
AddRow("1", "武汉群力加油站", "中国石化群力加油站", "地址一", 114.1, 30.2, "湖北省", "武汉市", "", false, "张工", "13800000001", 12.5, 12.5, 100.5).
|
||||||
|
AddRow("2", "已有合作日期站", "", "地址二", 120.1, 31.2, "上海市", "上海市", "", true, "李工", "13800000002", 13.0, 8.5, 80.5).
|
||||||
|
AddRow("3", "普通外部站", "", "地址三", 121.1, 32.2, "江苏省", "南京市", "", false, "", "", 0, 0, 0))
|
||||||
|
|
||||||
|
cooperateOnly := true
|
||||||
|
stations, err := NewMySQLRepository(db).HydrogenStations(context.Background(), HydrogenStationRequest{Province: "湖北省", City: "武汉市", CooperateOnly: &cooperateOnly})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stations) != 2 {
|
||||||
|
t.Fatalf("expected roster and canonical cooperative stations, got %#v", stations)
|
||||||
|
}
|
||||||
|
if stations[0].Name != "武汉群力加油站" || !stations[0].Cooperative {
|
||||||
|
t.Fatalf("Excel SINOPEC station was not classified as cooperative: %#v", stations[0])
|
||||||
|
}
|
||||||
|
if stations[1].Name != "已有合作日期站" || !stations[1].Cooperative {
|
||||||
|
t.Fatalf("canonical cooperative station was lost: %#v", stations[1])
|
||||||
|
}
|
||||||
|
if stations[0].ContactPerson != "张工" || stations[0].ContactPhone != "13800000001" || stations[0].UnitPrice != 12.5 {
|
||||||
|
t.Fatalf("partner station contact and price were not mapped: %#v", stations[0])
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelSinopecRosterMatchesEitherExcelNameColumn(t *testing.T) {
|
||||||
|
for _, name := range []string{"中国石化青岛炼油化工有限责任公司", "青岛中石化加氢站", "南京溧水柘塘东站", "南京中石化溧水加氢站", "中国石化樟坑加油加氢站"} {
|
||||||
|
if !isExcelSinopecStation(name) {
|
||||||
|
t.Fatalf("expected roster match for %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isExcelSinopecStation("普通外部加氢站") {
|
||||||
|
t.Fatal("non-roster station must not be classified as cooperative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
|
||||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
||||||
@@ -185,6 +234,30 @@ func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStationaryLocationPointsUsesTimeVINAndSmallBoundingBox(t *testing.T) {
|
||||||
|
mysqlDB, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mysqlDB.Close()
|
||||||
|
tdDB, tdMock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer tdDB.Close()
|
||||||
|
start := time.Date(2026, 8, 6, 10, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||||
|
tdMock.ExpectQuery("FROM lingniu_vehicle_ts.vehicle_locations.*ts>='2026-08-06T10:00:00\\+08:00'.*vin IN \\('LTEST32960VIN0001'\\).*speed_kmh BETWEEN 0 AND 3\\.000.*ORDER BY vin,ts ASC,protocol ASC").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"ts", "vin", "protocol", "longitude", "latitude", "speed_kmh"}).
|
||||||
|
AddRow(start.UnixMilli(), "LTEST32960VIN0001", "GB32960", 120.000001, 30.000001, 0.2))
|
||||||
|
points, err := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts").StationaryLocationPoints(context.Background(), []string{"LTEST32960VIN0001"}, start, start.Add(time.Hour), 120, 30, 5, 3)
|
||||||
|
if err != nil || len(points) != 1 || points[0].SpeedKmh != 0.2 || !points[0].ObservedAt.Equal(start) {
|
||||||
|
t.Fatalf("points=%#v err=%v", points, err)
|
||||||
|
}
|
||||||
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,19 +265,20 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
||||||
|
dayStart := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||||
vin := "LTEST32960VIN0001"
|
vin := "LTEST32960VIN0001"
|
||||||
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||||
WithArgs(vin, now.Add(-10*time.Minute)).
|
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}).
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}).
|
||||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)).
|
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true).
|
||||||
AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second)))
|
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true))
|
||||||
|
|
||||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
point := points[vin]
|
point := points[vin]
|
||||||
if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
if point.Protocol != "GB32960" || point.SOCPercent == nil || *point.SOCPercent != 86.5 || !point.Online || !point.ActiveToday || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
||||||
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -34,10 +35,12 @@ type Repository interface {
|
|||||||
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||||
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||||
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||||
|
MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error)
|
||||||
CreateMileageSnapshot(context.Context, MileageSnapshot) error
|
CreateMileageSnapshot(context.Context, MileageSnapshot) error
|
||||||
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
||||||
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
||||||
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
||||||
|
StationaryLocationPoints(context.Context, []string, time.Time, time.Time, float64, float64, float64, float64) ([]StationaryLocationPoint, error)
|
||||||
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
|
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
|
||||||
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
|
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
|
||||||
Audit(context.Context, uint64, string, string, string, int, string) error
|
Audit(context.Context, uint64, string, string, string, int, string) error
|
||||||
@@ -50,6 +53,215 @@ type Repository interface {
|
|||||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
stationaryCoordinateWGS84 = "WGS84"
|
||||||
|
stationaryCoordinateGCJ02 = "GCJ02"
|
||||||
|
stationaryDefaultRadiusMeters = 5.0
|
||||||
|
stationaryMinimumRadiusMeters = 1.0
|
||||||
|
stationaryMaximumRadiusMeters = 100.0
|
||||||
|
stationaryMaxSpeedKmh = 3.0
|
||||||
|
stationaryMinimumStay = time.Minute
|
||||||
|
stationarySegmentGap = 10 * time.Minute
|
||||||
|
stationaryQueryMaximumWindow = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) QueryStationaryVehicles(ctx context.Context, appKey, traceID string, request StationaryVehicleQueryRequest) ([]StationaryVehicleResult, error) {
|
||||||
|
plates, start, end, longitude, latitude, radiusMeters, err := s.validateStationaryVehicleQuery(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(plates) == 0 {
|
||||||
|
plates = vehiclePlates(vehicles)
|
||||||
|
}
|
||||||
|
points, err := s.repository.StationaryLocationPoints(ctx, vehicleVINs(vehicles), start, end, longitude, latitude, radiusMeters, stationaryMaxSpeedKmh)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plateByVIN := make(map[string]string, len(vehicles))
|
||||||
|
for plate, vehicle := range vehicles {
|
||||||
|
plateByVIN[vehicle.VIN] = plate
|
||||||
|
}
|
||||||
|
results := stationaryMatches(points, plateByVIN, longitude, latitude, radiusMeters)
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "success", traceID, len(results), "")
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateStationaryVehicleQuery(request StationaryVehicleQueryRequest) ([]string, time.Time, time.Time, float64, float64, float64, error) {
|
||||||
|
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
start, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.StartTime), s.location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid startTime", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
end, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.EndTime), s.location)
|
||||||
|
if err != nil || !end.After(start) {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid endTime", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if end.Sub(start) > stationaryQueryMaximumWindow {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: time window exceeds 24 hours", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
longitude, latitude := request.Longitude, request.Latitude
|
||||||
|
if !validCoordinate(longitude, latitude) {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid coordinate", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
coordinateSystem := strings.ToUpper(strings.TrimSpace(request.CoordinateSystem))
|
||||||
|
if coordinateSystem == "" {
|
||||||
|
coordinateSystem = stationaryCoordinateWGS84
|
||||||
|
}
|
||||||
|
switch coordinateSystem {
|
||||||
|
case stationaryCoordinateWGS84:
|
||||||
|
case stationaryCoordinateGCJ02:
|
||||||
|
longitude, latitude = gcj02ToWGS84(longitude, latitude)
|
||||||
|
default:
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: coordinateSystem must be WGS84 or GCJ02", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
radiusMeters := stationaryDefaultRadiusMeters
|
||||||
|
if request.RadiusMeters != nil {
|
||||||
|
radiusMeters = *request.RadiusMeters
|
||||||
|
}
|
||||||
|
if math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) || radiusMeters < stationaryMinimumRadiusMeters || radiusMeters > stationaryMaximumRadiusMeters {
|
||||||
|
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: radiusMeters must be between 1 and 100", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
return plates, start, end, longitude, latitude, radiusMeters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gcj02ToWGS84(longitude, latitude float64) (float64, float64) {
|
||||||
|
// GCJ-02 only applies within mainland China's obfuscation area. Returning
|
||||||
|
// the supplied value elsewhere keeps the transformation safe for outlying
|
||||||
|
// stations and callers that supply valid geographic coordinates.
|
||||||
|
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
|
||||||
|
return longitude, latitude
|
||||||
|
}
|
||||||
|
const semiMajorAxis = 6378245.0
|
||||||
|
const eccentricitySquared = 0.006693421622965943
|
||||||
|
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
|
||||||
|
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
|
||||||
|
radianLatitude := latitude / 180 * math.Pi
|
||||||
|
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
|
||||||
|
squareRootMagic := math.Sqrt(magic)
|
||||||
|
convertedLatitude := latitude - latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
|
||||||
|
convertedLongitude := longitude - longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
|
||||||
|
return convertedLongitude, convertedLatitude
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformGCJLatitude(longitude, latitude float64) float64 {
|
||||||
|
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
|
||||||
|
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||||
|
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
|
||||||
|
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformGCJLongitude(longitude, latitude float64) float64 {
|
||||||
|
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
|
||||||
|
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||||
|
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
|
||||||
|
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func stationaryMatches(points []StationaryLocationPoint, plateByVIN map[string]string, longitude, latitude, radiusMeters float64) []StationaryVehicleResult {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return []StationaryVehicleResult{}
|
||||||
|
}
|
||||||
|
sort.Slice(points, func(i, j int) bool {
|
||||||
|
if points[i].VIN != points[j].VIN {
|
||||||
|
return points[i].VIN < points[j].VIN
|
||||||
|
}
|
||||||
|
return points[i].ObservedAt.Before(points[j].ObservedAt)
|
||||||
|
})
|
||||||
|
results := make([]StationaryVehicleResult, 0)
|
||||||
|
for from := 0; from < len(points); {
|
||||||
|
to := from + 1
|
||||||
|
for to < len(points) && points[to].VIN == points[from].VIN {
|
||||||
|
to++
|
||||||
|
}
|
||||||
|
for segmentStart := from; segmentStart < to; {
|
||||||
|
segmentEnd := segmentStart + 1
|
||||||
|
for segmentEnd < to && points[segmentEnd].ObservedAt.Sub(points[segmentEnd-1].ObservedAt) <= stationarySegmentGap {
|
||||||
|
segmentEnd++
|
||||||
|
}
|
||||||
|
if result, ok := stationaryMatch(points[segmentStart:segmentEnd], plateByVIN[points[from].VIN], longitude, latitude, radiusMeters); ok {
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
segmentStart = segmentEnd
|
||||||
|
}
|
||||||
|
from = to
|
||||||
|
}
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
if results[i].MatchScore != results[j].MatchScore {
|
||||||
|
return results[i].MatchScore > results[j].MatchScore
|
||||||
|
}
|
||||||
|
if results[i].StayDurationSeconds != results[j].StayDurationSeconds {
|
||||||
|
return results[i].StayDurationSeconds > results[j].StayDurationSeconds
|
||||||
|
}
|
||||||
|
return results[i].VIN < results[j].VIN
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func stationaryMatch(points []StationaryLocationPoint, plate string, longitude, latitude, radiusMeters float64) (StationaryVehicleResult, bool) {
|
||||||
|
if len(points) < 2 {
|
||||||
|
return StationaryVehicleResult{}, false
|
||||||
|
}
|
||||||
|
duration := points[len(points)-1].ObservedAt.Sub(points[0].ObservedAt)
|
||||||
|
if duration < stationaryMinimumStay {
|
||||||
|
return StationaryVehicleResult{}, false
|
||||||
|
}
|
||||||
|
var totalDistance, totalSpeed, maxDistance, maxSpeed float64
|
||||||
|
protocols := map[string]struct{}{}
|
||||||
|
for _, point := range points {
|
||||||
|
distance := haversineMeters(latitude, longitude, point.Latitude, point.Longitude)
|
||||||
|
if distance > radiusMeters || point.SpeedKmh < 0 || point.SpeedKmh > stationaryMaxSpeedKmh {
|
||||||
|
return StationaryVehicleResult{}, false
|
||||||
|
}
|
||||||
|
totalDistance += distance
|
||||||
|
totalSpeed += point.SpeedKmh
|
||||||
|
maxDistance = math.Max(maxDistance, distance)
|
||||||
|
maxSpeed = math.Max(maxSpeed, point.SpeedKmh)
|
||||||
|
protocols[point.Protocol] = struct{}{}
|
||||||
|
}
|
||||||
|
protocolList := make([]string, 0, len(protocols))
|
||||||
|
for protocol := range protocols {
|
||||||
|
protocolList = append(protocolList, externalMileageProtocol(protocol))
|
||||||
|
}
|
||||||
|
sort.Strings(protocolList)
|
||||||
|
averageDistance := totalDistance / float64(len(points))
|
||||||
|
averageSpeed := totalSpeed / float64(len(points))
|
||||||
|
score := 40*(1-averageDistance/radiusMeters) + 25*(1-maxSpeed/stationaryMaxSpeedKmh) + 20*math.Min(1, duration.Seconds()/900) + 15*math.Min(1, float64(len(points))/4)
|
||||||
|
return StationaryVehicleResult{
|
||||||
|
VIN: points[0].VIN,
|
||||||
|
PlateNumber: plate,
|
||||||
|
StayStartTime: points[0].ObservedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
StayEndTime: points[len(points)-1].ObservedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
StayDurationSeconds: int64(duration.Seconds()),
|
||||||
|
StayDurationMinutes: round3(duration.Minutes()),
|
||||||
|
MatchScore: round3(math.Max(0, math.Min(100, score))),
|
||||||
|
AverageDistanceM: round3(averageDistance),
|
||||||
|
MaxDistanceM: round3(maxDistance),
|
||||||
|
AverageSpeedKmh: round3(averageSpeed),
|
||||||
|
MaxSpeedKmh: round3(maxSpeed),
|
||||||
|
MatchedSamples: len(points),
|
||||||
|
SourceProtocols: protocolList,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func haversineMeters(latitude1, longitude1, latitude2, longitude2 float64) float64 {
|
||||||
|
const earthRadiusMeters = 6371000.0
|
||||||
|
lat1, lat2 := latitude1*math.Pi/180, latitude2*math.Pi/180
|
||||||
|
dLat, dLon := (latitude2-latitude1)*math.Pi/180, (longitude2-longitude1)*math.Pi/180
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
|
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
|
||||||
now := s.now().In(s.location)
|
now := s.now().In(s.location)
|
||||||
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||||
@@ -78,10 +290,11 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
|||||||
if difference < 0 {
|
if difference < 0 {
|
||||||
difference = 0
|
difference = 0
|
||||||
}
|
}
|
||||||
item.Protocol = point.Protocol
|
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||||
item.TimeDifferenceSeconds = &difference
|
item.TimeDifferenceSeconds = &difference
|
||||||
item.Online = point.Online
|
item.Online = point.Online
|
||||||
|
item.ActiveToday = point.ActiveToday
|
||||||
item.MotionStatus = "offline"
|
item.MotionStatus = "offline"
|
||||||
if item.Online && point.SpeedKmh > 3 {
|
if item.Online && point.SpeedKmh > 3 {
|
||||||
item.MotionStatus = "driving"
|
item.MotionStatus = "driving"
|
||||||
@@ -90,6 +303,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
|||||||
}
|
}
|
||||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||||
|
item.SOCPercent = point.SOCPercent
|
||||||
if validCoordinate(point.Longitude, point.Latitude) {
|
if validCoordinate(point.Longitude, point.Latitude) {
|
||||||
longitude, latitude := point.Longitude, point.Latitude
|
longitude, latitude := point.Longitude, point.Latitude
|
||||||
item.Longitude, item.Latitude = &longitude, &latitude
|
item.Longitude, item.Latitude = &longitude, &latitude
|
||||||
@@ -177,10 +391,14 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
|||||||
// Sampling sufficiency is decided by the producer and persisted in
|
// Sampling sufficiency is decided by the producer and persisted in
|
||||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||||
// with one transaction, while pressure-derived rows require two samples.
|
// 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 {
|
||||||
consumption := round3(value.ConsumptionKg)
|
item.CalculationPhase = value.CalculationPhase
|
||||||
item.HydrogenConsumptionKg = &consumption
|
item.AlgorithmVersion = value.AlgorithmVersion
|
||||||
item.Status = StatusNormal
|
if strings.EqualFold(value.QualityStatus, "OK") {
|
||||||
|
consumption := round3(value.ConsumptionKg)
|
||||||
|
item.HydrogenConsumptionKg = &consumption
|
||||||
|
item.Status = StatusNormal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
results = append(results, item)
|
results = append(results, item)
|
||||||
}
|
}
|
||||||
@@ -211,7 +429,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
|||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
missingVINs := missingMileageVINs(vins, values)
|
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
|
||||||
carried := map[string]DailyMileage{}
|
carried := map[string]DailyMileage{}
|
||||||
if len(missingVINs) > 0 {
|
if len(missingVINs) > 0 {
|
||||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
||||||
@@ -226,6 +449,8 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
|||||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||||
fillMileageResult(&item, value, value.MileageKm)
|
fillMileageResult(&item, value, value.MileageKm)
|
||||||
|
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
|
||||||
|
fillMileageAnomaly(&item)
|
||||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||||
fillMileageResult(&item, value, 0)
|
fillMileageResult(&item, value, 0)
|
||||||
}
|
}
|
||||||
@@ -321,6 +546,7 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
|||||||
}
|
}
|
||||||
values := map[string]DailyMileage{}
|
values := map[string]DailyMileage{}
|
||||||
carried := map[string]DailyMileage{}
|
carried := map[string]DailyMileage{}
|
||||||
|
rollbacks := map[string]bool{}
|
||||||
if len(positions) > 0 {
|
if len(positions) > 0 {
|
||||||
vins := make([]string, 0, len(vinSet))
|
vins := make([]string, 0, len(vinSet))
|
||||||
for vin := range vinSet {
|
for vin := range vinSet {
|
||||||
@@ -332,7 +558,12 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
|||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||||
return MileageRangeResponse{}, err
|
return MileageRangeResponse{}, err
|
||||||
}
|
}
|
||||||
missingVINs := missingMileageRangeInitialVINs(positions, values)
|
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||||
|
return MileageRangeResponse{}, err
|
||||||
|
}
|
||||||
|
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
|
||||||
if len(missingVINs) > 0 {
|
if len(missingVINs) > 0 {
|
||||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -355,6 +586,9 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
|||||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
||||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||||
carried[position.vehicle.VIN] = value
|
carried[position.vehicle.VIN] = value
|
||||||
|
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
|
||||||
|
fillMileageRangeAnomaly(&item)
|
||||||
|
delete(carried, position.vehicle.VIN)
|
||||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
||||||
fillMileageRangeResult(&item, value, 0)
|
fillMileageRangeResult(&item, value, 0)
|
||||||
}
|
}
|
||||||
@@ -715,9 +949,12 @@ func validDailyMileage(value DailyMileage) bool {
|
|||||||
value.UpdatedAt != ""
|
value.UpdatedAt != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
|
func missingMileageVINs(vins []string, values map[string]DailyMileage, rollbacks map[string]bool, date string) []string {
|
||||||
missing := make([]string, 0)
|
missing := make([]string, 0)
|
||||||
for _, vin := range vins {
|
for _, vin := range vins {
|
||||||
|
if rollbacks[dailyMileageKey(vin, date)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
|
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
|
||||||
missing = append(missing, vin)
|
missing = append(missing, vin)
|
||||||
}
|
}
|
||||||
@@ -725,7 +962,7 @@ func missingMileageVINs(vins []string, values map[string]DailyMileage) []string
|
|||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
|
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage, rollbacks map[string]bool) []string {
|
||||||
seen := make(map[string]struct{}, len(positions))
|
seen := make(map[string]struct{}, len(positions))
|
||||||
missing := make([]string, 0)
|
missing := make([]string, 0)
|
||||||
for _, position := range positions {
|
for _, position := range positions {
|
||||||
@@ -734,6 +971,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[vin] = struct{}{}
|
seen[vin] = struct{}{}
|
||||||
|
if rollbacks[dailyMileageKey(vin, position.date)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
value, ok := values[dailyMileageKey(vin, position.date)]
|
value, ok := values[dailyMileageKey(vin, position.date)]
|
||||||
if !ok || !validDailyMileage(value) {
|
if !ok || !validDailyMileage(value) {
|
||||||
missing = append(missing, vin)
|
missing = append(missing, vin)
|
||||||
@@ -769,6 +1009,20 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
|
|||||||
item.Status = StatusNormal
|
item.Status = StatusNormal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
|
||||||
|
|
||||||
|
func fillMileageAnomaly(item *MileageResult) {
|
||||||
|
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
|
||||||
|
item.Status = StatusDataAnomaly
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillMileageRangeAnomaly(item *MileageRangeResult) {
|
||||||
|
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
|
||||||
|
item.Status = StatusDataAnomaly
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPointer(value string) *string { return &value }
|
||||||
|
|
||||||
func dailyMileageKey(vin, date string) string {
|
func dailyMileageKey(vin, date string) string {
|
||||||
return vin + "\x00" + date
|
return vin + "\x00" + date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,27 +12,32 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func float64Pointer(value float64) *float64 { return &value }
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
app AppCredential
|
app AppCredential
|
||||||
authErr error
|
authErr error
|
||||||
vehicles map[string]AuthorizedVehicle
|
vehicles map[string]AuthorizedVehicle
|
||||||
hydrogen map[string]DailyHydrogen
|
hydrogen map[string]DailyHydrogen
|
||||||
mileage map[string]DailyMileage
|
mileage map[string]DailyMileage
|
||||||
priorMileage map[string]DailyMileage
|
priorMileage map[string]DailyMileage
|
||||||
authorizedVIN bool
|
rollbacks map[string]bool
|
||||||
totalMileage *TotalMileagePoint
|
authorizedVIN bool
|
||||||
realtime map[string]RealtimeVehiclePoint
|
totalMileage *TotalMileagePoint
|
||||||
stations []HydrogenStation
|
realtime map[string]RealtimeVehiclePoint
|
||||||
audits []string
|
stationary []StationaryLocationPoint
|
||||||
createdHash [sha256.Size]byte
|
stations []HydrogenStation
|
||||||
createdPrefix string
|
audits []string
|
||||||
requestedPlates []string
|
createdHash [sha256.Size]byte
|
||||||
dailyVINs []string
|
createdPrefix string
|
||||||
dailyProtocols []string
|
requestedPlates []string
|
||||||
rangeVINs []string
|
dailyVINs []string
|
||||||
priorVINs []string
|
dailyProtocols []string
|
||||||
priorCalls int
|
rangeVINs []string
|
||||||
snapshot MileageSnapshot
|
priorVINs []string
|
||||||
|
priorCalls int
|
||||||
|
snapshot MileageSnapshot
|
||||||
|
stationaryRadius float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
|
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
|
||||||
@@ -64,6 +69,9 @@ func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _
|
|||||||
f.dailyProtocols = append([]string(nil), protocols...)
|
f.dailyProtocols = append([]string(nil), protocols...)
|
||||||
return f.priorMileage, nil
|
return f.priorMileage, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error) {
|
||||||
|
return f.rollbacks, nil
|
||||||
|
}
|
||||||
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
|
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
|
||||||
f.snapshot = snapshot
|
f.snapshot = snapshot
|
||||||
return nil
|
return nil
|
||||||
@@ -80,6 +88,10 @@ func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Tim
|
|||||||
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
||||||
return f.totalMileage, nil
|
return f.totalMileage, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) StationaryLocationPoints(_ context.Context, _ []string, _ time.Time, _ time.Time, _ float64, _ float64, radiusMeters float64, _ float64) ([]StationaryLocationPoint, error) {
|
||||||
|
f.stationaryRadius = radiusMeters
|
||||||
|
return f.stationary, nil
|
||||||
|
}
|
||||||
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
|
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||||
return f.realtime, nil
|
return f.realtime, nil
|
||||||
}
|
}
|
||||||
@@ -100,7 +112,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
|||||||
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||||
},
|
},
|
||||||
realtime: map[string]RealtimeVehiclePoint{
|
realtime: map[string]RealtimeVehiclePoint{
|
||||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, SOCPercent: float64Pointer(78.5), TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
|
||||||
},
|
},
|
||||||
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
|
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
|
||||||
}
|
}
|
||||||
@@ -112,7 +124,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
|
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable || vehicles[0].Protocol != "GB32960" || vehicles[0].SOCPercent == nil || *vehicles[0].SOCPercent != 78.5 {
|
||||||
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
|
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
|
||||||
}
|
}
|
||||||
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
|
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
|
||||||
@@ -127,6 +139,76 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStationaryVehicleQueryBuildsAndRanksStayMatches(t *testing.T) {
|
||||||
|
location := time.FixedZone("CST", 8*3600)
|
||||||
|
start := time.Date(2026, 8, 6, 10, 0, 0, 0, location)
|
||||||
|
repository := &fakeRepository{
|
||||||
|
app: AppCredential{ID: 9, Name: "refuel-check"},
|
||||||
|
vehicles: map[string]AuthorizedVehicle{
|
||||||
|
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||||
|
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||||
|
},
|
||||||
|
stationary: []StationaryLocationPoint{
|
||||||
|
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(10 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0},
|
||||||
|
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(13 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0.1},
|
||||||
|
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
|
||||||
|
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(5 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
|
||||||
|
{VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", ObservedAt: start.Add(6 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewService(repository)
|
||||||
|
service.now = func() time.Time { return start }
|
||||||
|
radiusMeters := 8.0
|
||||||
|
values, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-stationary", StationaryVehicleQueryRequest{
|
||||||
|
StartTime: start.Format("2006-01-02 15:04:05"), EndTime: start.Add(time.Hour).Format("2006-01-02 15:04:05"), Longitude: 120, Latitude: 30, RadiusMeters: &radiusMeters,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(values) != 2 || values[0].VIN != "LTEST32960VIN0001" || values[0].StayDurationSeconds != 300 || values[0].MatchedSamples != 3 || values[0].SourceProtocols[0] != "GB32960" || values[0].SourceProtocols[1] != "MQTT" || values[0].MatchScore <= values[1].MatchScore {
|
||||||
|
t.Fatalf("stationary matches=%#v", values)
|
||||||
|
}
|
||||||
|
if repository.stationaryRadius != radiusMeters {
|
||||||
|
t.Fatalf("stationary radius=%v, want %v", repository.stationaryRadius, radiusMeters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStationaryVehicleQueryRejectsInvalidWindowAndCoordinate(t *testing.T) {
|
||||||
|
service := NewService(&fakeRepository{})
|
||||||
|
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-07 10:00:01", Longitude: 120, Latitude: 30}); !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("window error=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 0, Latitude: 0}); !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("coordinate error=%v", err)
|
||||||
|
}
|
||||||
|
overRadius := 101.0
|
||||||
|
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 120, Latitude: 30, RadiusMeters: &overRadius}); !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("radius error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStationaryVehicleQueryConvertsGCJ02Coordinate(t *testing.T) {
|
||||||
|
service := NewService(&fakeRepository{})
|
||||||
|
_, _, _, longitude, latitude, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
|
||||||
|
StartTime: "2026-08-06 17:53:01",
|
||||||
|
EndTime: "2026-08-06 17:55:01",
|
||||||
|
Longitude: 121.045455,
|
||||||
|
Latitude: 30.640433,
|
||||||
|
CoordinateSystem: "gcj02",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if longitude < 121.0409 || longitude > 121.0411 || latitude < 30.6425 || latitude > 30.6428 {
|
||||||
|
t.Fatalf("converted coordinate=%f,%f", longitude, latitude)
|
||||||
|
}
|
||||||
|
if _, _, _, _, _, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
|
||||||
|
StartTime: "2026-08-06 17:53:01", EndTime: "2026-08-06 17:55:01", Longitude: 121.045455, Latitude: 30.640433, CoordinateSystem: "BD09",
|
||||||
|
}); !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("coordinateSystem error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
|
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
|
||||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||||
repository := &fakeRepository{
|
repository := &fakeRepository{
|
||||||
@@ -179,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
|||||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||||
},
|
},
|
||||||
hydrogen: map[string]DailyHydrogen{
|
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{
|
mileage: map[string]DailyMileage{
|
||||||
"LTEST32960VIN0001": {
|
"LTEST32960VIN0001": {
|
||||||
@@ -200,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
|||||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
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)
|
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)
|
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -337,6 +422,28 @@ func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMileageRollbackIsNotExposedAsNormalCarriedData(t *testing.T) {
|
||||||
|
const vin = "LNXNEGRR1SR321395"
|
||||||
|
repository := &fakeRepository{
|
||||||
|
app: AppCredential{ID: 7},
|
||||||
|
vehicles: map[string]AuthorizedVehicle{"粤A08190F": {VIN: vin, Plate: "粤A08190F"}},
|
||||||
|
priorMileage: map[string]DailyMileage{
|
||||||
|
vin: {VIN: vin, Date: "2026-08-05", Protocol: "GB32960", TotalMileageKm: 48233.3, DataTime: "2026-08-05T23:00:00+08:00", UpdatedAt: "2026-08-05T23:00:01+08:00"},
|
||||||
|
},
|
||||||
|
rollbacks: map[string]bool{dailyMileageKey(vin, "2026-08-06"): true},
|
||||||
|
}
|
||||||
|
service := NewService(repository)
|
||||||
|
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-rollback", QueryRequest{
|
||||||
|
PlateNumbers: []string{"粤A08190F"}, Date: "2026-08-06",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result) != 1 || result[0].Status != StatusDataAnomaly || result[0].DataQuality == nil || *result[0].DataQuality != mileageTotalRollbackQuality || result[0].TotalMileageKm != nil || repository.priorCalls != 0 {
|
||||||
|
t.Fatalf("rollback result=%#v priorCalls=%d", result, repository.priorCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
|
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
|
||||||
repository := &fakeRepository{
|
repository := &fakeRepository{
|
||||||
app: AppCredential{ID: 7, Name: "partner"},
|
app: AppCredential{ID: 7, Name: "partner"},
|
||||||
@@ -577,7 +684,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|||||||
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
allResponse := httptest.NewRecorder()
|
allResponse := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(allResponse, allRequest)
|
handler.ServeHTTP(allResponse, allRequest)
|
||||||
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
|
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber": "粤A12345"`) {
|
||||||
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
|
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +692,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|||||||
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
rangeResponse := httptest.NewRecorder()
|
rangeResponse := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rangeResponse, rangeRequest)
|
handler.ServeHTTP(rangeResponse, rangeRequest)
|
||||||
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
|
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor": null`) {
|
||||||
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
|
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +700,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|||||||
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
badResponse := httptest.NewRecorder()
|
badResponse := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(badResponse, badDate)
|
handler.ServeHTTP(badResponse, badDate)
|
||||||
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
|
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code": "INVALID_DATE_FORMAT"`) {
|
||||||
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
|
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,8 +709,8 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|||||||
badProtocolResponse := httptest.NewRecorder()
|
badProtocolResponse := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(badProtocolResponse, badProtocol)
|
handler.ServeHTTP(badProtocolResponse, badProtocol)
|
||||||
if badProtocolResponse.Code != http.StatusBadRequest ||
|
if badProtocolResponse.Code != http.StatusBadRequest ||
|
||||||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
|
!strings.Contains(badProtocolResponse.Body.String(), `"message": "protocolPriority不能包含重复协议"`) ||
|
||||||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
|
!strings.Contains(badProtocolResponse.Body.String(), `"traceId": "`) {
|
||||||
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
|
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +718,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|||||||
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
nullProtocolResponse := httptest.NewRecorder()
|
nullProtocolResponse := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
|
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
|
||||||
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
|
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message": "protocolPriority不能为空"`) {
|
||||||
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// excelSinopecStationNames is the approved cooperative-station roster from
|
||||||
|
// “中石化加氢站订单来源统计.xlsx” (站点名称、简称). Both columns are retained so
|
||||||
|
// the classification survives the naming differences between OneOS imports.
|
||||||
|
var excelSinopecStationNames = map[string]struct{}{
|
||||||
|
"中国石化青岛炼油化工有限责任公司": {},
|
||||||
|
"青岛中石化加氢站": {},
|
||||||
|
"武汉群力加油站": {},
|
||||||
|
"中国石化群力加油站": {},
|
||||||
|
"中国石化张家港朝阳加能站": {},
|
||||||
|
"张家港中石化朝阳加氢站加氢站": {},
|
||||||
|
"中国石化广州金坑加氢站": {},
|
||||||
|
"广州中石化金坑加氢站": {},
|
||||||
|
"中国石化广州开泰北加油加氢站": {},
|
||||||
|
"广州中石化开泰北加氢站": {},
|
||||||
|
"佛山佛西加气站": {},
|
||||||
|
"佛山中石化佛西加氢站": {},
|
||||||
|
"盐城创咏加氢站": {},
|
||||||
|
"盐城中石化创咏加氢站": {},
|
||||||
|
"宁波镇海区中国石化加氢站": {},
|
||||||
|
"宁波镇海中石化加氢站": {},
|
||||||
|
"佛山青龙加油站": {},
|
||||||
|
"佛山中石化青龙加氢站": {},
|
||||||
|
// OneOS uses “樟坑” while the approved Excel roster uses “青龙”; the
|
||||||
|
// station address is on 佛山青龙线, so retain both OneOS aliases.
|
||||||
|
"中国石化樟坑加油加氢站": {},
|
||||||
|
"佛山中石化樟坑加氢站": {},
|
||||||
|
"中石化西上海发展站加油站": {},
|
||||||
|
"上海中石化西上海加氢站": {},
|
||||||
|
"中石化青卫油氢混合站": {},
|
||||||
|
"上海中石化青卫加氢站": {},
|
||||||
|
"嘉兴滨海大道加油加气站": {},
|
||||||
|
"嘉兴中石化滨海加氢站": {},
|
||||||
|
"中国石化善通加油加氢站": {},
|
||||||
|
"嘉善中石化善通加氢站": {},
|
||||||
|
"中国石化站前路加气加氢站": {},
|
||||||
|
"嘉善中石化站前路加氢站": {},
|
||||||
|
"中国石化滨文路CNG加气站": {},
|
||||||
|
"杭州中石化加氢站": {},
|
||||||
|
"武汉革新大道加油站": {},
|
||||||
|
"武汉中石化革新加氢站": {},
|
||||||
|
"中国石化绿能加气加氢站": {},
|
||||||
|
"桐乡中石化绿能加氢站": {},
|
||||||
|
"中国石化青云店气能加油加气站": {},
|
||||||
|
"北京中石化青云加氢站": {},
|
||||||
|
"扬州文昌西路站": {},
|
||||||
|
"扬州中石化文昌西路加氢站": {},
|
||||||
|
"东明三路综合能源站": {},
|
||||||
|
"广州中石化东明三路加氢站": {},
|
||||||
|
"中国石化龙珠源加油加气站": {},
|
||||||
|
"深圳中石化龙珠源加氢站": {},
|
||||||
|
"中国石化江南西彭综合能源站": {},
|
||||||
|
"重庆中石化西彭综合能源站": {},
|
||||||
|
"成都天府机场高速北站": {},
|
||||||
|
"成都中石化天府机场高速北站加氢站": {},
|
||||||
|
"成都天府机场高速南站": {},
|
||||||
|
"成都中石化天府机场高速南站加氢站": {},
|
||||||
|
"永川高升加氢站": {},
|
||||||
|
"重庆中石化高升加氢站": {},
|
||||||
|
"高管汉宜潜江服务北站": {},
|
||||||
|
"潜江中石化潜江服务区加氢站": {},
|
||||||
|
"涪陵长寿化中大道加能站": {},
|
||||||
|
"重庆中石化长寿化中大道加氢站": {},
|
||||||
|
"江南空港加气站": {},
|
||||||
|
"重庆中石化空港加氢站": {},
|
||||||
|
"中国石化江南半山环道加能站": {},
|
||||||
|
"重庆中石化半山环道加氢站": {},
|
||||||
|
"武汉中石化双龙站": {},
|
||||||
|
"南京溧水柘塘东站": {},
|
||||||
|
"南京中石化溧水加氢站": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExcelSinopecStation(names ...string) bool {
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||||
|
if _, ok := excelSinopecStationNames[name]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,142 @@
|
|||||||
package openplatform
|
package openplatform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) {
|
func TestBuildHydrogenDailyStatsUsesDirectValidBoundaries(t *testing.T) {
|
||||||
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10.5, 9.5)
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00},
|
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 20)
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50},
|
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30},
|
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90},
|
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98},
|
|
||||||
}, "2026-07-01", 0.05, 5)
|
|
||||||
if len(stats) != 1 {
|
if len(stats) != 1 {
|
||||||
t.Fatalf("stats = %#v", stats)
|
t.Fatalf("stats = %#v", stats)
|
||||||
}
|
}
|
||||||
stat := stats[0]
|
stat := stats[0]
|
||||||
if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" {
|
if stat.ConsumptionKg != 1 || stat.FirstMassKg != 10.5 || stat.LastMassKg != 9.5 {
|
||||||
t.Fatalf("stat = %#v", stat)
|
t.Fatalf("stat = %#v", stat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) {
|
func TestBuildHydrogenDailyStatsMergesSequentialSources(t *testing.T) {
|
||||||
base := time.Now()
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20},
|
for index := len(values) / 2; index < len(values); index++ {
|
||||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1},
|
values[index].Source = "source-b"
|
||||||
}, "2026-07-01", 0.05, 5)
|
}
|
||||||
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 {
|
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5)
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats = %#v", stats)
|
||||||
|
}
|
||||||
|
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
|
||||||
|
t.Fatalf("stat = %#v", stats[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHydrogenDailyStatsDeduplicatesSameEventAcrossSources(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
|
||||||
|
duplicates := append([]HydrogenObservation(nil), values...)
|
||||||
|
for index := range duplicates {
|
||||||
|
duplicates[index].Source = "source-b"
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStats(append(values, duplicates...), "2026-07-01", 0.05, 5)
|
||||||
|
if len(stats) != 1 || stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
|
||||||
t.Fatalf("stats = %#v", stats)
|
t.Fatalf("stats = %#v", stats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) {
|
func TestBuildHydrogenDailyStatsOrderedMatchesUnorderedCalculation(t *testing.T) {
|
||||||
base := time.Now()
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.6)
|
||||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10},
|
duplicates := append([]HydrogenObservation(nil), values...)
|
||||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8},
|
for index := range duplicates {
|
||||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6},
|
duplicates[index].Source = "source-b"
|
||||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20},
|
}
|
||||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9},
|
ordered := make([]HydrogenObservation, 0, len(values)+len(duplicates))
|
||||||
}, "2026-07-01", 0.05, 5)
|
for index := range values {
|
||||||
if len(stats) != 1 {
|
ordered = append(ordered, values[index], duplicates[index])
|
||||||
|
}
|
||||||
|
stats := BuildHydrogenDailyStatsOrdered(ordered, "2026-07-01", 0.05, 5)
|
||||||
|
if len(stats) != 1 || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 10 {
|
||||||
t.Fatalf("stats = %#v", stats)
|
t.Fatalf("stats = %#v", stats)
|
||||||
}
|
}
|
||||||
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 {
|
}
|
||||||
t.Fatalf("stat = %#v", stats[0])
|
|
||||||
|
func TestBuildHydrogenDailyStatsDoesNotReplaceBoundariesWithWholeDayMinimum(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
values := hydrogenActiveTestSegment("LTEST32960VIN0001", "source-a", base, 10, 9.8)
|
||||||
|
values = append(values[:5], append([]HydrogenObservation{{
|
||||||
|
VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(5 * time.Second),
|
||||||
|
MassKg: 9, NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true,
|
||||||
|
SOCPercent: 50, SOCKnown: true, MileageKm: 10, MileageKnown: true,
|
||||||
|
VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true,
|
||||||
|
RunningMode: 2, RunningModeKnown: true,
|
||||||
|
}}, values[5:]...)...)
|
||||||
|
for index := 6; index < len(values); index++ {
|
||||||
|
values[index].ObservedAt = base.Add(time.Duration(index) * time.Second)
|
||||||
}
|
}
|
||||||
|
stats := BuildHydrogenDailyStats(values, "2026-07-01", 0.05, 5)
|
||||||
|
if len(stats) != 1 || stats[0].ConsumptionKg != 0.2 {
|
||||||
|
t.Fatalf("stats = %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenSegmentAccumulatorKeepsOnlyBoundedEndpointWindows(t *testing.T) {
|
||||||
|
base := time.Now()
|
||||||
|
accumulator := newHydrogenSegmentAccumulator(0.05, 20)
|
||||||
|
for index := 0; index < 10000; index++ {
|
||||||
|
accumulator.Add(HydrogenObservation{
|
||||||
|
ObservedAt: base.Add(time.Duration(index) * time.Second), MassKg: 20 - float64(index)/10000,
|
||||||
|
NoiseKg: 0.05, RefuelThresholdKg: 1, FuelCellActive: true, FuelCellStateKnown: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(accumulator.segmentFirst) != hydrogenSegmentEndpointWindow || len(accumulator.segmentTail) != hydrogenSegmentEndpointWindow {
|
||||||
|
t.Fatalf("first=%d tail=%d", len(accumulator.segmentFirst), len(accumulator.segmentTail))
|
||||||
|
}
|
||||||
|
consumption, segments, _, _ := accumulator.Finalize()
|
||||||
|
if consumption <= 0 || segments != 1 {
|
||||||
|
t.Fatalf("consumption=%v segments=%d", consumption, segments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHydrogenDailyStatsRejectsInactivePressureFall(t *testing.T) {
|
||||||
|
base := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||||
|
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10, PressureMPa: 28.2, VehicleState: 2, VehicleStateKnown: true},
|
||||||
|
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 8, PressureMPa: 22, VehicleState: 2, VehicleStateKnown: true},
|
||||||
|
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Minute), MassKg: 5, PressureMPa: 12.8, VehicleState: 2, VehicleStateKnown: true},
|
||||||
|
}, "2026-08-15", 0.05, 20)
|
||||||
|
if len(stats) != 1 || stats[0].ConsumptionKg != 0 || stats[0].QualityStatus != "NO_DATA" {
|
||||||
|
t.Fatalf("stats = %#v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrogenActiveTestSegment(vin, source string, start time.Time, startMass, endMass float64) []HydrogenObservation {
|
||||||
|
values := make([]HydrogenObservation, 0, hydrogenSegmentEndpointWindow*2)
|
||||||
|
for index := 0; index < hydrogenSegmentEndpointWindow*2; index++ {
|
||||||
|
mass := startMass
|
||||||
|
if index >= hydrogenSegmentEndpointWindow {
|
||||||
|
mass = endMass
|
||||||
|
}
|
||||||
|
values = append(values, HydrogenObservation{
|
||||||
|
VIN: vin, Source: source, ObservedAt: start.Add(time.Duration(index) * time.Second),
|
||||||
|
MassKg: mass, PressureMPa: mass, NoiseKg: 0.05, RefuelThresholdKg: 1,
|
||||||
|
FuelCellActive: true, FuelCellStateKnown: true,
|
||||||
|
SOCPercent: 50, SOCKnown: true,
|
||||||
|
MileageKm: float64(index) * 2, MileageKnown: true,
|
||||||
|
VehicleState: 1, VehicleStateKnown: true,
|
||||||
|
ChargeState: 3, ChargeStateKnown: true,
|
||||||
|
RunningMode: 2, RunningModeKnown: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
|
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
|
||||||
@@ -77,3 +165,319 @@ func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) {
|
|||||||
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
|
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractHydrogenPressureTemperatureRejectsPlaceholders(t *testing.T) {
|
||||||
|
for _, encoded := range []string{
|
||||||
|
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":0,"gb32960.fuel_cell.max_hydrogen_temperature_c":30}`,
|
||||||
|
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":22.8,"gb32960.fuel_cell.max_hydrogen_temperature_c":-40}`,
|
||||||
|
} {
|
||||||
|
if _, _, ok := ExtractHydrogenPressureTemperature(encoded); ok {
|
||||||
|
t.Fatalf("pressure/temperature placeholder must be rejected: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHydrogenFuelCellState(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
encoded string
|
||||||
|
active bool
|
||||||
|
known bool
|
||||||
|
}{
|
||||||
|
{`{"gb32960.gd_fc_stack.engine_work_state":2}`, true, true},
|
||||||
|
{`{"gb32960.gd_fc_stack.engine_work_state":0}`, false, true},
|
||||||
|
{`{"gb32960.gd_fc_stack.engine_work_state":1}`, false, false},
|
||||||
|
{`{"gb32960.gd_fc_stack.engine_work_state":1,"gb32960.fuel_cell.fuel_cell_current_a":94.1}`, true, true},
|
||||||
|
{`{"gb32960.gd_fc_stack.engine_work_state":2,"gb32960.fuel_cell.fuel_cell_current_a":0}`, false, true},
|
||||||
|
{`{"gb32960.fuel_cell.fuel_cell_current_a":0.2}`, false, true},
|
||||||
|
{`{}`, false, false},
|
||||||
|
} {
|
||||||
|
active, known := ExtractHydrogenFuelCellState(test.encoded)
|
||||||
|
if active != test.active || known != test.known {
|
||||||
|
t.Fatalf("encoded=%s active=%v known=%v", test.encoded, active, known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHydrogenTelemetryReadsPressureTemperatureAndStateTogether(t *testing.T) {
|
||||||
|
pressure, temperature, active, known, ok := ExtractHydrogenTelemetry(`{
|
||||||
|
"gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,
|
||||||
|
"gb32960.fuel_cell.max_hydrogen_temperature_c":35,
|
||||||
|
"gb32960.gd_fc_stack.engine_work_state":2
|
||||||
|
}`)
|
||||||
|
if !ok || pressure != 28.2 || temperature != 35 || !active || !known {
|
||||||
|
t.Fatalf("pressure=%v temperature=%v active=%v known=%v ok=%v", pressure, temperature, active, known, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHydrogenTelemetryFastMatchesJSONDecoder(t *testing.T) {
|
||||||
|
for _, encoded := range []string{
|
||||||
|
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35,"gb32960.gd_fc_stack.engine_work_state":2}`,
|
||||||
|
`{ "gb32960.fuel_cell.fuel_cell_current_a" : "0.5", "gb32960.fuel_cell.max_hydrogen_temperature_c" : "35", "gb32960.fuel_cell.max_hydrogen_pressure_mpa" : "28.2" }`,
|
||||||
|
`{"unrelated":"gb32960.fuel_cell.max_hydrogen_pressure_mpa","gb32960.fuel_cell.max_hydrogen_pressure_mpa":28.2,"gb32960.fuel_cell.max_hydrogen_temperature_c":35}`,
|
||||||
|
} {
|
||||||
|
wantPressure, wantTemperature, wantActive, wantKnown, wantOK := ExtractHydrogenTelemetry(encoded)
|
||||||
|
pressure, temperature, active, known, ok := extractHydrogenTelemetryFast(encoded)
|
||||||
|
if pressure != wantPressure || temperature != wantTemperature || active != wantActive || known != wantKnown || ok != wantOK {
|
||||||
|
t.Fatalf("fast=(%v,%v,%v,%v,%v) decoded=(%v,%v,%v,%v,%v) input=%s",
|
||||||
|
pressure, temperature, active, known, ok,
|
||||||
|
wantPressure, wantTemperature, wantActive, wantKnown, wantOK, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenObservationQueryUsesEventTimeAndScopedVIN(t *testing.T) {
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
|
||||||
|
query, err := buildHydrogenObservationQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), "LB9A32A24R0LS1376")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"CAST(event_time AS BIGINT)",
|
||||||
|
"ts>='2026-08-12T00:00:00+08:00'",
|
||||||
|
"ts<'2026-08-13T00:00:00+08:00'",
|
||||||
|
"event_time>='2026-08-12T00:00:00+08:00'",
|
||||||
|
"event_time<'2026-08-13T00:00:00+08:00'",
|
||||||
|
"vin='LB9A32A24R0LS1376'",
|
||||||
|
"protocol='GB32960'",
|
||||||
|
"ORDER BY vin,event_time,source_endpoint,ts",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("query missing %q:\n%s", want, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "CAST(ts AS BIGINT)") {
|
||||||
|
t.Fatalf("received time must not become observation time:\n%s", query)
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") {
|
||||||
|
t.Fatalf("hydrogen query must only use GB32960 frames:\n%s", query)
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "2026-08-05") || strings.Contains(query, "2026-08-20") {
|
||||||
|
t.Fatalf("received-time window must not include delayed frames:\n%s", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenObservationVINQueryUsesSameDayGB32960Watermark(t *testing.T) {
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
|
||||||
|
query, err := buildHydrogenObservationVINQuery("lingniu_vehicle_ts", start, start.AddDate(0, 0, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"SELECT DISTINCT vin", "protocol='GB32960'",
|
||||||
|
"ts>='2026-08-12T00:00:00+08:00'", "ts<'2026-08-13T00:00:00+08:00'",
|
||||||
|
"event_time>='2026-08-12T00:00:00+08:00'", "event_time<'2026-08-13T00:00:00+08:00'",
|
||||||
|
"ORDER BY vin",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("query missing %q:\n%s", want, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "YUTONG_MQTT") || strings.Contains(query, "JT808") {
|
||||||
|
t.Fatalf("VIN query must only use GB32960:\n%s", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery("SELECT DISTINCT vin").WillReturnRows(sqlmock.NewRows([]string{"vin"}).
|
||||||
|
AddRow("lb9a32a25r0ls1452").
|
||||||
|
AddRow("LB9A32A24R0LS1376"))
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
start := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
|
||||||
|
vins, err := LoadHydrogenObservationVINs(context.Background(), db, "lingniu_vehicle_ts", start, start.AddDate(0, 0, 1), map[string]float64{
|
||||||
|
"LB9A32A25R0LS1452": 520,
|
||||||
|
})
|
||||||
|
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
|
||||||
|
t.Fatalf("vins=%v err=%v", vins, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")).
|
||||||
|
WithArgs("现代").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"vin"}).
|
||||||
|
AddRow("lb9a32a25r0ls1452").
|
||||||
|
AddRow("invalid"))
|
||||||
|
vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ")
|
||||||
|
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
|
||||||
|
t.Fatalf("vins=%v err=%v", vins, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
first := time.Date(2026, 6, 21, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
last := time.Date(2026, 8, 18, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
mock.ExpectQuery("ORDER BY event_time ASC LIMIT 1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"event_time"}).AddRow(first))
|
||||||
|
mock.ExpectQuery("ORDER BY event_time DESC LIMIT 1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"event_time"}).AddRow(last))
|
||||||
|
gotFirst, gotLast, found, err := HydrogenObservationDateRangeForAll(context.Background(), db, "lingniu_vehicle_ts")
|
||||||
|
if err != nil || !found || !gotFirst.Equal(first) || !gotLast.Equal(last) {
|
||||||
|
t.Fatalf("range first=%v last=%v found=%v err=%v", gotFirst, gotLast, found, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
vin := "LB9A32A24R0LS1376"
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?")).
|
||||||
|
WithArgs("2026-08-12", vin).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||||
|
WithArgs(vin, "2026-08-12", "source-a", 8.691, 7.64, 10.515, 2803, 2, "OK", "",
|
||||||
|
8.691, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||||
|
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
err = ReplaceHydrogenDailyStatsForVIN(context.Background(), db, "2026-08-12", vin, []HydrogenDailyStat{{
|
||||||
|
VIN: vin, Date: "2026-08-12", Source: "source-a", ConsumptionKg: 8.691,
|
||||||
|
FirstMassKg: 7.64, LastMassKg: 10.515, SampleCount: 2803, RefuelCount: 2, QualityStatus: "OK",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"}
|
||||||
|
stat := HydrogenDailyStat{
|
||||||
|
VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5,
|
||||||
|
FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK",
|
||||||
|
}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")).
|
||||||
|
WithArgs(stat.Date, vins[0], vins[1]).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 2))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||||
|
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||||
|
stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg,
|
||||||
|
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||||
|
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
vin := "LB9A32A24R0LS1376"
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
first := HydrogenObservation{VIN: vin, Source: "source-a", ObservedAt: time.Date(2026, 8, 19, 8, 0, 0, 0, loc), MassKg: 12, TankCapacityLiter: 520, PressureMPa: 30, TemperatureC: 31, FuelCellActive: true, FuelCellStateKnown: true}
|
||||||
|
last := HydrogenObservation{VIN: vin, Source: "source-b", ObservedAt: time.Date(2026, 8, 19, 11, 0, 0, 0, loc), MassKg: 9.4, TankCapacityLiter: 520, PressureMPa: 22, TemperatureC: 35, FuelCellActive: true, FuelCellStateKnown: true}
|
||||||
|
stat := HydrogenDailyStat{
|
||||||
|
VIN: vin, Date: "2026-08-19", Source: "source-a", ConsumptionKg: 2.6,
|
||||||
|
FirstMassKg: 12, LastMassKg: 9.4, CycleMinimumMassKg: 9.4,
|
||||||
|
SampleCount: 500, RefuelCount: 1, EligibleIntervalCount: 460, QualifiedSegmentCount: 2,
|
||||||
|
FirstObservation: first, LastObservation: last, QualityStatus: "OK",
|
||||||
|
}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
|
||||||
|
WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 3))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
|
||||||
|
WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||||
|
WithArgs(vin, "2026-08-19", "source-a", 2.6, 12.0, 9.4, 500, 1, "OK", "",
|
||||||
|
2.6, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||||
|
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 2, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state(")).
|
||||||
|
WithArgs(vin, "2026-08-19", "source-a", 2.6, 2.6, 500, 1, 0, 460, 2, 9.4, last.ObservedAt, "", sqlmock.AnyArg(), "OK", "").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, "2026-08-19", []HydrogenDailyStat{stat}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := hydrogenSegmentStreamSeedJSON(stat)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var state map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion {
|
||||||
|
t.Fatalf("seed state=%s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
stat := HydrogenDailyStat{
|
||||||
|
VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a",
|
||||||
|
QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条",
|
||||||
|
}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
|
||||||
|
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
|
||||||
|
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||||
|
WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason,
|
||||||
|
0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||||
|
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping {
|
|||||||
var gb32960FieldReferences = map[string]gb32960FieldReference{
|
var gb32960FieldReferences = map[string]gb32960FieldReference{
|
||||||
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||||
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||||
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃油等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃油", "燃油驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||||
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
|
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
|
||||||
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
|
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
|
||||||
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
|
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
||||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||||
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
||||||
|
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/hydrogen-evidence", h.handleHydrogenDailyEvidence)
|
||||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||||
@@ -862,6 +863,11 @@ func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Req
|
|||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleHydrogenDailyEvidence(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.HydrogenDailyEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date"))
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
var query MileageQuery
|
var query MileageQuery
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
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) {
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEvents(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
columns := []string{
|
||||||
|
"vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct",
|
||||||
|
"battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg",
|
||||||
|
"mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km",
|
||||||
|
"sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count",
|
||||||
|
"quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at",
|
||||||
|
}
|
||||||
|
parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}`
|
||||||
|
evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]`
|
||||||
|
mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||||
|
"LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126,
|
||||||
|
56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
|
||||||
|
[]byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000",
|
||||||
|
))
|
||||||
|
result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
|
||||||
|
t.Fatalf("result=%#v", result)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
||||||
|
m.profileMu.RLock()
|
||||||
|
defer m.profileMu.RUnlock()
|
||||||
vehicles := filterVehicles(m.vehicles, query)
|
vehicles := filterVehicles(m.vehicles, query)
|
||||||
byVIN := map[string]*VehicleCoverageRow{}
|
byVIN := map[string]*VehicleCoverageRow{}
|
||||||
for _, vehicle := range vehicles {
|
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))
|
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
||||||
for _, row := range 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)
|
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
||||||
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
||||||
for _, vehicle := range vehicles {
|
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) {
|
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||||
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
|
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) {
|
for _, keyword := range normalizedKeywords(query.Keywords) {
|
||||||
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
|
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)
|
resolution := buildVehicleIdentityResolution(keyword, vehicles)
|
||||||
identity := resolveVehicleIdentity(keyword, vehicles)
|
identity := resolveVehicleIdentity(keyword, vehicles)
|
||||||
resolvedVIN := ""
|
resolvedVIN := ""
|
||||||
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
|
|||||||
resolvedVIN = resolution.VIN
|
resolvedVIN = resolution.VIN
|
||||||
}
|
}
|
||||||
if resolvedVIN == "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
|
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
|
||||||
|
|||||||
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
|
|||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
OEM string `json:"oem"`
|
OEM string `json:"oem"`
|
||||||
|
BrandName string `json:"brandName"`
|
||||||
|
ModelName string `json:"modelName"`
|
||||||
Protocols []string `json:"protocols"`
|
Protocols []string `json:"protocols"`
|
||||||
MissingProtocols []string `json:"missingProtocols"`
|
MissingProtocols []string `json:"missingProtocols"`
|
||||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
@@ -1881,10 +1883,83 @@ type DailyMileageRow struct {
|
|||||||
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
|
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
|
||||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
|
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
|
||||||
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
|
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
|
||||||
|
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
|
||||||
|
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"`
|
||||||
|
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
|
||||||
|
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
|
||||||
|
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
AnomalySeverity string `json:"anomalySeverity,omitempty"`
|
AnomalySeverity string `json:"anomalySeverity,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HydrogenCalculationParameterEvidence struct {
|
||||||
|
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
|
||||||
|
HydrogenEnergyKWhPerKg float64 `json:"hydrogenEnergyKWhPerKg"`
|
||||||
|
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
|
||||||
|
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
|
||||||
|
RefuelRiseMpa float64 `json:"refuelRiseMpa"`
|
||||||
|
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
|
||||||
|
PureElectricDropMpa float64 `json:"pureElectricDropMpa"`
|
||||||
|
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
|
||||||
|
AlgorithmVersion string `json:"algorithmVersion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenIntervalEvidenceRow struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
StartTime string `json:"startTime"`
|
||||||
|
EndTime string `json:"endTime"`
|
||||||
|
StartEventID string `json:"startEventId"`
|
||||||
|
EndEventID string `json:"endEventId"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
StartPressureMpa float64 `json:"startPressureMpa"`
|
||||||
|
EndPressureMpa float64 `json:"endPressureMpa"`
|
||||||
|
StartTemperatureC float64 `json:"startTemperatureC"`
|
||||||
|
EndTemperatureC float64 `json:"endTemperatureC"`
|
||||||
|
StartMassKg float64 `json:"startMassKg"`
|
||||||
|
EndMassKg float64 `json:"endMassKg"`
|
||||||
|
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
|
||||||
|
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
|
||||||
|
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
|
||||||
|
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||||
|
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||||
|
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||||
|
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
|
||||||
|
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
|
||||||
|
MileageKm *float64 `json:"mileageKm,omitempty"`
|
||||||
|
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||||
|
SampleCount int `json:"sampleCount"`
|
||||||
|
QualityStatus string `json:"qualityStatus"`
|
||||||
|
QualityReason string `json:"qualityReason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenDailyEvidence struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
RawConsumptionKg float64 `json:"rawConsumptionKg"`
|
||||||
|
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||||
|
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||||
|
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||||
|
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||||
|
MixedMileageKm float64 `json:"mixedMileageKm"`
|
||||||
|
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||||
|
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||||
|
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
|
||||||
|
SampleCount int `json:"sampleCount"`
|
||||||
|
RefuelCount int `json:"refuelCount"`
|
||||||
|
ChargeCount int `json:"chargeCount"`
|
||||||
|
ValidSegmentCount int `json:"validSegmentCount"`
|
||||||
|
InvalidSegmentCount int `json:"invalidSegmentCount"`
|
||||||
|
QualityStatus string `json:"qualityStatus"`
|
||||||
|
QualityReason string `json:"qualityReason"`
|
||||||
|
AlgorithmVersion string `json:"algorithmVersion"`
|
||||||
|
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
|
||||||
|
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
|
||||||
|
CalculatedAt string `json:"calculatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// MileageQuery is the POST contract used by mileage statistics and daily
|
// MileageQuery is the POST contract used by mileage statistics and daily
|
||||||
// mileage queries. Array fields keep large vehicle selections out of the URL
|
// mileage queries. Array fields keep large vehicle selections out of the URL
|
||||||
// and avoid ambiguity when clients submit thousands of VINs.
|
// and avoid ambiguity when clients submit thousands of VINs.
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
}
|
}
|
||||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
`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 vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
`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 ` +
|
`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, ` +
|
Text: `SELECT v.vin, ` +
|
||||||
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
`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 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, ` +
|
`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, ` +
|
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||||
@@ -575,6 +578,12 @@ WHERE ` + strings.Join(where, " AND ")
|
|||||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||||
|
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
|
||||||
|
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
|
||||||
|
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
|
||||||
|
`MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` +
|
||||||
|
`COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` +
|
||||||
|
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
|
||||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||||
Args: args,
|
Args: args,
|
||||||
@@ -591,7 +600,9 @@ WHERE ` + strings.Join(where, " AND ")
|
|||||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
||||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
|
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
|
||||||
|
`h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
||||||
|
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
|
||||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
||||||
Args: args,
|
Args: args,
|
||||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
CountText: `SELECT COUNT(*) ` + fromSQL,
|
||||||
@@ -687,6 +698,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
|||||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||||
|
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
|
||||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||||
`FROM vehicle_daily_mileage m
|
`FROM vehicle_daily_mileage m
|
||||||
@@ -702,8 +714,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
|||||||
base, args := buildMileageStatisticsBaseSQL(query)
|
base, args := buildMileageStatisticsBaseSQL(query)
|
||||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
||||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
||||||
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
||||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,8 +724,9 @@ func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
|||||||
base, args := buildMileageStatisticsBaseSQL(query)
|
base, args := buildMileageStatisticsBaseSQL(query)
|
||||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
||||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
||||||
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
||||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p Principal) CanMenu(key string) bool {
|
func (p Principal) CanMenu(key string) bool {
|
||||||
if p.UserType == "admin" || p.Role == "admin" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, value := range p.MenuKeys {
|
for _, value := range p.MenuKeys {
|
||||||
if value == key {
|
if value == key {
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
|||||||
var protocols string
|
var protocols string
|
||||||
var onlineProtocols string
|
var onlineProtocols string
|
||||||
var online int
|
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
|
return Page[VehicleCoverageRow]{}, err
|
||||||
}
|
}
|
||||||
row.Protocols = splitCSV(protocols)
|
row.Protocols = splitCSV(protocols)
|
||||||
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
|
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
|
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 = ? "
|
protocolJoin = " AND s.protocol = ? "
|
||||||
args = append(args, 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{
|
return SQLQuery{
|
||||||
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
|
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, ` +
|
`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 + ` ` +
|
`WHERE ` + realtimeWhere + ` ` +
|
||||||
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
|
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
|
||||||
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
|
`) 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,
|
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
|
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latestRealtimeProjectionTimeSQL = "COALESCE(s.received_at, s.event_time, s.updated_at)"
|
||||||
|
|
||||||
|
func buildLatestRealtimeFramesSQL(query RawFrameQuery) SQLQuery {
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
|
||||||
|
limit = len(canonicalVehicleProtocols)
|
||||||
|
}
|
||||||
|
where := []string{"s.vin = ?"}
|
||||||
|
args := []any{strings.TrimSpace(query.VIN)}
|
||||||
|
if protocol := strings.ToUpper(strings.TrimSpace(query.Protocol)); protocol != "" {
|
||||||
|
where = append(where, "s.protocol = ?")
|
||||||
|
args = append(args, protocol)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(query.DateFrom); value != "" {
|
||||||
|
if parsed, ok := parseTrackRequestTime(value); ok {
|
||||||
|
where = append(where, latestRealtimeProjectionTimeSQL+" >= ?")
|
||||||
|
args = append(args, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(query.DateTo); value != "" {
|
||||||
|
if parsed, ok := parseTrackRequestTime(value); ok {
|
||||||
|
where = append(where, latestRealtimeProjectionTimeSQL+" <= ?")
|
||||||
|
args = append(args, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = append(args, limit)
|
||||||
|
return SQLQuery{
|
||||||
|
Text: `SELECT COALESCE(NULLIF(s.event_id, ''), CONCAT('snapshot:', s.protocol, ':', s.vin)), ` +
|
||||||
|
`s.vin, COALESCE(s.plate, ''), s.protocol, ` +
|
||||||
|
`COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
|
||||||
|
`COALESCE(DATE_FORMAT(s.received_at, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
|
||||||
|
`COALESCE(s.peer, ''), COALESCE(s.parsed_json, '') ` +
|
||||||
|
`FROM vehicle_realtime_snapshot s WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||||
|
`ORDER BY ` + latestRealtimeProjectionTimeSQL + ` DESC, s.protocol ASC LIMIT ?`,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestRealtimeFrames returns one durable latest projection per protocol.
|
||||||
|
// Unlike Redis, these rows remain available after the online TTL expires.
|
||||||
|
func (s *ProductionStore) LatestRealtimeFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
built := buildLatestRealtimeFramesSQL(query)
|
||||||
|
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||||
|
if err != nil {
|
||||||
|
return Page[RawFrameRow]{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]RawFrameRow, 0, len(canonicalVehicleProtocols))
|
||||||
|
for rows.Next() {
|
||||||
|
var row RawFrameRow
|
||||||
|
var parsedJSON string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.ID,
|
||||||
|
&row.VIN,
|
||||||
|
&row.Plate,
|
||||||
|
&row.Protocol,
|
||||||
|
&row.DeviceTime,
|
||||||
|
&row.ServerTime,
|
||||||
|
&row.SourceEndpoint,
|
||||||
|
&parsedJSON,
|
||||||
|
); err != nil {
|
||||||
|
return Page[RawFrameRow]{}, err
|
||||||
|
}
|
||||||
|
row.FrameType = "realtime_projection"
|
||||||
|
row.ParseStatus = "OK"
|
||||||
|
if query.IncludeFields || len(query.Fields) > 0 {
|
||||||
|
row.ParsedFields = parsedFieldsFromString(parsedJSON)
|
||||||
|
if len(query.Fields) > 0 {
|
||||||
|
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return Page[RawFrameRow]{}, err
|
||||||
|
}
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
|
||||||
|
limit = len(canonicalVehicleProtocols)
|
||||||
|
}
|
||||||
|
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
||||||
built := buildMileageSummarySQL(query)
|
built := buildMileageSummarySQL(query)
|
||||||
var summary MileageSummary
|
var summary MileageSummary
|
||||||
@@ -1054,15 +1153,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
|||||||
items := make([]DailyMileageRow, 0)
|
items := make([]DailyMileageRow, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row DailyMileageRow
|
var row DailyMileageRow
|
||||||
var hydrogen sql.NullFloat64
|
var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64
|
||||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
|
var evidenceAvailable int
|
||||||
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
|
||||||
|
&hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
|
||||||
return Page[DailyMileageRow]{}, err
|
return Page[DailyMileageRow]{}, err
|
||||||
}
|
}
|
||||||
if hydrogen.Valid {
|
if hydrogen.Valid {
|
||||||
consumption := hydrogen.Float64
|
consumption := hydrogen.Float64
|
||||||
row.HydrogenConsumptionKg = &consumption
|
row.HydrogenConsumptionKg = &consumption
|
||||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
|
if physicalRate.Valid {
|
||||||
|
value := physicalRate.Float64
|
||||||
|
row.HydrogenConsumptionKgPer100Km = &value
|
||||||
|
} else {
|
||||||
|
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.PureHydrogenMileageKm, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if balanced.Valid {
|
||||||
|
value := balanced.Float64
|
||||||
|
row.HydrogenSOCBalancedKg = &value
|
||||||
|
}
|
||||||
|
if balancedRate.Valid {
|
||||||
|
value := balancedRate.Float64
|
||||||
|
row.HydrogenSOCBalancedKgPer100Km = &value
|
||||||
|
}
|
||||||
|
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
|
||||||
items = append(items, row)
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -1075,11 +1190,66 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
|||||||
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, bool, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `SELECT h.vin,COALESCE(NULLIF(b.plate,''),''),DATE_FORMAT(h.stat_date,'%Y-%m-%d'),
|
||||||
|
h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct,
|
||||||
|
h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg,
|
||||||
|
h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km,
|
||||||
|
h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count,
|
||||||
|
h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json,
|
||||||
|
DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
|
||||||
|
FROM vehicle_open_daily_energy h
|
||||||
|
LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin
|
||||||
|
WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
|
||||||
|
LIMIT 1`, vin, date)
|
||||||
|
var result HydrogenDailyEvidence
|
||||||
|
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64
|
||||||
|
var parameterJSON, evidenceJSON []byte
|
||||||
|
if err := row.Scan(
|
||||||
|
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
|
||||||
|
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm,
|
||||||
|
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount,
|
||||||
|
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
|
||||||
|
&result.AlgorithmVersion, ¶meterJSON, &evidenceJSON, &result.CalculatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return HydrogenDailyEvidence{}, false, nil
|
||||||
|
}
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
|
||||||
|
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||||
|
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||||
|
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||||
|
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||||
|
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||||
|
result.Intervals = []HydrogenIntervalEvidenceRow{}
|
||||||
|
if len(parameterJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(evidenceJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableFloatPointer(value sql.NullFloat64) *float64 {
|
||||||
|
if !value.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value.Float64
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||||
result := MileageStatistics{
|
result := MileageStatistics{
|
||||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的总里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||||
}
|
}
|
||||||
summary := buildMileageStatisticsSummarySQL(query)
|
summary := buildMileageStatisticsSummarySQL(query)
|
||||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -141,7 +143,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)
|
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||||
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
||||||
@@ -200,6 +219,61 @@ func TestRawFramesReturnsEmptyPageWhenTDengineTableIsMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildLatestRealtimeFramesSQLUsesDurableVINProtocolProjection(t *testing.T) {
|
||||||
|
built := buildLatestRealtimeFramesSQL(RawFrameQuery{
|
||||||
|
VIN: "LMRKH9AC0R1004086",
|
||||||
|
Protocol: "yutong_mqtt",
|
||||||
|
DateFrom: "2026-07-01T00:00:00+08:00",
|
||||||
|
DateTo: "2026-08-25T18:00:00+08:00",
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
for _, expected := range []string{
|
||||||
|
"FROM vehicle_realtime_snapshot s",
|
||||||
|
"s.vin = ?",
|
||||||
|
"s.protocol = ?",
|
||||||
|
latestRealtimeProjectionTimeSQL + " >= ?",
|
||||||
|
latestRealtimeProjectionTimeSQL + " <= ?",
|
||||||
|
"ORDER BY " + latestRealtimeProjectionTimeSQL + " DESC",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(built.Text, expected) {
|
||||||
|
t.Fatalf("latest projection SQL missing %q: %s", expected, built.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(built.Args) != 5 || built.Args[0] != "LMRKH9AC0R1004086" || built.Args[1] != "YUTONG_MQTT" || built.Args[4] != len(canonicalVehicleProtocols) {
|
||||||
|
t.Fatalf("latest projection args = %#v", built.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestRealtimeFramesMapsPersistentProjectionToRawEvidence(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery("(?s)FROM vehicle_realtime_snapshot s WHERE s\\.vin = \\?").
|
||||||
|
WithArgs("LMRKH9AC0R1004086", len(canonicalVehicleProtocols)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"event_id", "vin", "plate", "protocol", "event_time", "received_at", "peer", "parsed_json"}).
|
||||||
|
AddRow("event-1", "LMRKH9AC0R1004086", "沪A03561F", "YUTONG_MQTT", "2026-08-25 16:49:56.451000", "2026-08-25 16:49:56.485000", "mqtt://yutong/4", `{"yutong_mqtt.data.speed_kmh":12.5}`))
|
||||||
|
store := &ProductionStore{db: db}
|
||||||
|
page, err := store.LatestRealtimeFrames(t.Context(), RawFrameQuery{VIN: "LMRKH9AC0R1004086", IncludeFields: true, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
|
t.Fatalf("latest projection page = %+v", page)
|
||||||
|
}
|
||||||
|
row := page.Items[0]
|
||||||
|
if row.ID != "event-1" || row.FrameType != "realtime_projection" || row.Protocol != "YUTONG_MQTT" || row.SourceEndpoint != "mqtt://yutong/4" {
|
||||||
|
t.Fatalf("latest projection row = %+v", row)
|
||||||
|
}
|
||||||
|
if got := row.ParsedFields["yutong_mqtt.data.speed_kmh"]; got != 12.5 {
|
||||||
|
t.Fatalf("projection parsed field = %#v", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
|
func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
|
||||||
db, err := sql.Open("ops_health_test", "")
|
db, err := sql.Open("ops_health_test", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
|||||||
func TestBuildVehicleCoverageSQL(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"}}
|
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
||||||
built := buildVehicleCoverageSQL(query)
|
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) {
|
if !strings.Contains(built.Text, want) {
|
||||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||||
}
|
}
|
||||||
@@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
|||||||
"COUNT(DISTINCT d.vin)",
|
"COUNT(DISTINCT d.vin)",
|
||||||
"SUM(d.daily_mileage_km)",
|
"SUM(d.daily_mileage_km)",
|
||||||
"SUM(d.pure_hydrogen_mileage_km)",
|
"SUM(d.pure_hydrogen_mileage_km)",
|
||||||
"COUNT(d.hydrogen_consumption_kg)",
|
"COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)",
|
||||||
"SUM(d.hydrogen_consumption_kg)",
|
"THEN d.hydrogen_consumption_kg ELSE 0 END",
|
||||||
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
|
"THEN d.hydrogen_matched_mileage_km ELSE 0 END",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(summary.Text, want) {
|
if !strings.Contains(summary.Text, want) {
|
||||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||||
@@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
trend := buildMileageStatisticsTrendSQL(query)
|
trend := buildMileageStatisticsTrendSQL(query)
|
||||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
||||||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
|
!strings.Contains(trend.Text, "THEN d.hydrogen_consumption_kg ELSE 0 END") {
|
||||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||||
}
|
}
|
||||||
ranking := buildMileageStatisticsRankingSQL(query)
|
ranking := buildMileageStatisticsRankingSQL(query)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ type Store interface {
|
|||||||
OpsHealth(context.Context) (OpsHealth, error)
|
OpsHealth(context.Context) (OpsHealth, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HydrogenDailyEvidenceStore interface {
|
||||||
|
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
type VehicleOverviewBatchStore interface {
|
type VehicleOverviewBatchStore interface {
|
||||||
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
|
|||||||
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
|
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime
|
||||||
|
// projection. The projection is used as a time anchor for bounded TDengine
|
||||||
|
// lookups, so an offline vehicle can still resolve its latest evidence without
|
||||||
|
// scanning all retained RAW history.
|
||||||
|
type RealtimeSnapshotFrameStore interface {
|
||||||
|
LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||||
|
}
|
||||||
|
|
||||||
type RawFrameQuery struct {
|
type RawFrameQuery struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
@@ -79,10 +91,11 @@ type RawFrameQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VehicleOverviewBatchQuery struct {
|
type VehicleOverviewBatchQuery struct {
|
||||||
Keywords []string `json:"keywords"`
|
Keywords []string `json:"keywords"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
|
ScopeVINs []string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -857,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||||
|
scopeVINs := principalVehicleVINScope(ctx)
|
||||||
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||||
Keywords: []string{keyword},
|
Keywords: []string{keyword},
|
||||||
Protocol: protocol,
|
Protocol: protocol,
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
|
ScopeVINs: scopeVINs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return VehicleServiceOverview{}, err
|
return VehicleServiceOverview{}, err
|
||||||
@@ -915,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
|||||||
query.Keywords = keywords
|
query.Keywords = keywords
|
||||||
query.Limit = limit
|
query.Limit = limit
|
||||||
query.Offset = offset
|
query.Offset = offset
|
||||||
|
query.ScopeVINs = principalVehicleVINScope(ctx)
|
||||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||||
page, err := batchStore.VehicleServiceOverviews(ctx, query)
|
page, err := batchStore.VehicleServiceOverviews(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Page[VehicleServiceOverview]{}, err
|
return Page[VehicleServiceOverview]{}, err
|
||||||
}
|
}
|
||||||
|
page = restrictVehicleOverviewPage(ctx, page)
|
||||||
page.Total = total
|
page.Total = total
|
||||||
page.Limit = limit
|
page.Limit = limit
|
||||||
page.Offset = offset
|
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
|
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) {
|
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||||
keyword := strings.TrimSpace(vin)
|
keyword := strings.TrimSpace(vin)
|
||||||
protocol = strings.TrimSpace(protocol)
|
protocol = strings.TrimSpace(protocol)
|
||||||
@@ -1029,7 +1074,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return VehicleDetail{}, err
|
return VehicleDetail{}, err
|
||||||
}
|
}
|
||||||
raw, err := s.RawFrames(ctx, rawQuery)
|
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return VehicleDetail{}, err
|
return VehicleDetail{}, err
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1139,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vehicleDetailRawProjectionLookback = 24 * time.Hour
|
||||||
|
|
||||||
|
type vehicleDetailRawPreviewResult struct {
|
||||||
|
anchor RawFrameRow
|
||||||
|
page Page[RawFrameRow]
|
||||||
|
}
|
||||||
|
|
||||||
|
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
|
||||||
|
// an unbounded TDengine query. MySQL supplies one persistent anchor per
|
||||||
|
// protocol; TDengine then reads only the day preceding that protocol's actual
|
||||||
|
// latest received time. If historical storage is unavailable, the projection
|
||||||
|
// itself remains useful evidence and the rest of the vehicle detail can load.
|
||||||
|
func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore)
|
||||||
|
if !ok {
|
||||||
|
return s.store.RawFrames(ctx, query)
|
||||||
|
}
|
||||||
|
anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{
|
||||||
|
VIN: query.VIN,
|
||||||
|
Protocol: query.Protocol,
|
||||||
|
DateFrom: query.DateFrom,
|
||||||
|
DateTo: query.DateTo,
|
||||||
|
Fields: query.Fields,
|
||||||
|
IncludeFields: query.IncludeFields,
|
||||||
|
Limit: len(canonicalVehicleProtocols),
|
||||||
|
SkipCount: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Page[RawFrameRow]{}, err
|
||||||
|
}
|
||||||
|
if len(anchors.Items) == 0 {
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
|
||||||
|
for _, anchor := range anchors.Items {
|
||||||
|
anchor := anchor
|
||||||
|
go func() {
|
||||||
|
bounded, valid := boundedRawPreviewQuery(query, anchor)
|
||||||
|
if !valid {
|
||||||
|
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, queryErr := s.store.RawFrames(ctx, bounded)
|
||||||
|
if queryErr != nil {
|
||||||
|
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
|
||||||
|
for range anchors.Items {
|
||||||
|
result := <-results
|
||||||
|
if len(result.page.Items) == 0 {
|
||||||
|
items = append(items, result.anchor)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, result.page.Items...)
|
||||||
|
}
|
||||||
|
items = newestRawFrames(items, query.Limit)
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) {
|
||||||
|
anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime))
|
||||||
|
if !ok {
|
||||||
|
return RawFrameQuery{}, false
|
||||||
|
}
|
||||||
|
start := anchorTime.Add(-vehicleDetailRawProjectionLookback)
|
||||||
|
end := anchorTime.Add(time.Minute)
|
||||||
|
if value := strings.TrimSpace(query.DateFrom); value != "" {
|
||||||
|
if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) {
|
||||||
|
start = scopedStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(query.DateTo); value != "" {
|
||||||
|
if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) {
|
||||||
|
end = scopedEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end.Before(start) {
|
||||||
|
return RawFrameQuery{}, false
|
||||||
|
}
|
||||||
|
bounded := query
|
||||||
|
bounded.Protocol = anchor.Protocol
|
||||||
|
bounded.DateFrom = start.Format(time.RFC3339Nano)
|
||||||
|
bounded.DateTo = end.Format(time.RFC3339Nano)
|
||||||
|
bounded.Offset = 0
|
||||||
|
bounded.SkipCount = true
|
||||||
|
return bounded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
deduplicated := make([]RawFrameRow, 0, len(items))
|
||||||
|
seen := make(map[string]bool, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID)
|
||||||
|
if key != "\x00" && seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
deduplicated = append(deduplicated, item)
|
||||||
|
}
|
||||||
|
sort.SliceStable(deduplicated, func(left, right int) bool {
|
||||||
|
leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime))
|
||||||
|
rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime))
|
||||||
|
if leftOK && rightOK && !leftTime.Equal(rightTime) {
|
||||||
|
return leftTime.After(rightTime)
|
||||||
|
}
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
if deduplicated[left].Protocol != deduplicated[right].Protocol {
|
||||||
|
return deduplicated[left].Protocol < deduplicated[right].Protocol
|
||||||
|
}
|
||||||
|
return deduplicated[left].ID < deduplicated[right].ID
|
||||||
|
})
|
||||||
|
if len(deduplicated) > limit {
|
||||||
|
deduplicated = deduplicated[:limit]
|
||||||
|
}
|
||||||
|
return deduplicated
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||||
vin = strings.TrimSpace(vin)
|
vin = strings.TrimSpace(vin)
|
||||||
if vin == "" {
|
if vin == "" {
|
||||||
@@ -1587,6 +1768,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
|
|||||||
|
|
||||||
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
||||||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
||||||
|
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
|
||||||
for index := range statuses {
|
for index := range statuses {
|
||||||
protocol := statuses[index].Protocol
|
protocol := statuses[index].Protocol
|
||||||
if protocol == "" {
|
if protocol == "" {
|
||||||
@@ -1599,7 +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))
|
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !statuses[index].HasRaw && scopeErr == nil {
|
if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection {
|
||||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true})
|
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true})
|
||||||
if err == nil && len(page.Items) > 0 {
|
if err == nil && len(page.Items) > 0 {
|
||||||
statuses[index].HasRaw = true
|
statuses[index].HasRaw = true
|
||||||
@@ -1933,7 +2115,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
|
|||||||
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
||||||
for _, protocol := range canonicalVehicleProtocols {
|
for _, protocol := range canonicalVehicleProtocols {
|
||||||
go func(protocol string) {
|
go func(protocol string) {
|
||||||
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
page, queryErr := s.vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
||||||
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
|
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
|
||||||
}(protocol)
|
}(protocol)
|
||||||
}
|
}
|
||||||
@@ -5409,6 +5591,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) {
|
||||||
|
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||||
|
date = strings.TrimSpace(date)
|
||||||
|
if len(vin) != 17 {
|
||||||
|
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"}
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"}
|
||||||
|
}
|
||||||
|
if !hydrogenConsumptionAllowed(ctx) {
|
||||||
|
return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"}
|
||||||
|
}
|
||||||
|
query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}}
|
||||||
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, err
|
||||||
|
}
|
||||||
|
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, err
|
||||||
|
}
|
||||||
|
store, ok := s.store.(HydrogenDailyEvidenceStore)
|
||||||
|
if !ok {
|
||||||
|
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"}
|
||||||
|
}
|
||||||
|
result, found, err := store.HydrogenDailyEvidence(ctx, vin, date)
|
||||||
|
if err != nil {
|
||||||
|
return HydrogenDailyEvidence{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -543,9 +543,11 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
|
|||||||
|
|
||||||
type countingStore struct {
|
type countingStore struct {
|
||||||
*MockStore
|
*MockStore
|
||||||
|
rawMu sync.Mutex
|
||||||
vehiclesCalls int
|
vehiclesCalls int
|
||||||
vehicleRealtimeCalls int
|
vehicleRealtimeCalls int
|
||||||
overviewBatchCalls int
|
overviewBatchCalls int
|
||||||
|
lastOverviewBatchQuery VehicleOverviewBatchQuery
|
||||||
lastVehicleQuery url.Values
|
lastVehicleQuery url.Values
|
||||||
lastRealtimeQuery url.Values
|
lastRealtimeQuery url.Values
|
||||||
lastHistoryQuery url.Values
|
lastHistoryQuery url.Values
|
||||||
@@ -555,6 +557,17 @@ type countingStore struct {
|
|||||||
lastMileageStatisticsQuery url.Values
|
lastMileageStatisticsQuery url.Values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type projectionCountingStore struct {
|
||||||
|
*countingStore
|
||||||
|
anchors Page[RawFrameRow]
|
||||||
|
projectionQueries []RawFrameQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
s.projectionQueries = append(s.projectionQueries, query)
|
||||||
|
return s.anchors, nil
|
||||||
|
}
|
||||||
|
|
||||||
type hydrogenMetricsStore struct{ *MockStore }
|
type hydrogenMetricsStore struct{ *MockStore }
|
||||||
|
|
||||||
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||||
@@ -572,15 +585,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile
|
|||||||
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
|
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
|
||||||
consumption, rate := 3.1, 5.5
|
consumption, rate := 3.1, 5.5
|
||||||
return MileageStatistics{
|
return MileageStatistics{
|
||||||
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
|
||||||
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
|
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||||
Trend: []MileageTrendPoint{{
|
Trend: []MileageTrendPoint{{
|
||||||
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
|
||||||
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||||
}},
|
}},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) {
|
||||||
|
return HydrogenDailyEvidence{
|
||||||
|
VIN: "LB9A32A24R0LS1426",
|
||||||
|
Date: "2026-07-13",
|
||||||
|
AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
|
||||||
|
Intervals: []HydrogenIntervalEvidenceRow{{
|
||||||
|
Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK",
|
||||||
|
}},
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newCountingStore() *countingStore {
|
func newCountingStore() *countingStore {
|
||||||
return &countingStore{MockStore: NewMockStore()}
|
return &countingStore{MockStore: NewMockStore()}
|
||||||
}
|
}
|
||||||
@@ -622,6 +646,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.
|
|||||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
|
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
|
||||||
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
|
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
|
||||||
}
|
}
|
||||||
|
evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" {
|
||||||
|
t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) {
|
||||||
|
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||||
|
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
customer := WithPrincipal(context.Background(), Principal{
|
||||||
|
Name: "业务客户", Role: "customer", UserType: "customer",
|
||||||
|
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||||
|
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||||
|
})
|
||||||
|
_, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13")
|
||||||
|
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||||
@@ -643,7 +688,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
s.rawMu.Lock()
|
||||||
s.rawFrameQueries = append(s.rawFrameQueries, query)
|
s.rawFrameQueries = append(s.rawFrameQueries, query)
|
||||||
|
s.rawMu.Unlock()
|
||||||
return s.MockStore.RawFrames(ctx, query)
|
return s.MockStore.RawFrames(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +739,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) {
|
||||||
|
base := newCountingStore()
|
||||||
|
store := &projectionCountingStore{
|
||||||
|
countingStore: base,
|
||||||
|
anchors: Page[RawFrameRow]{Items: []RawFrameRow{
|
||||||
|
{ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}},
|
||||||
|
{ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}},
|
||||||
|
{ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" {
|
||||||
|
t.Fatalf("projection queries = %+v", store.projectionQueries)
|
||||||
|
}
|
||||||
|
if len(store.rawFrameQueries) != 3 {
|
||||||
|
t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries)
|
||||||
|
}
|
||||||
|
for _, query := range store.rawFrameQueries {
|
||||||
|
if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount {
|
||||||
|
t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") {
|
||||||
|
t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") {
|
||||||
|
t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(detail.Raw.Items) != 3 {
|
||||||
|
t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw)
|
||||||
|
}
|
||||||
|
projectionFallbacks := 0
|
||||||
|
for _, item := range detail.Raw.Items {
|
||||||
|
if item.FrameType == "realtime_projection" {
|
||||||
|
projectionFallbacks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectionFallbacks != 2 {
|
||||||
|
t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) {
|
||||||
|
base := newCountingStore()
|
||||||
|
store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}}
|
||||||
|
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(store.rawFrameQueries) != 0 {
|
||||||
|
t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries)
|
||||||
|
}
|
||||||
|
if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 {
|
||||||
|
t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||||
s.lastMileageStatisticsQuery = cloneValues(query)
|
s.lastMileageStatisticsQuery = cloneValues(query)
|
||||||
return s.MockStore.MileageStatistics(ctx, query)
|
return s.MockStore.MileageStatistics(ctx, query)
|
||||||
@@ -904,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
|
|||||||
|
|
||||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||||
s.overviewBatchCalls++
|
s.overviewBatchCalls++
|
||||||
|
s.lastOverviewBatchQuery = query
|
||||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) {
|
||||||
|
store := newCountingStore()
|
||||||
|
service := NewService(store)
|
||||||
|
ctx := WithPrincipal(context.Background(), Principal{
|
||||||
|
Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||||
|
})
|
||||||
|
page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||||
|
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("customer batch lookup returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" {
|
||||||
|
t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs)
|
||||||
|
}
|
||||||
|
if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" {
|
||||||
|
t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
|
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
|
||||||
service := NewService(NewMockStore())
|
service := NewService(NewMockStore())
|
||||||
summary, err := service.VehicleServiceSummary(context.Background())
|
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))
|
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
|
||||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||||
|
setCachePolicy(w, r.URL.Path)
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
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 =
|
type Topic =
|
||||||
| "overview" | "quickstart" | "auth" | "errors" | "limits"
|
| "overview" | "quickstart" | "auth" | "errors" | "limits"
|
||||||
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage"
|
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage" | "stationary"
|
||||||
| "vehicle-auth" | "key-rotation" | "quality" | "checklist";
|
| "vehicle-auth" | "key-rotation" | "quality" | "checklist";
|
||||||
|
|
||||||
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
|
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
|
||||||
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] },
|
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] },
|
||||||
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] },
|
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"], ["stationary", "加氢车辆停留核验"]] },
|
||||||
{ title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
|
{ title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ function TopicContent({ topic }: { topic: Topic }) {
|
|||||||
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
|
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
|
||||||
if (topic === "mileage-range") return <MileageRangeReference />;
|
if (topic === "mileage-range") return <MileageRangeReference />;
|
||||||
if (topic === "total-mileage") return <TotalMileageReference />;
|
if (topic === "total-mileage") return <TotalMileageReference />;
|
||||||
|
if (topic === "stationary") return <StationaryVehicleReference />;
|
||||||
if (topic === "overview") return <Overview />;
|
if (topic === "overview") return <Overview />;
|
||||||
if (topic === "auth") return <AuthGuide />;
|
if (topic === "auth") return <AuthGuide />;
|
||||||
if (topic === "errors") return <ErrorGuide />;
|
if (topic === "errors") return <ErrorGuide />;
|
||||||
@@ -135,7 +136,7 @@ function LimitsGuide() {
|
|||||||
|
|
||||||
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
||||||
const product = products[kind === "hydrogen" ? 0 : 1];
|
const product = products[kind === "hydrogen" ? 0 : 1];
|
||||||
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
|
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315,\n "calculationPhase": "FINAL",\n "algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
|
||||||
return <>
|
return <>
|
||||||
<h1>{product.name}</h1><p className="docs-lead">{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。</p>
|
<h1>{product.name}</h1><p className="docs-lead">{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。</p>
|
||||||
<Endpoint method={product.method} path={product.path} />
|
<Endpoint method={product.method} path={product.path} />
|
||||||
@@ -149,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
|||||||
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
|
"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></>}
|
{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" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td>当天实时暂估为 PRELIMINARY,日终重算为 FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td>氢耗计算算法版本</td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td>同一协议在当日最后有效时刻的累计总里程</td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td>统计实际采用的最后一条车辆源数据时间</td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td>日统计投影最后更新时间</td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td>实际选中的 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><CodeBlock language="json" value={`{
|
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||||
"code": "SUCCESS",
|
"code": "SUCCESS",
|
||||||
"message": "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() {
|
function VehicleAuthGuide() {
|
||||||
return <>
|
return <>
|
||||||
<h1>车辆授权</h1><p className="docs-lead">平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。</p>
|
<h1>车辆授权</h1><p className="docs-lead">平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。</p>
|
||||||
@@ -304,6 +322,7 @@ function tocFor(topic: Topic) {
|
|||||||
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
||||||
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
|
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
|
||||||
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
|
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
|
||||||
|
stationary: ["请求参数", "请求示例", "核验口径", "响应字段"],
|
||||||
"vehicle-auth": ["授权关系", "配置步骤"],
|
"vehicle-auth": ["授权关系", "配置步骤"],
|
||||||
"key-rotation": ["推荐流程", "权限要求"],
|
"key-rotation": ["推荐流程", "权限要求"],
|
||||||
quality: ["状态定义", "统计口径"],
|
quality: ["状态定义", "统计口径"],
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ export const products: Product[] = [
|
|||||||
path: "/api/v1/vehicles/total-mileage/query",
|
path: "/api/v1/vehicles/total-mileage/query",
|
||||||
unit: "km"
|
unit: "km"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "stationary_vehicle_verification",
|
||||||
|
name: "加氢车辆停留核验",
|
||||||
|
description: "按加氢站坐标与时间区间核验授权车辆的 5 米内低速停留,并按匹配度排序。",
|
||||||
|
version: "v1",
|
||||||
|
status: "available",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/vehicles/stationary/query",
|
||||||
|
unit: "辆"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "realtime_vehicle",
|
code: "realtime_vehicle",
|
||||||
name: "车辆实时位置与状态",
|
name: "车辆实时位置与状态",
|
||||||
@@ -83,6 +93,17 @@ export const curlExample = (product: Product) => {
|
|||||||
"time": "2026-07-21 09:30:00",
|
"time": "2026-07-21 09:30:00",
|
||||||
"protocol": "GB32960"
|
"protocol": "GB32960"
|
||||||
}'`;
|
}'`;
|
||||||
|
if (product.code === "stationary_vehicle_verification") return `curl --request POST \\
|
||||||
|
--url https://open.d.lnoneos.com${product.path} \\
|
||||||
|
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data '{
|
||||||
|
"startTime": "2026-08-06 10:00:00",
|
||||||
|
"endTime": "2026-08-06 12:00:00",
|
||||||
|
"longitude": 120.752312,
|
||||||
|
"latitude": 30.746281,
|
||||||
|
"radiusMeters": 5
|
||||||
|
}'`;
|
||||||
if (product.code === "mileage_range") return `curl --request POST \\
|
if (product.code === "mileage_range") return `curl --request POST \\
|
||||||
--url https://open.d.lnoneos.com${product.path} \\
|
--url https://open.d.lnoneos.com${product.path} \\
|
||||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
AlertRuleRevision,
|
AlertRuleRevision,
|
||||||
AlertSummary,
|
AlertSummary,
|
||||||
DailyMileageRow,
|
DailyMileageRow,
|
||||||
|
HydrogenDailyEvidence,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
HistoryLocationRow,
|
HistoryLocationRow,
|
||||||
HistoryDataResponse,
|
HistoryDataResponse,
|
||||||
@@ -487,6 +488,10 @@ export const api = {
|
|||||||
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
|
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||||
}, signal)),
|
}, 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({
|
mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||||
}, signal)),
|
}, signal)),
|
||||||
|
|||||||
@@ -470,6 +470,8 @@ export interface VehicleCoverageRow {
|
|||||||
plate: string;
|
plate: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
oem: string;
|
oem: string;
|
||||||
|
brandName?: string;
|
||||||
|
modelName?: string;
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
missingProtocols: string[];
|
missingProtocols: string[];
|
||||||
sourceStatus: VehicleSourceStatus[];
|
sourceStatus: VehicleSourceStatus[];
|
||||||
@@ -908,10 +910,40 @@ export interface DailyMileageRow {
|
|||||||
pureHydrogenMileageKm?: number;
|
pureHydrogenMileageKm?: number;
|
||||||
hydrogenConsumptionKg?: number | null;
|
hydrogenConsumptionKg?: number | null;
|
||||||
hydrogenConsumptionKgPer100Km?: number | null;
|
hydrogenConsumptionKgPer100Km?: number | null;
|
||||||
|
hydrogenSocBalancedKg?: number | null;
|
||||||
|
hydrogenSocBalancedKgPer100Km?: number | null;
|
||||||
|
hydrogenEvidenceAvailable?: boolean;
|
||||||
|
hydrogenQualityStatus?: string;
|
||||||
|
hydrogenAlgorithmVersion?: string;
|
||||||
source: string;
|
source: string;
|
||||||
anomalySeverity?: string;
|
anomalySeverity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HydrogenIntervalEvidence {
|
||||||
|
index: number; type: 'MIXED' | 'PURE_ELECTRIC'; startTime: string; endTime: string;
|
||||||
|
startEventId: string; endEventId: string; source: string;
|
||||||
|
startPressureMpa: number; endPressureMpa: number; startTemperatureC: number; endTemperatureC: number;
|
||||||
|
startMassKg: number; endMassKg: number; rawHydrogenConsumptionKg: number;
|
||||||
|
startSocPercent?: number; endSocPercent?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
||||||
|
socBalancedConsumptionKg?: number; startMileageKm?: number; endMileageKm?: number; mileageKm?: number;
|
||||||
|
consumptionKgPer100Km?: number; sampleCount: number; qualityStatus: string; qualityReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenDailyEvidence {
|
||||||
|
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
|
||||||
|
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
||||||
|
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
|
||||||
|
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
|
||||||
|
refuelCount: number; chargeCount: number; validSegmentCount: number; invalidSegmentCount: number;
|
||||||
|
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
|
||||||
|
parameters: {
|
||||||
|
batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number;
|
||||||
|
powerOffLeadSeconds: number; refuelRiseMpa: number; refuelSustainSeconds: number;
|
||||||
|
pureElectricDropMpa: number; pureElectricWindowSeconds: number; algorithmVersion: string;
|
||||||
|
};
|
||||||
|
intervals: HydrogenIntervalEvidence[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MileageSummary {
|
export interface MileageSummary {
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
recordCount: number;
|
recordCount: number;
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export function VehicleDetail({
|
|||||||
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
|
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
|
||||||
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
||||||
const latestRaw = detail?.raw?.items?.[0];
|
const latestRaw = detail?.raw?.items?.[0];
|
||||||
|
const latestRawType = latestRaw?.frameType === 'realtime_projection' ? '最新状态投影' : latestRaw?.frameType;
|
||||||
const qualityCount = detail?.quality?.total ?? 0;
|
const qualityCount = detail?.quality?.total ?? 0;
|
||||||
const priorityQualityIssue = useMemo(() => {
|
const priorityQualityIssue = useMemo(() => {
|
||||||
const items = detail?.quality?.items ?? [];
|
const items = detail?.quality?.items ?? [];
|
||||||
@@ -617,7 +618,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
label: '历史数据',
|
label: '历史数据',
|
||||||
value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0),
|
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
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -766,7 +767,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
title: '历史数据',
|
title: '历史数据',
|
||||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
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,
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||||
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
|
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
|
||||||
disabled: !hasResolvedVIN,
|
disabled: !hasResolvedVIN,
|
||||||
@@ -863,7 +864,7 @@ export function VehicleDetail({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '查询历史数据',
|
title: '查询历史数据',
|
||||||
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`,
|
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRawType || '-'}`,
|
||||||
acceptance: '关键字段来自解析字段而不是页面二次推断。',
|
acceptance: '关键字段来自解析字段而不是页面二次推断。',
|
||||||
action: '历史数据',
|
action: '历史数据',
|
||||||
disabled: !hasResolvedVIN,
|
disabled: !hasResolvedVIN,
|
||||||
@@ -938,7 +939,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
title: '查询历史数据',
|
title: '查询历史数据',
|
||||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
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,
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||||
primaryAction: '历史数据',
|
primaryAction: '历史数据',
|
||||||
secondaryAction: '导出档案',
|
secondaryAction: '导出档案',
|
||||||
@@ -991,7 +992,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
title: '历史数据查询',
|
title: '历史数据查询',
|
||||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
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,
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||||
action: '查询历史',
|
action: '查询历史',
|
||||||
disabled: !hasResolvedVIN,
|
disabled: !hasResolvedVIN,
|
||||||
@@ -1047,7 +1048,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
title: '数据导出',
|
title: '数据导出',
|
||||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||||
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段',
|
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与字段',
|
||||||
action: '历史导出',
|
action: '历史导出',
|
||||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||||
disabled: !hasResolvedVIN,
|
disabled: !hasResolvedVIN,
|
||||||
@@ -1085,7 +1086,7 @@ export function VehicleDetail({
|
|||||||
{
|
{
|
||||||
title: '历史证据',
|
title: '历史证据',
|
||||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||||
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与解析字段',
|
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与解析字段',
|
||||||
action: '历史查询',
|
action: '历史查询',
|
||||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||||
disabled: !hasResolvedVIN,
|
disabled: !hasResolvedVIN,
|
||||||
@@ -2175,7 +2176,7 @@ export function VehicleDetail({
|
|||||||
dataSource={detail?.raw?.items ?? []}
|
dataSource={detail?.raw?.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
{ 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: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
|
||||||
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
|
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
|
||||||
{ title: '入库时间', dataIndex: 'serverTime', 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();
|
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 () => {
|
test('treats login and logout as complete query and mutation cache boundaries', async () => {
|
||||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
return <AuthLoadingState />;
|
return <AuthLoadingState />;
|
||||||
}
|
}
|
||||||
if (!session.data) {
|
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">
|
return <div className="v2-auth-screen">
|
||||||
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
|
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
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(() => {
|
afterEach(() => {
|
||||||
window.sessionStorage.clear();
|
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);
|
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', () => {
|
test('invalidates only the currently active rejected token', () => {
|
||||||
const unauthorized = vi.fn();
|
const unauthorized = vi.fn();
|
||||||
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
|
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ export function canOperate(session: PlatformSession) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasMenu(session: PlatformSession, menu: string) {
|
export function hasMenu(session: PlatformSession, menu: string) {
|
||||||
if (session.role === 'admin') return true;
|
if (session.menuKeys) return session.menuKeys.includes(menu);
|
||||||
if (!session.menuKeys) return session.role !== 'customer';
|
return session.role !== 'customer';
|
||||||
return session.menuKeys.includes(menu);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAdminister(session: PlatformSession) {
|
export function canAdminister(session: PlatformSession) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
|||||||
dateTo: '2026-07-14',
|
dateTo: '2026-07-14',
|
||||||
dates: ['2026-07-13', '2026-07-14'],
|
dates: ['2026-07-13', '2026-07-14'],
|
||||||
vehicles: [
|
vehicles: [
|
||||||
{ vin: 'LTEST000000000001', plate: '粤A12345' },
|
{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' },
|
||||||
{ vin: 'LTEST000000000002', plate: '粤A54321' }
|
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
|
||||||
],
|
],
|
||||||
mileageRows: [
|
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, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' },
|
||||||
@@ -26,25 +26,29 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
|||||||
const sheet = workbook.getWorksheet('里程查询')!;
|
const sheet = workbook.getWorksheet('里程查询')!;
|
||||||
|
|
||||||
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
|
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
|
||||||
expect(sheet.getCell('C6').value).toBeInstanceOf(Date);
|
expect(sheet.getCell('C6').value).toBe('品牌');
|
||||||
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
expect(sheet.getCell('D6').value).toBe('车型');
|
||||||
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
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('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
|
||||||
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
|
expect(sheet.getCell('G6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
|
||||||
expect(sheet.getCell('C7').value).toBe(88.7);
|
expect(sheet.getCell('C7').value).toBe('现代');
|
||||||
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 });
|
expect(sheet.getCell('D7').value).toBe('XCIENT');
|
||||||
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"');
|
expect(sheet.getCell('E7').value).toBe(88.7);
|
||||||
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
|
expect(sheet.getCell('G7').value).toMatchObject({ formula: 'SUM(E7:F7)', result: 193.3 });
|
||||||
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
|
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);
|
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||||
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
||||||
expect(hydrogenSheet.getRow(1).values).toEqual([
|
expect(hydrogenSheet.getRow(1).values).toEqual([
|
||||||
undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
|
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
|
||||||
]);
|
]);
|
||||||
expect(hydrogenSheet.getRow(2).values).toEqual([
|
expect(hydrogenSheet.getRow(2).values).toEqual([
|
||||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', 88.7, 56.2, 3.1, 5.5, 'GB32960'
|
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 88.7, 56.2, 3.1, 5.5, 'GB32960'
|
||||||
]);
|
]);
|
||||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 8 } });
|
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 10 } });
|
||||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { DailyMileageRow } from '../../api/types';
|
import type { DailyMileageRow } from '../../api/types';
|
||||||
import { downloadBlob } from './download';
|
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 MileageExportSource = { protocol: string; label: string; mileageType: string };
|
||||||
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
workbook.calcProperties.fullCalcOnLoad = true;
|
workbook.calcProperties.fullCalcOnLoad = true;
|
||||||
|
|
||||||
const sheet = workbook.addWorksheet('里程查询', {
|
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 } },
|
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 }
|
properties: { defaultRowHeight: 21 }
|
||||||
});
|
});
|
||||||
const firstDateColumn = 3;
|
const firstDateColumn = 5;
|
||||||
const lastDateColumn = firstDateColumn + input.dates.length - 1;
|
const lastDateColumn = firstDateColumn + input.dates.length - 1;
|
||||||
const totalColumn = lastDateColumn + 1;
|
const totalColumn = lastDateColumn + 1;
|
||||||
const lastColumnLetter = excelColumn(totalColumn);
|
const lastColumnLetter = excelColumn(totalColumn);
|
||||||
@@ -72,14 +72,14 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
sheet.getRow(5).height = 8;
|
sheet.getRow(5).height = 8;
|
||||||
|
|
||||||
const header = sheet.getRow(headerRowNumber);
|
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.height = 30;
|
||||||
header.eachCell((cell, columnNumber) => {
|
header.eachCell((cell, columnNumber) => {
|
||||||
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
|
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
|
||||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
|
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
|
||||||
cell.alignment = {
|
cell.alignment = {
|
||||||
vertical: 'middle',
|
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.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
|
||||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
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 dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
|
||||||
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||||
const row = sheet.getRow(rowNumber);
|
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.height = 25;
|
||||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
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.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.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||||
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
|
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(1).width = 15;
|
||||||
sheet.getColumn(2).width = 24;
|
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;
|
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
|
||||||
sheet.getColumn(totalColumn).width = 17;
|
sheet.getColumn(totalColumn).width = 17;
|
||||||
if (input.vehicles.length) {
|
if (input.vehicles.length) {
|
||||||
@@ -135,7 +137,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
||||||
properties: { defaultRowHeight: 21 }
|
properties: { defaultRowHeight: 21 }
|
||||||
});
|
});
|
||||||
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
|
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
|
||||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||||
hydrogenHeader.height = 30;
|
hydrogenHeader.height = 30;
|
||||||
hydrogenHeader.eachCell((cell) => {
|
hydrogenHeader.eachCell((cell) => {
|
||||||
@@ -144,13 +146,16 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
|
||||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
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));
|
const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
|
||||||
energyRows.forEach((value, index) => {
|
energyRows.forEach((value, index) => {
|
||||||
|
const vehicle = vehicleByVIN.get(value.vin);
|
||||||
const row = hydrogenSheet.addRow([
|
const row = hydrogenSheet.addRow([
|
||||||
value.date,
|
value.date,
|
||||||
plateByVIN.get(value.vin) ?? value.plate ?? '未绑定',
|
vehicle?.plate || value.plate || '未绑定',
|
||||||
value.vin,
|
value.vin,
|
||||||
|
vehicle?.brandName || '待维护',
|
||||||
|
vehicle?.modelName || '待维护',
|
||||||
value.dailyMileageKm,
|
value.dailyMileageKm,
|
||||||
value.pureHydrogenMileageKm ?? null,
|
value.pureHydrogenMileageKm ?? null,
|
||||||
value.hydrogenConsumptionKg ?? null,
|
value.hydrogenConsumptionKg ?? null,
|
||||||
@@ -160,17 +165,17 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
row.height = 24;
|
row.height = 24;
|
||||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||||
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
|
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
|
||||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 4 && columnNumber <= 7 ? 'right' : 'left' };
|
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 9 ? 'right' : 'left' };
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||||
if (columnNumber >= 4 && columnNumber <= 7) cell.numFmt = '#,##0.0';
|
if (columnNumber >= 6 && columnNumber <= 9) cell.numFmt = '#,##0.0';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
[13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
[13, 15, 24, 18, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
||||||
hydrogenSheet.getColumn(index + 1).width = width;
|
hydrogenSheet.getColumn(index + 1).width = width;
|
||||||
});
|
});
|
||||||
if (energyRows.length) {
|
if (energyRows.length) {
|
||||||
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } };
|
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } };
|
||||||
}
|
}
|
||||||
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
|
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
|
||||||
return workbook;
|
return workbook;
|
||||||
|
|||||||
@@ -136,6 +136,23 @@ test('summarizes only material automation changes before release', () => {
|
|||||||
{ key: 'status', label: '发布状态', before: '启用', after: '停用' }
|
{ key: 'status', label: '发布状态', before: '启用', after: '停用' }
|
||||||
]);
|
]);
|
||||||
expect(automationReleaseChanges(baseline, baseline)).toEqual([]);
|
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 () => {
|
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: '事件自动化编辑' });
|
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
|
||||||
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
|
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
|
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();
|
expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
|
||||||
const nameInput = screen.getByLabelText('规则名称');
|
const nameInput = screen.getByLabelText('规则名称');
|
||||||
const thresholdInput = 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')).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(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
|
||||||
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
|
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).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
|
||||||
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument();
|
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.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();
|
expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
|
||||||
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '下一步:测试规则' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
|
||||||
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument();
|
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
|
||||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
||||||
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
|
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: '运行测试' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
|
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());
|
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.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
|
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
|
||||||
mocks.metricCatalog.mockResolvedValue({
|
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(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
|
||||||
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
|
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
|
||||||
expect(within(editor).getByLabelText('自动化配置摘要')).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).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument();
|
||||||
expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
|
expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
|
||||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
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();
|
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
||||||
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
|
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();
|
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('button', { name: '发布更新' })).toBeEnabled();
|
||||||
|
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
|
||||||
|
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
|
fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
|
||||||
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
||||||
expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.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: /事件与条件/ }));
|
fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ }));
|
||||||
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
|
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 () => {
|
test('copies an automation into an audited disabled draft without changing the source rule', async () => {
|
||||||
const source = alertRule();
|
const source = alertRule();
|
||||||
const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 };
|
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).getByText('2 项变更')).toBeInTheDocument();
|
||||||
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
|
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
|
||||||
expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument();
|
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();
|
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: '创建副本' }));
|
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();
|
expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument();
|
||||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
|
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
|
||||||
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
|
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
|
||||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:测试规则' }));
|
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' }));
|
||||||
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeDisabled();
|
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
|
||||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
|
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
|
||||||
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
|
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
|
||||||
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
|
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: 1, label: '事件与条件', description: '定义触发信号' },
|
||||||
{ step: 2, label: '车辆范围', description: '选择生效对象' },
|
{ step: 2, label: '车辆范围', description: '选择生效对象' },
|
||||||
{ step: 3, label: '执行动作', description: '通知、恢复与冷却' },
|
{ step: 3, label: '执行动作', description: '通知、恢复与冷却' },
|
||||||
{ step: 4, label: '测试并发布', description: '验证后正式启用' }
|
{ step: 4, label: '检查并发布', description: '确认配置后发布' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function alertFiltersFromParams(params: URLSearchParams): Filters {
|
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 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[] {
|
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) => ({
|
const snapshot = (rule: AlertRuleInput) => ({
|
||||||
name: rule.name || '未命名自动化',
|
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),
|
scope: ruleScopeSummary(rule),
|
||||||
action: `${ruleActionSummary(rule)} · ${rule.repeatIntervalSec ? `${formatAlertDuration(rule.repeatIntervalSec)}内不重复` : '允许每次匹配执行'}`,
|
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 ? '启用' : '停用'
|
status: rule.enabled ? '启用' : '停用'
|
||||||
});
|
});
|
||||||
const before = snapshot(baseline);
|
const before = snapshot(baseline);
|
||||||
const after = snapshot(draft);
|
const after = snapshot(draft);
|
||||||
return [
|
return [
|
||||||
{ key: 'name', label: '规则名称', before: before.name, after: after.name },
|
changed((rule) => rule.name) ? { key: 'name', label: '规则名称', before: before.name, after: after.name } : undefined,
|
||||||
{ key: 'event', label: '事件与条件', before: before.event, after: after.event },
|
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,
|
||||||
{ key: 'scope', label: '车辆范围', before: before.scope, after: after.scope },
|
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,
|
||||||
{ key: 'action', label: '执行动作', before: before.action, after: after.action },
|
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,
|
||||||
{ key: 'status', label: '发布状态', before: before.status, after: after.status }
|
changed((rule) => ({ recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold })) ? { key: 'recovery', label: '恢复条件', before: before.recovery, after: after.recovery } : undefined,
|
||||||
].filter((item) => item.before !== item.after);
|
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 }) {
|
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);
|
moveToStep((editorStep + 1) as AutomationEditorStep);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (testRan) save.mutate(draft);
|
save.mutate(draft);
|
||||||
};
|
};
|
||||||
const openNewRule = () => {
|
const openNewRule = () => {
|
||||||
const nextDraft = emptyRule();
|
const nextDraft = emptyRule();
|
||||||
@@ -1168,7 +1177,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
|
|||||||
</div>
|
</div>
|
||||||
<div className="v2-automation-review-test">
|
<div className="v2-automation-review-test">
|
||||||
<section className="v2-automation-sample-test" aria-labelledby="v2-automation-review-test-title">
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:测试规则' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
|
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:检查发布' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
|
||||||
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入样例测试,不会立即发送通知。' : '样例测试只验证契约与计划动作,不会产生真实通知。';
|
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入发布检查,不会立即发送通知。' : '配置有效即可发布;样例测试为可选预演,不会产生真实通知。';
|
||||||
const editorFooterNote = currentStepError
|
const editorFooterNote = currentStepError
|
||||||
? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em>
|
? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em>
|
||||||
: mutationError
|
: mutationError
|
||||||
? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em>
|
? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em>
|
||||||
|
: reviewMode && draft.version > 0 && !draftDirty
|
||||||
|
? <span>当前配置与线上版本一致,没有可发布的更改</span>
|
||||||
: draftDirty
|
: draftDirty
|
||||||
? <span className="v2-automation-editor-draft-status" role="status">草稿未保存 · 关闭前会再次确认</span>
|
? <span className="v2-automation-editor-draft-status" role="status">{reviewMode ? '配置已就绪,可直接发布更新' : '草稿未保存 · 关闭前会再次确认'}</span>
|
||||||
: editorGuidance;
|
: 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 = () => {
|
const closeEditor = () => {
|
||||||
setDiscardConfirmOpen(false);
|
setDiscardConfirmOpen(false);
|
||||||
setCloneSource(undefined);
|
setCloneSource(undefined);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
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 { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||||
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
|
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 exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
|
||||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||||
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
|
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
|
||||||
@@ -29,17 +29,28 @@ function prepareData() {
|
|||||||
mocks.mileageStatistics.mockResolvedValue({
|
mocks.mileageStatistics.mockResolvedValue({
|
||||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
||||||
periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4,
|
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,
|
fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||||
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
|
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
|
||||||
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
||||||
});
|
});
|
||||||
mocks.dailyMileage.mockResolvedValue({ items: [
|
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-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, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, 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 });
|
], 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.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 });
|
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);
|
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 () => {
|
test('renders only the desktop matrix with dates as columns and a period total', async () => {
|
||||||
prepareData();
|
prepareData();
|
||||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
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('104.6 km').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('7/13').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' });
|
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
|
||||||
expect(matrixScroller!.scrollLeft).toBe(96);
|
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.semi-table-wrapper')).toBeInTheDocument();
|
||||||
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
|
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
|
||||||
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
|
fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
|
||||||
expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument();
|
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(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('氢耗匹配纯氢里程128.4 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('区间氢耗7.3 kg5.7 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('百公里纯氢耗5.7 kg/100km2 个有效车辆日');
|
||||||
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('4.2 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('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));
|
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||||
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
|
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
|
||||||
expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']);
|
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 () => {
|
test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => {
|
||||||
prepareData();
|
prepareData();
|
||||||
mocks.mileageStatistics.mockResolvedValue({
|
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();
|
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 () => {
|
test('shows query failures inside the result panel without a misleading empty matrix', async () => {
|
||||||
prepareData();
|
prepareData();
|
||||||
mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时'));
|
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.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||||
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
|
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-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-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-plate')).toHaveClass('is-plate');
|
||||||
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
|
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'));
|
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();
|
prepareData();
|
||||||
const exportRows = [
|
const exportRows = [
|
||||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
{ 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).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.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]).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.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(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||||
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||||
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
|
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 () => {
|
test('shows export progress and lets the user cancel the active request', async () => {
|
||||||
prepareData();
|
prepareData();
|
||||||
let exportSignal: AbortSignal | undefined;
|
let exportSignal: AbortSignal | undefined;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
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 { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { api, type MileageQuery } from '../../api/client';
|
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 { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
|
||||||
import { formatZhNumber } from '../domain/formatters';
|
import { formatZhNumber } from '../domain/formatters';
|
||||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||||
@@ -32,15 +32,34 @@ const BATCH_RESOLVE_SIZE = 200;
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
||||||
const EXPORT_VIN_BATCH_SIZE = 50;
|
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 MAX_MILEAGE_RANGE_DAYS = 366;
|
||||||
const CURRENT_DAY_REFRESH_MS = 60_000;
|
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 MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||||
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
||||||
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
||||||
type ExportProgress = { label: string; completed?: number; total?: number };
|
type ExportProgress = { label: string; completed?: number; total?: number };
|
||||||
type MetricView = 'mileage' | 'hydrogen';
|
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 SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
|
||||||
const DEFAULT_SOURCES: MileageSourceOption[] = [
|
const DEFAULT_SOURCES: MileageSourceOption[] = [
|
||||||
@@ -147,6 +166,20 @@ function mileageQuery(criteria: Criteria, offset = 0): MileageQuery {
|
|||||||
return query;
|
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) {
|
function mileageRouteParams(criteria: Criteria) {
|
||||||
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||||
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
|
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="数据视图">
|
return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
|
||||||
<span>
|
<span>
|
||||||
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
|
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
|
||||||
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'}</small>
|
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'}</small>
|
||||||
</span>
|
</span>
|
||||||
<ButtonGroup aria-label="切换里程或氢耗视图">
|
<ButtonGroup aria-label="切换里程或氢耗视图">
|
||||||
<Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}>里程</Button>
|
<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>;
|
</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 }) {
|
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 days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||||
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
|
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
|
||||||
@@ -542,9 +595,9 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
|||||||
tone: 'success',
|
tone: 'success',
|
||||||
emphasis: 'primary'
|
emphasis: 'primary'
|
||||||
}] : [{
|
}] : [{
|
||||||
label: '氢耗匹配里程',
|
label: '氢耗匹配纯氢里程',
|
||||||
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
|
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
|
||||||
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日总里程' : '按有氢耗数据的车辆日总里程计算',
|
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程',
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
emphasis: 'primary'
|
emphasis: 'primary'
|
||||||
}, {
|
}, {
|
||||||
@@ -560,7 +613,7 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
|||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
emphasis: 'primary'
|
emphasis: 'primary'
|
||||||
}, {
|
}, {
|
||||||
label: '百公里氢耗',
|
label: '百公里纯氢耗',
|
||||||
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
|
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
|
||||||
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
|
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
|
||||||
tone: 'warning',
|
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 & {
|
type VehicleMileageMatrix = VehicleOption & {
|
||||||
days: Map<string, number>;
|
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>;
|
sources: Map<string, string>;
|
||||||
totalMileageKm?: number;
|
totalMileageKm?: number;
|
||||||
|
totalPureElectricMileageKm?: number;
|
||||||
|
totalPureHydrogenMileageKm?: number;
|
||||||
totalHydrogenConsumptionKg?: number;
|
totalHydrogenConsumptionKg?: number;
|
||||||
totalHydrogenRateKgPer100Km?: number;
|
totalHydrogenRateKgPer100Km?: number;
|
||||||
};
|
};
|
||||||
@@ -604,14 +671,31 @@ function dateLabel(date: string) {
|
|||||||
return `${Number(month)}/${Number(day)}`;
|
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;
|
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="氢耗矩阵读表说明">
|
||||||
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
<div className="v2-mileage-matrix-guide-toolbar">
|
||||||
<span><Tag color="orange" type="light" size="small">kg</Tag>每日耗氢量</span>
|
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
||||||
<span><Tag color="green" type="light" size="small">km</Tag>当日总里程</span>
|
<HydrogenMetricSelector value={hydrogenMetrics} onChange={onHydrogenMetricsChange} />
|
||||||
<span><Tag color="amber" type="light" size="small">kg/100km</Tag>百公里氢耗</span>
|
</div>
|
||||||
<span className="is-mobile-hint">左右滑动查看日期 · 车牌与区间合计固定</span>
|
<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>;
|
</aside>;
|
||||||
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
|
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>
|
<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();
|
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(() => {
|
const { columns, tableWidth } = useMemo(() => {
|
||||||
let maxDailyMileage = 1;
|
let maxDailyMileage = 1;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
|
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
|
||||||
}
|
}
|
||||||
const dateColumnWidth = view === 'hydrogen' ? 128 : 96;
|
const metrics: MatrixMetric[] = view === 'hydrogen' ? ['mileage', ...hydrogenMetrics] : ['mileage'];
|
||||||
const totalColumnWidth = view === 'hydrogen' ? 148 : 128;
|
const metricsWidth = metrics.reduce((sum, metric) => sum + MATRIX_METRIC_META[metric].width, 0);
|
||||||
return {
|
return {
|
||||||
columns: [
|
columns: [
|
||||||
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
|
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
|
||||||
...dates.map((date) => ({
|
...dates.map((date) => ({
|
||||||
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: dateColumnWidth,
|
title: dateLabel(date),
|
||||||
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日${view === 'mileage' ? '里程' : '氢耗'}` }),
|
dataIndex: `date:${date}`,
|
||||||
onCell: (row?: VehicleMileageMatrix) => {
|
className: 'is-date-group',
|
||||||
const mileage = row?.days.get(date);
|
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 指标分组` }),
|
||||||
const hydrogen = row?.hydrogenDays.get(date);
|
children: metrics.map((metric) => {
|
||||||
const source = row?.sources.get(date);
|
const meta = MATRIX_METRIC_META[metric];
|
||||||
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},无可用氢耗数据`
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
|
title: <MatrixMetricHeader metric={metric} />,
|
||||||
title: mileage != null ? `里程 ${formatKm(mileage)} km · 来源:${source || '—'}` : '无可用里程',
|
dataIndex: `${date}:${metric}`,
|
||||||
'aria-label': mileage != null
|
className: `is-number is-date is-metric-${metric}`,
|
||||||
? `${date},里程 ${formatKm(mileage)} 公里,来源 ${source || '未知'}`
|
width: meta.width,
|
||||||
: `${date},无可用里程`,
|
onHeaderCell: () => ({ 'aria-label': `${date} ${meta.label} ${meta.unit}` }),
|
||||||
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
|
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 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} />;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
})
|
||||||
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>;
|
|
||||||
}
|
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
title: view === 'mileage' ? '区间总里程' : '区间氢耗', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: totalColumnWidth,
|
title: '区间合计',
|
||||||
onCell: (row?: VehicleMileageMatrix) => ({
|
dataIndex: 'period',
|
||||||
className: `is-number is-period is-total${view === 'hydrogen' ? ' is-hydrogen-total' : ''}${row?.totalMileageKm == null ? ' is-empty' : ''}`,
|
className: 'is-total is-total-group',
|
||||||
'aria-label': row?.totalMileageKm == null
|
children: metrics.map((metric) => {
|
||||||
? view === 'mileage' ? '区间总里程,无可用里程' : '区间氢耗,无可用氢耗数据'
|
const meta = MATRIX_METRIC_META[metric];
|
||||||
: view === 'mileage'
|
return {
|
||||||
? `区间总里程,${formatKm(row.totalMileageKm)} 公里`
|
title: <MatrixMetricHeader metric={metric} />,
|
||||||
: `区间耗氢 ${formatKm(row.totalHydrogenConsumptionKg)} 千克,总里程 ${formatKm(row.totalMileageKm)} 公里,百公里氢耗 ${formatKm(row.totalHydrogenRateKgPer100Km)} 千克`
|
dataIndex: `period:${metric}`,
|
||||||
}),
|
className: `is-number is-total is-metric-${metric}`,
|
||||||
render: (value: number | undefined, row: VehicleMileageMatrix) => {
|
width: meta.width,
|
||||||
if (value == null) return '—';
|
onHeaderCell: () => ({ 'aria-label': `区间${meta.label} ${meta.unit}` }),
|
||||||
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
|
onCell: (row?: VehicleMileageMatrix) => {
|
||||||
<strong>{formatKm(row.totalHydrogenConsumptionKg)} kg</strong>
|
const value = row ? matrixTotalMetricValue(row, metric) : undefined;
|
||||||
<small>总里程 {formatKm(row.totalMileageKm)} km</small>
|
return {
|
||||||
<small>百公里 {formatKm(row.totalHydrogenRateKgPer100Km)} kg</small>
|
className: `is-number is-period is-total is-metric-${metric}${value == null ? ' is-empty' : ''}`,
|
||||||
</span>;
|
'aria-label': value == null
|
||||||
return <strong>{formatKm(value)} km</strong>;
|
? 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
|
return <div
|
||||||
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
|
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -719,12 +836,101 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }
|
|||||||
aria-describedby="v2-mileage-matrix-guide"
|
aria-describedby="v2-mileage-matrix-guide"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={scrollMileageMatrixFromKeyboard}
|
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) }} />
|
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
|
||||||
</div>;
|
</div>;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type HydrogenEvidenceTarget = { vin: string; plate: string; date: string };
|
||||||
|
|
||||||
|
function evidenceNumber(value?: number | null, digits = 3) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—';
|
||||||
|
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceTarget; onClose: () => void }) {
|
||||||
|
const evidence = useQuery<HydrogenDailyEvidence>({
|
||||||
|
queryKey: ['hydrogen-daily-evidence', target?.vin, target?.date],
|
||||||
|
queryFn: ({ signal }) => api.hydrogenDailyEvidence(target!.vin, target!.date, signal),
|
||||||
|
enabled: Boolean(target),
|
||||||
|
staleTime: CURRENT_DAY_REFRESH_MS,
|
||||||
|
gcTime: QUERY_MEMORY.summaryGcTime
|
||||||
|
});
|
||||||
|
const data = evidence.data;
|
||||||
|
return <WorkspaceSideSheet
|
||||||
|
className="v2-hydrogen-evidence-sheet"
|
||||||
|
variant="detail"
|
||||||
|
visible={Boolean(target)}
|
||||||
|
ariaLabel="氢耗计算溯源"
|
||||||
|
closeLabel="关闭氢耗计算溯源"
|
||||||
|
dialogId="v2-hydrogen-evidence-sheet"
|
||||||
|
title={`${target?.plate || target?.vin || '车辆'} · ${target?.date || ''}`}
|
||||||
|
description="每日结果、计算参数、有效区间及原始报文标识"
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
badge={data?.qualityStatus || '读取中'}
|
||||||
|
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
|
||||||
|
width="min(760px, 94vw)"
|
||||||
|
summaryItems={data ? [
|
||||||
|
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
|
||||||
|
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
|
||||||
|
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
|
||||||
|
] : []}
|
||||||
|
footerNote="物理耗氢与SOC修正值分开保存;点击区间中的报文ID可复制后到原始报文查询中检索。"
|
||||||
|
primaryAction={{ label: '关闭', onClick: onClose }}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{evidence.isLoading ? <PanelLoading title="正在读取计算证据" description="加载每日参数与区间明细。" /> : null}
|
||||||
|
{evidence.error ? <InlineError message={evidence.error instanceof Error ? evidence.error.message : '氢耗计算证据加载失败'} onRetry={() => evidence.refetch()} /> : null}
|
||||||
|
{data ? <div className="v2-hydrogen-evidence-content">
|
||||||
|
<section>
|
||||||
|
<header><strong>计算公式与口径</strong><Tag color="blue" type="light" size="small">{data.algorithmVersion}</Tag></header>
|
||||||
|
<code>m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Zₙᵢₛₜ)</code>
|
||||||
|
<code>区间物理耗氢 = 起始质量 − 结束质量</code>
|
||||||
|
<code>SOC平衡氢耗 = 物理耗氢 + 电池容量 × (起始SOC − 结束SOC) ÷ 100 ÷ {evidenceNumber(data.parameters.hydrogenEnergyKWhPerKg, 1)}</code>
|
||||||
|
<p>{data.qualityReason || '数据通过完整性、运行状态、加氢、充电、异常跳变和端点中位数校验。'}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<header><strong>参数与质量计数</strong></header>
|
||||||
|
<dl className="v2-hydrogen-evidence-parameters">
|
||||||
|
<div><dt>上电稳定</dt><dd>{data.parameters.powerOnDelaySeconds}s</dd></div>
|
||||||
|
<div><dt>下电提前</dt><dd>{data.parameters.powerOffLeadSeconds}s</dd></div>
|
||||||
|
<div><dt>加氢识别</dt><dd>+{data.parameters.refuelRiseMpa}MPa / {data.parameters.refuelSustainSeconds}s</dd></div>
|
||||||
|
<div><dt>纯电异常</dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div>
|
||||||
|
<div><dt>样本</dt><dd>{data.sampleCount}</dd></div>
|
||||||
|
<div><dt>加氢 / 充电</dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div>
|
||||||
|
<div><dt>无效区间</dt><dd>{data.invalidSegmentCount}</dd></div>
|
||||||
|
<div><dt>计算时间</dt><dd>{data.calculatedAt}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<header><strong>区间计算证据</strong><small>{data.intervals.length} 个区间</small></header>
|
||||||
|
<div className="v2-hydrogen-evidence-intervals">
|
||||||
|
{data.intervals.map((interval) => <article key={`${interval.index}-${interval.startTime}`} className={`is-${interval.qualityStatus.toLowerCase()}`}>
|
||||||
|
<header><span><Tag color={interval.type === 'MIXED' ? 'green' : 'blue'} type="light" size="small">{interval.type === 'MIXED' ? '混动' : '纯电'}</Tag><strong>区间 {interval.index}</strong></span><Tag color={interval.qualityStatus === 'OK' ? 'green' : 'orange'} type="light" size="small">{interval.qualityStatus}</Tag></header>
|
||||||
|
<p>{interval.startTime} → {interval.endTime} · {interval.sampleCount} 条样本</p>
|
||||||
|
<div className="v2-hydrogen-evidence-metrics">
|
||||||
|
<span><small>压力</small><strong>{evidenceNumber(interval.startPressureMpa)} → {evidenceNumber(interval.endPressureMpa)} MPa</strong></span>
|
||||||
|
<span><small>温度</small><strong>{evidenceNumber(interval.startTemperatureC, 1)} → {evidenceNumber(interval.endTemperatureC, 1)} ℃</strong></span>
|
||||||
|
<span><small>质量</small><strong>{evidenceNumber(interval.startMassKg)} → {evidenceNumber(interval.endMassKg)} kg</strong></span>
|
||||||
|
<span><small>物理耗氢</small><strong>{evidenceNumber(interval.rawHydrogenConsumptionKg)} kg</strong></span>
|
||||||
|
<span><small>SOC</small><strong>{evidenceNumber(interval.startSocPercent, 1)} → {evidenceNumber(interval.endSocPercent, 1)} %</strong></span>
|
||||||
|
<span><small>区间里程</small><strong>{evidenceNumber(interval.mileageKm)} km</strong></span>
|
||||||
|
<span><small>电池折氢</small><strong>{evidenceNumber(interval.batteryEquivalentKg)} kg</strong></span>
|
||||||
|
<span><small>SOC平衡氢耗</small><strong>{evidenceNumber(interval.socBalancedConsumptionKg)} kg</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="v2-hydrogen-evidence-events">
|
||||||
|
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.startEventId)}><small>起始报文</small><code>{interval.startEventId || '—'}</code></button>
|
||||||
|
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.endEventId)}><small>结束报文</small><code>{interval.endEventId || '—'}</code></button>
|
||||||
|
</div>
|
||||||
|
{interval.qualityReason ? <p className="is-reason">{interval.qualityReason}</p> : null}
|
||||||
|
</article>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div> : null}
|
||||||
|
</WorkspaceSideSheet>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatisticsPage() {
|
export default function StatisticsPage() {
|
||||||
const { session } = usePlatformSession();
|
const { session } = usePlatformSession();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -740,11 +946,16 @@ export default function StatisticsPage() {
|
|||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState('');
|
||||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||||
const [metricView, setMetricView] = useState<MetricView>('mileage');
|
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 hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer';
|
||||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||||
const exportControllerRef = useRef<AbortController | null>(null);
|
const exportControllerRef = useRef<AbortController | null>(null);
|
||||||
const pageRef = useRef<HTMLDivElement>(null);
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
const tableScrollRef = 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);
|
const mountedRef = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
@@ -816,20 +1027,30 @@ export default function StatisticsPage() {
|
|||||||
const index = new Map<string, {
|
const index = new Map<string, {
|
||||||
plate: string;
|
plate: string;
|
||||||
days: Map<string, number>;
|
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>;
|
sources: Map<string, string>;
|
||||||
}>();
|
}>();
|
||||||
for (const row of mileage.data?.items ?? []) {
|
for (const row of mileage.data?.items ?? []) {
|
||||||
let entry = index.get(row.vin);
|
let entry = index.get(row.vin);
|
||||||
if (!entry) {
|
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);
|
index.set(row.vin, entry);
|
||||||
} else if (!entry.plate && row.plate) entry.plate = row.plate;
|
} else if (!entry.plate && row.plate) entry.plate = row.plate;
|
||||||
entry.days.set(row.date, row.dailyMileageKm);
|
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) {
|
if (row.hydrogenConsumptionKg != null) {
|
||||||
entry.hydrogenDays.set(row.date, {
|
entry.hydrogenDays.set(row.date, {
|
||||||
consumptionKg: row.hydrogenConsumptionKg,
|
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);
|
entry.sources.set(row.date, row.source);
|
||||||
@@ -841,23 +1062,35 @@ export default function StatisticsPage() {
|
|||||||
const daily = mileageByVin.get(vehicle.vin);
|
const daily = mileageByVin.get(vehicle.vin);
|
||||||
const ranking = rankingByVin.get(vehicle.vin);
|
const ranking = rankingByVin.get(vehicle.vin);
|
||||||
const days = daily?.days ?? new Map<string, number>();
|
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>();
|
const sources = daily?.sources ?? new Map<string, string>();
|
||||||
let dailyTotal = 0;
|
let dailyTotal = 0;
|
||||||
let hydrogenTotal = 0;
|
let hydrogenTotal = 0;
|
||||||
let hydrogenRatedMileage = 0;
|
let hydrogenRatedMileage = 0;
|
||||||
|
let pureElectricTotal = 0;
|
||||||
|
let pureHydrogenTotal = 0;
|
||||||
for (const value of days.values()) dailyTotal += value;
|
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) {
|
for (const [date, value] of hydrogenDays) {
|
||||||
|
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
|
||||||
|
if (matchedMileage <= 0) continue;
|
||||||
hydrogenTotal += value.consumptionKg;
|
hydrogenTotal += value.consumptionKg;
|
||||||
hydrogenRatedMileage += days.get(date) ?? 0;
|
hydrogenRatedMileage += matchedMileage;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...vehicle,
|
...vehicle,
|
||||||
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
|
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
|
||||||
days,
|
days,
|
||||||
|
pureElectricDays,
|
||||||
|
pureHydrogenDays,
|
||||||
hydrogenDays,
|
hydrogenDays,
|
||||||
sources,
|
sources,
|
||||||
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
|
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
|
||||||
|
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
|
||||||
|
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
|
||||||
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
|
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
|
||||||
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
|
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
|
||||||
};
|
};
|
||||||
@@ -908,7 +1141,11 @@ export default function StatisticsPage() {
|
|||||||
];
|
];
|
||||||
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
||||||
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
|
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
|
const exportPercent = exportProgress?.total
|
||||||
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -924,14 +1161,39 @@ export default function StatisticsPage() {
|
|||||||
setExportFeedback('');
|
setExportFeedback('');
|
||||||
let exportStream: MileageExportStream | undefined;
|
let exportStream: MileageExportStream | undefined;
|
||||||
try {
|
try {
|
||||||
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
|
let vehicles: VehicleOption[];
|
||||||
if (!vehicles.length) {
|
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 = [];
|
vehicles = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
|
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
|
||||||
while (offset < totalVehicles) {
|
while (offset < totalVehicles) {
|
||||||
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
|
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 });
|
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
|
||||||
if (!result.items.length) break;
|
if (!result.items.length) break;
|
||||||
offset += result.items.length;
|
offset += result.items.length;
|
||||||
@@ -949,21 +1211,27 @@ export default function StatisticsPage() {
|
|||||||
const vehicleBatches = criteria.vehicles.length
|
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))
|
? 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[]];
|
: [[] 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;
|
let offset = 0;
|
||||||
const mileageLabel = vehicleBatches.length > 1
|
const batchLabel = vehicleBatches.length > 1 ? ` · 车辆批次 ${batchIndex + 1}/${vehicleBatches.length}` : '';
|
||||||
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length} 批)`
|
const mileageLabel = `正在读取里程数据(${window.dateFrom} 至 ${window.dateTo}${batchLabel})`;
|
||||||
: '正在读取全部车辆里程';
|
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
|
||||||
reportProgress({ label: mileageLabel });
|
|
||||||
while (true) {
|
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);
|
const result = await api.dailyMileage(query, controller.signal);
|
||||||
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
|
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
|
||||||
exportStream.appendRows(result.items);
|
exportStream.appendRows(result.items);
|
||||||
offset += result.items.length;
|
offset += result.items.length;
|
||||||
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
|
|
||||||
if (!result.items.length || offset >= result.total) break;
|
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) || '' }));
|
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
|
||||||
reportProgress({ label: '正在后台生成 Excel 文件' });
|
reportProgress({ label: '正在后台生成 Excel 文件' });
|
||||||
@@ -1086,11 +1354,12 @@ export default function StatisticsPage() {
|
|||||||
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
|
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
{resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : 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 && 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} /> : 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}
|
{!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>
|
<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>
|
</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>
|
<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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28523,6 +28523,300 @@
|
|||||||
color: #8b6a3e;
|
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) {
|
@media (max-width: 700px) {
|
||||||
.v2-mileage-page {
|
.v2-mileage-page {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -28563,6 +28857,40 @@
|
|||||||
padding: 5px 7px;
|
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 {
|
.v2-mileage-matrix-guide > .v2-mileage-matrix-guide-mobile {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 2px 5px;
|
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/017_reconciliation_center.sql"
|
||||||
test -f "$root/current/deploy/migrations/036_alert_rule_archive.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/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 -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
|
||||||
test ! -e "$root/current/lingniu-vehicle-platform.service"
|
test ! -e "$root/current/lingniu-vehicle-platform.service"
|
||||||
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
|
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;
|
||||||
@@ -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/033_open_mileage_range_snapshot.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql \
|
||||||
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/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 `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 `038` adds daily pure-hydrogen mileage to the elected mileage table and its per-source evidence table. Deploy the migration before the updated stat writer and API so GB32960 `engine_work_state=2` and Yutong `TRIANGLE_STATE=4/11` intervals can be accumulated and returned as `pureHydrogenMileageKm`.
|
||||||
|
|
||||||
|
Migration `042` creates the per-VIN/per-day bounded state used by the GB32960 hydrogen segment stream. Apply it before switching `vehicle-stat-writer`; the previous writer ignores the additive table, so rolling back the binary does not require dropping it.
|
||||||
|
|
||||||
|
For the first switch to the segment stream, stop `lingniu-go-stat-writer.service`, run the current-day `open-platform-stat` rebuild once with `-seed-stream-state`, then switch and restart the writer. The seed transaction stores the rebuilt total and the exact last event-time watermark for every VIN, preventing already rebuilt Kafka backlog from being counted again. Do not pass this flag to the normal completed-day timer.
|
||||||
|
|
||||||
Migration `031` adds explicit metric, geofence, stationary and offline automation triggers. It must be applied before the API and both alert evaluators are restarted; geofence rules deliberately require one positioning protocol to avoid multi-source coordinate drift and duplicate boundary events.
|
Migration `031` adds explicit metric, geofence, stationary and offline automation triggers. It must be applied before the API and both alert evaluators are restarted; geofence rules deliberately require one positioning protocol to avoid multi-source coordinate drift and duplicate boundary events.
|
||||||
|
|
||||||
Migration `035` adds rule recipient-group references plus notification leases and dispatch indexes. Apply it before publishing an API/evaluator that writes `notification_targets_json` or enabling `lingniu-vehicle-alert-notification-dispatcher`. The dispatcher must remain disabled until every configured gateway has passed a signed canary that returns a non-empty provider message ID.
|
Migration `035` adds rule recipient-group references plus notification leases and dispatch indexes. Apply it before publishing an API/evaluator that writes `notification_targets_json` or enabling `lingniu-vehicle-alert-notification-dispatcher`. The dispatcher must remain disabled until every configured gateway has passed a signed canary that returns a non-empty provider message ID.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 氢耗可信计算与溯源(V2)
|
||||||
|
|
||||||
|
## 结果口径
|
||||||
|
|
||||||
|
- `raw_consumption_kg`:物理耗氢量,仅由GB/T 32960氢压、氢温、车型储氢容积经NIST实气压缩因子换算后,按有效区间质量下降累计。
|
||||||
|
- `battery_equivalent_kg`:动力电池净放电能量折算氢量。仅使用已确认的车型/VIN电池容量;停车充电区间不参与计算。
|
||||||
|
- `soc_balanced_consumption_kg`:SOC平衡氢耗,用于把不同首尾SOC的运行区间调整到可比较口径,不覆盖物理耗氢量。
|
||||||
|
|
||||||
|
压力—质量公式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Z = 1 + Σ ai × (100 / Tk)^bi × P^ci
|
||||||
|
m = P × 1000 × 0.00201588 × V / (8.314472 × Tk × Z)
|
||||||
|
```
|
||||||
|
|
||||||
|
SOC能量守恒公式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
E_battery_discharge = C_battery × (SOC_start - SOC_end) / 100
|
||||||
|
m_battery_equivalent = E_battery_discharge / 16
|
||||||
|
m_soc_balanced = m_raw_hydrogen + m_battery_equivalent
|
||||||
|
```
|
||||||
|
|
||||||
|
电池净放电为车辆提供了额外能量,因此等效总能耗应增加;电池净充电时该项为负。邮件示例中将净放电量从物理耗氢中扣除,不符合能量守恒,V2不采用该符号方向。
|
||||||
|
|
||||||
|
## 区间规则
|
||||||
|
|
||||||
|
1. 上电后等待60秒、下电前提前60秒,端点使用连续5条有效样本的中位质量对应报文。
|
||||||
|
2. 停车充电由整车充电状态与车辆状态识别,充电区间切断且不参与SOC修正;充电结束后重新建立基线。
|
||||||
|
3. 压力累计上升超过3MPa且保持5分钟识别为加氢;无连续报文时仍保留质量回升阈值作为补充识别。
|
||||||
|
4. 纯电状态30分钟内压力下降超过5MPa的样本标记无效。
|
||||||
|
5. 数据间隔超过5分钟、质量异常下降、运行模式切换均切断区间。
|
||||||
|
6. 每个有效区间至少10条样本;首尾各5条取中位质量以抑制单点波动。
|
||||||
|
|
||||||
|
## 溯源数据
|
||||||
|
|
||||||
|
每日结果保留算法版本、参数JSON和区间证据JSON。每个区间包含起止时间、起止原始报文ID、数据源、压力、温度、质量、SOC、里程、分项公式结果、样本数及质量原因。统计页面的“可溯源”入口展示相同信息。
|
||||||
|
|
||||||
|
车型/VIN的动力电池容量及氢电换算系数由 `vehicle_hydrogen_energy_parameter` 维护。未配置或未经确认时,系统仍输出物理耗氢,但不生成SOC平衡结果。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vehicle-data-platform/apps/api
|
||||||
|
go test ./internal/openplatform -run 'TestPressureHydrogenMassNISTValidationGridHasAtLeast100Samples|TestTrustedHydrogenCalculatorProducesTraceableSOCBalancedResultFrom120Samples' -v
|
||||||
|
|
||||||
|
cd ../web
|
||||||
|
NODE_OPTIONS=--localstorage-file=/tmp/codex-vitest-localstorage pnpm exec vitest run src/v2/pages/StatisticsPage.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
压力—质量测试使用12个压力点与10个温度点,共120个独立样本;日计算测试使用120条连续车辆报文,验证上下电窗口、区间证据和SOC能量守恒。
|
||||||
@@ -68,6 +68,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
|
|||||||
"plateNumber": "粤A12345",
|
"plateNumber": "粤A12345",
|
||||||
"date": "2026-07-01",
|
"date": "2026-07-01",
|
||||||
"hydrogenConsumptionKg": 12.315,
|
"hydrogenConsumptionKg": 12.315,
|
||||||
|
"calculationPhase": "FINAL",
|
||||||
|
"algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",
|
||||||
"status": "NORMAL"
|
"status": "NORMAL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +83,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
当天查询可能返回 `calculationPhase: PRELIMINARY`,表示结果由 V3.5 流式状态机生成,后续帧可能使结果回修;完成日终全量重算后变为 `FINAL`。
|
||||||
|
|
||||||
## 单日里程
|
## 单日里程
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -343,35 +347,40 @@ PUT /portal-api/account/password
|
|||||||
|
|
||||||
## 用氢量统计
|
## 用氢量统计
|
||||||
|
|
||||||
当天数据由 `vehicle-stat-writer` 直接消费 GB32960 fields Kafka 流,统一采用压力法:
|
用氢量只接收 `GB32960` 帧,不读取 JT808、宇通 MQTT 或其他协议。统一采用压力法:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000
|
剩余氢量 kg = NIST氢气密度(最高氢压 MPa, 最高氢温 ℃) × 车型氢瓶容量 L / 1000
|
||||||
```
|
```
|
||||||
|
|
||||||
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比或百公里氢耗推算质量;
|
- 不使用广东扩展直接上报的剩余氢量、飞驰剩余百分比、宇通 MQTT 剩余氢量或百公里氢耗推算质量;
|
||||||
- `vehicle-stat-writer` 每六小时从 `ln_asset_management.vehicle_info → vehicle_model.tank_capacity` 同步 VIN 容积到本地 `vehicle_hydrogen_tank_capacity`;
|
- `vehicle-stat-writer` 启动时及每六小时从 `ln_asset_management.vehicle_info → vehicle_model` 同步车型参数:`tank_capacity` 按 VIN 投影到 `vehicle_hydrogen_tank_capacity`,`battery_capacity` 按 VIN 投影到 `vehicle_hydrogen_energy_parameter`;
|
||||||
- 同步完成后一次性加载到进程内存,高频帧计算不访问资产库、Redis或MySQL;缺少VIN容积时拒绝生成氢量;
|
- 额定电量按车辆关联的具体车型读取,不按吨位写死;后续生效日期更晚的人工业务参数仍可覆盖自动同步基线;
|
||||||
- 压力法使用 NIST 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围。
|
- 储氢容积同步后一次性加载到统计进程内存,高频帧计算不访问资产库、Redis或MySQL;额定电量由日氢耗任务按VIN和统计日期读取;缺少VIN容积时拒绝生成氢量;
|
||||||
|
- 压力法使用 NIST 实氢气密度方程,允许 0–70 MPa、220–1000 K 的输入范围;
|
||||||
|
- GB32960 上报的是“最高氢压”和“最高氢温”,两者不保证来自同一探头,也不保证代表静置平衡后的储氢瓶状态。因此压力法结果是车载信号估算值,不能用加氢站计量值直接反标公式系数。
|
||||||
|
|
||||||
每个有效样本在同一个 MySQL 事务中更新
|
当天准实时数据由 `vehicle-stat-writer` 消费 GB32960 fields Kafka 流。每个有效样本在同一个 MySQL 事务中更新
|
||||||
`vehicle_open_hydrogen_stream_state`,并投影到
|
`vehicle_open_hydrogen_segment_stream_state`,并投影到
|
||||||
`vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按
|
`vehicle_open_daily_energy`。Kafka offset 只在事务提交成功后提交,因此服务重启或消息重放不会重复累计。状态按
|
||||||
`VIN + 日期 + source_endpoint` 保存,每条消息只做常数次数据库操作,不扫描当天历史数据。
|
`VIN + 日期` 保存并合并全部连接端点;每条消息只做常数次数据库操作,不扫描当天历史数据。
|
||||||
|
|
||||||
统计规则:
|
统计规则:
|
||||||
|
|
||||||
- 先按 VIN 和 `source_endpoint` 分别统计,避免多个采集源的序列交错产生虚假变化;
|
- 已结束日期先按 VIN 合并全部 `source_endpoint`,同一事件时间只保留一条可信样本;连接端口仅用于追踪,不再作为互斥统计来源;
|
||||||
- 同一 VIN 优先采用质量正常、样本数量更多的数据源,并记录入库;
|
- 只有相邻两条样本都确认燃料电池处于工作状态时,才纳入工作段;优先采用广东扩展 `engine_work_state=2`,缺失时以燃料电池电流大于 `1 A` 作为回退;完全没有工作区间的日期标记为 `NO_DATA`;
|
||||||
- 每次加氢之间维护质量低水位,只累计新的有效最低质量,避免压力温度波动反复计量;
|
- 加氢、超过 5 分钟的数据断点、燃料电池停机和异常跳变会切分工作段;每段开始与结束各取 5 条样本的氢质量中位数做差,再累加为当日用氢量;
|
||||||
|
- 每个可计算工作段至少需要 10 条有效样本;段首尾差值未超过动态噪声阈值时不累计;
|
||||||
|
- 燃料电池未工作时,下降的压力质量只更新防重复计算的低水位,不降低对外剩余氢量;压力或质量恢复上升后再更新剩余量;
|
||||||
- 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`;
|
- 抖动阈值按该VIN容积对应的 `0.2 MPa` 质量变化动态计算,最小 `0.05 kg`、最大 `1 kg`;
|
||||||
- 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位;
|
- 质量相对本轮低水位上升超过 `max(1 kg, 当前质量×5%)` 时识别为加氢并重置低水位;
|
||||||
|
- 相邻样本在 60 秒内无工作耗氢却恢复至少 `8 MPa` 时,按压力信号恢复处理,重置低水位但不增加加氢次数;
|
||||||
- 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`;
|
- 单次下降超过 `OPEN_STAT_HYDROGEN_MAX_DROP_KG` 时过滤并标记 `SUSPECT`,默认 `20 kg`;
|
||||||
- 至少两条有效样本才标记 `OK`;
|
|
||||||
- 对外只返回 `quality_status=OK` 的结果。
|
- 对外只返回 `quality_status=OK` 的结果。
|
||||||
|
|
||||||
当天接口结果是准实时的进行中累计值。乱序、重复和迟到样本不会覆盖更新的状态;跨日迟到数据由
|
分段计算由流式状态机直接执行:每车仅保留当前段的首 5 帧、尾 5 帧、低水位、上一帧和事件时间水位,状态不会随当天帧数增长。水位只覆盖当前自然日 `00:00:00–24:00:00`,不向前后日期扩窗,也不借用次日帧;早于或等于当前事件时间水位的延迟帧直接忽略。流式结果采用与日终相同的工作段、中位数、加氢和异常跳变规则;已结束日期仍由 `open-platform-stat` 重算,作为最终对账结果。
|
||||||
`open-platform-stat` 夜间批处理兜底。批处理默认重算昨天和前天,以修正迟到上报,并作为已结束日期的权威结果。
|
|
||||||
|
历史重放只接收事件时间和接收时间同时落在统计日内的 GB32960 帧。每个统计日先读取有数据且配置了氢瓶容积的 VIN,再在同一进程内按单车单日并行查询;默认 4 个工作线程,全部车辆成功后才一次性事务替换当天结果。单车帧按 `event_time` 排序并单遍去重;JSON 只读取压力、温度和工作状态字段,不展开整帧,从而减少全车排序与反序列化开销。可通过 `-vin-workers` 或 `OPEN_STAT_VIN_WORKERS` 调整工作线程数。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/lingniu-vehicle-platform/current/open-platform-stat \
|
/opt/lingniu-vehicle-platform/current/open-platform-stat \
|
||||||
@@ -380,3 +389,6 @@ PUT /portal-api/account/password
|
|||||||
|
|
||||||
systemctl enable --now lingniu-vehicle-open-stat.timer
|
systemctl enable --now lingniu-vehicle-open-stat.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
流式算法首次切换时,先停止 `lingniu-go-stat-writer.service`,再对当天执行一次
|
||||||
|
`-seed-stream-state`。该参数会在同一个 MySQL 事务中替换当天结果并写入每车最后事件时间水位;恢复 Kafka 消费后,已经包含在重算结果中的 backlog 不会再次累计。正常日终任务不使用此参数。
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OPEN_STAT_BIN=${OPEN_STAT_BIN:-/opt/lingniu-vehicle-platform/current/open-platform-stat}
|
||||||
|
BASE_ENV_FILE=${BASE_ENV_FILE:-/opt/lingniu-go-native/env/base.env}
|
||||||
|
PLATFORM_ENV_FILE=${PLATFORM_ENV_FILE:-/opt/lingniu-vehicle-platform/env/platform.env}
|
||||||
|
LOCK_FILE=${LOCK_FILE:-/run/lock/lingniu-hydrogen-history-replay.lock}
|
||||||
|
|
||||||
|
test -x "$OPEN_STAT_BIN" || { printf 'open-platform-stat is not executable: %s\n' "$OPEN_STAT_BIN" >&2; exit 1; }
|
||||||
|
test -r "$BASE_ENV_FILE" || { printf 'base environment file is unavailable: %s\n' "$BASE_ENV_FILE" >&2; exit 1; }
|
||||||
|
test -r "$PLATFORM_ENV_FILE" || { printf 'platform environment file is unavailable: %s\n' "$PLATFORM_ENV_FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
flock -n 9 || { printf 'another hydrogen history replay is already running\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
run_stat() {
|
||||||
|
python3 -c '
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
binary, base_env, platform_env, *args = sys.argv[1:]
|
||||||
|
for path in (base_env, platform_env):
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.rstrip("\n")
|
||||||
|
if not line.strip() or line.lstrip().startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split("=", 1)
|
||||||
|
os.environ[name] = value
|
||||||
|
os.execv(binary, [binary, *args])
|
||||||
|
' "$OPEN_STAT_BIN" "$BASE_ENV_FILE" "$PLATFORM_ENV_FILE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
if test "${1:-}" = "--from"; then
|
||||||
|
test "$#" -eq 4 && test "${3:-}" = "--to" || {
|
||||||
|
printf 'usage: %s --from yyyy-mm-dd --to yyyy-mm-dd\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
start_date=$2
|
||||||
|
end_date=$4
|
||||||
|
while IFS= read -r replay_date; do
|
||||||
|
printf 'replay_date=%s status=starting\n' "$replay_date"
|
||||||
|
run_stat -date "$replay_date" -lookback-days 1
|
||||||
|
done < <(python3 -c '
|
||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
|
||||||
|
start = datetime.datetime.strptime(sys.argv[1], "%Y-%m-%d").date()
|
||||||
|
end = datetime.datetime.strptime(sys.argv[2], "%Y-%m-%d").date()
|
||||||
|
if end < start:
|
||||||
|
raise SystemExit("end date must not precede start date")
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
print(current.isoformat())
|
||||||
|
current += datetime.timedelta(days=1)
|
||||||
|
' "$start_date" "$end_date")
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if test "$#" -eq 0; then
|
||||||
|
run_stat -all-dates
|
||||||
|
else
|
||||||
|
run_stat "$@"
|
||||||
|
fi
|
||||||
+29
-5
@@ -22,7 +22,7 @@ const i18n = {
|
|||||||
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
|
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
|
||||||
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
|
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
|
||||||
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
|
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
|
||||||
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
|
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailContactPerson: '联系人', detailContactPhone: '联系方式', detailUnitPrice: '单价', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
|
||||||
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
|
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
|
||||||
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
|
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
|
||||||
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
|
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
|
||||||
@@ -50,7 +50,7 @@ const i18n = {
|
|||||||
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
|
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
|
||||||
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
|
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
|
||||||
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
|
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
|
||||||
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
|
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailContactPerson: 'Contact', detailContactPhone: 'Phone', detailUnitPrice: 'Unit price', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
|
||||||
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.',
|
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.',
|
||||||
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
|
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
|
||||||
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
|
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
|
||||||
@@ -1247,6 +1247,17 @@ function detailField(label, value, wide = false, extra = false) {
|
|||||||
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
|
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detailPhoneField(label, phone, wide = false) {
|
||||||
|
const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, '');
|
||||||
|
if (!href) return detailField(label, i18n[currentLang].valueUnavailable, wide);
|
||||||
|
return `<div class="detail-field${wide ? ' is-wide' : ''}"><dt>${escapeHTML(label)}</dt><dd><a class="detail-phone-link" href="tel:${escapeHTML(href)}">${escapeHTML(value)}</a></dd></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationUnitPrice(station, fallback = i18n[currentLang].valueUnavailable) {
|
||||||
|
const value = Number(station?.unitPrice);
|
||||||
|
return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function vehicleStatusLabel(vehicle) {
|
function vehicleStatusLabel(vehicle) {
|
||||||
const status = vehicleActiveToday(vehicle)
|
const status = vehicleActiveToday(vehicle)
|
||||||
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
|
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
|
||||||
@@ -1307,8 +1318,15 @@ function navigateToSelectedEntity() {
|
|||||||
}
|
}
|
||||||
button?.classList.add('is-launching');
|
button?.classList.add('is-launching');
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
// Paint the acknowledgement before Safari hands control to the AMap URL scheme.
|
// Open synchronously to avoid Safari's popup blocker, then let the launch
|
||||||
navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140);
|
// animation acknowledge the action while the current map remains intact.
|
||||||
|
const target = window.open('', '_blank');
|
||||||
|
navigationLaunchTimer = window.setTimeout(() => {
|
||||||
|
if (target) {
|
||||||
|
target.opener = null;
|
||||||
|
target.location.href = url;
|
||||||
|
} else window.open(url, '_blank', 'noopener');
|
||||||
|
}, 140);
|
||||||
window.setTimeout(resetNavigationLaunch, 4000);
|
window.setTimeout(resetNavigationLaunch, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,6 +1355,11 @@ function showEntityDetails(mode, entity) {
|
|||||||
const publicFields = [
|
const publicFields = [
|
||||||
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
|
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
|
||||||
detailField(dict.detailAdmin, detailValue(admin)),
|
detailField(dict.detailAdmin, detailValue(admin)),
|
||||||
|
...(entity.cooperative ? [
|
||||||
|
detailField(dict.detailUnitPrice, stationUnitPrice(entity)),
|
||||||
|
detailField(dict.detailContactPerson, detailValue(entity.contactPerson)),
|
||||||
|
detailPhoneField(dict.detailContactPhone, entity.contactPhone),
|
||||||
|
] : []),
|
||||||
detailField(dict.detailAddress, detailValue(entity.address), true),
|
detailField(dict.detailAddress, detailValue(entity.address), true),
|
||||||
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
|
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
|
||||||
];
|
];
|
||||||
@@ -1483,6 +1506,7 @@ function renderRankingList(type) {
|
|||||||
name: node.name,
|
name: node.name,
|
||||||
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
|
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
|
||||||
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
|
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
|
||||||
|
price: level === 'station' && node.station?.cooperative ? stationUnitPrice(node.station, '') : '',
|
||||||
location: node.lnglat,
|
location: node.lnglat,
|
||||||
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
|
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
|
||||||
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
|
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
|
||||||
@@ -1496,7 +1520,7 @@ function renderRankingList(type) {
|
|||||||
rows.forEach((item, index) => {
|
rows.forEach((item, index) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
|
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
|
||||||
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
|
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-value-stack">${item.price ? `<span class="station-list-price">${escapeHTML(item.price)}</span>` : ''}<span class="r-val">${escapeHTML(item.value)}</span></span>`;
|
||||||
row.onclick = () => {
|
row.onclick = () => {
|
||||||
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
|
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
|
||||||
if (item.entity) showEntityDetails(item.mode, item.entity);
|
if (item.entity) showEntityDetails(item.mode, item.entity);
|
||||||
|
|||||||
+134
-13
@@ -42,11 +42,15 @@ AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERV
|
|||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
_cache = {}
|
_cache = {}
|
||||||
_cache_load_locks = {}
|
_cache_load_locks = {}
|
||||||
|
_cache_refreshing = set()
|
||||||
_station_geocode_cache_lock = threading.Lock()
|
_station_geocode_cache_lock = threading.Lock()
|
||||||
_station_geocode_cache = None
|
_station_geocode_cache = None
|
||||||
_station_geocode_cache_dirty = False
|
_station_geocode_cache_dirty = False
|
||||||
_station_geocode_rate_lock = threading.Lock()
|
_station_geocode_rate_lock = threading.Lock()
|
||||||
_station_geocode_next_request_at = 0.0
|
_station_geocode_next_request_at = 0.0
|
||||||
|
_station_geocode_refresh_lock = threading.Lock()
|
||||||
|
_station_geocode_refresh_pending = {}
|
||||||
|
_station_geocode_refresh_running = False
|
||||||
|
|
||||||
|
|
||||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||||
@@ -85,24 +89,54 @@ def _post_open_platform(path, body):
|
|||||||
return payload.get("data") or []
|
return payload.get("data") or []
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_cached_value(key, loader):
|
||||||
|
try:
|
||||||
|
value = loader()
|
||||||
|
except Exception as exc:
|
||||||
|
print("{} cache background refresh deferred: {}".format(key, exc))
|
||||||
|
else:
|
||||||
|
with _cache_lock:
|
||||||
|
_cache[key] = (time.time(), value)
|
||||||
|
finally:
|
||||||
|
with _cache_lock:
|
||||||
|
_cache_refreshing.discard(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_cache_refresh(key, loader):
|
||||||
|
thread = threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
def _cached(key, ttl_seconds, loader):
|
def _cached(key, ttl_seconds, loader):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
refresh_in_background = False
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
cached = _cache.get(key)
|
cached = _cache.get(key)
|
||||||
if cached and now - cached[0] < ttl_seconds:
|
if cached and now - cached[0] < ttl_seconds:
|
||||||
return cached[1]
|
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())
|
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
|
||||||
# Startup prewarming and the first browser request can arrive together.
|
if cached:
|
||||||
# Coalesce them so a cold geocode cache is filled only once.
|
if refresh_in_background:
|
||||||
|
_start_cache_refresh(key, loader)
|
||||||
|
return stale_value
|
||||||
|
# Cold starts still need one synchronous load. Coalesce startup prewarming
|
||||||
|
# and the first browser request so only one upstream request is issued.
|
||||||
with load_lock:
|
with load_lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
cached = _cache.get(key)
|
cached = _cache.get(key)
|
||||||
if cached and now - cached[0] < ttl_seconds:
|
if cached:
|
||||||
return cached[1]
|
return _cached(key, ttl_seconds, loader)
|
||||||
value = loader()
|
value = loader()
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_cache[key] = (now, value)
|
_cache[key] = (time.time(), value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +215,7 @@ def _reverse_geocode_station(longitude, latitude):
|
|||||||
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
|
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
|
||||||
|
|
||||||
|
|
||||||
def _station_region_from_gps(station, now=None):
|
def _station_region_from_gps(station, now=None, refresh_queue=None):
|
||||||
global _station_geocode_cache_dirty
|
global _station_geocode_cache_dirty
|
||||||
coordinate_key = _station_coordinate_key(station)
|
coordinate_key = _station_coordinate_key(station)
|
||||||
if not coordinate_key:
|
if not coordinate_key:
|
||||||
@@ -191,6 +225,11 @@ def _station_region_from_gps(station, now=None):
|
|||||||
cached = cache.get(coordinate_key) or {}
|
cached = cache.get(coordinate_key) or {}
|
||||||
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
|
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
|
||||||
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
||||||
|
cached_region = cached.get("region")
|
||||||
|
if isinstance(cached_region, dict):
|
||||||
|
if refresh_queue is not None:
|
||||||
|
refresh_queue.append(station)
|
||||||
|
return cached_region
|
||||||
try:
|
try:
|
||||||
longitude, latitude = (float(value) for value in coordinate_key.split(","))
|
longitude, latitude = (float(value) for value in coordinate_key.split(","))
|
||||||
region = _reverse_geocode_station(longitude, latitude)
|
region = _reverse_geocode_station(longitude, latitude)
|
||||||
@@ -203,22 +242,92 @@ def _station_region_from_gps(station, now=None):
|
|||||||
return region
|
return region
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_station_region(station):
|
||||||
|
coordinate_key = _station_coordinate_key(station)
|
||||||
|
if not coordinate_key:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
cache = _load_station_geocode_cache()
|
||||||
|
with _station_geocode_cache_lock:
|
||||||
|
cached = cache.get(coordinate_key) or {}
|
||||||
|
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
longitude, latitude = (float(value) for value in coordinate_key.split(","))
|
||||||
|
region = _reverse_geocode_station(longitude, latitude)
|
||||||
|
except Exception as exc:
|
||||||
|
print("station reverse geocode refresh deferred for {}: {}".format(coordinate_key, exc))
|
||||||
|
return False
|
||||||
|
with _station_geocode_cache_lock:
|
||||||
|
cache[coordinate_key] = {"updatedAt": now, "region": region}
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_station_regions(stations):
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
|
changed = any(list(executor.map(_refresh_station_region, stations)))
|
||||||
|
if changed:
|
||||||
|
with _station_geocode_cache_lock:
|
||||||
|
_persist_station_geocode_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_station_region_refreshes():
|
||||||
|
global _station_geocode_refresh_running
|
||||||
|
restart = False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
with _station_geocode_refresh_lock:
|
||||||
|
batch = list(_station_geocode_refresh_pending.values())
|
||||||
|
_station_geocode_refresh_pending.clear()
|
||||||
|
if not batch:
|
||||||
|
return
|
||||||
|
_refresh_station_regions(batch)
|
||||||
|
except Exception as exc:
|
||||||
|
print("station reverse geocode background refresh deferred: {}".format(exc))
|
||||||
|
finally:
|
||||||
|
with _station_geocode_refresh_lock:
|
||||||
|
_station_geocode_refresh_running = False
|
||||||
|
restart = bool(_station_geocode_refresh_pending)
|
||||||
|
if restart:
|
||||||
|
_schedule_station_region_refresh([])
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_station_region_refresh(stations):
|
||||||
|
global _station_geocode_refresh_running
|
||||||
|
start_worker = False
|
||||||
|
with _station_geocode_refresh_lock:
|
||||||
|
for station in stations:
|
||||||
|
coordinate_key = _station_coordinate_key(station)
|
||||||
|
if coordinate_key:
|
||||||
|
_station_geocode_refresh_pending[coordinate_key] = station
|
||||||
|
if _station_geocode_refresh_pending and not _station_geocode_refresh_running:
|
||||||
|
_station_geocode_refresh_running = True
|
||||||
|
start_worker = True
|
||||||
|
if start_worker:
|
||||||
|
threading.Thread(target=_drain_station_region_refreshes, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _stations_with_gps_regions(stations):
|
def _stations_with_gps_regions(stations):
|
||||||
global _station_geocode_cache_dirty
|
global _station_geocode_cache_dirty
|
||||||
def enrich(station):
|
def enrich(station):
|
||||||
item = dict(station)
|
item = dict(station)
|
||||||
region = _station_region_from_gps(item)
|
refresh_queue = []
|
||||||
|
region = _station_region_from_gps(item, refresh_queue=refresh_queue)
|
||||||
# Never fall back to data-table administrative fields. The raw address
|
# Never fall back to data-table administrative fields. The raw address
|
||||||
# remains untouched and is the only directly displayed location text.
|
# remains untouched and is the only directly displayed location text.
|
||||||
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
|
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
|
||||||
return item
|
return item, refresh_queue[0] if refresh_queue else None
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
enriched = list(executor.map(enrich, stations))
|
results = list(executor.map(enrich, stations))
|
||||||
|
enriched = [item for item, _ in results]
|
||||||
|
stale_stations = [station for _, station in results if station is not None]
|
||||||
with _station_geocode_cache_lock:
|
with _station_geocode_cache_lock:
|
||||||
if _station_geocode_cache_dirty:
|
if _station_geocode_cache_dirty:
|
||||||
_persist_station_geocode_cache()
|
_persist_station_geocode_cache()
|
||||||
_station_geocode_cache_dirty = False
|
_station_geocode_cache_dirty = False
|
||||||
|
if stale_stations:
|
||||||
|
_schedule_station_region_refresh(stale_stations)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
@@ -233,7 +342,13 @@ def _load_stations():
|
|||||||
def _public_station_directory():
|
def _public_station_directory():
|
||||||
stations = _load_stations()
|
stations = _load_stations()
|
||||||
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
|
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
|
||||||
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations]
|
partner_fields = ("contactPerson", "contactPhone", "unitPrice")
|
||||||
|
directory = []
|
||||||
|
for station in stations:
|
||||||
|
item = {field: station.get(field) for field in fields if station.get(field) is not None}
|
||||||
|
if item.get("cooperative"):
|
||||||
|
item.update({field: station.get(field) for field in partner_fields if station.get(field) not in (None, "", 0)})
|
||||||
|
directory.append(item)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
@@ -331,7 +446,6 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
if path == "/api/dashboard":
|
if path == "/api/dashboard":
|
||||||
try:
|
try:
|
||||||
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
||||||
self._write_json(200, payload)
|
|
||||||
except Exception as exc: # keep upstream details server-side only
|
except Exception as exc: # keep upstream details server-side only
|
||||||
self.log_error("dashboard refresh failed: %s", exc)
|
self.log_error("dashboard refresh failed: %s", exc)
|
||||||
self._write_json(502, {
|
self._write_json(502, {
|
||||||
@@ -339,10 +453,12 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
"code": "UPSTREAM_UNAVAILABLE",
|
"code": "UPSTREAM_UNAVAILABLE",
|
||||||
"message": "地图数据暂时不可用,请稍后重试",
|
"message": "地图数据暂时不可用,请稍后重试",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
self._write_json(200, payload)
|
||||||
return
|
return
|
||||||
if path == "/api/stations":
|
if path == "/api/stations":
|
||||||
try:
|
try:
|
||||||
self._write_json(200, _public_station_directory())
|
payload = _public_station_directory()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log_error("station directory refresh failed: %s", exc)
|
self.log_error("station directory refresh failed: %s", exc)
|
||||||
self._write_json(502, {
|
self._write_json(502, {
|
||||||
@@ -350,6 +466,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
"code": "UPSTREAM_UNAVAILABLE",
|
"code": "UPSTREAM_UNAVAILABLE",
|
||||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
self._write_json(200, payload)
|
||||||
return
|
return
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
@@ -385,7 +503,10 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
for name, value in (headers or {}).items():
|
for name, value in (headers or {}).items():
|
||||||
self.send_header(name, value)
|
self.send_header(name, value)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
try:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
self.log_message("client disconnected before JSON response completed")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -919,6 +919,15 @@ body {
|
|||||||
font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif;
|
font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-phone-link {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-field.is-extra { display: none; }
|
.detail-field.is-extra { display: none; }
|
||||||
.map-detail-card.is-expanded .detail-field.is-extra { display: block; }
|
.map-detail-card.is-expanded .detail-field.is-extra { display: block; }
|
||||||
|
|
||||||
@@ -1216,6 +1225,21 @@ body {
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.r-value-stack {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-price {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Scrollbars */
|
/* Custom Scrollbars */
|
||||||
::-webkit-scrollbar { width: 3px; }
|
::-webkit-scrollbar { width: 3px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -1578,6 +1602,16 @@ body {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-glass-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.r-value-stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 390px) {
|
@media (max-width: 390px) {
|
||||||
|
|||||||
@@ -105,13 +105,15 @@ filterState.district = '';
|
|||||||
|
|
||||||
dashboard = {
|
dashboard = {
|
||||||
stations: [
|
stations: [
|
||||||
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
|
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, contactPerson: '张工', contactPhone: '13800000000', unitPrice: 12.5, totalHydrogenKg: 20 },
|
||||||
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
|
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
|
||||||
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
|
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
currentMode = 'station';
|
currentMode = 'station';
|
||||||
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
|
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
|
||||||
|
assert.equal(stationUnitPrice(dashboard.stations[0]), '¥12.50 / kg');
|
||||||
|
assert.match(detailPhoneField('联系方式', dashboard.stations[0].contactPhone), /tel:13800000000/);
|
||||||
assert.equal(fuzzySearchScore('广州', 'gz'), 3);
|
assert.equal(fuzzySearchScore('广州', 'gz'), 3);
|
||||||
assert.equal(fuzzySearchScore('广州市', '广州'), 1);
|
assert.equal(fuzzySearchScore('广州市', '广州'), 1);
|
||||||
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
|
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
|
||||||
@@ -153,7 +155,9 @@ document.getElementById = id => id === 'navigationLaunchOverlay' ? launchOverlay
|
|||||||
window.requestAnimationFrame = callback => callback();
|
window.requestAnimationFrame = callback => callback();
|
||||||
window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
|
window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
|
||||||
window.clearTimeout = () => {};
|
window.clearTimeout = () => {};
|
||||||
window.location = { assign() {} };
|
const navigationTarget = { opener: {}, location: {} };
|
||||||
|
const openedNavigation = [];
|
||||||
|
window.open = (...args) => { openedNavigation.push(args); return navigationTarget; };
|
||||||
selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
|
selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
|
||||||
navigateToSelectedEntity();
|
navigateToSelectedEntity();
|
||||||
assert.equal(launchOverlay.hidden, false);
|
assert.equal(launchOverlay.hidden, false);
|
||||||
@@ -161,6 +165,10 @@ assert.ok(launchClassSet.has('is-visible'));
|
|||||||
assert.ok(launchClassSet.has('button:is-launching'));
|
assert.ok(launchClassSet.has('button:is-launching'));
|
||||||
assert.equal(launchButton.disabled, true);
|
assert.equal(launchButton.disabled, true);
|
||||||
assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
|
assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
|
||||||
|
assert.deepEqual(openedNavigation[0], ['', '_blank']);
|
||||||
|
launchTimers[0].callback();
|
||||||
|
assert.equal(navigationTarget.opener, null);
|
||||||
|
assert.equal(navigationTarget.location.href, stationNavigationUrl);
|
||||||
const stationView = stationViewportSummary();
|
const stationView = stationViewportSummary();
|
||||||
assert.equal(stationView.level, 'station');
|
assert.equal(stationView.level, 'station');
|
||||||
assert.equal(stationView.visibleNodes.length, 2);
|
assert.equal(stationView.visibleNodes.length, 2);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -52,6 +54,20 @@ class DashboardTest(unittest.TestCase):
|
|||||||
self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120)
|
self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120)
|
||||||
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
|
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
|
||||||
|
|
||||||
|
def test_public_station_directory_limits_partner_contact_and_price_to_cooperative_stations(self):
|
||||||
|
stations = [
|
||||||
|
{"id": "partner", "cooperative": True, "contactPerson": "张工", "contactPhone": "13800000000", "unitPrice": 12.5},
|
||||||
|
{"id": "external", "cooperative": False, "contactPerson": "李工", "contactPhone": "13900000000", "unitPrice": 10.0},
|
||||||
|
]
|
||||||
|
with patch.object(server, "_load_stations", return_value=stations):
|
||||||
|
directory = server._public_station_directory()["stations"]
|
||||||
|
self.assertEqual(directory[0]["contactPerson"], "张工")
|
||||||
|
self.assertEqual(directory[0]["contactPhone"], "13800000000")
|
||||||
|
self.assertEqual(directory[0]["unitPrice"], 12.5)
|
||||||
|
self.assertNotIn("contactPerson", directory[1])
|
||||||
|
self.assertNotIn("contactPhone", directory[1])
|
||||||
|
self.assertNotIn("unitPrice", directory[1])
|
||||||
|
|
||||||
def test_cache_reuses_dashboard_snapshot_within_window(self):
|
def test_cache_reuses_dashboard_snapshot_within_window(self):
|
||||||
calls = []
|
calls = []
|
||||||
with patch.object(server, "_cache", {}):
|
with patch.object(server, "_cache", {}):
|
||||||
@@ -60,6 +76,45 @@ class DashboardTest(unittest.TestCase):
|
|||||||
self.assertEqual(calls, ["load"])
|
self.assertEqual(calls, ["load"])
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_expired_cache_returns_stale_value_while_refreshing_in_background(self):
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
def loader():
|
||||||
|
started.set()
|
||||||
|
release.wait(1)
|
||||||
|
return {"version": 2}
|
||||||
|
|
||||||
|
cache = {"dashboard": (time.time() - 121, {"version": 1})}
|
||||||
|
with patch.object(server, "_cache", cache), \
|
||||||
|
patch.object(server, "_cache_refreshing", set()), \
|
||||||
|
patch.object(server, "_cache_load_locks", {}):
|
||||||
|
result = server._cached("dashboard", 120, loader)
|
||||||
|
self.assertEqual(result, {"version": 1})
|
||||||
|
self.assertTrue(started.wait(1))
|
||||||
|
release.set()
|
||||||
|
for _ in range(100):
|
||||||
|
if cache["dashboard"][1] == {"version": 2}:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.assertEqual(cache["dashboard"][1], {"version": 2})
|
||||||
|
|
||||||
|
def test_expired_station_region_is_served_while_refresh_is_scheduled(self):
|
||||||
|
station = {"longitude": 113.2, "latitude": 23.1, "province": "错误省份"}
|
||||||
|
coordinate = server._station_coordinate_key(station)
|
||||||
|
stale_region = {"province": "广东省", "city": "广州市", "district": "黄埔区"}
|
||||||
|
cache = {coordinate: {"updatedAt": time.time() - server.STATION_GEOCODE_CACHE_SECONDS - 1, "region": stale_region}}
|
||||||
|
with patch.object(server, "_station_geocode_cache", cache), \
|
||||||
|
patch.object(server, "_station_geocode_cache_dirty", False), \
|
||||||
|
patch.object(server, "_schedule_station_region_refresh") as schedule, \
|
||||||
|
patch.object(server, "_reverse_geocode_station") as reverse:
|
||||||
|
result = server._stations_with_gps_regions([station])
|
||||||
|
self.assertEqual(result[0]["province"], "广东省")
|
||||||
|
self.assertEqual(result[0]["city"], "广州市")
|
||||||
|
reverse.assert_not_called()
|
||||||
|
schedule.assert_called_once()
|
||||||
|
self.assertEqual(len(schedule.call_args[0][0]), 1)
|
||||||
|
|
||||||
def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self):
|
def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self):
|
||||||
station = {
|
station = {
|
||||||
"longitude": 113.200001,
|
"longitude": 113.200001,
|
||||||
|
|||||||
Reference in New Issue
Block a user