Author SHA1 Message Date
lingniu f7e7176f88 chore: snapshot production code before Apple Design UI refinement 2026-09-17 18:05:57 +08:00
lingniu 9f0a87f49e perf: 优化监控刷新与历史里程分页查询 2026-09-09 10:13:44 +08:00
lingniu 1849bc1870 feat: 上线历史剩余氢质量批量查询接口 2026-09-09 01:00:29 +08:00
lingniu 4ab654e1c1 feat: 按35MPa满充模型提供剩余氢量估算与百分比 2026-09-08 23:12:57 +08:00
lingniu a6e31effc2 feat: 补齐开放平台车辆实况与统计时间字段 2026-09-08 21:47:58 +08:00
lingniu 9fa3d81777 feat: 完善每日氢耗明细导出与质量标记 2026-09-06 02:35:36 +08:00
lingniu e402eb5e60 feat: 推广 V3.5 实时氢耗并完善导出 2026-09-04 15:42:02 +08:00
lingniu a26179c8fe 功能:完善加氢站导航详情与缓存刷新 2026-09-02 14:05:49 +08:00
lingniu e7ee8583aa 功能:完善车辆地图合作站信息与缓存刷新 2026-09-02 14:05:49 +08:00
lingniu c6875fbb98 功能:扩展开放平台氢耗溯源与合作站数据 2026-09-02 14:05:34 +08:00
lingniu 8759668d39 功能:完善里程边界与氢耗流式统计 2026-09-02 14:05:04 +08:00
lingniu 4201878c35 fix(capacity): accept dedicated fields projector 2026-09-01 11:12:08 +08:00
lingniu 6fceb0f5b4 fix(gateway): prefer known JT808 identity snapshot 2026-09-01 10:58:46 +08:00
lingniu 861e0bf207 fix(navigation): polish mobile search layout 2026-08-12 13:51:57 +08:00
lingniu c81cfcb377 feat(navigation): add multi-region picker 2026-08-12 13:40:34 +08:00
lingniu 5b565d57fc fix(map): rate limit station reverse geocoding 2026-08-12 12:04:39 +08:00
lingniu 4c547757a7 feat(map): derive station regions from GPS 2026-08-12 12:03:09 +08:00
lingniu f707e6ad68 fix(navigation): support Python 3.6 static assets 2026-08-12 11:30:07 +08:00
lingniu 1403b84b0b feat(navigation): refine mobile station explorer 2026-08-12 11:28:50 +08:00
lingniu 9452e30538 feat(map): support multi-location station filters 2026-08-12 11:23:16 +08:00
lingniu 6ef98d03c2 feat(map): add cached public station navigation 2026-08-12 10:50:06 +08:00
lingniu 357dd490e9 refactor(map): remove access gate 2026-08-11 19:24:32 +08:00
lingniu d1ba129e99 fix(map): fingerprint static assets on release 2026-08-11 18:59:22 +08:00
lingniu d4593c44cd feat(map): add protected operations access 2026-08-11 18:30:16 +08:00
lingniu f535b1570b feat(map): streamline search and detail panels 2026-08-11 17:41:45 +08:00
lingniu c42570c0ee feat(map): sync station list with viewport 2026-08-11 17:01:27 +08:00
lingniu 22f3904ad7 fix(open-platform): align realtime online status across protocols 2026-08-03 21:49:33 +08:00
lingniu 8539af13e6 feat: expose vehicle map data APIs 2026-08-03 19:55:38 +08:00
199 changed files with 29063 additions and 1065 deletions
+11 -1
View File
@@ -513,12 +513,22 @@ BACKFILL_DATE_TO=2026-07-12 \
三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时,stat-writer 会按同一 VIN、自然日、协议和物理来源累计相邻坐标距离,状态持久化在 `vehicle_daily_gps_mileage_state`,并以 `quality_reason=gps_coordinate_accumulation` 写入低优先级候选。该路径既覆盖总里程字段缺失,也覆盖字段存在但数值与上一帧相同;重复总里程仍计入 `skipped_same_mileage` 证据,只有同时具备有效移动点时才增加 GPS 候选。JT808 兼容没有速度字段的存量终端;GB32960、宇通 MQTT 必须同时满足设备速度大于 0,且实时状态写入按 10 秒节流,避免静止 GPS 漂移和 1 秒级宇通上报放大 MySQL 压力。相邻点超过 10 分钟、时间倒序或隐含速度超过 220 km/h 的线段不累计;低于 100 米的自然日累计只保留在状态表中,不投影为业务候选,避免瞬时车速噪声和微小定位漂移生成虚假日里程。`stats-backfill` 每次扫描还会查找窗口内阈值规则上线前遗留的低于 100 米 GPS 候选:dry-run 只报告数量,正式回补在单车/日期/协议事务中删除候选并重新执行来源选举,确保最终表不会继续保留旧漂移值。选举顺序为“正向累计里程 > 正向 GPS 轨迹估算 > 累计里程零值占位”,因此稀疏累计里程造成的错误 0 km 会被已有移动轨迹纠正,真实正向累计里程恢复后仍会自动覆盖估算值;禁止把估算值混入平台累计里程归并。`vehicle_stat_samples_total{status="recovered_gps_coordinate"}``vehicle_stat_cache_entries{cache="gps_accumulation"}` 用于观察实时恢复量及节流缓存。`stats-backfill` 默认用 TDengine `vehicle_locations` 为缺少正向累计里程但存在可用移动线段的车辆补建同口径候选;可临时设置 `BACKFILL_GPS_FALLBACK=false` 禁用全部协议的降级路径,旧环境变量 `BACKFILL_JT808_GPS_FALLBACK` 继续兼容。 三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时,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
@@ -633,111 +633,6 @@ WHERE m.protocol = 'GB32960' AND m.daily_mileage_km > 0
} }
func applyMigration(ctx context.Context, conn *sql.Conn) error { func applyMigration(ctx context.Context, conn *sql.Conn) error {
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) // The legacy table has no protocol provenance. Never label its rows GB32960.
if err != nil { return errors.New("legacy mileage migration disabled: source rows have no verified protocol; retain the legacy table without projecting protocol mileage")
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_data_source
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind,
trust_priority, enabled, first_seen_at, latest_seen_at, remark)
VALUES ('GB32960', ?, 'lingniu_prod.ln_vehicle_day_mileage', ?, ?, 'UNKNOWN', 1000, 1,
'2024-10-17 00:00:00', '2026-07-20 23:59:59',
'仅用于迁移 2026-07-21 之前缺失或为 0 的 GB32960 日里程')
ON DUPLICATE KEY UPDATE
latest_source_endpoint = VALUES(latest_source_endpoint),
platform_name = VALUES(platform_name),
source_code = VALUES(source_code),
source_kind = VALUES(source_kind),
trust_priority = VALUES(trust_priority),
enabled = 1,
remark = VALUES(remark)`, legacySourceIP, legacyPlatformName, legacySourceCode); err != nil {
return fmt.Errorf("upsert legacy data source: %w", err)
}
var sourceID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM vehicle_data_source WHERE protocol = 'GB32960' AND source_ip = ?`, legacySourceIP).Scan(&sourceID); err != nil {
return fmt.Errorf("lookup legacy data source: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason, is_selected)
SELECT
st.vin, st.stat_date, 'GB32960', ?, ?, 'lingniu_prod.ln_vehicle_day_mileage', ?,
CASE WHEN st.total_mileage_km IS NOT NULL AND st.total_mileage_km >= st.daily_mileage_km
THEN st.total_mileage_km - st.daily_mileage_km ELSE NULL END,
st.total_mileage_km, st.daily_mileage_km, 1,
TIMESTAMP(st.stat_date, '00:00:00'), TIMESTAMP(st.stat_date, '23:59:59'),
'OK', ?, 0
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
NOT (vehicle_daily_mileage_source.first_total_mileage_km <=> VALUES(first_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage_source.quality_status <=> VALUES(quality_status))
OR NOT (vehicle_daily_mileage_source.quality_reason <=> VALUES(quality_reason)),
CURRENT_TIMESTAMP,
vehicle_daily_mileage_source.updated_at
),
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
platform_name = VALUES(platform_name),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason)`, legacySourceKey, legacySourceIP, legacyPlatformName, legacyQuality, sourceID); err != nil {
return fmt.Errorf("upsert legacy mileage candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km)
SELECT st.vin, st.stat_date, 'GB32960', ?, st.daily_mileage_km, st.total_mileage_km
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
vehicle_daily_mileage.daily_mileage_km = 0
OR (
(vehicle_daily_mileage.source_id <=> VALUES(source_id))
AND (
NOT (vehicle_daily_mileage.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
)
),
CURRENT_TIMESTAMP,
vehicle_daily_mileage.updated_at
),
source_id = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(source_id), vehicle_daily_mileage.source_id),
latest_total_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(latest_total_mileage_km), vehicle_daily_mileage.latest_total_mileage_km),
daily_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(daily_mileage_km), vehicle_daily_mileage.daily_mileage_km)`, sourceID, sourceID); err != nil {
return fmt.Errorf("upsert final daily mileage: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st
ON st.vin = s.vin AND st.stat_date = s.stat_date
JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
SET s.is_selected = CASE WHEN s.source_key = ? THEN 1 ELSE 0 END
WHERE s.protocol = 'GB32960'
AND m.source_id = ?
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceKey, sourceID); err != nil {
return fmt.Errorf("mark legacy mileage selections: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration: %w", err)
}
return nil
} }
@@ -1,7 +1,9 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
"strings"
"testing" "testing"
) )
@@ -54,3 +56,10 @@ func TestNullableTotalMileageConvertsMetersToKM(t *testing.T) {
t.Fatalf("invalid mileage should remain nil, got %#v", got) t.Fatalf("invalid mileage should remain nil, got %#v", got)
} }
} }
func TestApplyRejectsUnknownProtocolBeforeDatabaseAccess(t *testing.T) {
err := applyMigration(context.Background(), nil)
if err == nil || !strings.Contains(err.Error(), "no verified protocol") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -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)
}
@@ -0,0 +1,46 @@
// Reproject explicitly listed, backed-up mileage days using the live writer SQL.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
"os"
"time"
)
func main() {
input := flag.String("input", "", "JSON array of vin/date/protocol")
flag.Parse()
b, e := os.ReadFile(*input)
must(e)
var rows []struct {
VIN string `json:"vin"`
Date string `json:"date"`
Protocol string `json:"protocol"`
}
must(json.Unmarshal(b, &rows))
db, e := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
must(e)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
for i, r := range rows {
must(stats.ProjectDailyMileage(ctx, db, r.VIN, r.Date, envelope.Protocol(r.Protocol)))
if (i+1)%500 == 0 {
fmt.Printf("projected=%d/%d\n", i+1, len(rows))
}
}
fmt.Printf("completed=%d\n", len(rows))
}
func must(e error) {
if e != nil {
fmt.Fprintln(os.Stderr, e)
os.Exit(1)
}
}
@@ -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)
} }
+127 -11
View File
@@ -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 TestAggregateFromDailySourceUsesHistoricalBoundaryAfterOfflineGap(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"),
@@ -226,7 +227,7 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
} }
} }
func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) { func TestAggregateFromDailySourceRejectsHistoricalBaselineJumpAcrossOfflineGap(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",
@@ -285,7 +286,7 @@ func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testin
} }
} }
func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) { func TestBuildLastDiffAggregatesRetainsBoundaryAfterEmptyDays(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)
@@ -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)
+108 -46
View File
@@ -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,23 @@ 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 future/invalid boundary cannot be used for this natural 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 TestWriterAppendUsesHistoricalBoundaryAfterOfflineGap(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)
@@ -1490,7 +1496,7 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
"", "",
float64(120672.0), float64(120672.0),
float64(120788.0), float64(120788.0),
approxFloat64{want: 116.0, tolerance: 0.000001}, float64(116),
float64(0), float64(0),
int64(1), int64(1),
int64(0), int64(0),
@@ -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 TestWriterApplyRealtimeBaselineIncludesPlausibleMultiDayFallbackDelta(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)
@@ -1737,15 +1743,62 @@ func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *tes
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical { if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical {
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 math.Abs(candidate.DailyKM-7833.5) > 0.000001 {
t.Fatalf("daily km = %v, want multi-day gap delta", candidate.DailyKM) t.Fatalf("daily km = %v, want historical cumulative difference after offline gap", candidate.DailyKM)
}
if candidate.FirstTotalKM != 8832.1 || !candidate.FirstEventTime.Equal(baselineTime) {
t.Fatalf("historical 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 TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJumpAcrossOfflineGap(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)
@@ -1777,11 +1830,11 @@ func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(t *testing.T)
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" { if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
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 math.Abs(candidate.DailyKM-30000) > 0.000001 {
t.Fatalf("daily km = %v, want historical jump delta", candidate.DailyKM) t.Fatalf("daily km = %v, want historical cumulative difference", candidate.DailyKM)
} }
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) { if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
t.Fatalf("previous-day baseline not preserved: %#v", candidate) t.Fatalf("historical 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,24 @@ func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM
return currentDayLatestKM - previousBaselineKM return currentDayLatestKM - previousBaselineKM
} }
// IsUsableDailyMileageBoundary accepts older same-source odometers.
// Offline increments belong to the recovery day so cumulative totals reconcile.
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.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 +463,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 +748,34 @@ 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 = `CASE
WHEN COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN s.latest_total_mileage_km
ELSE 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) END`
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 TestIsUsableDailyMileageBoundaryAcceptsOlderOfflineGap(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: true},
{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
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
BINARY="${BINARY:-/opt/lingniu-go-native/current/stats-backfill}"
DATE_FROM="${DATE_FROM:-}"
DATE_TO="${DATE_TO:-}"
PARALLEL="${PARALLEL:-4}"
APPLY=false
WORK_DIR=""
usage() {
cat <<'USAGE'
Usage: replay-pure-hydrogen-mileage.sh --date-from YYYY-MM-DD --date-to YYYY-MM-DD [options]
Options:
--apply Write the rebuilt daily mileage rows. Default is dry-run.
--parallel N Concurrent VIN workers. Default: 4.
--binary PATH stats-backfill binary path.
--work-dir PATH Keep VIN lists and per-VIN logs in this directory.
USAGE
}
if [[ "${1:-}" == "--worker" ]]; then
vin="$2"
dry_run="$3"
log_dir="$4"
env \
BACKFILL_METHOD=scan \
BACKFILL_DRY_RUN="$dry_run" \
BACKFILL_DATE_FROM="$DATE_FROM" \
BACKFILL_DATE_TO="$DATE_TO" \
BACKFILL_PROTOCOLS=GB32960 \
BACKFILL_VINS="$vin" \
BACKFILL_PROGRESS_EVERY=100000 \
"$BINARY" >"$log_dir/$vin.log" 2>&1
printf 'completed vin=%s\n' "$vin"
exit 0
fi
while (($#)); do
case "$1" in
--date-from) DATE_FROM="$2"; shift 2 ;;
--date-to) DATE_TO="$2"; shift 2 ;;
--parallel) PARALLEL="$2"; shift 2 ;;
--binary) BINARY="$2"; shift 2 ;;
--work-dir) WORK_DIR="$2"; shift 2 ;;
--apply) APPLY=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ ! "$DATE_FROM" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ || ! "$DATE_TO" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "--date-from and --date-to are required" >&2
exit 2
fi
if [[ ! "$PARALLEL" =~ ^[1-9][0-9]*$ ]]; then
echo "--parallel must be a positive integer" >&2
exit 2
fi
if [[ ! -x "$BINARY" ]]; then
echo "stats-backfill is not executable: $BINARY" >&2
exit 2
fi
if [[ -z "$WORK_DIR" ]]; then
WORK_DIR="$(mktemp -d /tmp/pure-hydrogen-replay.XXXXXX)"
else
mkdir -p "$WORK_DIR"
fi
LOG_DIR="$WORK_DIR/logs"
mkdir -p "$LOG_DIR"
hydrogen_vins="$WORK_DIR/hydrogen-vins.txt"
active_vins="$WORK_DIR/active-gb32960-vins.txt"
target_vins="$WORK_DIR/target-vins.txt"
pending_vins="$WORK_DIR/pending-vins.txt"
BACKFILL_LIST_HYDROGEN_VINS=true "$BINARY" | LC_ALL=C sort -u >"$hydrogen_vins"
env \
BACKFILL_LIST_ACTIVE_VINS=true \
BACKFILL_DATE_FROM="$DATE_FROM" \
BACKFILL_DATE_TO="$DATE_TO" \
BACKFILL_PROTOCOLS=GB32960 \
"$BINARY" | LC_ALL=C sort -u >"$active_vins"
comm -12 "$hydrogen_vins" "$active_vins" >"$target_vins"
target_count="$(wc -l <"$target_vins" | tr -d ' ')"
: >"$pending_vins"
while IFS= read -r vin; do
if [[ -f "$LOG_DIR/$vin.log" ]] && grep -q 'stats backfill complete' "$LOG_DIR/$vin.log"; then
continue
fi
echo "$vin" >>"$pending_vins"
done <"$target_vins"
pending_count="$(wc -l <"$pending_vins" | tr -d ' ')"
dry_run=true
if [[ "$APPLY" == true ]]; then
dry_run=false
fi
printf 'pure-hydrogen replay: date=%s..%s vins=%s pending=%s parallel=%s dry_run=%s work_dir=%s\n' \
"$DATE_FROM" "$DATE_TO" "$target_count" "$pending_count" "$PARALLEL" "$dry_run" "$WORK_DIR"
if [[ "$pending_count" == "0" ]]; then
printf 'pure-hydrogen replay complete: vins=%s logs=%s\n' "$target_count" "$LOG_DIR"
exit 0
fi
export BINARY DATE_FROM DATE_TO
set +e
xargs -I '{}' -P "$PARALLEL" "$0" --worker '{}' "$dry_run" "$LOG_DIR" <"$pending_vins"
xargs_status=$?
set -e
failed_vins="$WORK_DIR/failed-vins.txt"
: >"$failed_vins"
while IFS= read -r vin; do
if [[ ! -f "$LOG_DIR/$vin.log" ]] || ! grep -q 'stats backfill complete' "$LOG_DIR/$vin.log"; then
echo "$vin" >>"$failed_vins"
fi
done <"$target_vins"
if [[ "$xargs_status" != "0" || -s "$failed_vins" ]]; then
echo "replay incomplete; inspect $failed_vins and $LOG_DIR" >&2
exit 1
fi
printf 'pure-hydrogen replay complete: vins=%s logs=%s\n' "$target_count" "$LOG_DIR"
@@ -0,0 +1,49 @@
"""Consistent logical backup of the complete current MySQL database; no DB writes."""
import gzip, hashlib, json, os, pathlib, time
import pymysql
from db_access import connect
os.umask(0o077)
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-reconciliation-20260916')
root.mkdir(parents=True,exist_ok=True)
path=root/'database.sql.gz'
if path.exists(): raise RuntimeError('Backup already exists; do not overwrite')
c=connect(); manifest={'started_at':time.strftime('%Y-%m-%dT%H:%M:%S%z'),'tables':{}}
with c.cursor() as q:
q.execute('SELECT DATABASE() AS db'); db=q.fetchone()['db'];manifest['database']=db
q.execute('SHOW FULL TABLES'); entries=q.fetchall()
tables=[list(r.values())[0] for r in entries if list(r.values())[1]=='BASE TABLE']
q.execute('SHOW TRIGGERS'); triggers=q.fetchall()
q.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
def literal(v):
if isinstance(v,bytes): return '0x'+v.hex()
return c.escape(v)
with gzip.open(str(path)+'.partial','wt',encoding='utf-8',compresslevel=3) as f:
f.write('SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n')
for table in tables:
q.execute('SHOW CREATE TABLE `'+table+'`');ddl=list(q.fetchone().values())[1]
f.write('DROP TABLE IF EXISTS `'+table+'`;\n'+ddl+';\n')
q.execute('SHOW COLUMNS FROM `'+table+'`');cols=[r['Field'] for r in q.fetchall() if 'GENERATED' not in r['Extra']]
names=','.join('`'+v+'`' for v in cols);count=0
with c.cursor(pymysql.cursors.SSCursor) as stream:
stream.execute('SELECT '+names+' FROM `'+table+'`')
while True:
rows=stream.fetchmany(500)
if not rows:break
f.write('INSERT INTO `'+table+'` ('+names+') VALUES\n'+',\n'.join('('+','.join(literal(v) for v in row)+')' for row in rows)+';\n')
count+=len(rows)
manifest['tables'][table]=count
print(json.dumps({'table':table,'rows':count}),flush=True)
for row in entries:
if list(row.values())[1]=='VIEW':
name=list(row.values())[0];q.execute('SHOW CREATE VIEW `'+name+'`');ddl=q.fetchone()['Create View'];f.write(ddl+';\n')
for trigger in triggers:
q.execute('SHOW CREATE TRIGGER `'+trigger['Trigger']+'`');ddl=q.fetchone()['SQL Original Statement'];f.write('DELIMITER ;;\n'+ddl+';;\nDELIMITER ;\n')
f.write('SET FOREIGN_KEY_CHECKS=1;\n')
c.rollback();c.close()
os.rename(str(path)+'.partial',path)
h=hashlib.sha256()
with path.open('rb') as f:
for block in iter(lambda:f.read(1024*1024),b''):h.update(block)
manifest.update(sha256=h.hexdigest(),bytes=path.stat().st_size,finished_at=time.strftime('%Y-%m-%dT%H:%M:%S%z'))
(root/'manifest.json').write_text(json.dumps(manifest,indent=2))
print(json.dumps({'complete':True,'bytes':manifest['bytes'],'sha256':manifest['sha256']}),flush=True)
@@ -0,0 +1,17 @@
import pymysql,pathlib,re,json
def connect():
env={}
for name in ['base.env','stat-writer.env']:
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);env[k]=v.strip().strip(chr(34)).strip(chr(39))
m=re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+).*',env['MYSQL_DSN'])
return pymysql.connect(user=m[1],password=m[2],host=m[3],port=int(m[4]),database=m[5],charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False)
if __name__=='__main__':
c=connect()
with c.cursor() as q:
q.execute('START TRANSACTION READ ONLY')
for sql in ["SELECT DATABASE() AS db, VERSION() AS version", "SELECT table_name,engine,table_rows,ROUND((data_length+index_length)/1024/1024,1) AS mb FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY data_length DESC", "SELECT protocol,source_ip,COUNT(*) AS n,COUNT(DISTINCT vin) AS vins FROM vehicle_daily_mileage_source WHERE source_ip IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage') GROUP BY protocol,source_ip"]:
q.execute(sql);print(json.dumps(q.fetchall(),default=str))
c.rollback();c.close()
@@ -0,0 +1,60 @@
"""Repair verified mileage provenance and offline baselines. Dry-run by default."""
import argparse,datetime,json,pathlib
from decimal import Decimal
from db_access import connect
p=argparse.ArgumentParser();p.add_argument('--apply',action='store_true');args=p.parse_args()
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-reconciliation-20260916')
legacy="('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')"
c=connect();report={}
with c.cursor() as q:
q.execute('START TRANSACTION READ ONLY')
q.execute("SELECT COUNT(*) AS n,COUNT(DISTINCT vin) AS vehicles FROM vehicle_daily_mileage_source WHERE protocol='GB32960' AND source_ip IN "+legacy);report['legacy']=q.fetchone()
q.execute("SELECT COUNT(*) AS n FROM vehicle_daily_mileage m JOIN vehicle_data_source d ON d.id=m.source_id WHERE m.protocol='GB32960' AND d.source_ip IN "+legacy);report['legacy_projections']=q.fetchone()
q.execute("""SELECT s.vin,s.stat_date,s.protocol,s.source_key,s.latest_total_mileage_km,s.first_total_mileage_km,s.daily_mileage_km,s.first_event_time,s.latest_event_time,
p.latest_total_mileage_km AS previous_total,p.latest_event_time AS previous_time
FROM vehicle_daily_mileage_source s
JOIN vehicle_daily_mileage_source p ON p.vin=s.vin AND p.protocol=s.protocol AND p.source_key=s.source_key
AND p.stat_date=(SELECT MAX(b.stat_date) FROM vehicle_daily_mileage_source b
WHERE b.vin=s.vin AND b.protocol=s.protocol AND b.source_key=s.source_key AND b.stat_date<s.stat_date
AND b.quality_status='OK' AND b.latest_total_mileage_km>0
AND b.latest_event_time>=TIMESTAMP(b.stat_date) AND b.latest_event_time<TIMESTAMP(b.stat_date)+INTERVAL 1 DAY)
WHERE s.stat_date<='2026-09-16' AND s.quality_status='OK'
AND s.quality_reason='current_day_first_baseline'
AND s.latest_total_mileage_km>0 AND s.first_event_time>=TIMESTAMP(s.stat_date)
AND s.source_ip NOT IN """+legacy+" ORDER BY s.vin,s.protocol,s.source_key,s.stat_date")
changes=q.fetchall();report['baseline_candidates']=len(changes)
q.execute("SELECT id FROM vehicle_data_source WHERE source_ip IN "+legacy);legacy_ids=[r['id'] for r in q.fetchall()]
c.rollback()
if args.apply:
assert (root/'manifest.json').is_file() and (root/'database.sql.gz').is_file(),'Complete backup required'
(root/'baseline-before.json').write_text(json.dumps(changes,default=str,ensure_ascii=False))
q.execute('START TRANSACTION')
q.execute("UPDATE vehicle_daily_mileage_source SET quality_status='INVALID_DELTA',quality_reason='UNVERIFIED_LEGACY_PROTOCOL',is_selected=0 WHERE protocol='GB32960' AND source_ip IN "+legacy)
report['quarantined_source_rows']=q.rowcount
q.execute("UPDATE vehicle_data_source SET enabled=0,remark='Protocol provenance unverified; quarantined 2026-09-16; original records retained' WHERE protocol='GB32960' AND source_ip IN "+legacy)
q.execute("DELETE m FROM vehicle_daily_mileage m JOIN vehicle_data_source d ON d.id=m.source_id WHERE m.protocol='GB32960' AND d.source_ip IN "+legacy)
report['removed_mislabelled_projections']=q.rowcount
affected=set()
for s in changes:
delta=s['latest_total_mileage_km']-s['previous_total']
days=max(1,(s['latest_event_time'].date()-s['previous_time'].date()).days)
status='OK';reason='historical_source_baseline'
if Decimal('-1')<=delta<0:delta=Decimal(0);reason='negative_jitter_clamped'
elif delta<0:status='INVALID_DELTA';reason='TOTAL_MILEAGE_ROLLBACK'
elif delta>Decimal(2500*days):status='INVALID_DELTA';reason='outside_daily_range'
q.execute("""UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=%s,first_event_time=%s,daily_mileage_km=%s,
quality_status=%s,quality_reason=%s WHERE vin=%s AND stat_date=%s AND protocol=%s AND source_key=%s
AND first_total_mileage_km <=> %s AND daily_mileage_km=%s""",(s['previous_total'],s['previous_time'],delta,status,reason,s['vin'],s['stat_date'],s['protocol'],s['source_key'],s['first_total_mileage_km'],s['daily_mileage_km']))
if q.rowcount:affected.add((s['vin'],s['stat_date'],s['protocol']))
# Restore valid non-import GB32960 projections that were hidden by migrated rows.
q.execute("""SELECT DISTINCT s.vin,s.stat_date,s.protocol FROM vehicle_daily_mileage_source s
JOIN vehicle_daily_mileage_source bad ON bad.vin=s.vin AND bad.stat_date=s.stat_date AND bad.protocol=s.protocol
WHERE bad.source_ip IN """+legacy+" AND s.source_ip NOT IN "+legacy+" AND s.quality_status='OK' AND s.latest_total_mileage_km IS NOT NULL")
affected.update((r['vin'],r['stat_date'],r['protocol']) for r in q.fetchall())
report['reproject_days']=len(affected)
# The Go projection utility is run before resuming writers, using canonical SQL.
(root/'reproject.json').write_text(json.dumps([{'vin':v,'date':str(d),'protocol':p} for v,d,p in sorted(affected)]))
c.commit()
(root if args.apply else pathlib.Path('/tmp')).joinpath('repair-result.json' if args.apply else 'mileage-repair-dry-run.json').write_text(json.dumps(report,default=str,indent=2))
print(json.dumps(report,default=str),flush=True)
c.close()
@@ -0,0 +1,7 @@
"""Run a specified maintenance binary without exposing environment credentials."""
import os,pathlib,subprocess,sys
for name in ['base.env','stat-writer.env']:
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);os.environ[k]=v.strip().strip(chr(34)).strip(chr(39))
sys.exit(subprocess.call(sys.argv[1:]))
+35
View File
@@ -0,0 +1,35 @@
# 羚牛氢能 · 加氢站导航
面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`
它复用运营地图已经按 GPS 逆地理解析、落盘缓存的站点目录,并只对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 API 返回。
## 本地运行
```bash
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
./start-local.sh
```
访问 <http://127.0.0.1:20804/>。
## API
- `GET /api/stations`:公开站点目录,不包含加氢量字段;
- `GET /api/health`:进程健康状态;
- 其他 `/api/*` 路由均返回 404。
站点目录在服务端缓存 2 分钟,并在服务启动后后台预热;因此正常页面打开直接返回缓存数据,最多延迟 2 分钟更新一次。省、市、区由运营地图根据 GCJ-02 坐标调用高德逆地理服务生成,结果按坐标缓存 7 天;详细地址仍直接展示资产原始字段。
## ECS 部署
服务运行在 `20804`,与运营地图端口隔离。部署单元复用
`/opt/lingniu-vehicle-map/env/vehicle-map.env` 中已有的 Open Platform 凭据,并通过
`VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current` 复用一致的品牌样式。
```bash
install -m 0644 deploy/lingniu-station-navigation.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now lingniu-station-navigation.service
deploy/install-release.sh <release-id> <release.tgz>
```
+279
View File
@@ -0,0 +1,279 @@
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true, regionPicker: null };
document.addEventListener('DOMContentLoaded', () => { initClock(); initMap(); loadStations(); });
document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeStationSearchSuggestions(); });
async function loadStations() {
setStatus('正在同步加氢站数据…');
try {
const response = await fetch('/api/stations', { headers: { Accept: 'application/json' } });
const payload = await response.json();
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
state.stations = payload.stations.filter(hasCoordinates);
updateSummary(payload.summary);
setStatus(`站点数据已同步 · ${formatNumber(payload.summary.totalStations)} 座加氢站 · ${payload.asOf}`);
render();
} catch (error) {
console.error('station directory refresh failed', error);
setStatus('站点数据同步失败 · 将自动重试', true);
}
}
function initMap() {
if (typeof AMap === 'undefined') { setStatus('地图组件加载失败', true); return; }
state.map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: 'amap://styles/light', showBuildingBlock: false, showLabel: true });
state.map.on('complete', render);
state.map.on('zoomend', render);
state.map.on('moveend', render);
}
function updateSummary(summary) {
const total = Number(summary.totalStations || 0), partner = Number(summary.cooperativeStations || 0), external = Math.max(0, total - partner);
setHTML('kpiTotal', `${formatNumber(total)} <small>座</small>`); setHTML('kpiPartner', `${formatNumber(partner)} <small>座</small>`);
setHTML('statusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('statusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('statusExternal', `${formatNumber(external)} <small>座</small>`);
setHTML('mobileStatusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('mobileStatusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('mobileStatusExternal', `${formatNumber(external)} <small>座</small>`);
document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
document.getElementById('externalSegment').style.width = `${total ? external * 100 / total : 0}%`;
document.getElementById('mobilePartnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
document.getElementById('mobileExternalSegment').style.width = `${total ? external * 100 / total : 0}%`;
}
function stationMatchesQuery(station, query = state.query) { const needle = normalize(query); return !needle || [station.name, station.shortName, station.address].some(value => normalize(value).includes(needle)); }
function locationFilterKey(filter) { return [filter.level || 'location', filter.province || '', filter.city || '', filter.district || ''].join('|'); }
function stationMatchesLocationFilter(station, filter) { return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); }
function stationMatchesLocation(station) { return !state.locationFilters.length || state.locationFilters.some(filter => stationMatchesLocationFilter(station, filter)); }
function stationTypeScope() { return state.stations.filter(station => state.stationFilter !== 'partner' || station.cooperative); }
function filteredStations() { return stationTypeScope().filter(station => stationMatchesLocation(station) && stationMatchesQuery(station)); }
function stationSearchScore(station, query) {
const fields = [station.name, station.shortName, station.address].filter(Boolean);
for (const value of fields) { const text = normalize(value); if (text.includes(query)) return 3; const pinyin = toPinyin(value); if (pinyin.includes(query) || initials(pinyin).includes(query)) return 2; }
return 0;
}
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
function adminNameKey(value) { return String(value || '').trim().replace(/(特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区|省|市)$/, ''); }
function isMunicipalityProvince(value) { return ['北京', '上海', '天津', '重庆'].includes(adminNameKey(value)); }
function isMunicipalityCity(province, city) { return isMunicipalityProvince(province) && adminNameKey(province) === adminNameKey(city); }
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
function render() { renderMarkers(); renderList(); }
function renderMarkers() {
if (!state.map || typeof AMap === 'undefined') return;
state.markers.forEach(marker => marker.remove()); state.markers = [];
const nodes = stationNodes(true);
nodes.forEach(node => {
const point = node.kind === 'station', content = document.createElement('div');
content.className = 'province-info-badge-wrap';
const label = point ? (node.cooperative ? 'H₂ · 合作' : '') : `${formatNumber(node.count)}`;
const subline = point ? node.adminPath : '';
content.innerHTML = `<div class="province-info-card${point ? ` is-station-point${node.cooperative ? '' : ' is-external'}${state.selected?.id === node.station.id ? ' is-selected' : ''}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span>${label ? `<span class="p-total">${escapeHTML(label)}</span>` : ''}</div>${subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
const marker = new AMap.Marker({ position: node.lnglat, content, offset: new AMap.Pixel(-30, -12), title: node.name, zIndex: point ? 20 : 10 });
marker.on('click', () => { if (point) selectStation(node.station, true); else state.map.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }); marker.setMap(state.map); state.markers.push(marker);
});
renderUserMarker();
}
function stationHierarchyLevel(zoom = state.map?.getZoom?.() || 0) {
if (normalize(state.query)) return 'station';
if (zoom < 7) return 'province';
if (zoom < 11) return 'city';
return 'station';
}
function stationGroupName(station, level) {
const raw = (level === 'province' ? station.province : station.city) || '未标注区域';
return level === 'province' ? String(raw).replace(/(壮族|回族|维吾尔)?自治区$|特别行政区$|省$|市$/, '') : String(raw).replace(/自治州$|地区$|林区$|[盟区县市]$/, '');
}
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevel()) {
if (level === 'station') return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', level, name: station.name || station.shortName || '未命名站点', lnglat: coords(station), station, count: 1, online: station.cooperative ? 1 : 0, adminPath: stationRegion(station), detail: [stationRegion(station), station.address].filter(Boolean).join(' · '), cooperative: Boolean(station.cooperative) }));
const grouped = new Map();
for (const station of stations) {
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
const group = grouped.get(key) || { kind: level === 'province' ? 'stationProvince' : 'stationCity', level, name: stationGroupName(station, level), lng: 0, lat: 0, count: 0, online: 0, stations: [] };
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); grouped.set(key, group);
}
return [...grouped.values()].map((group, index) => ({ ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') }));
}
function currentMapBounds() { const bounds = state.map?.getBounds?.(), sw = bounds?.getSouthWest?.(), ne = bounds?.getNorthEast?.(); if (!sw || !ne) return null; return { west: sw.getLng(), south: sw.getLat(), east: ne.getLng(), north: ne.getLat() }; }
function nodesInsideCurrentMapBounds(nodes) { const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); }
function stationNodes(visibleOnly = false) { const nodes = buildStationNodes(); return visibleOnly && !normalize(state.query) ? nodesInsideCurrentMapBounds(nodes) : nodes; }
function stationViewportSummary() { const level = stationHierarchyLevel(), allNodes = stationNodes(false), visibleNodes = normalize(state.query) ? allNodes : nodesInsideCurrentMapBounds(allNodes); return { level, allNodes, visibleNodes }; }
function nextZoomForLevel(level) { return level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14; }
function renderList() {
const box = document.getElementById('stationList'); if (!box) return;
const { level, allNodes, visibleNodes } = stationViewportSummary();
const display = [...visibleNodes].sort(compareNodes);
document.getElementById('resultMeta').textContent = `视野 ${formatNumber(visibleNodes.reduce((sum, node) => sum + node.count, 0))} / ${formatNumber(filteredStations().length)}`;
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
box.innerHTML = display.slice(0, 100).map((node, index) => {
const station = node.kind === 'station' ? node.station : null, distance = station ? stationDistance(station) : Number.POSITIVE_INFINITY;
const partnerTag = station ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '';
const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)}`;
const price = station?.cooperative ? stationUnitPrice(station) : '';
const valueStack = price || rowValue ? `<span class="r-value-stack">${price ? `<span class="station-list-price">${escapeHTML(price)}</span>` : ''}${rowValue ? `<span class="r-val">${escapeHTML(rowValue)}</span>` : ''}</span>` : '';
return `<button type="button" class="rank-glass-row${station && state.selected?.id === station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${valueStack}</button>`;
}).join('');
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
}
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
function fuzzySearchScore(value, query) {
const needle = normalize(query); if (!needle) return 4;
const text = normalize(value); if (text === needle) return 0; if (text.startsWith(needle)) return 1; if (text.includes(needle)) return 2;
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
}
function compactPath(parts) { return parts.filter(Boolean).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
function stationLocationOptions() {
const groups = new Map();
const add = (level, station) => {
const province = station.province || '', city = station.city || '', district = station.district || '';
const key = level === 'province' ? `province|${province}` : level === 'city' ? `city|${province}|${city}` : `district|${province}|${city}|${district}`;
const filter = level === 'province' ? { province, city: '', district: '' } : level === 'city' ? { province, city, district: '' } : { province, city, district };
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
groups.get(key).stations.push(station);
};
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city && !isMunicipalityCity(station.province, station.city)) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
return [...groups.values()].map(option => {
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
const label = option.level === 'district' ? option.district : stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city');
return { ...option, label, path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
});
}
function stationLocationSuggestions(query) {
return stationLocationOptions().map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, query), fuzzySearchScore(option.path, query)) }))
.filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, normalize(query) ? 8 : 6);
}
function stationEntitySuggestions(query) {
if (!normalize(query)) return [];
return filteredStations().map(station => ({ type: 'station', station, score: stationSearchScore(station, query) }))
.filter(item => item.score > 0).sort((left, right) => right.score - left.score || String(left.station.name || '').localeCompare(String(right.station.name || ''), 'zh-CN')).slice(0, 5);
}
function renderStationSearchSuggestions() {
const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return;
state.suggestions = stationEntitySuggestions(state.searchText); state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的站点或详细地址</span>'; return; }
box.innerHTML = `<span class="suggestion-section-label">加氢站</span>${state.suggestions.map((item, index) => { const station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(station.address || stationRegion(station) || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
}
function openStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (!box) return; box.hidden = false; renderStationSearchSuggestions(); }
function closeStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (box) box.hidden = true; state.activeSuggestion = -1; }
function locationFilterLabel(filter) { return filter.label || (filter.district ? stationGroupName({ city: filter.district }, 'city') : filter.city ? stationGroupName({ city: filter.city }, 'city') : stationGroupName({ province: filter.province }, 'province')); }
function renderLocationFilterChips() {
const rail = document.getElementById('selectedLocationFilters'); if (!rail) return;
rail.hidden = !state.locationFilters.length;
rail.innerHTML = state.locationFilters.map(filter => { const key = locationFilterKey(filter), label = locationFilterLabel(filter); return `<button class="selected-location-filter-chip" type="button" onclick="removeLocationFilter('${escapeAttribute(key)}')" aria-label="移除位置筛选 ${escapeAttribute(label)}"><span>${escapeHTML(label)}</span><b aria-hidden="true">×</b></button>`; }).join('');
const count = document.getElementById('regionPickerTriggerCount'); if (count) { count.hidden = !state.locationFilters.length; count.textContent = state.locationFilters.length; }
}
function removeLocationFilter(key) {
state.locationFilters = state.locationFilters.filter(filter => locationFilterKey(filter) !== key);
state.selected = null; closeDetail(); renderLocationFilterChips(); render(); renderStationSearchSuggestions();
document.getElementById('selectedRegionHint').textContent = state.locationFilters.length ? `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}` : '视角: 全国加氢站网络';
}
function addLocationFilter(option) {
const filter = { level: option.level, province: option.province, city: option.city, district: option.district, label: option.label };
if (!state.locationFilters.some(current => locationFilterKey(current) === locationFilterKey(filter))) state.locationFilters.push(filter);
renderLocationFilterChips();
}
function cloneLocationFilter(filter) { return { level: filter.level, province: filter.province || '', city: filter.city || '', district: filter.district || '', label: filter.label || '' }; }
function openRegionPicker() {
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: state.locationFilters.map(cloneLocationFilter), options: [] };
const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = false;
renderRegionPicker(); window.setTimeout(() => document.getElementById('regionPickerSearch')?.focus(), 0);
}
function closeRegionPicker() { state.regionPicker = null; const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = true; }
function regionPickerOptions() {
const picker = state.regionPicker; if (!picker) return [];
const all = stationLocationOptions(); const needle = normalize(picker.search);
if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
if (picker.level === 'province') return all.filter(option => option.level === 'province').sort((left, right) => right.stations.length - left.stations.length || left.label.localeCompare(right.label, 'zh-CN'));
if (picker.level === 'city' && isMunicipalityProvince(picker.province)) return all.filter(option => option.level === 'district' && option.province === picker.province);
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
return all.filter(option => option.level === 'district' && option.province === picker.province && (!picker.city || option.city === picker.city));
}
function regionPickerOptionPath(option) {
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
return `${compactPath(parts)} · ${formatNumber(option.stations.length)} 座站点`;
}
function regionPickerOptionSelected(option) { return state.regionPicker?.draft.some(filter => locationFilterKey(filter) === locationFilterKey(option)); }
function renderRegionPicker() {
const picker = state.regionPicker; if (!picker) return;
const search = document.getElementById('regionPickerSearch'); if (search && search.value !== picker.search) search.value = picker.search;
const clear = document.getElementById('regionPickerSearchClear'); if (clear) clear.hidden = !picker.search;
const selected = document.getElementById('regionPickerSelected');
if (selected) selected.innerHTML = picker.draft.length ? `<span class="region-picker-selected-label">已选</span>${picker.draft.map((filter, index) => `<button type="button" class="region-picker-selected-chip" onclick="removeRegionPickerDraft(${index})">${escapeHTML(locationFilterLabel(filter))}<b aria-hidden="true">×</b></button>`).join('')}` : '<span class="region-picker-selected-placeholder">可同时选择多个省 / 市 / 区县</span>';
const path = document.getElementById('regionPickerPath');
if (path) {
const crumbs = [{ label: '全部省份', level: 'province' }];
if (picker.province) crumbs.push({ label: stationGroupName({ province: picker.province }, 'province'), level: 'city' });
if (picker.city) crumbs.push({ label: stationGroupName({ city: picker.city }, 'city'), level: 'district' });
path.innerHTML = picker.search ? '<span>搜索结果</span>' : crumbs.map((crumb, index) => `<button type="button" class="${index === crumbs.length - 1 ? 'is-current' : ''}" onclick="navigateRegionPicker('${crumb.level}')">${escapeHTML(crumb.label)}</button>`).join('<i></i>');
}
const options = regionPickerOptions(); picker.options = options;
const list = document.getElementById('regionPickerList');
if (list) list.innerHTML = options.length ? options.map((option, index) => {
const selectedOption = regionPickerOptionSelected(option), canDescend = !picker.search && option.level !== 'district';
return `<article class="region-picker-row${selectedOption ? ' is-selected' : ''}"><button type="button" class="region-picker-check" onclick="toggleRegionPickerOption(${index})" aria-label="${selectedOption ? '取消选择' : '选择'} ${escapeAttribute(option.path)}"><span>${selectedOption ? '✓' : ''}</span></button><button type="button" class="region-picker-row-main" onclick="toggleRegionPickerOption(${index})"><strong>${escapeHTML(option.label)}</strong><small>${escapeHTML(regionPickerOptionPath(option))}</small></button>${canDescend ? `<button type="button" class="region-picker-next" onclick="descendRegionPicker(${index})" aria-label="浏览${escapeAttribute(option.label)}下级"></button>` : '<span class="region-picker-next is-empty"></span>'}</article>`;
}).join('') : '<div class="region-picker-empty">没有可选择的运营区域</div>';
const summary = document.getElementById('regionPickerSummary'); if (summary) summary.textContent = `已选 ${picker.draft.length}`;
const confirm = document.querySelector('.region-picker-confirm'); if (confirm) confirm.textContent = picker.draft.length ? `确认(${picker.draft.length}` : '确认';
}
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' && isMunicipalityProvince(option.province) ? 'district' : option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.level === 'province' && isMunicipalityProvince(option.province) ? '' : option.city || ''; renderRegionPicker(); }
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
function confirmRegionPicker() { if (!state.regionPicker) return; state.locationFilters = state.regionPicker.draft.map(cloneLocationFilter); state.query = ''; state.searchText = ''; const input = document.getElementById('stationSearch'); if (input) input.value = ''; const clear = document.getElementById('clearSearch'); if (clear) clear.hidden = true; state.selected = null; closeDetail(); closeStationSearchSuggestions(); renderLocationFilterChips(); closeRegionPicker(); render(); if (state.locationFilters.length) { focusSearchResults(); document.getElementById('selectedRegionHint').textContent = `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}`; } else resetMap(); }
function focusSearchResults() {
const stations = filteredStations(); if (!state.map || !stations.length) return;
const longitudes = stations.map(station => Number(station.longitude)), latitudes = stations.map(station => Number(station.latitude));
const center = [(Math.min(...longitudes) + Math.max(...longitudes)) / 2, (Math.min(...latitudes) + Math.max(...latitudes)) / 2];
const span = Math.max(Math.max(...longitudes) - Math.min(...longitudes), Math.max(...latitudes) - Math.min(...latitudes));
const zoom = stations.length === 1 ? 14 : span < .1 ? 13 : span < .35 ? 11.5 : span < 1 ? 9.5 : 7;
state.map.setZoomAndCenter(zoom, center);
}
function handleStationSearchInput(value) { state.searchText = value; document.getElementById('clearSearch').hidden = !normalize(value); state.activeSuggestion = -1; openStationSearchSuggestions(); window.clearTimeout(state.searchDebounce); state.searchDebounce = window.setTimeout(() => { state.query = value; state.selected = null; closeDetail(); render(); if (normalize(value)) focusSearchResults(); }, 180); }
function selectStationSearchSuggestion(index) {
const item = state.suggestions[index]; if (!item) return;
closeStationSearchSuggestions(); selectStation(item.station, true);
}
function setStationFilter(filter) { if (!['all', 'partner'].includes(filter) || state.stationFilter === filter) return; state.stationFilter = filter; state.selected = null; closeDetail(); document.querySelectorAll('[data-station-filter]').forEach(button => button.classList.toggle('is-active', button.dataset.stationFilter === filter)); render(); renderStationSearchSuggestions(); }
function clearSearch() { window.clearTimeout(state.searchDebounce); document.getElementById('stationSearch').value = ''; state.query = ''; state.searchText = ''; state.locationFilters = []; renderLocationFilterChips(); closeStationSearchSuggestions(); closeDetail(); resetMap(); render(); }
function handleSearchKeydown(event) { if (event.key === 'Escape') { closeStationSearchSuggestions(); event.currentTarget.blur(); return; } if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return; const box = document.getElementById('stationSearchSuggestions'); if (box?.hidden) openStationSearchSuggestions(); if (event.key === 'Enter' && state.activeSuggestion >= 0) { event.preventDefault(); selectStationSearchSuggestion(state.activeSuggestion); return; } if (event.key !== 'Enter') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; state.activeSuggestion = (state.activeSuggestion + direction + state.suggestions.length) % Math.max(state.suggestions.length, 1); renderStationSearchSuggestions(); } }
function selectStation(station, fit = false) {
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
const distance = stationDistance(station), partnerFields = cooperative ? `${detailField('单价', stationUnitPrice(station) || '暂无')}${detailField('联系人', station.contactPerson || '暂无')}${detailPhoneField('联系方式', station.contactPhone)}` : '';
document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${partnerFields}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
}
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
function detailPhoneField(label, phone) { const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, ''); return `<div class="detail-field"><dt>${escapeHTML(label)}</dt><dd>${href ? `<a class="detail-phone-link" href="tel:${escapeAttribute(href)}">${escapeHTML(value)}</a>` : '暂无'}</dd></div>`; }
function stationUnitPrice(station) { const value = Number(station?.unitPrice); return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : ''; }
function stationRegion(station) { return compactPath([station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]')); }
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
function locationFailed(label) { const button = document.getElementById('locateDeviceBtn'); button.disabled = false; button.classList.add('is-error'); document.getElementById('locateDeviceLabel').textContent = label; window.setTimeout(() => { button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = state.userLocation ? '已定位' : '定位'; }, 2600); }
function renderUserMarker() { if (!state.map || !state.userLocation || typeof AMap === 'undefined') return; if (state.userMarker) state.userMarker.remove(); const content = document.createElement('div'); content.className = 'user-location-marker'; state.userMarker = new AMap.Marker({ position: state.userLocation, content, offset: new AMap.Pixel(-9, -9), zIndex: 50 }); state.userMarker.setMap(state.map); }
function stationDistance(station) { return state.userLocation ? distanceKm(state.userLocation, coords(station)) : Number.POSITIVE_INFINITY; }
function distanceKm(origin, target) { const radians = value => value * Math.PI / 180, dLat = radians(target[1] - origin[1]), dLng = radians(target[0] - origin[0]), a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(origin[1])) * Math.cos(radians(target[1])) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'), target = window.open('', '_blank'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => { const url = `https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`; if (target) { target.opener = null; target.location.href = url; } else window.open(url, '_blank', 'noopener'); }, 140); }
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '&#96;'); }
function initClock() { const clock = document.getElementById('clockTime'), update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); }
function wgs84ToGcj02(lng, lat) { if (lng < 72.004 || lng > 137.8347 || lat < .8293 || lat > 55.8271) return [lng, lat]; const transformLat = (x, y) => -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(y*Math.PI)+40*Math.sin(y/3*Math.PI))*2/3 + (160*Math.sin(y/12*Math.PI)+320*Math.sin(y*Math.PI/30))*2/3; const transformLng = (x, y) => 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(x*Math.PI)+40*Math.sin(x/3*Math.PI))*2/3 + (150*Math.sin(x/12*Math.PI)+300*Math.sin(x/30*Math.PI))*2/3; const a = 6378245, ee = .00669342162296594323; let dLat = transformLat(lng-105, lat-35), dLng = transformLng(lng-105, lat-35); const radLat = lat / 180 * Math.PI, magic = 1 - ee * Math.sin(radLat) ** 2, sqrtMagic = Math.sqrt(magic); dLat = dLat * 180 / ((a * (1-ee)) / (magic * sqrtMagic) * Math.PI); dLng = dLng * 180 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; }
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
release_id=${1:?release id is required}
archive=${2:?release archive is required}
root=${STATION_NAVIGATION_ROOT:-/opt/lingniu-station-navigation}
service=${STATION_NAVIGATION_SERVICE:-lingniu-station-navigation.service}
base_url=${STATION_NAVIGATION_BASE_URL:-http://127.0.0.1:20804}
case "$release_id" in
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$release_id" >&2; exit 1 ;;
esac
test -f "$archive"
if ! id station-navigation >/dev/null 2>&1; then
useradd --system --home-dir "$root" --shell /sbin/nologin station-navigation
fi
mkdir -p "$root/releases"
next="$root/releases/$release_id"
test ! -e "$next"
mkdir -p "$next"
while IFS= read -r member; do
case "$member" in
/*|../*|*/../*|*/..) printf 'archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;;
esac
done < <(tar -tzf "$archive")
tar --no-same-owner -xzf "$archive" -C "$next"
test -f "$next/server.py"
test -f "$next/index.html"
chown -R root:station-navigation "$next"
chmod -R u=rwX,g=rX,o= "$next"
ln -s "$next" "$root/current.next"
python3 -c 'import os,sys; os.replace(sys.argv[1],sys.argv[2])' "$root/current.next" "$root/current"
systemctl restart "$service"
for _ in $(seq 1 30); do
if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" | grep -q '"service":"station-navigation"'; then
curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"'
curl -fsS "$base_url/api/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400; forbidden={"hydrogenVolume","fuelingVolume","dailyHydrogenVolume"}; assert not (forbidden & set().union(*(x.keys() for x in d["stations"])))'
printf 'station_navigation_release_install=ok release=%s\n' "$release_id"
exit 0
fi
sleep 1
done
systemctl status "$service" --no-pager >&2 || true
exit 1
@@ -0,0 +1,31 @@
[Unit]
Description=Lingniu Public Hydrogen Station Navigation
After=network-online.target lingniu-vehicle-open-platform.service
Wants=network-online.target
[Service]
Type=simple
User=station-navigation
Group=station-navigation
# The public process needs read-only access to the operations map's shared
# logo and stylesheet. Its own release remains owned by station-navigation.
SupplementaryGroups=vehicle-map
WorkingDirectory=/opt/lingniu-station-navigation/current
# Reuse the operations map's existing Open Platform credential; the public
# service still returns only the station-directory allowlist in server.py.
EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env
Environment=STATION_NAVIGATION_HOST=0.0.0.0
Environment=STATION_NAVIGATION_PORT=20804
Environment=STATION_NAVIGATION_CACHE_SECONDS=120
Environment=VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current
ExecStart=/usr/bin/python3 /opt/lingniu-station-navigation/current/server.py
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>羚牛氢能 - 加氢站导航</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/vehicle-map.css?v=__ASSET_VERSION__">
<link rel="stylesheet" href="styles.css?v=__ASSET_VERSION__">
<script>window._AMapSecurityConfig = { securityJsCode: '0b54a41143bec162788d01deba851340' };</script>
<script src="https://webapi.amap.com/maps?v=2.0&key=1868920ac8ff6b6f88dbe9fa2609c183&plugin=AMap.Scale,AMap.ToolBar,AMap.Marker"></script>
</head>
<body class="theme-white">
<div class="liquid-cockpit-wrapper station-navigation-shell">
<header class="liquid-header floating-glass">
<div class="header-left">
<div class="brand-block"><img class="brand-logo-svg" src="/assets/logo_light.svg" width="150" height="36" alt="羚牛氢能"></div>
<div class="brand-divider"></div>
<div class="cockpit-title-wrap"><h1 class="cockpit-title">加氢站导航</h1></div>
</div>
<div class="header-kpi-group">
<div class="kpi-glass-capsule"><span class="capsule-lbl">站点总数</span><span class="capsule-val" id="kpiTotal">-- <small></small></span></div>
<div class="kpi-glass-capsule primary-capsule"><span class="capsule-lbl">合作站点</span><span class="capsule-val" id="kpiPartner">-- <small></small></span></div>
</div>
<div class="header-controls">
<div class="theme-switcher-bw">
<button class="bw-btn theme-dark-btn" title="深色模式" onclick="setTheme('theme-dark')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>深色</span></button>
<button class="bw-btn theme-white-btn active" title="浅色模式" onclick="setTheme('theme-white')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4m0-15.6-1.4 1.4M5.6 18.4l-1.4 1.4"></path></svg><span>浅色</span></button>
</div>
<div class="time-widget" id="clockTime"></div>
</div>
</header>
<section class="glass-panel station-summary-strip" aria-label="加氢站类型分布"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="mobileStatusTotal">-- <small></small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusPartner">-- <small></small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusExternal">-- <small></small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="mobilePartnerSegment"></div><div class="seg seg-stopped" id="mobileExternalSegment"></div></div></section>
<main class="cockpit-main-grid">
<section class="map-spatial-container floating-glass">
<div class="map-top-bar"><div class="status-glass-pill"><span class="pulse-ring"></span><span id="mapStatusText">正在同步加氢站数据…</span></div></div>
<div class="map-explore-toolbar" aria-label="地图搜索与定位">
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateMe()" aria-label="定位附近站点"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path><circle cx="12" cy="12" r="8"></circle></svg><span id="locateDeviceLabel">定位</span></button>
<button class="region-picker-trigger" id="regionPickerTrigger" type="button" onclick="openRegionPicker()" aria-haspopup="dialog"><span class="region-picker-trigger-icon"></span><span>运营区域</span><b id="regionPickerTriggerCount" hidden>0</b></button>
<div class="explore-search-shell"><label class="entity-search-field" for="stationSearch"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg><input id="stationSearch" type="search" autocomplete="off" placeholder="搜索站点或详细地址" aria-label="搜索站点或详细地址" oninput="handleStationSearchInput(this.value)" onfocus="openStationSearchSuggestions()" onkeydown="handleSearchKeydown(event)"><button class="search-clear-btn" id="clearSearch" type="button" onclick="clearSearch()" aria-label="清除搜索" hidden>×</button></label><div class="explore-suggestions" id="stationSearchSuggestions" role="listbox" aria-label="站点搜索建议" hidden></div></div>
<div class="selected-location-filter-rail" id="selectedLocationFilters" aria-label="已选位置" hidden></div>
<div class="filter-chip-rail" id="filterChipRail" role="group" aria-label="站点类型筛选"><button class="map-filter-chip is-active" type="button" data-station-filter="all" onclick="setStationFilter('all')">全部站点</button><button class="map-filter-chip" type="button" data-station-filter="partner" onclick="setStationFilter('partner')">合作站</button></div>
<span class="filter-result-meta" id="resultMeta" aria-live="polite">全部 0</span>
</div>
<div class="amap-canvas-box">
<div id="amapContainer"></div>
<div class="navigation-launch-overlay" id="navigationOverlay" role="status" aria-live="polite" hidden><div class="navigation-launch-panel"><span class="navigation-launch-spinner"></span><div><strong>正在打开高德地图</strong><span>正在准备导航路线,请稍候</span></div></div></div>
<article class="map-detail-card" id="stationDetail" aria-live="polite" hidden>
<div class="detail-card-accent is-station"></div><div class="detail-card-header"><div class="detail-title-wrap"><span class="detail-type-pill is-station" id="detailType">合作站</span><h2 id="detailName"></h2><p id="detailRegion"></p></div><div class="detail-card-actions"><button class="detail-navigate-btn" id="navigateButton" type="button" onclick="navigateToStation()" aria-label="导航"><svg viewBox="0 0 24 24"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg></button><button class="detail-close-btn" type="button" onclick="closeDetail()" aria-label="关闭详情">×</button></div></div><dl class="detail-grid" id="detailFields"></dl>
</article>
<div class="map-action-controls"><button class="glass-btn" onclick="resetMap()">复位视角</button><button class="glass-btn" id="pitchButton" onclick="toggleMapPitch()">3D/2D 视角</button><button class="glass-btn" onclick="clearSearch()">全国视图</button></div>
</div>
<div class="map-bottom-info"><span>地图引擎: 羚牛氢能 GIS (AMap 3D Engine)</span><span>站点数据服务 · 公开目录</span><span id="selectedRegionHint">视角: 全国加氢站网络</span></div>
</section>
<aside class="sidebar-operations">
<div class="glass-panel sidebar-card station-status-card"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="statusTotal">-- <small></small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="statusPartner">-- <small></small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="statusExternal">-- <small></small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="partnerSegment"></div><div class="seg seg-stopped" id="externalSegment"></div></div></div>
<div class="glass-panel sidebar-card flex-fill-auto"><div class="panel-header"><div class="panel-heading-stack"><span class="panel-title" id="panelRankTitle">加氢站省份 TOP 排名</span><span class="viewport-meta" id="rankViewportMeta"></span></div><div class="glass-tab-control"><button class="gtab active" id="rankPrimaryTab">按站点数</button></div></div><div class="liquid-ranking-list" id="stationList"></div></div>
</aside>
</main>
</div>
<div class="region-picker-backdrop" id="regionPickerModal" role="dialog" aria-modal="true" aria-labelledby="regionPickerTitle" hidden>
<section class="region-picker-dialog">
<header class="region-picker-header"><div><span class="region-picker-eyebrow">加氢站导航</span><h2 id="regionPickerTitle">运营区域选择 <em>多选</em></h2></div><button type="button" class="region-picker-close" onclick="closeRegionPicker()" aria-label="关闭">×</button></header>
<div class="region-picker-selected" id="regionPickerSelected"></div>
<label class="region-picker-search" for="regionPickerSearch"><span></span><input id="regionPickerSearch" type="search" autocomplete="off" placeholder="搜索省 / 市 / 区县" aria-label="搜索省、市或区县" oninput="setRegionPickerSearch(this.value)"><button type="button" id="regionPickerSearchClear" onclick="setRegionPickerSearch('')" aria-label="清除搜索" hidden>×</button></label>
<nav class="region-picker-path" id="regionPickerPath" aria-label="行政区域路径"></nav>
<div class="region-picker-list" id="regionPickerList" aria-live="polite"></div>
<footer class="region-picker-footer"><span id="regionPickerSummary">已选 0 项</span><div><button type="button" class="region-picker-text-button" onclick="clearRegionPickerDraft()">清空</button><button type="button" class="region-picker-confirm" onclick="confirmRegionPicker()">确认</button></div></footer>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
<script src="app.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
"""Public hydrogen station navigation service.
This process deliberately has a smaller API boundary than the operations map:
it retrieves only station directory fields and never exposes vehicle records or
hydrogen-volume fields.
"""
import hashlib
from http.server import HTTPServer, SimpleHTTPRequestHandler
import json
import os
from pathlib import Path
from socketserver import ThreadingMixIn
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent
# Production deliberately reuses the operations map's visual assets. Keeping
# this configurable lets the standalone release live under its own directory.
VEHICLE_MAP_ROOT = Path(os.getenv("VEHICLE_MAP_ASSET_ROOT", str(ROOT.parent / "vehicle-map")))
HOST = os.getenv("STATION_NAVIGATION_HOST", "0.0.0.0")
PORT = int(os.getenv("STATION_NAVIGATION_PORT", "20804"))
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120"))
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
def _static_asset_version():
digest = hashlib.sha256()
for filename in ("app.js", "styles.css"):
digest.update((ROOT / filename).read_bytes())
digest.update((VEHICLE_MAP_ROOT / "styles.css").read_bytes())
return digest.hexdigest()[:16]
STATIC_ASSET_VERSION = _static_asset_version()
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def _json_bytes(value):
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def _post_open_platform(path, body):
if not OPEN_PLATFORM_APP_KEY:
raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
request = Request(
OPEN_PLATFORM_BASE_URL + path,
data=_json_bytes(body),
method="POST",
headers={
"Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "lingniu-station-navigation/1.0",
},
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except HTTPError as exc:
detail = exc.read(2048).decode("utf-8", errors="replace")
raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc
if payload.get("code") != "SUCCESS":
raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}")
return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True).start()
def _cached(key, ttl_seconds, loader):
now = time.time()
refresh_in_background = False
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
with load_lock:
with _cache_lock:
cached = _cache.get(key)
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (time.time(), value)
return value
def _load_station_directory():
"""Use the operations map's GPS-resolved public station directory.
The map service owns reverse-geocoding and its persistent cache, keeping
the public navigation view and the operations view on identical boundaries.
"""
request = Request(
VEHICLE_MAP_INTERNAL_BASE_URL + "/api/stations",
headers={"Accept": "application/json", "User-Agent": "lingniu-station-navigation/1.0"},
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except (HTTPError, URLError, ValueError) as exc:
raise RuntimeError("vehicle map station directory unavailable: {}".format(exc)) from exc
if payload.get("status") != "ok" or not isinstance(payload.get("stations"), list):
raise RuntimeError("vehicle map returned an invalid station directory")
return payload
def _prewarm_station_directory_cache():
"""Fetch the public directory after startup so first navigation is warm."""
try:
_cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc: # The request path will retry without blocking startup.
print(f"station navigation cache warmup deferred: {exc}")
class StationNavigationHandler(SimpleHTTPRequestHandler):
server_version = "LingniuStationNavigation/1.0"
def do_GET(self):
path = urlparse(self.path).path
if path in {"/", "/index.html"}:
self._serve_index()
return
if path == "/api/health":
self._write_json(200, {
"status": "ok",
"service": "station-navigation",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
})
return
if path == "/api/stations":
try:
payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc:
self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, {
"status": "error",
"code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/assets/logo_light.svg":
self._serve_logo()
return
if path == "/assets/vehicle-map.css":
self._serve_vehicle_map_styles()
return
if path.startswith("/api/"):
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
super().do_GET()
def _serve_index(self):
try:
template = (ROOT / "index.html").read_text(encoding="utf-8")
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
except OSError as exc:
self.log_error("index template unavailable: %s", exc)
self.send_error(500, "Station navigation is temporarily unavailable")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_logo(self):
try:
body = (VEHICLE_MAP_ROOT / "logo_light.svg").read_bytes()
except OSError:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "image/svg+xml")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(body)
def _serve_vehicle_map_styles(self):
try:
body = (VEHICLE_MAP_ROOT / "styles.css").read_bytes()
except OSError:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "text/css; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def end_headers(self):
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
self.send_header("X-Frame-Options", "SAMEORIGIN")
super().end_headers()
def _write_json(self, status, payload):
body = _json_bytes(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
# Python 3.6 on ECS predates SimpleHTTPRequestHandler(directory=...).
# Resolve static assets from this release directory before serving.
os.chdir(str(ROOT))
server = ThreadingHTTPServer((HOST, PORT), StationNavigationHandler)
threading.Thread(target=_prewarm_station_directory_cache, daemon=True).start()
print(f"Lingniu Station Navigation listening on http://{HOST}:{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
export STATION_NAVIGATION_HOST="${STATION_NAVIGATION_HOST:-127.0.0.1}"
export STATION_NAVIGATION_PORT="${STATION_NAVIGATION_PORT:-20804}"
exec python3 "$(dirname "$0")/server.py"
+225
View File
@@ -0,0 +1,225 @@
/* Keep the public service visually identical to the main map shell. */
.station-navigation-shell .station-summary-strip { display: none; }
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 8px; }
.station-navigation-shell .station-summary-strip .status-glass-grid3 { margin-bottom: 7px; }
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px 9px; }
.station-navigation-shell .map-filter-chip { min-height: 27px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 8px; background: var(--pill-bg); color: var(--text-muted); font-size: 10px; font-weight: 700; cursor: pointer; }
.station-navigation-shell .map-filter-chip.is-active { border-color: rgba(0,113,67,.24); background: rgba(0,113,67,.1); color: var(--accent-primary); }
.station-navigation-shell .filter-chip-rail { display: flex; gap: 6px; }
.station-navigation-shell .region-picker-trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-main); font-size: 10px; font-weight: 700; white-space: nowrap; cursor: pointer; }
.station-navigation-shell .region-picker-trigger:hover { border-color: rgba(0,113,67,.32); color: var(--accent-primary); }
.station-navigation-shell .region-picker-trigger-icon { color: var(--accent-primary); font-size: 14px; line-height: 1; }
.station-navigation-shell .region-picker-trigger b { display: grid; min-width: 15px; height: 15px; place-items: center; border-radius: 99px; background: var(--accent-primary); color: #fff; font-size: 9px; }
.region-picker-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(15,23,42,.38); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); }
.region-picker-backdrop[hidden] { display: none; }
.region-picker-dialog { display: flex; width: min(500px, 100%); max-height: min(720px, calc(100dvh - 40px)); flex-direction: column; overflow: hidden; border: 1px solid var(--glass-border); border-radius: 18px; background: var(--glass-bg); box-shadow: 0 28px 70px rgba(15,23,42,.28); }
.region-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 17px 12px; border-bottom: 1px solid var(--glass-border); }
.region-picker-eyebrow { display: block; margin-bottom: 3px; color: var(--text-muted); font-size: 9px; font-weight: 700; letter-spacing: .07em; }
.region-picker-header h2 { margin: 0; color: var(--text-main); font-size: 16px; letter-spacing: -.02em; }
.region-picker-header h2 em { margin-left: 5px; color: var(--accent-primary); font-size: 11px; font-style: normal; font-weight: 700; }
.region-picker-close { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-muted); font: 400 20px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
.region-picker-selected { display: flex; min-height: 38px; align-items: center; gap: 6px; overflow-x: auto; padding: 8px 17px; border-bottom: 1px solid var(--glass-border); scrollbar-width: none; }
.region-picker-selected::-webkit-scrollbar { display: none; }
.region-picker-selected-label { flex: 0 0 auto; color: var(--text-muted); font-size: 10px; font-weight: 700; }
.region-picker-selected-placeholder { color: var(--text-muted); font-size: 11px; }
.region-picker-selected-chip { display: inline-flex; min-height: 25px; flex: 0 0 auto; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid rgba(0,113,67,.2); border-radius: 99px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
.region-picker-selected-chip b { font: 700 14px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
.region-picker-search { display: flex; height: 37px; align-items: center; gap: 8px; margin: 12px 17px 9px; padding: 0 10px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--pill-bg); color: var(--text-muted); }
.region-picker-search span { font-size: 17px; line-height: 1; }
.region-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text-main); font: 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.region-picker-search button { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 50%; background: var(--segmented-bg); color: var(--text-muted); font-size: 14px; cursor: pointer; }
.region-picker-search button[hidden] { display: none; }
.region-picker-path { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 0 17px 7px; color: var(--text-muted); font-size: 10px; }
.region-picker-path button { padding: 0; border: 0; background: transparent; color: var(--text-muted); font-size: inherit; cursor: pointer; }
.region-picker-path button.is-current { color: var(--text-main); font-weight: 700; cursor: default; }
.region-picker-path i { color: var(--text-sub); font-style: normal; }
.region-picker-list { min-height: 240px; flex: 1 1 auto; overflow-y: auto; padding: 2px 9px 10px; }
.region-picker-row { display: grid; grid-template-columns: 31px minmax(0, 1fr) 32px; min-height: 54px; align-items: center; border-bottom: 1px solid color-mix(in srgb, var(--glass-border) 72%, transparent); }
.region-picker-row.is-selected { background: rgba(0,113,67,.045); }
.region-picker-check { display: grid; width: 19px; height: 19px; margin-left: 6px; place-items: center; border: 1px solid var(--glass-border); border-radius: 5px; background: var(--pill-bg); color: #fff; font-size: 12px; cursor: pointer; }
.region-picker-row.is-selected .region-picker-check { border-color: var(--accent-primary); background: var(--accent-primary); }
.region-picker-row-main { min-width: 0; padding: 8px 3px; border: 0; background: transparent; color: var(--text-main); text-align: left; cursor: pointer; }
.region-picker-row-main strong { display: block; overflow: hidden; font-size: 12px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.region-picker-row-main small { display: block; overflow: hidden; margin-top: 2px; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.region-picker-next { display: grid; width: 30px; height: 30px; place-items: center; border: 0; background: transparent; color: var(--text-muted); font: 400 22px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
.region-picker-next:hover { color: var(--accent-primary); }
.region-picker-next.is-empty { visibility: hidden; }
.region-picker-empty { display: grid; min-height: 170px; place-items: center; color: var(--text-muted); font-size: 11px; }
.region-picker-footer { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 17px; border-top: 1px solid var(--glass-border); }
.region-picker-footer > span { color: var(--text-muted); font-size: 11px; }
.region-picker-footer > div { display: flex; align-items: center; gap: 8px; }
.region-picker-text-button, .region-picker-confirm { min-height: 32px; padding: 0 12px; border-radius: 8px; font-size: 11px; font-weight: 700; cursor: pointer; }
.region-picker-text-button { border: 1px solid var(--glass-border); background: var(--pill-bg); color: var(--text-muted); }
.region-picker-confirm { border: 1px solid var(--accent-primary); background: var(--accent-primary); color: #fff; }
.station-navigation-shell .selected-location-filter-rail { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: min(360px, 30vw); overflow-x: auto; scrollbar-width: none; }
.station-navigation-shell .selected-location-filter-rail::-webkit-scrollbar { display: none; }
.station-navigation-shell .selected-location-filter-rail[hidden] { display: none; }
.station-navigation-shell .selected-location-filter-chip { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; min-height: 29px; max-width: 132px; padding: 0 8px 0 10px; border: 1px solid rgba(0,113,67,.22); border-radius: 999px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
.station-navigation-shell .selected-location-filter-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.station-navigation-shell .selected-location-filter-chip b { font: 700 15px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
.station-navigation-shell .province-info-card.is-station-point { min-width: 92px; }
.station-navigation-shell .province-info-card.is-station-point .p-total { color: var(--accent-primary); }
.station-navigation-shell .province-info-card.is-external .p-total { color: var(--text-muted); }
.station-navigation-shell .rank-glass-row { cursor: pointer; }
.station-navigation-shell .rank-glass-row.is-selected { background: rgba(0,113,67,.09); border-color: rgba(0,113,67,.2); }
.station-navigation-shell .r-name { display: flex; min-width: 0; align-items: flex-start; gap: 5px; line-height: 1.35; }
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; }
.station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; }
.station-navigation-shell .r-value-stack { display: flex; flex: 0 0 auto; align-items: flex-end; gap: 4px; margin-left: auto; }
.station-navigation-shell .station-list-price { color: var(--accent-primary); font: 700 9px/1 'JetBrains Mono', monospace; letter-spacing: -.03em; white-space: nowrap; }
.station-navigation-shell .detail-phone-link { color: var(--accent-primary); font: inherit; font-weight: 700; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent); text-underline-offset: 2px; }
.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; }
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
@media (max-width: 760px) {
/* Mobile is navigation-first: keep the first screen focused on map,
search and the next action, rather than dashboard chrome. */
.station-navigation-shell .liquid-header {
position: relative;
padding: 9px 10px 10px;
gap: 8px;
}
.station-navigation-shell .header-left {
width: calc(100% - 78px);
gap: 8px;
}
.station-navigation-shell .brand-block { flex-basis: 118px; min-width: 96px; }
.station-navigation-shell .brand-logo-svg { max-width: 118px; }
.station-navigation-shell .brand-divider { height: 18px; }
.station-navigation-shell .cockpit-title { font-size: 14px; }
.station-navigation-shell .header-controls {
position: absolute;
top: 8px;
right: 8px;
width: auto;
order: initial;
}
.station-navigation-shell .theme-switcher-bw { padding: 2px; border-radius: 9px; }
.station-navigation-shell .theme-switcher-bw .bw-btn { display: grid; width: 31px; min-height: 28px; padding: 4px; place-items: center; }
.station-navigation-shell .theme-switcher-bw .bw-btn span { display: none; }
.station-navigation-shell .header-kpi-group,
.station-navigation-shell .station-status-card { display: none; }
.station-navigation-shell .station-summary-strip { display: block; padding: 9px 11px; border-radius: 14px; flex: 0 0 auto; }
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 7px; padding-bottom: 5px; }
.station-navigation-shell .station-summary-strip .status-glass-grid3 { gap: 5px; margin-bottom: 6px; }
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px; }
.station-navigation-shell .map-spatial-container {
height: clamp(430px, 58dvh, 540px);
min-height: clamp(430px, 58dvh, 540px);
}
.station-navigation-shell .map-top-bar { display: none; }
.station-navigation-shell .map-explore-toolbar {
top: 8px;
left: 8px;
right: 8px;
display: grid;
grid-template-columns: 36px max-content minmax(0, 1fr);
gap: 6px;
padding: 5px;
border-radius: 14px;
}
.station-navigation-shell .region-picker-trigger { grid-column: 1 / 3; grid-row: 2; min-height: 30px; padding: 0 9px; border-radius: 9px; }
.station-navigation-shell .explore-search-shell { grid-column: 2 / -1; grid-row: 1; min-width: 0; }
.station-navigation-shell .locate-btn {
grid-column: 1;
grid-row: 1;
flex-basis: 36px;
width: 36px;
min-height: 36px;
border-radius: 10px;
}
.station-navigation-shell .entity-search-field {
height: 36px;
border-radius: 10px;
}
.station-navigation-shell .entity-search-field input { font-size: 12px; }
.station-navigation-shell .selected-location-filter-rail { grid-column: 1 / -1; grid-row: 3; max-width: none; padding: 1px 0 0; }
.station-navigation-shell .filter-chip-rail { grid-column: 3; grid-row: 2; min-width: 0; max-width: none; overflow-x: auto; }
.station-navigation-shell .map-filter-chip { min-height: 30px; padding: 0 8px; font-size: 10px; }
.station-navigation-shell .filter-result-meta { display: none; }
.region-picker-backdrop { align-items: end; padding: 0; }
.region-picker-dialog { width: 100%; max-height: min(82dvh, 700px); border-radius: 20px 20px 0 0; }
.region-picker-header { padding: 14px 15px 10px; }
.region-picker-selected { padding: 8px 15px; }
.region-picker-search { margin: 10px 15px 7px; }
.region-picker-path { padding: 0 15px 6px; }
.region-picker-footer { padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); }
.station-navigation-shell .explore-suggestions {
top: calc(100% + 43px);
left: -42px;
width: calc(100vw - 32px);
max-height: min(280px, calc(100dvh - 250px));
border-radius: 14px;
}
.station-navigation-shell .map-explore-toolbar:has(.selected-location-filter-rail:not([hidden])) .explore-suggestions {
top: calc(100% + 79px);
}
.station-navigation-shell .map-action-controls {
right: 8px;
bottom: 31px;
}
.station-navigation-shell .map-action-controls .glass-btn:nth-child(1),
.station-navigation-shell .map-action-controls .glass-btn:nth-child(2) { display: none; }
.station-navigation-shell .map-action-controls .glass-btn {
min-height: 32px;
padding: 6px 10px;
border-radius: 10px;
font-size: 10px;
}
.station-navigation-shell .map-detail-card {
bottom: 46px;
max-height: min(42dvh, 300px);
padding: 12px;
border-radius: 17px;
}
.station-navigation-shell .detail-title-wrap h2 {
display: -webkit-box;
overflow: hidden;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.station-navigation-shell .detail-navigate-btn,
.station-navigation-shell .detail-close-btn {
width: 32px;
height: 32px;
flex-basis: 32px;
}
.station-navigation-shell .detail-navigate-btn svg { width: 15px; height: 15px; }
.station-navigation-shell .sidebar-operations { gap: 8px; }
.station-navigation-shell .sidebar-card { padding: 10px 11px; border-radius: 14px; }
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
.station-navigation-shell .glass-cell { padding: 6px; }
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
.station-navigation-shell .r-value-stack { flex-direction: column; align-items: flex-end; gap: 3px; }
.station-navigation-shell .station-list-price { font-size: 8px; }
}
@media (max-width: 390px) {
.station-navigation-shell .header-left { width: calc(100% - 76px); }
.station-navigation-shell .brand-block { flex-basis: 104px; min-width: 88px; }
.station-navigation-shell .brand-logo-svg { max-width: 104px; }
.station-navigation-shell .cockpit-title { font-size: 13px; }
.station-navigation-shell .brand-divider { margin-left: -2px; }
}
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const styles = fs.readFileSync(new URL('../styles.css', import.meta.url), 'utf8');
const sandbox = {
console,
document: { addEventListener() {}, getElementById() { return null; } },
window: {},
pinyinPro: { pinyin(value) { return value === '广州合作站' ? 'guang zhou he zuo zhan' : value; } },
navigator: {},
};
sandbox.globalThis = sandbox;
vm.runInNewContext(source, sandbox, { filename: 'app.js' });
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, 'gz'), 2);
assert.equal(sandbox.stationSearchScore({ address: '广东省广州市黄埔区' }, '黄埔'), 3);
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, '北京'), 0);
assert.equal(sandbox.hasCoordinates({ longitude: 113.2, latitude: 23.1 }), true);
assert.equal(sandbox.hasCoordinates({ longitude: 'bad', latitude: 23.1 }), false);
vm.runInNewContext(`
state.stations = [
{ id: 'partner', name: '广州合作站', cooperative: true },
{ id: 'external', name: '广州外部站', cooperative: false }
];
globalThis.allStationIds = filteredStations().map(station => station.id);
state.stationFilter = 'partner';
globalThis.partnerStationIds = filteredStations().map(station => station.id);
`, sandbox);
assert.equal(sandbox.allStationIds.join(','), 'partner,external');
assert.equal(sandbox.partnerStationIds.join(','), 'partner');
vm.runInNewContext(`
state.stations = [
{ id: 'gd-hp', name: '黄埔合作站', province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, cooperative: true },
{ id: 'gd-nh', name: '南海合作站', province: '广东省', city: '佛山市', district: '南海区', longitude: 113.1, latitude: 23.0, cooperative: true },
{ id: 'zj-jx', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', district: '秀洲区', longitude: 120.7, latitude: 30.7, cooperative: true }
];
globalThis.hierarchy = [stationHierarchyLevel(4.8), stationHierarchyLevel(8), stationHierarchyLevel(13)];
globalThis.provinceNodeCount = buildStationNodes(filteredStations(), 'province').length;
globalThis.cityNodeCount = buildStationNodes(filteredStations(), 'city').length;
globalThis.stationNodeCount = buildStationNodes(filteredStations(), 'station').length;
globalThis.locationOptionCount = stationLocationOptions().length;
globalThis.provinceOptionScope = stationLocationOptions().find(option => option.level === 'province' && option.province === '广东省');
globalThis.cityOptionScope = stationLocationOptions().find(option => option.level === 'city' && option.city === '广州市');
`, sandbox);
assert.equal(sandbox.hierarchy.join(','), 'province,city,station');
assert.equal(sandbox.provinceNodeCount, 2);
assert.equal(sandbox.cityNodeCount, 3);
assert.equal(sandbox.stationNodeCount, 3);
assert.equal(sandbox.locationOptionCount, 8);
assert.equal(sandbox.provinceOptionScope.city, '');
assert.equal(sandbox.provinceOptionScope.district, '');
assert.equal(sandbox.cityOptionScope.district, '');
vm.runInNewContext(`
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
globalThis.regionProvinceLabels = regionPickerOptions().map(option => option.label).sort().join(',');
state.regionPicker = { level: 'city', province: '广东省', city: '', search: '', draft: [], options: [] };
globalThis.regionCityLabels = regionPickerOptions().map(option => option.label).sort().join(',');
state.regionPicker = { level: 'province', province: '', city: '', search: '嘉兴', draft: [], options: [] };
globalThis.regionSearchLabels = regionPickerOptions().map(option => option.label).join(',');
`, sandbox);
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
assert.equal(sandbox.regionCityLabels, '佛山,广州');
assert.match(sandbox.regionSearchLabels, /嘉兴/);
vm.runInNewContext(`
state.stations = [
{ id: 'sh-hp', name: '上海黄浦站', province: '上海市', city: '上海市', district: '黄浦区', longitude: 121.5, latitude: 31.2, cooperative: true },
{ id: 'sh-pd', name: '上海浦东站', province: '上海市', city: '上海市', district: '浦东新区', longitude: 121.6, latitude: 31.2, cooperative: true },
{ id: 'gd-gz', name: '广州站', province: '广东省', city: '广州市', district: '天河区', longitude: 113.3, latitude: 23.1, cooperative: true },
{ id: 'zj-hz-1', name: '杭州站一', province: '浙江省', city: '杭州市', district: '西湖区', longitude: 120.1, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-2', name: '杭州站二', province: '浙江省', city: '杭州市', district: '上城区', longitude: 120.2, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-3', name: '杭州站三', province: '浙江省', city: '杭州市', district: '拱墅区', longitude: 120.3, latitude: 30.2, cooperative: true }
];
globalThis.shanghaiCityOption = stationLocationOptions().find(option => option.level === 'city' && option.province === '上海市');
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
globalThis.primaryRegionLabels = regionPickerOptions().map(option => option.label).join(',');
state.regionPicker = { level: 'city', province: '上海市', city: '', search: '', draft: [], options: [] };
globalThis.shanghaiDistrictLabels = regionPickerOptions().map(option => option.label).sort().join(',');
globalThis.shanghaiPath = compactPath(['上海市', '上海市', '黄浦区']);
globalThis.shanghaiRegion = stationRegion(state.stations[0]);
globalThis.directMunicipalityFlags = ['北京市', '上海市', '天津市', '重庆市'].map(isMunicipalityProvince).join(',');
`, sandbox);
assert.equal(sandbox.shanghaiCityOption, undefined);
assert.equal(sandbox.primaryRegionLabels, '浙江,上海,广东');
assert.equal(sandbox.shanghaiDistrictLabels, '浦东新区,黄浦区');
assert.equal(sandbox.shanghaiPath, '上海市 · 黄浦区');
assert.equal(sandbox.shanghaiRegion, '上海市 · 黄浦区');
assert.equal(sandbox.directMunicipalityFlags, 'true,true,true,true');
vm.runInNewContext(`
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
state.locationFilters = [];
state.query = '广州';
globalThis.queryMatches = stationMatchesQuery({ name: '广州合作站' });
globalThis.queryMisses = stationMatchesQuery({ name: '杭州合作站' });
`, sandbox);
assert.equal(sandbox.inferredDistrict, '海盐县');
assert.equal(sandbox.queryMatches, true);
assert.equal(sandbox.queryMisses, false);
vm.runInNewContext(`
state.query = '';
state.locationFilters = [
{ level: 'city', province: '广东省', city: '广州市', district: '', label: '广州' },
{ level: 'city', province: '浙江省', city: '嘉兴市', district: '', label: '嘉兴' }
];
globalThis.multiLocationMatches = [
stationMatchesLocation({ province: '广东省', city: '广州市', district: '白云区' }),
stationMatchesLocation({ province: '浙江省', city: '嘉兴市', district: '海盐县' }),
stationMatchesLocation({ province: '广东省', city: '深圳市', district: '南山区' })
];
`, sandbox);
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
assert.equal(sandbox.stationUnitPrice({ unitPrice: 12.5 }), '¥12.50 / kg');
assert.equal(sandbox.stationUnitPrice({ unitPrice: 0 }), '');
assert.match(sandbox.detailPhoneField('联系方式', '138 0000 0000'), /href="tel:13800000000"/);
vm.runInNewContext(`
const detailNodes = {
detailName: { textContent: '' }, detailRegion: { textContent: '' },
detailType: { textContent: '', classList: { toggle() {} } },
detailFields: { innerHTML: '' }, stationDetail: { hidden: true }
};
document.getElementById = id => detailNodes[id] || null;
state.map = null; state.selected = null;
selectStation({ id: 'partner-detail', name: '合作站', cooperative: true, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, contactPerson: '张工', contactPhone: '138 0000 0000', unitPrice: 12.5 });
globalThis.partnerDetailHtml = detailNodes.detailFields.innerHTML;
selectStation({ id: 'external-detail', name: '外部站', cooperative: false, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.4, latitude: 23.1, contactPerson: '李工', contactPhone: '13900000000', unitPrice: 10.5 });
globalThis.externalDetailHtml = detailNodes.detailFields.innerHTML;
`, sandbox);
assert.match(sandbox.partnerDetailHtml, /张工/);
assert.match(sandbox.partnerDetailHtml, /tel:13800000000/);
assert.match(sandbox.partnerDetailHtml, /¥12\.50 \/ kg/);
assert.doesNotMatch(sandbox.externalDetailHtml, /李工|13900000000|¥10\.50/);
assert.match(source, /zoom < 11\) return 'city'/);
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
assert.match(source, /stationSearchSuggestions/);
assert.match(source, /handleStationSearchInput/);
assert.match(source, /locationFilters/);
assert.match(source, /renderLocationFilterChips/);
assert.match(source, /openRegionPicker/);
assert.match(source, /kpiTotal|kpiPartner/);
const index = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
assert.match(index, /station-summary-strip/);
assert.match(index, /station-status-card/);
assert.equal((index.match(/id="statusTotal"/g) || []).length, 1);
assert.equal((index.match(/id="mobileStatusTotal"/g) || []).length, 1);
assert.match(source, /window\.setTimeout\(\(\) => \{ state\.query = value/);
assert.match(source, /focusSearchResults\(\)/);
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
assert.match(source, /station-list-partner-tag/);
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
assert.match(source, /const rowValue = Number\.isFinite\(distance\)/);
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
assert.match(source, /uri\.amap\.com\/navigation/);
assert.match(source, /window\.open\('', '_blank'\)/);
assert.doesNotMatch(source, /window\.location\.assign/);
assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
assert.match(source, /station-list-price/);
assert.match(styles, /station-summary-strip/);
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
assert.match(styles, /height: clamp\(430px, 58dvh, 540px\)/);
assert.match(styles, /map-action-controls \.glass-btn:nth-child\(2\)/);
console.log('station navigation app tests: ok');
+79
View File
@@ -0,0 +1,79 @@
import importlib.util
import io
import json
from pathlib import Path
import threading
import time
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("station_navigation_server", Path(__file__).parents[1] / "server.py")
server = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(server)
class StationNavigationTest(unittest.TestCase):
def test_directory_is_a_positive_allowlist_without_hydrogen_data(self):
source = {
"status": "ok",
"asOf": "2026-08-12 12:00:00",
"summary": {"totalStations": 1, "cooperativeStations": 1},
"stations": [{
"id": "GD-1", "name": "广州合作站", "province": "广东省", "city": "广州市",
"district": "黄埔区", "address": "开源大道1号", "longitude": 113.2, "latitude": 23.1,
"cooperative": True,
}],
}
with patch.object(server, "urlopen", return_value=io.BytesIO(json.dumps(source).encode("utf-8"))):
result = server._load_station_directory()
self.assertEqual(result["summary"], {"totalStations": 1, "cooperativeStations": 1})
station = result["stations"][0]
self.assertEqual(station["name"], "广州合作站")
self.assertEqual(station["province"], "广东省")
self.assertEqual(server.VEHICLE_MAP_INTERNAL_BASE_URL, "http://127.0.0.1:20800")
def test_static_assets_are_fingerprinted(self):
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
def test_static_assets_resolve_from_the_release_directory(self):
self.assertIn('os.chdir(str(ROOT))', Path(__file__).parents[1].joinpath('server.py').read_text(encoding='utf-8'))
def test_default_cache_window_is_two_minutes(self):
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
def test_cache_reuses_directory_snapshot_within_window(self):
calls = []
with patch.object(server, "_cache", {}):
first = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 1})
second = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 2})
self.assertEqual(calls, ["load"])
self.assertEqual(first, second)
def test_expired_directory_returns_stale_value_while_refreshing_in_background(self):
started = threading.Event()
release = threading.Event()
def loader():
started.set()
release.wait(1)
return {"version": 2}
cache = {"station-directory": (time.time() - 121, {"version": 1})}
with patch.object(server, "_cache", cache), \
patch.object(server, "_cache_refreshing", set()), \
patch.object(server, "_cache_load_locks", {}):
result = server._cached("station-directory", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["station-directory"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["station-directory"][1], {"version": 2})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,115 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath =
"/Users/lingniu/Downloads/智能管车_车辆里程日报-105652096.xlsx";
const input = await FileBlob.load(inputPath);
const workbook = await SpreadsheetFile.importXlsx(input);
const summary = await workbook.inspect({
kind: "workbook,sheet,table",
maxChars: 12000,
tableMaxRows: 8,
tableMaxCols: 40,
tableMaxCellChars: 120,
});
const sheet = workbook.worksheets.getItemAt(0);
const usedRange = sheet.getUsedRange(true);
const rowCount = usedRange?.rowCount ?? 0;
const columnCount = usedRange?.columnCount ?? 0;
const values = usedRange?.values ?? [];
const headers = values[0] ?? [];
const dateStartIndex = 3;
const dateEndIndex = Math.max(dateStartIndex, headers.indexOf("运行时长"));
let positiveDailyCellCount = 0;
let nonZeroVehicleCount = 0;
let noteVehicleCount = 0;
let mismatchVehicleCount = 0;
let totalDailyKm = 0;
let totalColumnKm = 0;
const nonZeroSamples = [];
const noteSamples = [];
for (const row of values.slice(1)) {
const dailyValues = row
.slice(dateStartIndex, dateEndIndex)
.map(numberValue);
const positiveValues = dailyValues.filter((value) => value > 0);
const rowDailyKm = dailyValues.reduce((sum, value) => sum + value, 0);
const rowTotalKm = numberValue(row[2]);
const note = row[dateEndIndex + 1];
positiveDailyCellCount += positiveValues.length;
totalDailyKm += rowDailyKm;
totalColumnKm += rowTotalKm;
if (positiveValues.length > 0) {
nonZeroVehicleCount += 1;
if (nonZeroSamples.length < 20) {
nonZeroSamples.push({
plate: row[0],
organization: row[1],
totalKm: rowTotalKm,
dailyKm: rowDailyKm,
positiveDayCount: positiveValues.length,
});
}
}
if (note !== null && note !== undefined && String(note).trim() !== "") {
noteVehicleCount += 1;
if (noteSamples.length < 20) {
noteSamples.push({ plate: row[0], note });
}
}
if (Math.abs(rowTotalKm - rowDailyKm) > 0.011) {
mismatchVehicleCount += 1;
}
}
const inspectRange = rowCount > 0 && columnCount > 0
? `A1:${columnName(Math.min(columnCount, 40))}${Math.min(rowCount, 15)}`
: "A1:A1";
const preview = await workbook.inspect({
kind: "table",
sheetId: sheet.name,
range: inspectRange,
include: "values,formulas",
maxChars: 25000,
tableMaxRows: 15,
tableMaxCols: 40,
tableMaxCellChars: 120,
});
console.log(JSON.stringify({
inputPath,
sheetName: sheet.name,
rowCount,
columnCount,
dateColumnCount: Math.max(0, dateEndIndex - dateStartIndex),
positiveDailyCellCount,
nonZeroVehicleCount,
noteVehicleCount,
mismatchVehicleCount,
totalDailyKm: round(totalDailyKm),
totalColumnKm: round(totalColumnKm),
nonZeroSamples,
noteSamples,
summary: summary.ndjson,
preview: preview.ndjson,
}, null, 2));
function columnName(columnCount) {
let value = columnCount;
let name = "";
while (value > 0) {
value -= 1;
name = String.fromCharCode(65 + (value % 26)) + name;
value = Math.floor(value / 26);
}
return name;
}
function numberValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function round(value) {
return Math.round(value * 1000) / 1000;
}
@@ -0,0 +1,741 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { once } from "node:events";
import {
FileBlob,
SpreadsheetFile,
Workbook,
} from "@oai/artifact-tool";
const root =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
const workDir = path.join(root, "outputs/g7-mileage-history-20260724");
const dailyDir = path.join(workDir, "raw/daily");
const statisticDir = path.join(workDir, "raw/statistic");
const mergedDir = path.join(workDir, "merged");
const outputXlsx = path.join(
mergedDir,
"G7车辆每日里程及里程统计_20220101-20260720.xlsx",
);
const importCsv = path.join(
mergedDir,
"G7车辆每日里程_20220101-20260720.csv",
);
const manifestPath = path.join(mergedDir, "manifest.json");
const priorMappingCsv = path.join(
root,
"outputs/g7-mileage-import-20260716/import_rows.csv",
);
await fs.mkdir(mergedDir, { recursive: true });
const months = buildMonths();
const dailyFilesByTaskId = await sortedXlsxFiles(dailyDir);
// 2022-02 was the one-file validation task. The remaining formal queue then
// started with 2022-01 and continued from 2022-03, so swap the first two task IDs
// back into natural month order.
const dailyFiles = [
dailyFilesByTaskId[1],
dailyFilesByTaskId[0],
...dailyFilesByTaskId.slice(2),
];
const statisticFiles = await sortedXlsxFiles(statisticDir);
assert(dailyFiles.length === months.length, `日报文件应为 ${months.length},实际 ${dailyFiles.length}`);
assert(
statisticFiles.length === months.length,
`里程统计文件应为 ${months.length},实际 ${statisticFiles.length}`,
);
const vehicles = new Map();
const dateOrder = [];
const dateSource = new Map();
const monthlyDailyTotals = new Map();
const dailyBatchManifest = [];
let dailyPositiveCells = 0;
let dailyZeroCells = 0;
let dailyTotalKm = 0;
let dailyTotalMismatchRows = 0;
let dailyCompletenessNoteRows = 0;
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
const month = months[monthIndex];
const file = dailyFiles[monthIndex];
const values = await readFirstSheetValues(file);
const headers = values[0] ?? [];
const runtimeIndex = headers.indexOf("运行时长");
assert(runtimeIndex > 3, `${path.basename(file)} 缺少运行时长列`);
const dateColumnCount = runtimeIndex - 3;
assert(
dateColumnCount === month.dayCount,
`${path.basename(file)} 日期列应为 ${month.dayCount},实际 ${dateColumnCount}`,
);
const monthDates = [];
for (let day = 1; day <= month.dayCount; day += 1) {
const date = `${month.year}-${pad(month.month)}-${pad(day)}`;
monthDates.push(date);
dateOrder.push(date);
dateSource.set(date, path.basename(file));
}
let batchTotal = 0;
let batchPositive = 0;
let batchZero = 0;
let batchMismatch = 0;
let batchNotes = 0;
const seenPlates = new Set();
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
const row = values[rowIndex] ?? [];
const plate = cleanText(row[0]);
if (!plate) continue;
assert(!seenPlates.has(plate), `${path.basename(file)} 存在重复车牌 ${plate}`);
seenPlates.add(plate);
const organization = cleanText(row[1]);
const statedTotal = numberValue(row[2]);
const dailyValues = row.slice(3, runtimeIndex).map(numberValue);
const calculatedTotal = dailyValues.reduce((sum, value) => sum + value, 0);
if (Math.abs(statedTotal - calculatedTotal) > 0.011) {
dailyTotalMismatchRows += 1;
batchMismatch += 1;
}
const note = cleanText(row[runtimeIndex + 1]);
if (note) {
dailyCompletenessNoteRows += 1;
batchNotes += 1;
}
let vehicle = vehicles.get(plate);
if (!vehicle) {
vehicle = {
plate,
organization,
values: new Map(),
};
vehicles.set(plate, vehicle);
} else if (!vehicle.organization && organization) {
vehicle.organization = organization;
}
for (let dayIndex = 0; dayIndex < dailyValues.length; dayIndex += 1) {
const value = dailyValues[dayIndex];
const date = monthDates[dayIndex];
vehicle.values.set(date, value);
batchTotal += value;
dailyTotalKm += value;
if (value > 0) {
batchPositive += 1;
dailyPositiveCells += 1;
} else {
batchZero += 1;
dailyZeroCells += 1;
}
}
}
monthlyDailyTotals.set(month.key, round(batchTotal));
dailyBatchManifest.push({
type: "车辆里程日报",
month: month.key,
date_from: month.dateFrom,
date_to: month.dateTo,
file: path.basename(file),
vehicle_rows: seenPlates.size,
date_columns: month.dayCount,
positive_cells: batchPositive,
zero_cells: batchZero,
total_km: round(batchTotal),
total_mismatch_rows: batchMismatch,
completeness_note_rows: batchNotes,
});
}
assert(dateOrder.length === 1662, `日期列应为 1662,实际 ${dateOrder.length}`);
assert(new Set(dateOrder).size === dateOrder.length, "日报日期列存在重复");
assert(dateOrder[0] === "2022-01-01", `起始日期异常:${dateOrder[0]}`);
assert(dateOrder.at(-1) === "2026-07-20", `结束日期异常:${dateOrder.at(-1)}`);
const statisticRows = [];
const monthlyStatisticTotals = new Map();
const statisticBatchManifest = [];
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
const month = months[monthIndex];
const file = statisticFiles[monthIndex];
const values = await readFirstSheetValues(file);
const headers = (values[0] ?? []).map(cleanText);
const plateIndex = findHeader(headers, ["车牌号"]);
const orgIndex = findHeader(headers, ["所属机构", "机构"]);
const sourceOrgIndex = findHeader(headers, ["来源机构"]);
const lengthIndex = findHeader(headers, ["车长(米)", "车长"]);
const boxIndex = findHeader(headers, ["厢型"]);
const runtimeIndex = findHeader(headers, ["运行时长"]);
const mileageIndex = findHeader(headers, ["行驶里程(KM)", "行驶里程"]);
assert(plateIndex >= 0, `${path.basename(file)} 缺少车牌号`);
assert(mileageIndex >= 0, `${path.basename(file)} 缺少行驶里程`);
let batchTotal = 0;
let vehicleRows = 0;
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
const row = values[rowIndex] ?? [];
const plate = cleanText(row[plateIndex]);
if (!plate) continue;
const mileage = numberValue(row[mileageIndex]);
batchTotal += mileage;
vehicleRows += 1;
statisticRows.push([
month.key,
plate,
orgIndex >= 0 ? cleanText(row[orgIndex]) : "",
sourceOrgIndex >= 0 ? cleanText(row[sourceOrgIndex]) : "",
lengthIndex >= 0 ? row[lengthIndex] ?? "" : "",
boxIndex >= 0 ? cleanText(row[boxIndex]) : "",
runtimeIndex >= 0 ? cleanText(row[runtimeIndex]) : "",
mileage,
path.basename(file),
]);
}
monthlyStatisticTotals.set(month.key, round(batchTotal));
statisticBatchManifest.push({
type: "车辆里程统计",
month: month.key,
date_from: month.dateFrom,
date_to: month.dateTo,
file: path.basename(file),
vehicle_rows: vehicleRows,
total_km: round(batchTotal),
});
}
const mapping = await readPriorMapping(priorMappingCsv);
const sortedVehicles = [...vehicles.values()].sort((a, b) =>
a.plate.localeCompare(b.plate, "zh-CN"),
);
const mappedVehicles = sortedVehicles.filter((vehicle) => mapping.has(vehicle.plate));
const unmappedVehicles = sortedVehicles.filter((vehicle) => !mapping.has(vehicle.plate));
await writeImportCsv(importCsv, sortedVehicles, dateOrder);
const workbook = Workbook.create();
const summarySheet = workbook.worksheets.add("汇总");
const positiveDailySheet = workbook.worksheets.add("每日里程非零");
const statisticSheet = workbook.worksheets.add("G7里程统计");
const mappingSheet = workbook.worksheets.add("车辆映射");
const batchSheet = workbook.worksheets.add("导出批次");
buildSummarySheet(summarySheet);
await buildPositiveDailySheet(positiveDailySheet);
await buildStatisticSheet(statisticSheet);
await buildMappingSheet(mappingSheet);
await buildBatchSheet(batchSheet);
const summaryInspect = await workbook.inspect({
kind: "table",
sheetId: "汇总",
range: "A1:F25",
include: "values,formulas",
maxChars: 12000,
tableMaxRows: 25,
tableMaxCols: 6,
});
const dailyInspect = await workbook.inspect({
kind: "table",
sheetId: "每日里程非零",
range: "A1:D12",
include: "values,formulas",
maxChars: 12000,
tableMaxRows: 12,
tableMaxCols: 4,
});
const formulaErrors = await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "final formula error scan",
});
assert(!formulaErrors.ndjson.includes('"match"'), "工作簿存在公式错误");
const summaryPreview = await workbook.render({
sheetName: "汇总",
range: "A1:F25",
scale: 1.5,
format: "png",
});
await fs.writeFile(
path.join(mergedDir, "summary-preview.png"),
new Uint8Array(await summaryPreview.arrayBuffer()),
);
const dailyPreview = await workbook.render({
sheetName: "每日里程非零",
range: "A1:D12",
scale: 1.2,
format: "png",
});
await fs.writeFile(
path.join(mergedDir, "daily-preview.png"),
new Uint8Array(await dailyPreview.arrayBuffer()),
);
try {
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputXlsx);
} catch (error) {
console.error(
JSON.stringify(
{
stage: "export_xlsx",
name: error?.name,
message: error?.message,
stack: String(error?.stack ?? "")
.split("\n")
.filter((line) => !line.includes("artifact_tool.mjs:3121"))
.slice(0, 12),
},
null,
2,
),
);
process.exit(1);
}
const monthlyReconciliation = months.map((month) => {
const dailyKm = monthlyDailyTotals.get(month.key) ?? 0;
const statisticKm = monthlyStatisticTotals.get(month.key) ?? 0;
return {
month: month.key,
daily_km: dailyKm,
statistic_km: statisticKm,
difference_km: round(dailyKm - statisticKm),
};
});
const manifest = {
generated_at: new Date().toISOString(),
range: {
date_from: dateOrder[0],
date_to: dateOrder.at(-1),
days: dateOrder.length,
months: months.length,
},
files: {
daily_raw: dailyFiles.map((file) => path.basename(file)),
statistic_raw: statisticFiles.map((file) => path.basename(file)),
workbook: path.basename(outputXlsx),
import_csv: path.basename(importCsv),
},
vehicle_count: sortedVehicles.length,
mapped_vehicle_count: mappedVehicles.length,
unmapped_vehicle_count: unmappedVehicles.length,
vehicle_day_rows: sortedVehicles.length * dateOrder.length,
mapped_vehicle_day_rows: mappedVehicles.length * dateOrder.length,
positive_cells: dailyPositiveCells,
zero_cells: dailyZeroCells,
daily_total_km: round(dailyTotalKm),
daily_total_mismatch_rows: dailyTotalMismatchRows,
completeness_note_rows: dailyCompletenessNoteRows,
monthly_reconciliation: monthlyReconciliation,
daily_batches: dailyBatchManifest,
statistic_batches: statisticBatchManifest,
verification: {
summary_inspect: summaryInspect.ndjson,
daily_inspect: dailyInspect.ndjson,
formula_errors: formulaErrors.ndjson,
},
};
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(
JSON.stringify(
{
outputXlsx,
importCsv,
manifestPath,
vehicleCount: sortedVehicles.length,
mappedVehicleCount: mappedVehicles.length,
unmappedVehicleCount: unmappedVehicles.length,
dateCount: dateOrder.length,
vehicleDayRows: sortedVehicles.length * dateOrder.length,
mappedVehicleDayRows: mappedVehicles.length * dateOrder.length,
positiveCells: dailyPositiveCells,
zeroCells: dailyZeroCells,
totalKm: round(dailyTotalKm),
monthlyDifferenceCount: monthlyReconciliation.filter(
(row) => Math.abs(row.difference_km) > 0.02,
).length,
},
null,
2,
),
);
function buildSummarySheet(sheet) {
sheet.showGridLines = false;
sheet.getRange("A1:F1").merge();
sheet.getRange("A1").values = [["G7 车辆里程全量导出汇总"]];
sheet.getRange("A1:F1").format = {
fill: "#0F766E",
font: { bold: true, color: "#FFFFFF", size: 16 },
horizontalAlignment: "center",
verticalAlignment: "center",
};
sheet.getRange("A1:F1").format.rowHeight = 30;
const metrics = [
["指标", "值", "说明"],
["统计起始日期", new Date(`${dateOrder[0]}T00:00:00+08:00`), "G7 日报"],
["统计结束日期", new Date(`${dateOrder.at(-1)}T00:00:00+08:00`), "含当日"],
["自然日数", dateOrder.length, "连续日期"],
["日报导出批次", dailyFiles.length, "每月 1 份"],
["里程统计导出批次", statisticFiles.length, "每月 1 份"],
["G7 车辆数", sortedVehicles.length, "当前导出车辆列表"],
["已映射 VIN 车辆数", mappedVehicles.length, "沿用现有车辆映射"],
["未映射车辆数", unmappedVehicles.length, "不进入正式导入"],
["映射覆盖率", null, "已映射 / G7 车辆数"],
["车辆-日期单元格", sortedVehicles.length * dateOrder.length, "包含 0 km"],
["可导入车辆-日期行", mappedVehicles.length * dateOrder.length, "包含 0 km"],
["正里程单元格", dailyPositiveCells, "> 0 km"],
["0 km 单元格", dailyZeroCells, "按用户确认保留"],
["日报累计里程(km)", round(dailyTotalKm), "55 份日报求和"],
["日报合计不一致车辆月", dailyTotalMismatchRows, "日报“行驶里程”与逐日求和"],
["完整度说明非空车辆月", dailyCompletenessNoteRows, "来自 G7 日报"],
];
sheet.getRangeByIndexes(2, 0, metrics.length, 3).values = metrics;
sheet.getRange("A3:C3").format = headerFormat();
sheet.getRange("B4:B5").format.numberFormat = "yyyy-mm-dd";
sheet.getRange("B6:B19").format.numberFormat = "#,##0.00";
sheet.getRange("B12").formulas = [["=IFERROR(B10/B9,0)"]];
sheet.getRange("B12").format.numberFormat = "0.0%";
sheet.getRange("A4:A19").format.font = { bold: true, color: "#134E4A" };
sheet.getRange("A3:C19").format.borders = {
preset: "inside",
style: "thin",
color: "#D1D5DB",
};
sheet.getRange("E3:H3").values = [["月份", "日报合计(km)", "里程统计合计(km)", "差异(km)"]];
sheet.getRange("E3:H3").format = headerFormat();
const monthlyRows = months.map((month) => [
month.key,
monthlyDailyTotals.get(month.key) ?? 0,
monthlyStatisticTotals.get(month.key) ?? 0,
null,
]);
sheet.getRangeByIndexes(3, 4, monthlyRows.length, 4).values = monthlyRows;
sheet.getRange("H4").formulas = [["=F4-G4"]];
sheet.getRange(`H4:H${3 + monthlyRows.length}`).fillDown();
sheet.getRange(`F4:H${3 + monthlyRows.length}`).format.numberFormat = "#,##0.00";
sheet.getRange(`E3:H${3 + monthlyRows.length}`).format.borders = {
preset: "inside",
style: "thin",
color: "#E5E7EB",
};
sheet.freezePanes.freezeRows(3);
sheet.getRange("A21:C23").merge();
sheet.getRange("A21").values = [[
"Excel 的“每日里程非零”工作表列出全部正里程记录;未出现的车辆×日期组合按用户确认均为 0 km。含全部 0 km 的逐行明细保存在同目录 CSVG7车辆每日里程_20220101-20260720.csv。",
]];
sheet.getRange("A21:C23").format = {
fill: "#ECFDF5",
font: { color: "#065F46", italic: true },
wrapText: true,
verticalAlignment: "center",
};
sheet.getRange(`A1:H${3 + monthlyRows.length}`).format.font = { name: "Aptos", size: 10 };
sheet.getRange(`A1:A${3 + monthlyRows.length}`).format.columnWidth = 24;
sheet.getRange(`B1:B${3 + monthlyRows.length}`).format.columnWidth = 18;
sheet.getRange(`C1:C${3 + monthlyRows.length}`).format.columnWidth = 30;
sheet.getRange(`D1:D${3 + monthlyRows.length}`).format.columnWidth = 3;
sheet.getRange(`E1:E${3 + monthlyRows.length}`).format.columnWidth = 12;
sheet.getRange(`F1:H${3 + monthlyRows.length}`).format.columnWidth = 18;
}
async function buildPositiveDailySheet(sheet) {
sheet.showGridLines = false;
const headers = ["日期", "车牌号", "机构", "每日里程(km)"];
sheet.getRange("A1:D1").values = [headers];
sheet.getRange("A1:D1").format = headerFormat();
const rows = [];
for (const vehicle of sortedVehicles) {
for (const date of dateOrder) {
const mileage = vehicle.values.get(date) ?? 0;
if (mileage <= 0) continue;
rows.push([
new Date(`${date}T00:00:00+08:00`),
vehicle.plate,
vehicle.organization,
mileage,
]);
}
}
const chunkSize = 1000;
for (let start = 0; start < rows.length; start += chunkSize) {
const chunk = rows.slice(start, start + chunkSize);
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
}
sheet.getRange(`A2:A${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
sheet.getRange(`D2:D${rows.length + 1}`).format.numberFormat = "#,##0.00";
sheet.freezePanes.freezeRows(1);
[14, 16, 30, 18].forEach((width, index) => {
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
});
}
async function buildStatisticSheet(sheet) {
sheet.showGridLines = false;
const headers = [
"月份",
"车牌号",
"所属机构",
"来源机构",
"车长(米)",
"厢型",
"运行时长",
"行驶里程(KM)",
"来源文件",
];
sheet.getRange("A1:I1").values = [headers];
sheet.getRange("A1:I1").format = headerFormat();
const chunkSize = 1000;
for (let start = 0; start < statisticRows.length; start += chunkSize) {
const chunk = statisticRows.slice(start, start + chunkSize);
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
}
sheet.getRange(`E2:E${statisticRows.length + 1}`).format.numberFormat = "0.0";
sheet.getRange(`H2:H${statisticRows.length + 1}`).format.numberFormat = "#,##0.00";
sheet.freezePanes.freezeRows(1);
const widths = [12, 16, 28, 28, 12, 16, 18, 18, 34];
widths.forEach((width, index) => {
sheet.getRangeByIndexes(0, index, statisticRows.length + 1, 1).format.columnWidth = width;
});
}
async function buildMappingSheet(sheet) {
sheet.showGridLines = false;
const headers = ["车牌号", "机构", "VIN", "终端号", "映射状态", "期间里程(km)", "正里程天数", "0 km天数"];
sheet.getRange("A1:H1").values = [headers];
sheet.getRange("A1:H1").format = headerFormat();
const rows = sortedVehicles.map((vehicle) => {
const mapped = mapping.get(vehicle.plate);
const allValues = dateOrder.map((date) => vehicle.values.get(date) ?? 0);
const positiveDays = allValues.filter((value) => value > 0).length;
return [
vehicle.plate,
vehicle.organization,
mapped?.vin ?? "",
mapped?.phone ?? "",
mapped ? "已映射" : "未映射",
round(allValues.reduce((sum, value) => sum + value, 0)),
positiveDays,
dateOrder.length - positiveDays,
];
});
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
sheet.getRange(`F2:F${rows.length + 1}`).format.numberFormat = "#,##0.00";
sheet.getRange(`E2:E${rows.length + 1}`).conditionalFormats.add("containsText", {
text: "未映射",
format: { fill: "#FEE2E2", font: { color: "#991B1B", bold: true } },
});
sheet.freezePanes.freezeRows(1);
[16, 30, 22, 18, 14, 18, 14, 14].forEach((width, index) => {
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
});
}
async function buildBatchSheet(sheet) {
sheet.showGridLines = false;
const headers = [
"类型",
"月份",
"开始日期",
"结束日期",
"来源文件",
"车辆数",
"日期列数",
"正里程单元格",
"0 km 单元格",
"合计里程(km)",
];
sheet.getRange("A1:J1").values = [headers];
sheet.getRange("A1:J1").format = headerFormat();
const rows = [
...dailyBatchManifest.map((batch) => [
batch.type,
batch.month,
new Date(`${batch.date_from}T00:00:00+08:00`),
new Date(`${batch.date_to}T00:00:00+08:00`),
batch.file,
batch.vehicle_rows,
batch.date_columns,
batch.positive_cells,
batch.zero_cells,
batch.total_km,
]),
...statisticBatchManifest.map((batch) => [
batch.type,
batch.month,
new Date(`${batch.date_from}T00:00:00+08:00`),
new Date(`${batch.date_to}T00:00:00+08:00`),
batch.file,
batch.vehicle_rows,
"",
"",
"",
batch.total_km,
]),
];
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
sheet.getRange(`C2:D${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
sheet.getRange(`F2:J${rows.length + 1}`).format.numberFormat = "#,##0.00";
sheet.freezePanes.freezeRows(1);
[18, 12, 14, 14, 36, 12, 12, 18, 18, 18].forEach((width, index) => {
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
});
}
async function readFirstSheetValues(file) {
const input = await FileBlob.load(file);
const workbook = await SpreadsheetFile.importXlsx(input);
const sheet = workbook.worksheets.getItemAt(0);
const usedRange = sheet.getUsedRange(true);
return usedRange?.values ?? [];
}
async function sortedXlsxFiles(directory) {
const names = (await fs.readdir(directory))
.filter((name) => name.endsWith(".xlsx"))
.sort((a, b) => extractTaskId(a) - extractTaskId(b));
return names.map((name) => path.join(directory, name));
}
function extractTaskId(file) {
const match = path.basename(file).match(/-(\d+)\.xlsx$/);
assert(match, `无法从文件名解析任务号:${file}`);
return Number(match[1]);
}
function buildMonths() {
const result = [];
for (let year = 2022, month = 1; year < 2026 || (year === 2026 && month <= 7); ) {
const naturalLastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const lastDay = year === 2026 && month === 7 ? 20 : naturalLastDay;
result.push({
key: `${year}-${pad(month)}`,
year,
month,
dayCount: lastDay,
dateFrom: `${year}-${pad(month)}-01`,
dateTo: `${year}-${pad(month)}-${pad(lastDay)}`,
});
month += 1;
if (month === 13) {
year += 1;
month = 1;
}
}
return result;
}
async function readPriorMapping(csvPath) {
const text = await fs.readFile(csvPath, "utf8");
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
const result = new Map();
for (const line of lines.slice(1)) {
if (!line) continue;
const columns = parseCsvLine(line);
const [vin, plate, phone] = columns;
if (!vin || !plate) continue;
const current = result.get(plate);
if (current && current.vin !== vin) {
result.delete(plate);
continue;
}
result.set(plate, { vin, phone: phone ?? "" });
}
return result;
}
async function writeImportCsv(file, vehicleRows, dates) {
const stream = fsSync.createWriteStream(file, { encoding: "utf8" });
stream.write("\uFEFFplate,date,daily_mileage_km\n");
for (const vehicle of vehicleRows) {
for (const date of dates) {
const value = vehicle.values.get(date) ?? 0;
if (!stream.write(`${csvCell(vehicle.plate)},${date},${numberText(value)}\n`)) {
await once(stream, "drain");
}
}
}
stream.end();
await once(stream, "finish");
}
function headerFormat() {
return {
fill: "#0F766E",
font: { bold: true, color: "#FFFFFF" },
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
borders: { preset: "outside", style: "thin", color: "#115E59" },
};
}
function findHeader(headers, names) {
return headers.findIndex((header) => names.includes(header));
}
function parseCsvLine(line) {
const result = [];
let value = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (quoted) {
if (char === '"' && line[index + 1] === '"') {
value += '"';
index += 1;
} else if (char === '"') {
quoted = false;
} else {
value += char;
}
} else if (char === '"') {
quoted = true;
} else if (char === ",") {
result.push(value);
value = "";
} else {
value += char;
}
}
result.push(value);
return result;
}
function csvCell(value) {
const text = cleanText(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function cleanText(value) {
return value === null || value === undefined ? "" : String(value).trim();
}
function numberValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function numberText(value) {
return String(round(value));
}
function round(value) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function pad(value) {
return String(value).padStart(2, "0");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
+787
View File
@@ -0,0 +1,787 @@
package main
import (
"compress/gzip"
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
)
const (
g7Protocol = "JT808"
g7SourceID = int64(661200)
g7SourceIP = "manual-g7s-excel"
g7SourceCode = "g7s_excel"
g7PlatformName = "G7s Excel Override"
g7QualityReason = "manual_g7s_gps_quarterly_import_20260101_20260630_20260728"
dateFrom = "2026-01-01"
dateTo = "2026-06-30"
expectedDays = 181
)
type mapping struct {
VIN string
Ambiguous bool
}
type sourceStats struct {
Rows int64 `json:"rows"`
Vehicles int `json:"vehicles"`
Dates int `json:"dates"`
Positive int64 `json:"positive_rows"`
Zero int64 `json:"zero_rows"`
TotalKM float64 `json:"total_km"`
MinDate string `json:"min_date"`
MaxDate string `json:"max_date"`
PlateCount int `json:"plate_count"`
}
type stageStats struct {
Rows int64 `json:"rows"`
Vehicles int `json:"vehicles"`
Dates int `json:"dates"`
Positive int64 `json:"positive_rows"`
Zero int64 `json:"zero_rows"`
TotalKM float64 `json:"total_km"`
MappedPlates int `json:"mapped_plates"`
UnmappedPlates int `json:"unmapped_plates"`
AmbiguousPlates int `json:"ambiguous_plates"`
VINCollisionPlates int `json:"vin_collision_plates"`
}
type existingStats struct {
ExistingFinalRows int64 `json:"existing_final_rows"`
NewFinalRows int64 `json:"new_final_rows"`
ChangedExistingFinalRows int64 `json:"changed_existing_final_rows"`
UnchangedExistingFinalRows int64 `json:"unchanged_existing_final_rows"`
OtherSourceFinalRows int64 `json:"other_source_final_rows"`
OtherSelectedCandidateRows int64 `json:"other_selected_candidate_rows"`
ExistingFinalTotalKM float64 `json:"existing_final_total_km"`
}
type result struct {
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
Source sourceStats `json:"source"`
Stage stageStats `json:"stage"`
Existing existingStats `json:"existing"`
UnmappedPlates []string `json:"unmapped_plates"`
AmbiguousPlates []string `json:"ambiguous_plates"`
CollisionPlates []string `json:"vin_collision_plates"`
BackupFinal string `json:"backup_final,omitempty"`
BackupCandidates string `json:"backup_candidates,omitempty"`
FinalMatchedRows int64 `json:"final_matched_rows"`
FinalTotalKM float64 `json:"final_total_km"`
CandidateMatchedRows int64 `json:"candidate_matched_rows"`
OtherSelectedRows int64 `json:"other_selected_rows"`
SourceID int64 `json:"source_id"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var inputPath, mappingPath, dsn, backupDir string
var apply bool
var timeout time.Duration
flag.StringVar(&inputPath, "input", "", "normalized CSV or CSV.gz")
flag.StringVar(&mappingPath, "mapping-csv", "", "optional existing vin,plate mapping CSV")
flag.StringVar(&dsn, "mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "MySQL DSN")
flag.StringVar(&backupDir, "backup-dir", "/tmp/g7-gps-quarterly-backup-20260728", "backup directory for apply")
flag.BoolVar(&apply, "apply", false, "apply changes; default is dry-run")
flag.DurationVar(&timeout, "timeout", 30*time.Minute, "overall timeout")
flag.Parse()
if strings.TrimSpace(inputPath) == "" {
return errors.New("-input is required")
}
if strings.TrimSpace(dsn) == "" {
return errors.New("MYSQL_DSN or -mysql-dsn is required")
}
normalized, err := normalizedDSN(dsn)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := sql.Open("mysql", normalized)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(2)
db.SetMaxIdleConns(2)
if err := db.PingContext(ctx); err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
started := time.Now()
source, inputPlates, err := scanInput(inputPath)
if err != nil {
return err
}
if source.MinDate != dateFrom || source.MaxDate != dateTo || source.Dates != expectedDays {
return fmt.Errorf("unexpected input range: %s..%s (%d dates)", source.MinDate, source.MaxDate, source.Dates)
}
mappings, err := loadMappings(ctx, conn)
if err != nil {
return err
}
if mappingPath != "" {
if err := mergeMappingCSV(mappingPath, mappings); err != nil {
return err
}
}
resolution := resolveInputPlates(inputPlates, mappings)
if err := createStage(ctx, conn); err != nil {
return err
}
stage, err := loadStage(ctx, conn, inputPath, resolution)
if err != nil {
return err
}
stage.MappedPlates = len(resolution.Mapped)
stage.UnmappedPlates = len(resolution.Unmapped)
stage.AmbiguousPlates = len(resolution.Ambiguous)
stage.VINCollisionPlates = len(resolution.Collisions)
if stage.Rows != int64(stage.MappedPlates*expectedDays) {
return fmt.Errorf("stage row count mismatch: got %d, expected %d", stage.Rows, stage.MappedPlates*expectedDays)
}
existing, err := inspectExisting(ctx, conn)
if err != nil {
return err
}
out := result{
Mode: "dry_run",
StartedAt: started.Format(time.RFC3339),
Source: source,
Stage: stage,
Existing: existing,
UnmappedPlates: resolution.Unmapped,
AmbiguousPlates: resolution.Ambiguous,
CollisionPlates: resolution.Collisions,
SourceID: g7SourceID,
}
if apply {
out.Mode = "apply"
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return err
}
out.BackupFinal = filepath.Join(backupDir, "vehicle_daily_mileage_before.csv.gz")
out.BackupCandidates = filepath.Join(backupDir, "vehicle_daily_mileage_source_before.csv.gz")
if err := backupQuery(ctx, conn, out.BackupFinal, `
SELECT m.*
FROM vehicle_daily_mileage m
JOIN tmp_g7_mileage_import s
ON s.vin = m.vin AND s.stat_date = m.stat_date
WHERE m.protocol = 'JT808'
ORDER BY m.vin, m.stat_date`); err != nil {
return fmt.Errorf("backup final rows: %w", err)
}
if err := backupQuery(ctx, conn, out.BackupCandidates, `
SELECT c.*
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
ORDER BY c.vin, c.stat_date, c.source_key`); err != nil {
return fmt.Errorf("backup candidate rows: %w", err)
}
finalRows, finalTotal, candidateRows, otherSelected, err := applyImport(ctx, conn)
if err != nil {
return err
}
out.FinalMatchedRows = finalRows
out.FinalTotalKM = finalTotal
out.CandidateMatchedRows = candidateRows
out.OtherSelectedRows = otherSelected
}
out.CompletedAt = time.Now().Format(time.RFC3339)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(out)
}
type plateResolution struct {
Mapped map[string]string
Unmapped []string
Ambiguous []string
Collisions []string
}
func resolveInputPlates(plates map[string]struct{}, mappings map[string]mapping) plateResolution {
result := plateResolution{Mapped: make(map[string]string)}
vinToPlates := make(map[string][]string)
for plate := range plates {
value, ok := mappings[plate]
switch {
case !ok:
result.Unmapped = append(result.Unmapped, plate)
case value.Ambiguous:
result.Ambiguous = append(result.Ambiguous, plate)
default:
result.Mapped[plate] = value.VIN
vinToPlates[value.VIN] = append(vinToPlates[value.VIN], plate)
}
}
for _, presentPlates := range vinToPlates {
if len(presentPlates) < 2 {
continue
}
for _, plate := range presentPlates {
delete(result.Mapped, plate)
result.Collisions = append(result.Collisions, plate)
}
}
sort.Strings(result.Unmapped)
sort.Strings(result.Ambiguous)
sort.Strings(result.Collisions)
return result
}
func scanInput(inputPath string) (sourceStats, map[string]struct{}, error) {
reader, closeReader, err := openCSV(inputPath)
if err != nil {
return sourceStats{}, nil, err
}
defer closeReader()
csvReader := csv.NewReader(reader)
header, err := csvReader.Read()
if err != nil {
return sourceStats{}, nil, err
}
if len(header) < 3 {
return sourceStats{}, nil, errors.New("input CSV needs plate,date,daily_mileage_km")
}
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
if header[0] != "plate" || header[1] != "date" || header[2] != "daily_mileage_km" {
return sourceStats{}, nil, fmt.Errorf("unexpected CSV header: %v", header)
}
plates := make(map[string]struct{})
dates := make(map[string]struct{})
stats := sourceStats{}
for {
record, readErr := csvReader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return sourceStats{}, nil, readErr
}
if len(record) < 3 {
return sourceStats{}, nil, fmt.Errorf("short CSV row at %d", stats.Rows+2)
}
plate := strings.TrimSpace(record[0])
date := strings.TrimSpace(record[1])
mileage, parseErr := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
if parseErr != nil || mileage < 0 || mileage > 2500 {
return sourceStats{}, nil, fmt.Errorf("invalid mileage at row %d: %q", stats.Rows+2, record[2])
}
if _, parseErr = time.Parse("2006-01-02", date); parseErr != nil {
return sourceStats{}, nil, fmt.Errorf("invalid date at row %d: %q", stats.Rows+2, date)
}
if plate == "" {
return sourceStats{}, nil, fmt.Errorf("empty plate at row %d", stats.Rows+2)
}
plates[plate] = struct{}{}
dates[date] = struct{}{}
stats.Rows++
stats.TotalKM += mileage
if mileage > 0 {
stats.Positive++
} else {
stats.Zero++
}
if stats.MinDate == "" || date < stats.MinDate {
stats.MinDate = date
}
if date > stats.MaxDate {
stats.MaxDate = date
}
}
stats.PlateCount = len(plates)
stats.Vehicles = len(plates)
stats.Dates = len(dates)
stats.TotalKM = round(stats.TotalKM)
return stats, plates, nil
}
func loadMappings(ctx context.Context, conn *sql.Conn) (map[string]mapping, error) {
rows, err := conn.QueryContext(ctx, `
SELECT plate, vin
FROM (
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
FROM vehicle
WHERE plate IS NOT NULL AND TRIM(plate) <> ''
AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
FROM vehicle_identifier
WHERE enabled = 1 AND plate IS NOT NULL AND TRIM(plate) <> ''
AND vin IS NOT NULL AND TRIM(vin) <> ''
) mappings`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]mapping)
for rows.Next() {
var plate, vin string
if err := rows.Scan(&plate, &vin); err != nil {
return nil, err
}
current, ok := result[plate]
if !ok {
result[plate] = mapping{VIN: vin}
} else if current.VIN != vin {
current.Ambiguous = true
result[plate] = current
}
}
return result, rows.Err()
}
func mergeMappingCSV(inputPath string, mappings map[string]mapping) error {
file, err := os.Open(inputPath)
if err != nil {
return err
}
defer file.Close()
reader := csv.NewReader(file)
header, err := reader.Read()
if err != nil {
return err
}
if len(header) < 2 {
return errors.New("mapping CSV must contain vin,plate")
}
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
if header[0] != "vin" || header[1] != "plate" {
return fmt.Errorf("unexpected mapping CSV header: %v", header[:2])
}
for {
record, readErr := reader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return readErr
}
if len(record) < 2 {
continue
}
vin := strings.TrimSpace(record[0])
plate := strings.TrimSpace(record[1])
if vin == "" || plate == "" {
continue
}
current, ok := mappings[plate]
if !ok {
mappings[plate] = mapping{VIN: vin}
} else if current.VIN != vin {
current.Ambiguous = true
mappings[plate] = current
}
}
return nil
}
func createStage(ctx context.Context, conn *sql.Conn) error {
_, err := conn.ExecContext(ctx, `
DROP TEMPORARY TABLE IF EXISTS tmp_g7_mileage_import`)
if err != nil {
return err
}
_, err = conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_g7_mileage_import (
vin varchar(32) NOT NULL,
plate varchar(32) NOT NULL,
stat_date date NOT NULL,
daily_mileage_km decimal(18,3) NOT NULL,
PRIMARY KEY (vin, stat_date),
KEY idx_tmp_plate (plate),
KEY idx_tmp_date (stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
return err
}
func loadStage(ctx context.Context, conn *sql.Conn, inputPath string, resolution plateResolution) (stageStats, error) {
reader, closeReader, err := openCSV(inputPath)
if err != nil {
return stageStats{}, err
}
defer closeReader()
csvReader := csv.NewReader(reader)
if _, err := csvReader.Read(); err != nil {
return stageStats{}, err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return stageStats{}, err
}
defer tx.Rollback()
type stageRow struct {
VIN, Plate, Date string
Mileage float64
}
batch := make([]stageRow, 0, 1000)
flush := func() error {
if len(batch) == 0 {
return nil
}
var query strings.Builder
query.WriteString("INSERT INTO tmp_g7_mileage_import (vin, plate, stat_date, daily_mileage_km) VALUES ")
args := make([]any, 0, len(batch)*4)
for index, row := range batch {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?)")
args = append(args, row.VIN, row.Plate, row.Date, row.Mileage)
}
if _, execErr := tx.ExecContext(ctx, query.String(), args...); execErr != nil {
return execErr
}
batch = batch[:0]
return nil
}
for {
record, readErr := csvReader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return stageStats{}, readErr
}
plate := strings.TrimSpace(record[0])
vin, ok := resolution.Mapped[plate]
if !ok {
continue
}
mileage, _ := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
batch = append(batch, stageRow{
VIN: vin, Plate: plate, Date: strings.TrimSpace(record[1]), Mileage: mileage,
})
if len(batch) == cap(batch) {
if err := flush(); err != nil {
return stageStats{}, err
}
}
}
if err := flush(); err != nil {
return stageStats{}, err
}
if err := tx.Commit(); err != nil {
return stageStats{}, err
}
var stats stageStats
var nullableSum sql.NullFloat64
err = conn.QueryRowContext(ctx, `
SELECT COUNT(*), COUNT(DISTINCT vin), COUNT(DISTINCT stat_date),
SUM(daily_mileage_km > 0), SUM(daily_mileage_km = 0),
SUM(daily_mileage_km)
FROM tmp_g7_mileage_import`).Scan(
&stats.Rows, &stats.Vehicles, &stats.Dates, &stats.Positive, &stats.Zero, &nullableSum,
)
if nullableSum.Valid {
stats.TotalKM = round(nullableSum.Float64)
}
return stats, err
}
func inspectExisting(ctx context.Context, conn *sql.Conn) (existingStats, error) {
var stats existingStats
var nullableSum sql.NullFloat64
err := conn.QueryRowContext(ctx, `
SELECT
SUM(m.vin IS NOT NULL),
SUM(m.vin IS NULL),
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) > 0.0005),
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005),
SUM(m.vin IS NOT NULL AND COALESCE(m.source_id, 0) <> ?),
SUM(CASE WHEN m.vin IS NOT NULL THEN m.daily_mileage_km ELSE 0 END)
FROM tmp_g7_mileage_import s
LEFT JOIN vehicle_daily_mileage m
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = 'JT808'`, g7SourceID).Scan(
&stats.ExistingFinalRows,
&stats.NewFinalRows,
&stats.ChangedExistingFinalRows,
&stats.UnchangedExistingFinalRows,
&stats.OtherSourceFinalRows,
&nullableSum,
)
if err != nil {
return stats, err
}
if nullableSum.Valid {
stats.ExistingFinalTotalKM = round(nullableSum.Float64)
}
err = conn.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808' AND c.is_selected = 1
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')`).Scan(
&stats.OtherSelectedCandidateRows,
)
return stats, err
}
func applyImport(ctx context.Context, conn *sql.Conn) (int64, float64, int64, int64, error) {
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return 0, 0, 0, 0, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
SET c.is_selected = 0
WHERE c.protocol = 'JT808'
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected <> 0`); err != nil {
return 0, 0, 0, 0, fmt.Errorf("clear other selected candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage_source (
vin, stat_date, protocol, source_key, source_ip, source_endpoint,
platform_name, first_total_mileage_km, latest_total_mileage_km,
daily_mileage_km, sample_count, first_event_time, latest_event_time,
quality_status, quality_reason, is_selected
)
SELECT
s.vin, s.stat_date, 'JT808',
CONCAT('JT808:', s.vin, '@PLATFORM:g7s_excel'),
'manual-g7s-excel', 'manual-g7s-excel',
'G7s Excel Override', NULL, NULL,
s.daily_mileage_km, 1,
TIMESTAMP(s.stat_date, '00:00:00'),
TIMESTAMP(s.stat_date, '23:59:59'),
'OK', ?, 1
FROM tmp_g7_mileage_import s
ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
platform_name = VALUES(platform_name),
first_total_mileage_km = NULL,
latest_total_mileage_km = NULL,
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
is_selected = 1`, g7QualityReason); err != nil {
return 0, 0, 0, 0, fmt.Errorf("upsert G7 candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage (
vin, stat_date, protocol, source_id, daily_mileage_km,
latest_total_mileage_km, updated_at
)
SELECT vin, stat_date, 'JT808', ?, daily_mileage_km, NULL, NOW()
FROM tmp_g7_mileage_import
ON DUPLICATE KEY UPDATE
source_id = VALUES(source_id),
daily_mileage_km = VALUES(daily_mileage_km),
latest_total_mileage_km = NULL,
updated_at = NOW()`, g7SourceID); err != nil {
return 0, 0, 0, 0, fmt.Errorf("upsert final mileage: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_data_source
SET latest_seen_at = GREATEST(COALESCE(latest_seen_at, '2026-06-30 23:59:59'), '2026-06-30 23:59:59'),
first_seen_at = LEAST(COALESCE(first_seen_at, '2026-01-01 00:00:00'), '2026-01-01 00:00:00'),
latest_source_endpoint = 'manual-g7s-excel',
platform_name = 'G7s Excel Override',
source_code = 'g7s_excel',
source_kind = 'PLATFORM',
trust_priority = 0,
enabled = 1,
remark = 'Manual GPS override imported from G7s quarterly daily mileage reports for 2026-01-01 through 2026-06-30 on 2026-07-28'
WHERE id = ? AND protocol = 'JT808' AND source_ip = 'manual-g7s-excel'`, g7SourceID); err != nil {
return 0, 0, 0, 0, fmt.Errorf("update G7 source metadata: %w", err)
}
var finalRows, candidateRows, otherSelected int64
var finalSum sql.NullFloat64
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*), SUM(m.daily_mileage_km)
FROM vehicle_daily_mileage m
JOIN tmp_g7_mileage_import s
ON s.vin = m.vin AND s.stat_date = m.stat_date
WHERE m.protocol = 'JT808' AND m.source_id = ?
AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005`, g7SourceID).Scan(
&finalRows, &finalSum,
); err != nil {
return 0, 0, 0, 0, err
}
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
AND c.source_key = CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected = 1
AND ABS(c.daily_mileage_km - s.daily_mileage_km) <= 0.0005`).Scan(&candidateRows); err != nil {
return 0, 0, 0, 0, err
}
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected = 1`).Scan(&otherSelected); err != nil {
return 0, 0, 0, 0, err
}
var stageRows int64
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tmp_g7_mileage_import`).Scan(&stageRows); err != nil {
return 0, 0, 0, 0, err
}
if finalRows != stageRows || candidateRows != stageRows || otherSelected != 0 {
return 0, 0, 0, 0, fmt.Errorf(
"verification failed before commit: stage=%d final=%d candidates=%d other_selected=%d",
stageRows, finalRows, candidateRows, otherSelected,
)
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, 0, err
}
return finalRows, round(finalSum.Float64), candidateRows, otherSelected, nil
}
func backupQuery(ctx context.Context, conn *sql.Conn, outputPath, query string) error {
rows, err := conn.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return err
}
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
csvWriter := csv.NewWriter(gzipWriter)
defer csvWriter.Flush()
if err := csvWriter.Write(columns); err != nil {
return err
}
raw := make([]sql.RawBytes, len(columns))
pointers := make([]any, len(columns))
for index := range raw {
pointers[index] = &raw[index]
}
for rows.Next() {
if err := rows.Scan(pointers...); err != nil {
return err
}
record := make([]string, len(columns))
for index, value := range raw {
if value == nil {
record[index] = "\\N"
} else {
record[index] = string(value)
}
}
if err := csvWriter.Write(record); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return err
}
if err := gzipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func openCSV(inputPath string) (io.Reader, func(), error) {
file, err := os.Open(inputPath)
if err != nil {
return nil, nil, err
}
closeReader := func() { _ = file.Close() }
if !strings.HasSuffix(strings.ToLower(inputPath), ".gz") {
return file, closeReader, nil
}
gzipReader, err := gzip.NewReader(file)
if err != nil {
file.Close()
return nil, nil, err
}
return gzipReader, func() {
_ = gzipReader.Close()
_ = file.Close()
}, nil
}
func normalizedDSN(raw string) (string, error) {
config, err := mysql.ParseDSN(raw)
if err != nil {
return "", err
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return "", err
}
config.ParseTime = true
config.Loc = location
config.Timeout = 10 * time.Second
config.ReadTimeout = 30 * time.Minute
config.WriteTimeout = 30 * time.Minute
config.Params = cloneParams(config.Params)
config.Params["charset"] = "utf8mb4"
return config.FormatDSN(), nil
}
func cloneParams(input map[string]string) map[string]string {
output := make(map[string]string, len(input)+1)
for key, value := range input {
output[key] = value
}
return output
}
func round(value float64) float64 {
return float64(int64(value*100+0.5)) / 100
}
@@ -0,0 +1,66 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
panic("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
defer db.Close()
for _, table := range []string{
"vehicle_data_source",
"vehicle_daily_mileage_source",
"vehicle_daily_mileage",
"vehicle",
"vehicle_identifier",
} {
var name, ddl string
if err := db.QueryRow("SHOW CREATE TABLE "+table).Scan(&name, &ddl); err != nil {
panic(err)
}
fmt.Printf("=== %s ===\n%s\n", name, ddl)
}
rows, err := db.Query("SELECT * FROM vehicle_data_source WHERE id = 661200")
if err != nil {
panic(err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
panic(err)
}
if rows.Next() {
values := make([]any, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
if err := rows.Scan(pointers...); err != nil {
panic(err)
}
record := make(map[string]any, len(columns))
for i, column := range columns {
switch value := values[i].(type) {
case []byte:
record[column] = string(value)
default:
record[column] = value
}
}
data, _ := json.Marshal(record)
fmt.Printf("=== source_661200 ===\n%s\n", data)
}
}
@@ -0,0 +1,41 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程及里程统计_20220101-20260720.xlsx";
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
const sheets = await workbook.inspect({
kind: "sheet",
include: "id,name",
maxChars: 4000,
});
const summary = await workbook.inspect({
kind: "table",
sheetId: "汇总",
range: "A1:H25",
include: "values,formulas",
maxChars: 12000,
tableMaxRows: 25,
tableMaxCols: 8,
});
const daily = await workbook.inspect({
kind: "table",
sheetId: "每日里程非零",
range: "A1:D12",
include: "values,formulas",
maxChars: 6000,
tableMaxRows: 12,
tableMaxCols: 4,
});
const errors = await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "post-export formula error scan",
});
console.log(JSON.stringify({
inputPath,
sheets: sheets.ndjson,
summary: summary.ndjson,
daily: daily.ndjson,
errors: errors.ndjson,
}, null, 2));
@@ -0,0 +1,364 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const taskDir = process.env.GB32960_TASK_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gb32960-export-20260817";
const outputDir = process.env.GB32960_OUTPUT_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377";
const data = JSON.parse(await fs.readFile(path.join(taskDir, "raw-frames.json"), "utf8"));
const exportDate = data.query.dateFrom.slice(0, 10);
const outputPath = path.join(outputDir, `${data.query.plate}_${exportDate}_GB32960原始数据_中文字段.xlsx`);
const categoryNames = {
alarm: "报警数据",
drive_motor: "驱动电机数据",
engine: "发动机数据",
extreme: "极值数据",
fuel_cell: "燃料电池数据",
gd_fc_air_conditioner: "广东燃料电池空调数据",
gd_fc_auxiliary: "广东燃料电池辅助系统数据",
gd_fc_dcdc: "广东燃料电池 DC/DC 数据",
gd_fc_demo_extension: "广东燃料电池演示扩展数据",
gd_fc_stack: "广东燃料电池电堆数据",
gd_fc_vehicle_info: "广东燃料电池车辆信息数据",
gd_fc_vendor_tlv: "广东燃料电池厂家 TLV 扩展",
header: "报文头",
platform: "平台信息",
position: "车辆位置数据",
temperature: "可充电储能装置温度数据",
vehicle: "整车数据",
voltage: "可充电储能装置电压数据",
};
const fieldNames = {
battery_faults: "可充电储能装置故障码",
engine_faults: "发动机故障码",
general_alarm_flag: "通用报警标志",
max_alarm_level: "最高报警等级",
motor_faults: "驱动电机故障码",
other_faults: "其他故障码",
type: "数据单元类型",
count: "数量",
controller_current_a: "控制器直流母线电流",
controller_temperature_c: "控制器温度",
controller_voltage_v: "控制器输入电压",
motor_temperature_c: "电机温度",
serial_no: "驱动电机序号",
speed_rpm: "转速",
state: "状态",
torque_nm: "转矩",
engine_status: "发动机状态",
fuel_rate: "燃料消耗率",
max_temp_c: "最高温度",
max_temp_probe_no: "最高温度探针序号",
max_temp_subsystem_no: "最高温度子系统号",
max_voltage_cell_no: "最高电压单体序号",
max_voltage_subsystem_no: "最高电压子系统号",
max_voltage_v: "最高电压",
min_temp_c: "最低温度",
min_temp_probe_no: "最低温度探针序号",
min_temp_subsystem_no: "最低温度子系统号",
min_voltage_cell_no: "最低电压单体序号",
min_voltage_subsystem_no: "最低电压子系统号",
min_voltage_v: "最低电压",
dc_dc_status: "高压 DC/DC 状态",
fuel_cell_current_a: "燃料电池电流",
fuel_cell_voltage_v: "燃料电池电压",
hydrogen_consumption_kg_per_100km: "氢耗",
max_hydrogen_concentration_fraction: "最高氢浓度比例",
max_hydrogen_concentration_percent: "最高氢浓度",
max_hydrogen_concentration_ppm: "最高氢浓度原始值",
max_hydrogen_concentration_probe_id: "最高氢浓度探针编号",
max_hydrogen_pressure_mpa: "最高氢压力",
max_hydrogen_pressure_probe_id: "最高氢压探针编号",
max_hydrogen_temperature_c: "氢系统最高温度",
max_hydrogen_temperature_probe_id: "最高氢温探针编号",
temperature_probe_count: "温度探针数量",
temperature_probe_values_c: "温度探针值列表",
status: "状态",
subsystem_count: "子系统数量",
air_compressor_motor_voltage_v: "空气压缩机电机电压",
air_compressor_power_kw: "空气压缩机功率",
low_voltage_battery_voltage_v: "低压蓄电池电压",
water_pump_voltage_v: "水泵电压",
controller_temp_c: "控制器温度",
input_current_a: "输入电流",
input_voltage_v: "输入电压",
output_current_a: "输出电流",
output_voltage_v: "输出电压",
air_compressor_current_a: "空气压缩机电流",
air_compressor_voltage_v: "空气压缩机电压",
declared_length: "声明长度",
hydrogen_pump_current_a: "氢气循环泵电流",
hydrogen_pump_voltage_v: "氢气循环泵电压",
stack_temp_c: "电堆温度",
air_inlet_pressure_kpa: "空气入口压力",
avg_cell_voltage_v: "单体平均电压",
cell_count: "单体总数",
engine_work_state: "发动机工作状态",
frame_cell_voltages_v: "本帧单体电压列表",
hydrogen_inlet_pressure_kpa: "氢气入口压力",
max_cell_voltage_id: "最高电压单体编号",
max_cell_voltage_v: "单体最高电压",
min_cell_voltage_id: "最低电压单体编号",
min_cell_voltage_v: "单体最低电压",
stack_count: "电堆数量",
stack_water_outlet_temp_c: "电堆出水温度",
collision_alarm: "碰撞报警",
hydrogen_mass_kg: "车载氢量",
payload_hex: "原始载荷(十六进制)",
actual_body_length: "实际报文体长度",
body_length: "报文体长度",
command: "命令标识",
encrypt: "加密方式",
response_flag: "应答标志",
version: "协议版本",
vin: "车辆识别代号",
platform_name: "平台名称",
latitude: "纬度",
longitude: "经度",
position_status: "定位状态标志",
accelerator_pct: "加速踏板行程",
brake_pct: "制动踏板状态",
charge_status: "充电状态",
gear: "挡位原始值",
insulation_kohm: "绝缘电阻",
running_mode: "运行模式",
soc_percent: "SOC",
speed_kmh: "车速",
total_current_a: "总电流",
total_mileage_km: "累计里程",
total_voltage_v: "总电压",
vehicle_status: "车辆状态",
max_cell_v: "单体最高电压",
min_cell_v: "单体最低电压",
};
const unitBySuffix = {
controller_current_a: "A", controller_voltage_v: "V", controller_temperature_c: "℃",
motor_temperature_c: "℃", speed_rpm: "rpm", torque_nm: "N·m", fuel_rate: "L/100km",
max_temp_c: "℃", min_temp_c: "℃", max_voltage_v: "V", min_voltage_v: "V",
fuel_cell_current_a: "A", fuel_cell_voltage_v: "V", hydrogen_consumption_kg_per_100km: "kg/100km",
max_hydrogen_concentration_fraction: "比例", max_hydrogen_concentration_percent: "%", max_hydrogen_concentration_ppm: "ppm",
max_hydrogen_pressure_mpa: "MPa", max_hydrogen_temperature_c: "℃", temperature_probe_values_c: "℃",
air_compressor_motor_voltage_v: "V", air_compressor_power_kw: "kW", low_voltage_battery_voltage_v: "V", water_pump_voltage_v: "V",
controller_temp_c: "℃", input_current_a: "A", input_voltage_v: "V", output_current_a: "A", output_voltage_v: "V",
air_compressor_current_a: "A", air_compressor_voltage_v: "V", hydrogen_pump_current_a: "A", hydrogen_pump_voltage_v: "V", stack_temp_c: "℃",
air_inlet_pressure_kpa: "kPa", avg_cell_voltage_v: "V", frame_cell_voltages_v: "V", hydrogen_inlet_pressure_kpa: "kPa",
max_cell_voltage_v: "V", min_cell_voltage_v: "V", stack_water_outlet_temp_c: "℃", hydrogen_mass_kg: "kg",
latitude: "°", longitude: "°", accelerator_pct: "%", brake_pct: "%", insulation_kohm: "kΩ", soc_percent: "%",
speed_kmh: "km/h", total_current_a: "A", total_mileage_km: "km", total_voltage_v: "V", max_cell_v: "V", min_cell_v: "V",
};
const valueMappings = {
"gb32960.vehicle.vehicle_status": { "1": "启动", "2": "熄火", "3": "其他", "254": "异常", "255": "无效" },
"gb32960.vehicle.charge_status": { "1": "停车充电", "2": "行驶充电", "3": "未充电", "4": "充电完成", "254": "异常", "255": "无效" },
"gb32960.vehicle.running_mode": { "1": "纯电", "2": "混合动力", "3": "燃料电池", "254": "异常", "255": "无效" },
"gb32960.vehicle.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
"gb32960.fuel_cell.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
"gb32960.drive_motor.motors.motor_1.state": { "1": "耗电", "2": "发电", "3": "关闭", "4": "准备", "254": "异常", "255": "无效" },
"gb32960.alarm.max_alarm_level": { "0": "无故障", "1": "一级故障", "2": "二级故障", "3": "三级故障" },
"gb32960.gd_fc_air_conditioner.status": { "0": "关闭", "1": "启动", "254": "异常", "255": "无效" },
"gb32960.gd_fc_vehicle_info.collision_alarm": { "0": "无碰撞报警", "1": "有碰撞报警", "254": "异常", "255": "无效" },
"gb32960.position.position_status": {
"0": "有效定位 · 北纬 · 东经", "1": "无效定位 · 北纬 · 东经", "2": "有效定位 · 南纬 · 东经", "3": "无效定位 · 南纬 · 东经",
"4": "有效定位 · 北纬 · 西经", "5": "无效定位 · 北纬 · 西经", "6": "有效定位 · 南纬 · 西经", "7": "无效定位 · 南纬 · 西经",
},
};
function fieldParts(key) {
const parts = key.split(".");
const category = parts[1] ?? "";
const suffix = parts.at(-1) ?? key;
const motorMatch = key.match(/\.motors\.motor_(\d+)\./);
const subsystemMatch = key.match(/\.subsystems\.subsystem_(\d+)\./);
const scope = motorMatch ? `电机${motorMatch[1]}-` : subsystemMatch ? `子系统${subsystemMatch[1]}-` : "";
const categoryName = categoryNames[category] ?? "GB/T 32960 扩展数据";
const label = fieldNames[suffix];
if (!label) throw new Error(`missing Chinese field mapping for ${key}`);
const unit = unitBySuffix[suffix] ?? "";
return { category, categoryName, suffix, label: `${categoryName}-${scope}${label}${unit ? `${unit}` : ""}`, unit };
}
function displayValue(key, value) {
if (value == null || value === "") return null;
const raw = String(value);
const mapped = valueMappings[key]?.[raw];
if (mapped) return `${mapped}${raw}`;
if (/^0x[0-9a-f]+$/i.test(raw)) return `十六进制 ${raw}`;
if (/^-?\d+(?:\.\d+)?$/.test(raw) && !key.endsWith(".vin")) return Number(raw);
return raw;
}
function mappingText(key) {
const mapping = valueMappings[key];
return mapping ? Object.entries(mapping).map(([value, label]) => `${value}=${label}`).join("") : "";
}
function columnName(index) {
let value = index + 1;
let out = "";
while (value > 0) {
const remainder = (value - 1) % 26;
out = String.fromCharCode(65 + remainder) + out;
value = Math.floor((value - 1) / 26);
}
return out;
}
const fieldKeys = data.fieldKeys;
const mappedFields = fieldKeys.map((key) => ({ key, ...fieldParts(key) }));
const metadata = [
["序号", (item, index) => index + 1],
["车牌", () => data.query.plate],
["VIN", (item) => item.vin],
["协议", (item) => item.protocol],
["设备时间(北京时间)", (item) => item.event_time],
["接收时间(北京时间)", (item) => item.received_at],
["存储时间(北京时间)", (item) => item.ts],
["消息ID", (item) => item.message_id],
["消息ID(十六进制)", (item) => item.message_id_hex ? `十六进制 ${item.message_id_hex}` : null],
["帧ID", (item) => item.frame_id],
["事件ID", (item) => item.event_id],
["解析状态", (item) => item.parse_status],
["解析错误", (item) => item.parse_error ?? null],
["来源端点", (item) => item.source_endpoint],
["原始报文字节数", (item) => item.raw_size_bytes],
["车辆标识", (item) => item.vehicle_key],
["手机号", (item) => item.phone ?? null],
["设备ID", (item) => item.device_id ?? null],
["原始文本", (item) => item.raw_text ?? null],
["原始报文(十六进制)", (item) => item.raw_hex ?? null],
];
const workbook = Workbook.create();
const summary = workbook.worksheets.add("导出说明");
const detail = workbook.worksheets.add("完整数据");
const mapping = workbook.worksheets.add("字段映射");
summary.showGridLines = false;
summary.getRange("A1:F1").merge();
summary.getRange("A1").values = [[`${data.query.plate} · ${exportDate} · GB/T 32960 原始数据导出`]];
summary.getRange("A1:F1").format = { fill: "#163A5F", font: { bold: true, color: "#FFFFFF", size: 16 }, verticalAlignment: "center" };
summary.getRange("A1:F1").format.rowHeight = 32;
summary.getRange("A3:B15").values = [
["车牌", data.query.plate],
["VIN", data.query.vin],
["协议", data.query.protocol],
["查询日期", `${exportDate}(北京时间)`],
["查询时间窗", `${data.query.dateFrom}${data.query.dateTo}`],
["接口统计总帧数", data.verification.apiTotal],
["实际导出帧数", data.verification.fetchedRows],
["唯一帧ID数", data.verification.uniqueFrameIds],
["唯一事件ID数", data.verification.uniqueEventIds],
["首条存储时间", data.verification.earliestTs],
["末条存储时间", data.verification.latestTs],
["解析字段数", data.verification.parsedFieldCount],
["消息类型统计", Object.entries(data.verification.messageCounts).map(([key, count]) => `${key}: ${count}`).join("")],
];
summary.getRange("A3:A15").format = { fill: "#DCE8F2", font: { bold: true, color: "#163A5F" } };
summary.getRange("A3:B15").format.borders = { preset: "inside", style: "thin", color: "#C8D4E0" };
summary.getRange("A17:F17").merge();
summary.getRange("A17").values = [["口径说明"]];
summary.getRange("A17:F17").format = { fill: "#2B6F91", font: { bold: true, color: "#FFFFFF" } };
summary.getRange("A18:F22").merge(true);
summary.getRange("A18:F22").values = [
[`1. 数据源为生产实时 API 的 GB32960 原始帧历史;导出保留原始报文十六进制、帧/事件标识、解析状态及全部 ${data.verification.parsedFieldCount} 个已出现的解析字段。`],
["2. 完整数据工作表使用中文字段名;字段映射工作表保留原始字段键、单位及状态值映射,便于审计和程序回溯。"],
["3. 状态类字段显示为“中文含义(协议值)”;普通测量值保持数值,列表/位图/十六进制载荷保持原始文本。"],
[`4. 查询边界为北京时间 ${data.query.dateFrom}${data.query.dateTo};该车辆实际存储时间覆盖 ${data.verification.earliestTs}${data.verification.latestTs}`],
["5. 中文术语参考:项目 GB/T 32960 字段展示基准及【字段】32960+广东燃料电池协议.jsonSHA-256: 0f0847b86cc692f2130255babbee37e6cb3cf36d717b18d6698164289f66eb82)。"],
];
summary.getRange("A18:F22").format = { wrapText: true, verticalAlignment: "top", font: { color: "#334155", size: 10 } };
summary.getRange("A18:F22").format.rowHeight = 34;
summary.getRange("A:A").format.columnWidth = 22;
summary.getRange("B:F").format.columnWidth = 18;
summary.getRange("B:B").format.columnWidth = 78;
const detailHeaders = [...metadata.map(([label]) => label), ...mappedFields.map((field) => field.label)];
const detailRows = data.items.map((item, index) => [
...metadata.map(([, getter]) => getter(item, index)),
...mappedFields.map(({ key }) => displayValue(key, item.parsed_fields?.[key])),
]);
const detailMatrix = [detailHeaders, ...detailRows];
const detailEndColumn = columnName(detailHeaders.length - 1);
detail.getRangeByIndexes(0, 0, detailMatrix.length, detailHeaders.length).values = detailMatrix;
detail.showGridLines = false;
detail.freezePanes.freezeRows(1);
detail.freezePanes.freezeColumns(3);
const dataTable = detail.tables.add(`A1:${detailEndColumn}${detailMatrix.length}`, true, "GB32960DataTable");
dataTable.style = "TableStyleMedium2";
dataTable.showFilterButton = true;
dataTable.showBandedColumns = false;
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format = {
fill: "#1F4E78", font: { bold: true, color: "#FFFFFF", size: 9 }, wrapText: true,
horizontalAlignment: "center", verticalAlignment: "center",
};
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format.rowHeight = 48;
detail.getRangeByIndexes(1, 0, detailRows.length, detailHeaders.length).format.font = { size: 9, color: "#1F2937" };
detail.getRangeByIndexes(0, 0, detailMatrix.length, 1).format.columnWidth = 8;
detail.getRangeByIndexes(0, 1, detailMatrix.length, 1).format.columnWidth = 13;
detail.getRangeByIndexes(0, 2, detailMatrix.length, 1).format.columnWidth = 22;
for (const col of [3, 7, 8, 11, 14]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 14;
for (const col of [4, 5, 6]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 21;
for (const col of [9, 10]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 36;
for (const col of [12, 13, 15, 16, 17]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 20;
detail.getRangeByIndexes(0, 18, detailMatrix.length, 1).format.columnWidth = 28;
detail.getRangeByIndexes(0, 19, detailMatrix.length, 1).format.columnWidth = 44;
for (let col = metadata.length; col < detailHeaders.length; col += 1) {
const key = mappedFields[col - metadata.length].key;
const wide = key.includes("voltages_v") || key.endsWith("faults") || key.endsWith("payload_hex");
detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = wide ? 38 : 18;
}
detail.getRangeByIndexes(1, 0, detailRows.length, 1).format.numberFormat = "0";
detail.getRangeByIndexes(1, 7, detailRows.length, 1).format.numberFormat = "0";
detail.getRangeByIndexes(1, 14, detailRows.length, 1).format.numberFormat = "0";
mapping.showGridLines = false;
mapping.freezePanes.freezeRows(1);
const mappingHeaders = ["序号", "中文字段名", "原始字段键", "数据类别", "单位", "状态值映射", "说明"];
const mappingRows = mappedFields.map((field, index) => [
index + 1,
field.label,
field.key,
field.categoryName,
field.unit,
mappingText(field.key),
valueMappings[field.key] ? "完整数据中显示中文含义并保留括号内协议值。" : "值按协议解析结果原样导出;数值字段保持数值类型。",
]);
mapping.getRangeByIndexes(0, 0, mappingRows.length + 1, mappingHeaders.length).values = [mappingHeaders, ...mappingRows];
const mappingTable = mapping.tables.add(`A1:G${mappingRows.length + 1}`, true, "GB32960FieldMappingTable");
mappingTable.style = "TableStyleMedium2";
mapping.getRange("A1:G1").format = { fill: "#1F4E78", font: { bold: true, color: "#FFFFFF" }, wrapText: true, horizontalAlignment: "center" };
mapping.getRange("A1:G1").format.rowHeight = 30;
mapping.getRange("A:A").format.columnWidth = 8;
mapping.getRange("B:B").format.columnWidth = 42;
mapping.getRange("C:C").format.columnWidth = 58;
mapping.getRange("D:D").format.columnWidth = 30;
mapping.getRange("E:E").format.columnWidth = 12;
mapping.getRange("F:F").format.columnWidth = 68;
mapping.getRange("G:G").format.columnWidth = 48;
mapping.getRange(`B2:G${mappingRows.length + 1}`).format.wrapText = true;
await fs.mkdir(outputDir, { recursive: true });
const summaryInspect = await workbook.inspect({ kind: "table", range: "导出说明!A1:F22", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 8 });
const detailInspect = await workbook.inspect({ kind: "table", range: "完整数据!A1:Z8", include: "values,formulas", tableMaxRows: 8, tableMaxCols: 26 });
const mappingInspect = await workbook.inspect({ kind: "table", range: "字段映射!A1:G16", include: "values,formulas", tableMaxRows: 16, tableMaxCols: 8 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" });
await fs.writeFile(path.join(taskDir, "summary-inspect.ndjson"), summaryInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "detail-inspect.ndjson"), detailInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "mapping-inspect.ndjson"), mappingInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "formula-errors.ndjson"), errors.ndjson, "utf8");
for (const [sheetName, range, filename] of [
["导出说明", "A1:F22", "preview-summary.png"],
["完整数据", "A1:T18", "preview-detail.png"],
["字段映射", "A1:G28", "preview-mapping.png"],
]) {
const preview = await workbook.render({ sheetName, range, scale: 1.25, format: "png" });
await fs.writeFile(path.join(taskDir, filename), new Uint8Array(await preview.arrayBuffer()));
}
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
await xlsx.save(outputPath);
console.log(JSON.stringify({ outputPath, sheets: ["导出说明", "完整数据", "字段映射"], rows: detailRows.length, parsedFields: mappedFields.length, columns: detailHeaders.length }, null, 2));
@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import path from "node:path";
const baseUrl = process.env.GB32960_BASE_URL || "http://115.29.187.205:20200";
const vin = process.env.GB32960_VIN || "LB9A32A24R0LS1720";
const plate = process.env.GB32960_PLATE || "粤AGP4377";
const exportDate = process.env.GB32960_DATE || "2026-08-17";
const dateFrom = `${exportDate} 00:00:00`;
const dateTo = `${exportDate} 23:59:59`;
const limit = 500;
const outputPath = process.env.GB32960_DATA_PATH || new URL("./raw-frames.json", import.meta.url).pathname;
async function fetchPage(offset, includeTotal = false) {
const params = new URLSearchParams({
protocol: "GB32960",
vin,
dateFrom,
dateTo,
orderBy: "eventTime",
limit: String(limit),
offset: String(offset),
includeFields: "true",
includePayload: "true",
includeTotal: includeTotal ? "true" : "false",
});
const response = await fetch(`${baseUrl}/api/history/raw-frames?${params}`, {
headers: { "Accept-Encoding": "gzip" },
signal: AbortSignal.timeout(60000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
const first = await fetchPage(0, true);
const total = Number(first.total ?? 0);
const offsets = [];
for (let offset = limit; offset < total; offset += limit) offsets.push(offset);
const remaining = await Promise.all(offsets.map((offset) => fetchPage(offset)));
const items = [...(first.items ?? []), ...remaining.flatMap((page) => page.items ?? [])];
const frameIds = new Set(items.map((item) => item.frame_id));
const eventIds = new Set(items.map((item) => item.event_id));
const fieldKeys = [...new Set(items.flatMap((item) => Object.keys(item.parsed_fields ?? {})))].sort();
const messageCounts = Object.fromEntries(
[...items.reduce((map, item) => map.set(item.message_id_hex, (map.get(item.message_id_hex) ?? 0) + 1), new Map())]
.sort(([a], [b]) => a.localeCompare(b)),
);
if (items.length !== total) throw new Error(`row count mismatch: fetched=${items.length} total=${total}`);
if (frameIds.size !== items.length) throw new Error(`duplicate frame_id: unique=${frameIds.size} rows=${items.length}`);
if (eventIds.size !== items.length) throw new Error(`duplicate event_id: unique=${eventIds.size} rows=${items.length}`);
for (const item of items) {
if (item.protocol !== "GB32960" || item.vin !== vin) throw new Error(`scope mismatch at ${item.frame_id}`);
if (item.ts < dateFrom || item.ts > dateTo) throw new Error(`time out of range at ${item.frame_id}: ${item.ts}`);
}
const output = {
exportedAt: new Date().toISOString(),
source: `${baseUrl}/api/history/raw-frames`,
query: { plate, vin, protocol: "GB32960", dateFrom, dateTo, orderBy: "eventTime" },
verification: {
apiTotal: total,
fetchedRows: items.length,
uniqueFrameIds: frameIds.size,
uniqueEventIds: eventIds.size,
earliestTs: items.reduce((min, item) => !min || item.ts < min ? item.ts : min, ""),
latestTs: items.reduce((max, item) => !max || item.ts > max ? item.ts : max, ""),
parsedFieldCount: fieldKeys.length,
messageCounts,
},
fieldKeys,
items,
};
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, JSON.stringify(output), "utf8");
console.log(JSON.stringify({ outputPath, ...output.verification }, null, 2));
@@ -0,0 +1,15 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const workbookPath = process.env.GB32960_WORKBOOK_PATH || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377/粤AGP4377_2026-08-17_GB32960完整数据_中文字段.xlsx";
const lastDataRow = Number(process.env.GB32960_LAST_ROW || "1233");
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 3000 });
const summary = await workbook.inspect({ kind: "table", range: "导出说明!A3:B15", include: "values,formulas", tableMaxRows: 20, tableMaxCols: 3 });
const detail = await workbook.inspect({ kind: "table", range: "完整数据!A1:T6", include: "values,formulas", tableMaxRows: 6, tableMaxCols: 20 });
const tail = await workbook.inspect({ kind: "table", range: `完整数据!A${lastDataRow - 4}:F${lastDataRow}`, include: "values,formulas", tableMaxRows: 6, tableMaxCols: 6 });
const mapping = await workbook.inspect({ kind: "table", range: "字段映射!A1:G10", include: "values,formulas", tableMaxRows: 10, tableMaxCols: 7 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reopened workbook formula error scan" });
const output = { sheets: sheets.ndjson, summary: summary.ndjson, detail: detail.ndjson, tail: tail.ndjson, mapping: mapping.ndjson, errors: errors.ndjson };
await fs.writeFile(new URL("./reopen-verification.json", import.meta.url), JSON.stringify(output, null, 2), "utf8");
console.log(JSON.stringify({ workbookPath, sizeBytes: (await fs.stat(workbookPath)).size, output }, null, 2));
@@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const sourceDir =
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
const workDir =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
const names = (await fs.readdir(sourceDir))
.filter((name) => name.endsWith(".xlsx"))
.sort((a, b) => a.localeCompare(b, "zh-CN"));
const manifest = [];
for (const name of names) {
const filePath = path.join(sourceDir, name);
const input = await FileBlob.load(filePath);
const workbook = await SpreadsheetFile.importXlsx(input);
const sheet = workbook.worksheets.getItemAt(0);
const usedRange = sheet.getUsedRange(true);
const values = usedRange?.values ?? [];
const rowCount = values.length;
const colCount = values.reduce((max, row) => Math.max(max, row.length), 0);
const topRows = values.slice(0, 8).map((row) =>
row.slice(0, Math.min(colCount, 40)).map((value) => {
if (value instanceof Date) return value.toISOString();
return value ?? null;
}),
);
const preview = await workbook.render({
sheetName: sheet.name,
range: `A1:${columnName(Math.min(Math.max(colCount, 1), 40))}${Math.min(
Math.max(rowCount, 1),
15,
)}`,
scale: 1,
format: "png",
});
const previewName = `${String(manifest.length + 1).padStart(2, "0")}-${safeName(
name,
)}.png`;
await fs.writeFile(
path.join(workDir, previewName),
new Uint8Array(await preview.arrayBuffer()),
);
manifest.push({
name,
sheet: sheet.name,
rowCount,
colCount,
firstRow: topRows[0] ?? [],
topRows,
preview: previewName,
});
}
await fs.writeFile(
path.join(workDir, "source-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
console.log(
JSON.stringify(
manifest.map(({ name, sheet, rowCount, colCount, firstRow, preview }) => ({
name,
sheet,
rowCount,
colCount,
firstRow,
preview,
})),
null,
2,
),
);
function safeName(name) {
return name
.replace(/\.xlsx$/i, "")
.replace(/[^\p{L}\p{N}._-]+/gu, "_")
.slice(0, 100);
}
function columnName(index) {
let value = index;
let result = "";
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result || "A";
}
@@ -0,0 +1,378 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const sourceDir =
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
const workDir =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
const priorCsv =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程_20220101-20260720.csv";
const normalizedCsv = path.join(
workDir,
"G7_GPS车辆每日里程_20251231-20260630.csv",
);
const combinedCsv = path.join(
workDir,
"G7_GPS车辆每日里程_合并优先季度统计_20251231-20260630.csv",
);
const statisticCsv = path.join(
workDir,
"G7_GPS季度统计每日里程_20260101-20260630.csv",
);
const dailyNames = [
"智能管车_车辆里程日报-091555330.xlsx",
"智能管车_车辆里程日报-090938059.xlsx",
"智能管车_车辆里程日报-091002580.xlsx",
"智能管车_车辆里程日报-091020404.xlsx",
"智能管车_车辆里程日报-091030619.xlsx",
"智能管车_车辆里程日报-091046118.xlsx",
"智能管车_车辆里程日报-091058580.xlsx",
];
const statisticNames = [
"里程统计[天][2026-01-01至2026-03-31] (1).xlsx",
"里程统计[天][2026-01-01至2026-03-31].xlsx",
"里程统计[天][2026-04-01至2026-06-30] (1).xlsx",
"里程统计[天][2026-04-01至2026-06-30].xlsx",
];
const rows = [];
const newByKey = new Map();
const dailyFiles = [];
for (const name of dailyNames) {
const values = await readFirstSheetValues(path.join(sourceDir, name));
const headers = values[0] ?? [];
const runtimeIndex = headers.indexOf("运行时长");
assert(runtimeIndex > 3, `${name} 缺少运行时长列`);
const dateHeaders = headers.slice(3, runtimeIndex);
const dates = dateHeaders.map((header) => parseHeaderDate(header));
const seenPlates = new Set();
let statedTotalKm = 0;
let calculatedTotalKm = 0;
let mismatchRows = 0;
for (const row of values.slice(1)) {
const plate = cleanText(row?.[0]);
if (!plate) continue;
assert(!seenPlates.has(plate), `${name} 存在重复车牌 ${plate}`);
seenPlates.add(plate);
const stated = numberValue(row?.[2], 10_000_000);
const dailyValues = row
.slice(3, runtimeIndex)
.map((value) => numberValue(value));
const calculated = dailyValues.reduce((sum, value) => sum + value, 0);
statedTotalKm += stated;
calculatedTotalKm += calculated;
if (Math.abs(stated - calculated) > 0.011) mismatchRows += 1;
for (let index = 0; index < dates.length; index += 1) {
const date = dates[index];
const mileage = dailyValues[index];
const key = `${plate}|${date}`;
assert(!newByKey.has(key), `日报重复车辆日 ${key}`);
const normalized = { plate, date, mileage };
newByKey.set(key, normalized);
rows.push(normalized);
}
}
dailyFiles.push({
name,
vehicleCount: seenPlates.size,
dateFrom: dates[0],
dateTo: dates.at(-1),
dateCount: dates.length,
rowCount: seenPlates.size * dates.length,
statedTotalKm: round(statedTotalKm),
calculatedTotalKm: round(calculatedTotalKm),
mismatchRows,
});
}
rows.sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
normalizedCsv,
`\uFEFFplate,date,daily_mileage_km\n${rows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const statisticByKey = new Map();
const statisticFiles = [];
for (const name of statisticNames) {
const values = await readFirstSheetValues(path.join(sourceDir, name));
const headers = (values[1] ?? []).map(cleanText);
const plateIndex = headers.indexOf("车牌号码");
const methodIndex = headers.indexOf("里程计算方式");
const dateIndex = headers.indexOf("日期");
const mileageIndex = headers.indexOf("里程(km");
assert(
[plateIndex, methodIndex, dateIndex, mileageIndex].every((index) => index >= 0),
`${name} 表头不完整`,
);
const fileKeys = new Set();
const methods = new Set();
let totalKm = 0;
for (const row of values.slice(2)) {
const plate = cleanText(row?.[plateIndex]);
const date = isoDate(row?.[dateIndex]);
if (!plate || !date) continue;
const method = cleanText(row?.[methodIndex]);
const mileage = numberValue(row?.[mileageIndex]);
const key = `${plate}|${date}`;
assert(!fileKeys.has(key), `${name} 文件内重复车辆日 ${key}`);
assert(!statisticByKey.has(key), `季度统计跨文件重复车辆日 ${key}`);
fileKeys.add(key);
statisticByKey.set(key, { plate, date, mileage, method, name });
methods.add(method);
totalKm += mileage;
}
statisticFiles.push({
name,
rowCount: fileKeys.size,
vehicleCount: new Set([...fileKeys].map((key) => key.split("|")[0])).size,
dateCount: new Set([...fileKeys].map((key) => key.split("|")[1])).size,
methods: [...methods].sort(),
totalKm: round(totalKm),
});
}
let statisticMatched = 0;
let statisticMissingInDaily = 0;
let statisticChanged = 0;
let statisticAbsoluteDifferenceKm = 0;
let statisticMaxDifferenceKm = 0;
const statisticExamples = [];
for (const [key, statistic] of statisticByKey) {
const daily = newByKey.get(key);
if (!daily) {
statisticMissingInDaily += 1;
continue;
}
statisticMatched += 1;
const difference = daily.mileage - statistic.mileage;
const absolute = Math.abs(difference);
statisticAbsoluteDifferenceKm += absolute;
statisticMaxDifferenceKm = Math.max(statisticMaxDifferenceKm, absolute);
if (absolute > 0.011) {
statisticChanged += 1;
if (statisticExamples.length < 20) {
statisticExamples.push({
key,
dailyKm: daily.mileage,
statisticKm: statistic.mileage,
differenceKm: round(difference),
});
}
}
}
const statisticRows = [...statisticByKey.values()].sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
statisticCsv,
`\uFEFFplate,date,daily_mileage_km\n${statisticRows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const combinedByKey = new Map(newByKey);
for (const [key, statistic] of statisticByKey) {
combinedByKey.set(key, {
plate: statistic.plate,
date: statistic.date,
mileage: statistic.mileage,
});
}
const combinedRows = [...combinedByKey.values()].sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
combinedCsv,
`\uFEFFplate,date,daily_mileage_km\n${combinedRows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const priorByKey = await loadPriorRange(
priorCsv,
"2025-12-31",
"2026-06-30",
);
let priorUnchanged = 0;
let priorChanged = 0;
let priorMissing = 0;
let priorExtra = 0;
let priorTotalDifferenceKm = 0;
let priorMaxDifferenceKm = 0;
const priorExamples = [];
for (const [key, row] of newByKey) {
const prior = priorByKey.get(key);
if (!prior) {
priorMissing += 1;
continue;
}
const difference = row.mileage - prior.mileage;
const absolute = Math.abs(difference);
priorTotalDifferenceKm += difference;
priorMaxDifferenceKm = Math.max(priorMaxDifferenceKm, absolute);
if (absolute <= 0.0005) {
priorUnchanged += 1;
} else {
priorChanged += 1;
if (priorExamples.length < 20) {
priorExamples.push({
key,
newKm: row.mileage,
priorKm: prior.mileage,
differenceKm: round(difference),
});
}
}
}
for (const key of priorByKey.keys()) {
if (!newByKey.has(key)) priorExtra += 1;
}
const result = {
normalizedCsv,
combinedCsv,
statisticCsv,
source: {
rowCount: rows.length,
vehicleCount: new Set(rows.map((row) => row.plate)).size,
dateCount: new Set(rows.map((row) => row.date)).size,
dateFrom: rows.reduce(
(min, row) => (!min || row.date < min ? row.date : min),
"",
),
dateTo: rows.reduce((max, row) => (row.date > max ? row.date : max), ""),
positiveRows: rows.filter((row) => row.mileage > 0).length,
zeroRows: rows.filter((row) => row.mileage === 0).length,
totalKm: round(rows.reduce((sum, row) => sum + row.mileage, 0)),
files: dailyFiles,
},
quarterlyReconciliation: {
rowCount: statisticByKey.size,
matchedRows: statisticMatched,
missingInDailyRows: statisticMissingInDaily,
changedRowsOver0011Km: statisticChanged,
absoluteDifferenceKm: round(statisticAbsoluteDifferenceKm),
maxDifferenceKm: round(statisticMaxDifferenceKm),
examples: statisticExamples,
files: statisticFiles,
},
combinedImport: {
rowCount: combinedRows.length,
vehicleCount: new Set(combinedRows.map((row) => row.plate)).size,
dateCount: new Set(combinedRows.map((row) => row.date)).size,
dateFrom: combinedRows.reduce(
(min, row) => (!min || row.date < min ? row.date : min),
"",
),
dateTo: combinedRows.reduce(
(max, row) => (row.date > max ? row.date : max),
"",
),
positiveRows: combinedRows.filter((row) => row.mileage > 0).length,
zeroRows: combinedRows.filter((row) => row.mileage === 0).length,
totalKm: round(
combinedRows.reduce((sum, row) => sum + row.mileage, 0),
),
precedence:
"季度里程统计(明确标注终端里程/经纬度)优先;无季度记录时使用车辆里程日报",
},
priorImportComparison: {
priorRangeRows: priorByKey.size,
unchangedRows: priorUnchanged,
changedRows: priorChanged,
missingInPriorRows: priorMissing,
extraPriorRows: priorExtra,
totalDifferenceKm: round(priorTotalDifferenceKm),
maxDifferenceKm: round(priorMaxDifferenceKm),
examples: priorExamples,
},
};
await fs.writeFile(
path.join(workDir, "normalization-manifest.json"),
`${JSON.stringify(result, null, 2)}\n`,
"utf8",
);
console.log(JSON.stringify(result, null, 2));
async function readFirstSheetValues(file) {
const input = await FileBlob.load(file);
const workbook = await SpreadsheetFile.importXlsx(input);
return workbook.worksheets.getItemAt(0).getUsedRange(true)?.values ?? [];
}
async function loadPriorRange(file, dateFrom, dateTo) {
const result = new Map();
const stream = fsSync.createReadStream(file, { encoding: "utf8" });
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
let first = true;
for await (const line of lines) {
if (first) {
first = false;
continue;
}
if (!line) continue;
const [plate, date, rawMileage] = line.split(",");
if (date < dateFrom || date > dateTo) continue;
result.set(`${plate}|${date}`, {
plate,
date,
mileage: numberValue(rawMileage),
});
}
return result;
}
function parseHeaderDate(value) {
const text = cleanText(value);
const match = text.match(/^(\d{2})月(\d{2})日$/);
assert(match, `无法解析日报日期表头 ${text}`);
const month = Number(match[1]);
const year = month === 12 ? 2025 : 2026;
return `${year}-${match[1]}-${match[2]}`;
}
function isoDate(value) {
if (value instanceof Date) return value.toISOString().slice(0, 10);
const text = cleanText(value);
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
}
function cleanText(value) {
return String(value ?? "").trim();
}
function numberValue(value, max = 2500) {
if (value === null || value === undefined || value === "") return 0;
const number = Number(String(value).replace(/,/g, ""));
assert(Number.isFinite(number) && number >= 0 && number <= max, `非法里程 ${value}`);
return number;
}
function formatNumber(value) {
if (Number.isInteger(value)) return String(value);
return String(Number(value.toFixed(3)));
}
function round(value) {
return Math.round((value + Number.EPSILON) * 1000) / 1000;
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
@@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
const base = 'http://115.29.187.205:20200';
const date = '2026-08-25';
const metrics = JSON.parse(await fs.readFile(new URL('./daily_metrics.json', import.meta.url), 'utf8'));
const candidates = metrics.items
.filter(x => String(x.platform_name || '').startsWith('现代') && Number(x.daily_mileage_km || 0) >= 10)
.sort((a, b) => Number(b.daily_mileage_km) - Number(a.daily_mileage_km));
async function one(x) {
const q = new URLSearchParams({
protocol: 'GB32960', vin: x.vin,
dateFrom: `${date} 00:00:00`, dateTo: `${date} 23:59:59`,
orderBy: 'eventTime', limit: '1', offset: '0', includeFields: 'true',
includePayload: 'false', includeTotal: 'true'
});
const res = await fetch(`${base}/api/history/raw-frames?${q}`);
if (!res.ok) throw new Error(`${x.vin}: ${res.status}`);
const body = await res.json();
const f = body.items?.[0]?.parsed_fields || {};
const required = [
'gb32960.fuel_cell.max_hydrogen_pressure_mpa',
'gb32960.fuel_cell.max_hydrogen_temperature_c',
'gb32960.vehicle.soc_percent',
'gb32960.vehicle.total_mileage_km',
'gb32960.vehicle.vehicle_status',
'gb32960.vehicle.charge_status'
];
return {...x, raw_total: Number(body.total || 0), first_has: required.filter(k => f[k] != null).length};
}
const out = [];
for (let i = 0; i < candidates.length; i += 12) {
const batch = candidates.slice(i, i + 12);
out.push(...await Promise.all(batch.map(one)));
process.stderr.write(`checked ${Math.min(i + 12, candidates.length)}\n`);
}
await fs.writeFile(new URL('./candidate_assessment.json', import.meta.url), JSON.stringify(out, null, 2));
const totals = out.map(x => x.raw_total).sort((a,b)=>a-b);
console.log(JSON.stringify({count: out.length, min: totals[0], median: totals[Math.floor(totals.length/2)], max: totals.at(-1), complete_first_frame: out.filter(x=>x.first_has===6).length, prefixes: Object.entries(Object.groupBy(out, x=>x.vin.slice(0,3))).map(([prefix, rows]) => ({prefix, count: rows.length}))}, null, 2));
@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import { Workbook, SpreadsheetFile } from "@oai/artifact-tool";
const root="/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
const inputDir=`${root}/tmp/hydrogen-audit-100-real/output-v4`;
const outputRoot=`${root}/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083`;
const packageDir=`${outputRoot}/100个真实车辆日氢耗核验资料包_V3`;
const outputPath=`${packageDir}/100个真实车辆日氢耗人工验算底稿_V3.xlsx`;
const previewDir=`${outputRoot}/preview-real-100-v3`;
const days=JSON.parse(await fs.readFile(`${inputDir}/daily_results.json`,"utf8"));
const C={navy:"#0B3A53",blue:"#0E7490",cyan:"#CDEEF4",pale:"#EAF6F8",white:"#FFFFFF",green:"#DCFCE7",greenText:"#166534",orange:"#FFEDD5",orangeText:"#9A3412",red:"#FEE2E2",redText:"#991B1B"};
const coeffA=[0.05888460,-0.06136111,-0.002650473,0.002731125,0.001802374,-0.001150707,0.00009588528,-0.0000001109040,0.0000000001264403],coeffB=[1.325,1.87,2.5,2.8,2.938,3.14,3.37,3.75,4.0],coeffC=[1,1,2,2,2.42,2.63,3,4,5];
function col(n){let s="";while(n){n--;s=String.fromCharCode(65+n%26)+s;n=Math.floor(n/26)}return s}
function title(sheet,address,text){const r=sheet.getRange(address);r.merge();r.values=[[text]];r.format={fill:C.navy,font:{bold:true,color:C.white,size:16},verticalAlignment:"center",horizontalAlignment:"left"};r.format.rowHeight=34}
function header(r){r.format={fill:C.blue,font:{bold:true,color:C.white},verticalAlignment:"center",horizontalAlignment:"center",wrapText:true,borders:{preset:"all",style:"thin",color:"#B8CDD5"}};r.format.rowHeight=34}
function body(r){r.format={verticalAlignment:"center",borders:{insideHorizontal:{style:"thin",color:"#DDE7EB"},bottom:{style:"thin",color:"#B8CDD5"}}}}
function widths(sheet,arr){arr.forEach((w,i)=>sheet.getRange(`${col(i+1)}:${col(i+1)}`).format.columnWidth=w)}
function val(v){return v===undefined||v===null?null:v}
function zFormula(p,t){return ["1",...Array.from({length:9},(_,i)=>`'参数与公式'!$B$${26+i}*POWER(100/(${t}+273.15),'参数与公式'!$C$${26+i})*POWER(${p},'参数与公式'!$D$${26+i})`)].join("+")}
function massFormula(p,t,v){return `${p}*1000*0.00201588*${v}/(8.314472*(${t}+273.15)*(${zFormula(p,t)}))`}
const wb=Workbook.create();wb.comments.setSelf({displayName:"车辆数据中台"});
const guide=wb.worksheets.add("核验说明"),params=wb.worksheets.add("参数与公式"),summary=wb.worksheets.add("100车辆日结果"),h2Detail=wb.worksheets.add("氢量计算明细"),operation=wb.worksheets.add("运行周期明细"),rawIndex=wb.worksheets.add("原始帧索引"),fields=wb.worksheets.add("原始字段说明");
for(const s of [guide,params,summary,h2Detail,operation,rawIndex,fields])s.showGridLines=false;
title(guide,"A1:H1","100个真实车辆日氢耗人工验算底稿(V3)");guide.getRange("A3:B3").values=[["项目","说明"]];header(guide.getRange("A3:B3"));
const statusCounts=Object.fromEntries(["OK","SUSPECT","NO_DATA"].map(k=>[k,days.filter(d=>d.stat.QualityStatus===k).length]));
const guideRows=[
["样本口径","2026-08-25,固定100个真实车辆日;4.5T普货41辆(380 L)、4.5T冷链59辆(520 L),动力电池均为21.04 kWh。"],
["原始数据","沿用原100个样本的232,291条GB/T 32960原始帧,未更换车辆、日期或报文;每车1个CSV.GZ,保留原始HEX及中文映射字段。"],
["有效分界点","取消上下电前后60秒窗口和首尾中位数,直接取满足车辆启动、字段完整、压力温度有效且不在外部充电/异常压力记录中的有效分界点。"],
["物理用氢量","无加氢:首个有效运行分界点剩余氢量-最后有效运行分界点剩余氢量;有加氢:按加氢事件切段后累计各段氢量下降。充电不切分物理氢量。"],
["充电识别","仅当充电状态=1且车辆状态=2时识别为外部充电;每次充电完成后建立新充电周期。"],
["纯电里程","每个充电周期若启动时为纯电,仅累计首次连续纯电前缀;混动连续3帧且相邻不超过30秒后,从首帧分界点锁定为混动,直至下一次充电。若周期从混动启动,则纯电里程为0。"],
["跨日状态","日界线只用于出报,不重置充电周期。若缺少日初已持久化状态,按已进入混动处理,不虚增日初纯电里程,并标记SUSPECT供复核。"],
["SOC修正","只使用各充电周期混动段的状态边界:ΔSOC=末SOC-初SOC;电等效氢=电池容量×ΔSOC÷100÷16。SOC上升为正并从用氢量扣除;SOC下降为负,扣除负值即补回电池放电能量。"],
["最终公式","修正耗氢量=物理用氢量-用电等效氢;混动里程=总里程-纯电里程;修正百公里氢耗=修正耗氢量÷混动里程×100。"],
["人工核对顺序","①在“100车辆日结果”看系统值与Excel复算差;②在“氢量计算明细”核对加氢分段及压力质量;③在“运行周期明细”核对纯电前缀和混动SOC;④按事件ID回查原始CSV。"],
["重算结果",`OK ${statusCounts.OK}辆日;SUSPECT ${statusCounts.SUSPECT}辆日;NO_DATA ${statusCounts.NO_DATA}辆日。SUSPECT不删除结果,保留原因和原始证据供人工判断。`],
["算法版本","PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3"]];
guide.getRange(`A4:B${3+guideRows.length}`).values=guideRows;body(guide.getRange(`A4:B${3+guideRows.length}`));guide.getRange(`B4:B${3+guideRows.length}`).format.wrapText=true;widths(guide,[24,112]);guide.freezePanes.freezeRows(3);
title(params,"A1:F1","系统参数、压力—质量公式与业务计算口径");params.getRange("A3:C3").values=[["参数","取值","说明"]];header(params.getRange("A3:C3"));
const pRows=[["4.5T普货储氢总容积(L)",380,"中台VIN参数表"],["4.5T冷链储氢总容积(L)",520,"中台VIN参数表"],["动力电池容量(kWh)",21.04,"两类4.5T车辆一致"],["氢气等效电量(kWh/kg)",16,"SOC能量折算"],["上电/下电时间窗口(s)",0,"直接使用有效分界点"],["加氢压升阈值(MPa)",3,"需持续300秒"],["加氢持续时间(s)",300,"5分钟"],["纯电异常压降(MPa)",5,"30分钟窗口"],["纯电异常窗口(s)",1800,"30分钟"],["混动确认连续帧数",3,"避免单帧运行模式抖动"],["混动确认相邻最大间隔(s)",30,"确认后回溯到首个混动帧"],["最低混动里程(km)",10,"不足标记SUSPECT"]];
params.getRange(`A4:C${3+pRows.length}`).values=pRows;body(params.getRange(`A4:C${3+pRows.length}`));params.getRange(`B4:B${3+pRows.length}`).format.numberFormat="0.000";
params.getRange("A18:F18").merge();params.getRange("A18:F18").values=[["核心公式"]];params.getRange("A18:F18").format={fill:C.cyan,font:{bold:true,color:C.navy}};
const formulas=["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]","剩余氢量 m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)","ΔSOC = SOC_end SOC_start;用电等效氢 = 电池容量 × ΔSOC ÷ 100 ÷ 16","修正耗氢量 = 物理用氢量 − 用电等效氢","混动里程 = 总里程 − 纯电里程;百公里氢耗 = 修正耗氢量 ÷ 混动里程 × 100"];
for(let i=0;i<formulas.length;i++){const row=19+i;params.getRange(`A${row}:F${row}`).merge();params.getRange(`A${row}:F${row}`).values=[[formulas[i]]];params.getRange(`A${row}:F${row}`).format={fill:i%2?C.white:C.pale,font:{color:C.navy,bold:i===3},wrapText:true}}
params.getRange("A25:D25").values=[["系数序号","aᵢ","bᵢ","cᵢ"]];header(params.getRange("A25:D25"));params.getRange("A26:D34").values=coeffA.map((a,i)=>[i+1,a,coeffB[i],coeffC[i]]);body(params.getRange("A26:D34"));params.getRange("B26:D34").format.numberFormat="0.0000000000";
wb.comments.addThread({cell:params.getRange("B4")},"普货按中台VIN车型参数取380 L,不与冷链520 L混用。");wb.comments.addThread({cell:params.getRange("B5")},"冷链按中台VIN车型参数取520 L。");wb.comments.addThread({cell:params.getRange("B6")},"4.5T普货和冷链动力电池容量均为21.04 kWh。");wb.comments.addThread({cell:params.getRange("B7")},"业务确认:1 kg氢气等效16 kWh,用于SOC能量折算。");widths(params,[32,18,58,15,15,15]);params.freezePanes.freezeRows(3);
const hHeaders=["样例","车牌","日期","VIN","车型","氢量段号","分段说明","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量(kg)","系统结束氢量(kg)","系统分段用氢(kg)","起始里程(km)","结束里程(km)","区间里程(km)","样本数","质量状态","起始氢量复算","结束氢量复算","起始质量差","结束质量差","分段用氢复算","用氢差值","复算结论"];
h2Detail.getRange(`A1:${col(hHeaders.length)}1`).values=[hHeaders];header(h2Detail.getRange(`A1:${col(hHeaders.length)}1`));const hRows=[];for(const d of days)for(const it of d.stat.HydrogenIntervals||[])hRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,it.index,it.qualityReason,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),it.sampleCount,it.qualityStatus]);
h2Detail.getRange(`A2:Y${hRows.length+1}`).values=hRows;for(let r=2;r<=hRows.length+1;r++){h2Detail.getRange(`Z${r}`).formulas=[[`=${massFormula(`N${r}`,`P${r}`,`M${r}`)}`]];h2Detail.getRange(`AA${r}`).formulas=[[`=${massFormula(`O${r}`,`Q${r}`,`M${r}`)}`]];h2Detail.getRange(`AB${r}`).formulas=[[`=Z${r}-R${r}`]];h2Detail.getRange(`AC${r}`).formulas=[[`=AA${r}-S${r}`]];h2Detail.getRange(`AD${r}`).formulas=[[`=Z${r}-AA${r}`]];h2Detail.getRange(`AE${r}`).formulas=[[`=AD${r}-T${r}`]];h2Detail.getRange(`AF${r}`).formulas=[[`=IF(MAX(ABS(AB${r}),ABS(AC${r}),ABS(AE${r}))<=0.002,"一致","复核")`]]}
body(h2Detail.getRange(`A2:AF${hRows.length+1}`));h2Detail.getRange(`M2:AE${hRows.length+1}`).format.numberFormat="0.000000";h2Detail.getRange(`Y2:Y${hRows.length+1}`).conditionalFormats.add("containsText",{text:"SUSPECT",format:{fill:C.orange,font:{color:C.orangeText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});h2Detail.tables.add(`A1:AF${hRows.length+1}`,true,"HydrogenEvidenceV3").style="TableStyleMedium2";h2Detail.freezePanes.freezeRows(1);h2Detail.freezePanes.freezeColumns(7);widths(h2Detail,[8,13,13,22,24,10,34,22,22,34,34,24,14,14,14,14,14,17,17,17,15,15,15,10,13,17,17,14,14,17,14,12]);
const oHeaders=["样例","车牌","日期","VIN","车型","充电周期号","区间类型","区间号","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","电池容量(kWh)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量","系统结束氢量","区间物理氢量","起始SOC","结束SOC","系统ΔSOC(末-初)","系统电量变化(kWh)","系统电等效氢(kg)","系统段修正氢量","起始里程","结束里程","系统区间里程","系统段百公里","样本数","质量状态","分界说明","起始氢量复算","结束氢量复算","起始质量差","结束质量差","ΔSOC复算","电量变化复算","电等效氢复算","段修正氢量复算","里程复算","纯电里程贡献","混动电等效氢贡献","复算结论"];
operation.getRange(`A1:${col(oHeaders.length)}1`).values=[oHeaders];header(operation.getRange(`A1:${col(oHeaders.length)}1`));const oRows=[];for(const d of days){let cycleNo=0,previous="";for(const it of d.stat.Intervals||[]){if(cycleNo===0||it.type==="PURE_ELECTRIC"||previous==="MIXED")cycleNo++;oRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,cycleNo,it.type,it.index,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,21.04,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startSocPercent),val(it.endSocPercent),val(it.batterySocDeltaPct),val(it.batteryEnergyChangeKWh),val(it.electricEquivalentHydrogenKg),val(it.correctedHydrogenConsumptionKg),val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),val(it.consumptionKgPer100Km),it.sampleCount,it.qualityStatus,it.qualityReason]);previous=it.type}}
operation.getRange(`A2:AI${oRows.length+1}`).values=oRows;for(let r=2;r<=oRows.length+1;r++){operation.getRange(`AJ${r}`).formulas=[[`=${massFormula(`P${r}`,`R${r}`,`N${r}`)}`]];operation.getRange(`AK${r}`).formulas=[[`=${massFormula(`Q${r}`,`S${r}`,`N${r}`)}`]];operation.getRange(`AL${r}`).formulas=[[`=AJ${r}-T${r}`]];operation.getRange(`AM${r}`).formulas=[[`=AK${r}-U${r}`]];operation.getRange(`AN${r}`).formulas=[[`=IF(OR(W${r}="",X${r}=""),"",X${r}-W${r})`]];operation.getRange(`AO${r}`).formulas=[[`=IF(AN${r}="","",O${r}*AN${r}/100)`]];operation.getRange(`AP${r}`).formulas=[[`=IF(AO${r}="","",AO${r}/'参数与公式'!$B$7)`]];operation.getRange(`AQ${r}`).formulas=[[`=IF(G${r}="MIXED",V${r}-AP${r},"")`]];operation.getRange(`AR${r}`).formulas=[[`=IF(OR(AC${r}="",AD${r}=""),"",AD${r}-AC${r})`]];operation.getRange(`AS${r}`).formulas=[[`=IF(G${r}="PURE_ELECTRIC",AR${r},0)`]];operation.getRange(`AT${r}`).formulas=[[`=IF(G${r}="MIXED",AP${r},0)`]];operation.getRange(`AU${r}`).formulas=[[`=IF(MAX(ABS(AL${r}),ABS(AM${r}),ABS(AN${r}-Y${r}),ABS(AP${r}-AA${r}),ABS(AR${r}-AE${r}))<=0.002,"一致","复核")`]]}
body(operation.getRange(`A2:AU${oRows.length+1}`));operation.getRange(`N2:AT${oRows.length+1}`).format.numberFormat="0.000000";operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"PURE_ELECTRIC",format:{fill:C.cyan,font:{color:C.navy}}});operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"MIXED",format:{fill:C.green,font:{color:C.greenText}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});operation.tables.add(`A1:AU${oRows.length+1}`,true,"OperationCyclesV3").style="TableStyleMedium2";operation.freezePanes.freezeRows(1);operation.freezePanes.freezeColumns(8);widths(operation,[8,13,13,22,24,11,15,9,22,22,34,34,24,14,15,14,14,14,14,16,16,16,12,12,15,17,17,18,15,15,16,17,10,13,42,17,17,14,14,15,17,17,18,15,16,20,12]);
const sHeaders=["样例","日期","车牌","VIN","车型","储氢容积(L)","电池容量(kWh)","原统计日里程(km)","有效起始仪表里程","有效结束仪表里程","总里程复算","API原始帧","算法样本","氢量分段数","运行区间数","加氢次数","充电次数","异常记录","系统物理用氢","物理用氢复算","物理差值","系统ΔSOC","ΔSOC复算","系统电等效氢","电等效氢复算","等效氢差值","系统修正耗氢","修正耗氢复算","修正差值","系统纯电里程","纯电里程复算","系统混动里程","混动里程复算","混动差值","系统物理百公里","物理百公里复算","系统修正百公里","修正百公里复算","百公里差值","质量状态","质量原因","算法版本","原始文件","打开原始帧"];
summary.getRange(`A1:${col(sHeaders.length)}1`).values=[sHeaders];header(summary.getRange(`A1:${col(sHeaders.length)}1`));
const sRows=days.map(d=>{const s=d.stat,c=d.candidate;return[c.sample_no,c.stat_date,c.plate,c.vin,c.model,c.tank_capacity_l,21.04,c.daily_mileage_km,val(s.FirstObservation?.MileageKm),val(s.LastObservation?.MileageKm),null,d.apiRawFrameCount,d.algorithmSamples,(s.HydrogenIntervals||[]).length,(s.Intervals||[]).length,s.RefuelCount,s.ChargeCount,s.InvalidSegmentCount+s.AbnormalDropCount,s.ConsumptionKg,null,null,val(s.BatterySOCDeltaPct),null,val(s.ElectricEquivalentKg),null,null,val(s.CorrectedConsumptionKg),null,null,s.PureElectricMileageKm,null,s.MixedMileageKm,null,null,val(s.ConsumptionKgPer100Km),null,val(s.SOCBalancedKgPer100Km),null,null,s.QualityStatus,s.QualityReason,s.CalculationParameters?.algorithmVersion,d.rawArchiveFile,null]});
summary.getRange(`A2:AR${sRows.length+1}`).values=sRows;const hEnd=hRows.length+1,oEnd=oRows.length+1;
for(let r=2;r<=sRows.length+1;r++){
summary.getRange(`K${r}`).formulas=[[`=IF(OR(I${r}="",J${r}=""),"",J${r}-I${r})`]];
summary.getRange(`T${r}`).formulas=[[`=SUMIFS('氢量计算明细'!$AD$2:$AD$${hEnd},'氢量计算明细'!$D$2:$D$${hEnd},$D${r})`]];summary.getRange(`U${r}`).formulas=[[`=T${r}-S${r}`]];
summary.getRange(`W${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AN$2:$AN$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r},'运行周期明细'!$G$2:$G$${oEnd},"MIXED")`]];
summary.getRange(`Y${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AT$2:$AT$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];summary.getRange(`Z${r}`).formulas=[[`=Y${r}-X${r}`]];
summary.getRange(`AB${r}`).formulas=[[`=T${r}-Y${r}`]];summary.getRange(`AC${r}`).formulas=[[`=AB${r}-AA${r}`]];
summary.getRange(`AE${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AS$2:$AS$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];
summary.getRange(`AG${r}`).formulas=[[`=K${r}-AE${r}`]];summary.getRange(`AH${r}`).formulas=[[`=AG${r}-AF${r}`]];
summary.getRange(`AJ${r}`).formulas=[[`=IF(AG${r}>0,T${r}*100/AG${r},"")`]];summary.getRange(`AL${r}`).formulas=[[`=IF(AG${r}>0,AB${r}*100/AG${r},"")`]];summary.getRange(`AM${r}`).formulas=[[`=IF(OR(AK${r}="",AL${r}=""),"",AL${r}-AK${r})`]];
summary.getRange(`AR${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];
}
body(summary.getRange(`A2:AR${sRows.length+1}`));summary.getRange(`F2:AM${sRows.length+1}`).format.numberFormat="0.000000";
for(const [text,fill,font] of [["OK",C.green,C.greenText],["SUSPECT",C.orange,C.orangeText],["NO_DATA",C.red,C.redText]])summary.getRange(`AN2:AN${sRows.length+1}`).conditionalFormats.add("containsText",{text,format:{fill,font:{color:font,bold:true}}});
for(const c of ["U","Z","AC","AH","AM"]){summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"greaterThan",formula:0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}});summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"lessThan",formula:-0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}})}
summary.tables.add(`A1:AR${sRows.length+1}`,true,"VehicleDayResultsV3").style="TableStyleMedium2";summary.freezePanes.freezeRows(1);summary.freezePanes.freezeColumns(5);widths(summary,[8,13,13,22,24,14,14,17,18,18,15,12,12,12,12,11,11,12,16,16,14,14,14,16,16,14,17,17,14,16,16,16,16,14,18,18,18,18,14,13,48,42,58,12]);
const rHeaders=["样例","车牌","VIN","日期","车型","储氢容积(L)","电池容量(kWh)","API原始帧数","唯一帧数","重复帧数","算法样本数","关键字段帧数","原始最早事件时间","原始最晚事件时间","原始文件","打开"];
rawIndex.getRange("A1:P1").values=[rHeaders];header(rawIndex.getRange("A1:P1"));const rRows=days.map(d=>[d.candidate.sample_no,d.candidate.plate,d.candidate.vin,d.candidate.stat_date,d.candidate.model,d.candidate.tank_capacity_l,21.04,d.apiRawFrameCount,d.uniqueFrameCount,d.duplicateFrameCount,d.algorithmSamples,d.criticalFieldRows,d.earliestEventTime,d.latestEventTime,d.rawArchiveFile,null]);rawIndex.getRange(`A2:P${rRows.length+1}`).values=rRows;for(let r=2;r<=rRows.length+1;r++)rawIndex.getRange(`P${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];body(rawIndex.getRange(`A2:P${rRows.length+1}`));rawIndex.tables.add(`A1:P${rRows.length+1}`,true,"RawFrameManifestV3").style="TableStyleMedium2";rawIndex.freezePanes.freezeRows(1);rawIndex.freezePanes.freezeColumns(5);widths(rawIndex,[8,13,22,13,24,14,14,14,12,12,14,15,21,21,58,12]);
title(fields,"A1:D1","原始帧字段及人工核验用途");fields.getRange("A3:D3").values=[["中文字段","GB/T 32960解析键/原始属性","用途","校验规则/枚举"]];header(fields.getRange("A3:D3"));
const fRows=[["原始报文HEX","raw_hex","回查原始帧","完整保留,不参与公式"],["事件ID","event_id","连接各明细起止事件","按ID在对应CSV查找"],["事件时间","event_time","确定自然日及排序","以事件时间为准"],["最高氢压(MPa)","gb32960.fuel_cell.max_hydrogen_pressure_mpa","压力—质量换算","0<P≤70"],["最高氢温(℃)","gb32960.fuel_cell.max_hydrogen_temperature_c","压力—质量换算","T>-40"],["电池SOC(%)","gb32960.vehicle.soc_percent","混动边界SOC修正","0100;只取相关状态边界"],["仪表总里程(km)","gb32960.vehicle.total_mileage_km","总/纯电/混动里程","分界点末值-初值"],["车辆状态","gb32960.vehicle.vehicle_status","启动及充电联合判断","1=启动,2=熄火,其他按国标保留"],["充电状态","gb32960.vehicle.charge_status","外部充电联合判断","1=停车充电,2=行驶充电,3=未充电,4=充电完成"],["运行模式","gb32960.vehicle.running_mode","纯电前缀/混动锁定","1=纯电,2=混动,3=燃油,254=异常,255=无效"],["燃料电池工作状态","gb32960.gd_fc_stack.engine_work_state","辅助确认纯电/混动","2=工作;0/1=未工作/停机"],["系统压力换算质量(kg)","工作底稿复算列","氢量端点","按VIN容积+NIST公式"],["计算角色/排除原因","工作底稿标注","定位参与/排除状态","有效分界点、外部充电、异常或字段缺失"]];
fields.getRange(`A4:D${3+fRows.length}`).values=fRows;body(fields.getRange(`A4:D${3+fRows.length}`));fields.getRange(`A4:D${3+fRows.length}`).format.wrapText=true;widths(fields,[28,62,40,54]);fields.freezePanes.freezeRows(3);
await fs.mkdir(packageDir,{recursive:true});
const checks={};for(const [name,range] of [["核验说明","A1:H15"],["参数与公式","A1:F34"],["100车辆日结果","A1:AR12"],["氢量计算明细","A1:AF15"],["运行周期明细","A1:AU15"],["原始帧索引","A1:P15"],["原始字段说明","A1:D16"]])checks[name]=(await wb.inspect({kind:"table",sheetId:name,range,include:"values,formulas",tableMaxRows:18,tableMaxCols:50,maxChars:18000})).ndjson;
const formulaErrors=await wb.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:10000});
const xlsx=await SpreadsheetFile.exportXlsx(wb);await xlsx.save(outputPath);
await fs.rm(previewDir,{recursive:true,force:true});await fs.mkdir(previewDir,{recursive:true});const renderRanges={"核验说明":"A1:H15","参数与公式":"A1:F34","100车辆日结果":"A1:AR14","氢量计算明细":"A1:AF14","运行周期明细":"A1:AU14","原始帧索引":"A1:P14","原始字段说明":"A1:D16"};for(const [name,range] of Object.entries(renderRanges)){const image=await wb.render({sheetName:name,range,scale:1,format:"png"});await fs.writeFile(`${previewDir}/${name}.png`,new Uint8Array(await image.arrayBuffer()))}
await fs.cp(`${inputDir}/raw`,`${packageDir}/raw`,{recursive:true,force:true});for(const name of ["daily_results.json","raw_manifest.csv","selected_vehicles.json"])await fs.copyFile(`${inputDir}/${name}`,`${packageDir}/${name}`);
await fs.writeFile(`${outputRoot}/verification-real-100-v3.json`,JSON.stringify({outputPath,checks,formulaErrors:formulaErrors.ndjson,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length},null,2));console.log(JSON.stringify({outputPath,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length,previewDir,formulaErrors:formulaErrors.ndjson},null,2));
@@ -0,0 +1,10 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const path = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/100个真实车辆日氢耗核验资料包_V3/100个真实车辆日氢耗人工验算底稿_V3.xlsx";
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(path));
for (const [sheetId, range] of [["100车辆日结果", "A53:AR53"], ["氢量计算明细", "A1:AF4"], ["运行周期明细", "A1:AU8"]]) {
const result = await workbook.inspect({kind:"table", sheetId, range, include:"values,formulas", tableMaxRows:10, tableMaxCols:50, maxChars:16000});
console.log(result.ndjson);
}
const errors = await workbook.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:4000});
console.log(errors.ndjson);
@@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath = "/Users/lingniu/Downloads/氢气计算公式.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/hydrogen-formula-analysis-20260826/preview";
await fs.mkdir(outputDir, { recursive: true });
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
const overview = await workbook.inspect({
kind: "workbook,sheet,table",
include: "id,name,values,formulas",
maxChars: 12000,
tableMaxRows: 30,
tableMaxCols: 20,
tableMaxCellChars: 300,
});
console.log("OVERVIEW");
console.log(overview.ndjson);
const sheets = (await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 6000 })).ndjson
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((item) => item.kind === "sheet");
for (let index = 0; index < sheets.length; index += 1) {
const sheet = sheets[index];
const region = await workbook.inspect({
kind: "region,formula,computedStyle",
sheetId: sheet.name,
range: "A1:Z80",
include: "values,formulas",
maxChars: 30000,
tableMaxRows: 80,
tableMaxCols: 26,
tableMaxCellChars: 500,
options: { maxResults: 300 },
});
console.log(`SHEET ${sheet.name}`);
console.log(region.ndjson);
const rendered = await workbook.render({ sheetName: sheet.name, autoCrop: "all", scale: 2, format: "png" });
const safeName = String(sheet.name).replace(/[\\/:*?"<>|]/g, "_");
await fs.writeFile(`${outputDir}/${String(index + 1).padStart(2, "0")}-${safeName}.png`, new Uint8Array(await rendered.arrayBuffer()));
}
console.log(JSON.stringify({ inputPath, outputDir, sheetCount: sheets.length }));
@@ -0,0 +1,496 @@
import fs from "node:fs/promises";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
const outputPath = `${outputDir}/氢耗算法120组人工验算底稿.xlsx`;
const previewDir = `${outputDir}/preview`;
const colors = {
navy: "#0B3A53",
blue: "#0E7490",
cyan: "#CDEEF4",
pale: "#EAF6F8",
green: "#DCFCE7",
greenText: "#166534",
orange: "#FFEDD5",
orangeText: "#9A3412",
red: "#FEE2E2",
redText: "#991B1B",
gray: "#E5E7EB",
grayText: "#475569",
white: "#FFFFFF",
};
const coeffA = [0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403];
const coeffB = [1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0];
const coeffC = [1, 1, 2, 2, 2.42, 2.63, 3, 4, 5];
function massKg(pressureMPa, temperatureC, volumeLiter) {
const temperatureK = temperatureC + 273.15;
let z = 1;
for (let i = 0; i < coeffA.length; i++) {
z += coeffA[i] * Math.pow(100 / temperatureK, coeffB[i]) * Math.pow(pressureMPa, coeffC[i]);
}
return pressureMPa * 1000 * 0.00201588 * volumeLiter / (8.314472 * temperatureK * z);
}
function excelCol(index) {
let result = "";
let value = index;
while (value > 0) {
value--;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
function styleTitle(sheet, range, title) {
range.merge();
range.values = [[title]];
range.format = {
fill: colors.navy,
font: { bold: true, color: colors.white },
verticalAlignment: "center",
horizontalAlignment: "left",
};
range.format.rowHeight = 32;
}
function styleHeader(range) {
range.format = {
fill: colors.blue,
font: { bold: true, color: colors.white },
verticalAlignment: "center",
horizontalAlignment: "center",
wrapText: true,
borders: { preset: "all", style: "thin", color: "#B8CDD5" },
};
range.format.rowHeight = 30;
}
function styleBody(range) {
range.format = {
verticalAlignment: "center",
borders: {
insideHorizontal: { style: "thin", color: "#DDE7EB" },
bottom: { style: "thin", color: "#B8CDD5" },
},
};
}
function setWidths(sheet, widths) {
widths.forEach((width, index) => {
sheet.getRange(`${excelCol(index + 1)}:${excelCol(index + 1)}`).format.columnWidth = width;
});
}
const workbook = Workbook.create();
workbook.comments.setSelf({ displayName: "User" });
const guide = workbook.worksheets.add("验算说明");
const params = workbook.worksheets.add("参数与系数");
const grid = workbook.worksheets.add("120组压力质量验算");
const raw = workbook.worksheets.add("原始报文120条");
const detail = workbook.worksheets.add("样本计算明细");
const summary = workbook.worksheets.add("每日与区间汇总");
for (const sheet of [guide, params, grid, raw, detail, summary]) sheet.showGridLines = false;
// 参数与NIST系数
styleTitle(params, params.getRange("A1:F1"), "氢耗算法参数与NIST实气压缩因子系数");
params.getRange("A3:C3").values = [["参数", "取值", "说明"]];
styleHeader(params.getRange("A3:C3"));
const parameterRows = [
["储氢系统总容积(L)", 520, "验算样本使用,可替换为车型确认值"],
["动力电池容量(kWh)", 21.04, "4.5T示例;必须使用售后技术部确认值"],
["1kg氢气等效电量(kWh/kg)", 16, "当前业务折算参数"],
["上电稳定等待(s)", 60, "上电后60秒内样本不作为计算端点"],
["下电提前窗口(s)", 60, "下电前60秒样本不作为计算端点"],
["加氢压力上升阈值(MPa)", 3, "并需持续达到保持时间"],
["加氢保持时间(s)", 300, "5分钟"],
["纯电异常压降阈值(MPa)", 5, "30分钟内超过该值判异常"],
["纯电异常窗口(s)", 1800, "30分钟"],
["端点中位样本数", 5, "区间首尾各5条"],
["最少有效样本数", 10, "不足10条不形成有效区间"],
["样本间隔(s)", 10, "本验算原始数据间隔"],
];
params.getRange(`A4:C${3 + parameterRows.length}`).values = parameterRows;
styleBody(params.getRange(`A4:C${3 + parameterRows.length}`));
params.getRange(`B4:B${3 + parameterRows.length}`).format.numberFormat = "0.000";
params.getRange("A18:D18").values = [["系数序号", "aᵢ", "bᵢ", "cᵢ"]];
styleHeader(params.getRange("A18:D18"));
params.getRange("A19:D27").values = coeffA.map((a, index) => [index + 1, a, coeffB[index], coeffC[index]]);
styleBody(params.getRange("A19:D27"));
params.getRange("B19:D27").format.numberFormat = "0.0000000000";
params.getRange("A29:F33").values = [
["压力—质量公式", null, null, null, null, null],
["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]", null, null, null, null, null],
["m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)", null, null, null, null, null],
["SOC平衡公式", null, null, null, null, null],
["m_SOC = m_H₂ + C_battery × (SOC_start SOC_end) ÷ 100 ÷ 16", null, null, null, null, null],
];
for (const row of [29, 30, 31, 32, 33]) params.getRange(`A${row}:F${row}`).merge();
params.getRange("A29:F29").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
params.getRange("A32:F32").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
params.getRange("A30:F31").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
params.getRange("A33:F33").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
setWidths(params, [28, 18, 52, 16, 16, 16]);
params.freezePanes.freezeRows(3);
// 120组独立压力—质量验算
const gridHeaders = ["序号", "压力P(MPa)", "温度T(℃)", "容积V(L)", "绝对温度Tₖ(K)", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "压缩因子Z", "分子", "分母", "Excel复算质量(kg)", "系统计算质量(kg)", "绝对差值(kg)", "核验结果"];
grid.getRange(`A1:${excelCol(gridHeaders.length)}1`).values = [gridHeaders];
styleHeader(grid.getRange(`A1:${excelCol(gridHeaders.length)}1`));
const pressures = [1, 3, 5, 8, 10, 12, 15, 18, 21, 25, 30, 35];
const temperatures = [-20, -10, 0, 10, 20, 30, 40, 50, 60, 70];
const gridInputs = [];
const gridSystem = [];
let gridIndex = 1;
for (const pressure of pressures) {
for (const temperature of temperatures) {
gridInputs.push([gridIndex++, pressure, temperature, 520]);
gridSystem.push(massKg(pressure, temperature, 520));
}
}
grid.getRange("A2:D121").values = gridInputs;
for (let row = 2; row <= 121; row++) {
grid.getRange(`E${row}`).formulas = [[`=C${row}+273.15`]];
for (let term = 0; term < 9; term++) {
const col = excelCol(6 + term);
const coeffRow = 19 + term;
grid.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$E${row},'参数与系数'!$C$${coeffRow})*POWER($B${row},'参数与系数'!$D$${coeffRow})`]];
}
grid.getRange(`O${row}`).formulas = [[`=1+SUM(F${row}:N${row})`]];
grid.getRange(`P${row}`).formulas = [[`=B${row}*1000*0.00201588*D${row}`]];
grid.getRange(`Q${row}`).formulas = [[`=8.314472*E${row}*O${row}`]];
grid.getRange(`R${row}`).formulas = [[`=P${row}/Q${row}`]];
grid.getRange(`S${row}`).values = [[gridSystem[row - 2]]];
grid.getRange(`T${row}`).formulas = [[`=ABS(R${row}-S${row})`]];
grid.getRange(`U${row}`).formulas = [[`=IF(T${row}<=0.0000000001,"通过","不通过")`]];
}
styleBody(grid.getRange("A2:U121"));
grid.getRange("B2:D121").format.numberFormat = "0.000";
grid.getRange("E2:Q121").format.numberFormat = "0.0000000000";
grid.getRange("R2:S121").format.numberFormat = "0.000000";
grid.getRange("T2:T121").format.numberFormat = "0.000000000000";
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "不通过", format: { fill: colors.red, font: { color: colors.redText, bold: true } } });
grid.tables.add("A1:U121", true, "NISTValidation120").style = "TableStyleMedium2";
grid.freezePanes.freezeRows(1);
grid.freezePanes.freezeColumns(5);
setWidths(grid, [8, 12, 12, 12, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 16, 16, 18, 18, 18, 12]);
// 构造120条可人工核验的原始GB/T 32960解析字段样本。
const rawHeaders = ["序号", "报文ID", "上报时间", "数据源", "车辆状态", "充电状态", "运行模式", "燃料电池工作", "氢压(MPa)", "氢温(℃)", "储氢容积(L)", "电SOC(%)", "仪表总里程(km)", "原始解析字段JSON"];
raw.getRange("A1:N1").values = [rawHeaders];
styleHeader(raw.getRange("A1:N1"));
const rawRows = [];
const systemMasses = [];
const baseTime = new Date(Date.UTC(2026, 7, 26, 0, 0, 0));
for (let index = 0; index < 120; index++) {
const eventId = `audit-32960-${String(index + 1).padStart(3, "0")}`;
const observedAt = new Date(baseTime.getTime() + index * 10_000);
let vehicleState = 1;
let chargeState = 3;
let runningMode = 2;
let fuelCellActive = 1;
let pressure;
let soc;
let mileage;
if (index < 40) {
pressure = 30 - index * 0.04;
soc = 80 - index * 0.04;
mileage = 1000 + index * 0.08;
} else if (index < 60) {
vehicleState = 2;
chargeState = 1;
runningMode = 0;
fuelCellActive = 0;
pressure = 28.4 - (index - 40) * 0.002;
soc = 78.4 + (index - 40) * (11.6 / 19);
mileage = 1003.2;
} else if (index < 80) {
chargeState = index === 60 ? 4 : 3;
runningMode = 1;
fuelCellActive = 0;
pressure = 28.36 - (index - 60) * 0.005;
soc = 90 - (index - 60) * 0.15;
mileage = 1003.2 + (index - 60) * 0.1;
} else {
pressure = 28.26 - (index - 80) * 0.06;
soc = 87 - (index - 80) * 0.04;
mileage = 1005.2 + (index - 80) * 0.12;
if (index === 119) vehicleState = 2;
}
const temperature = 30 + Math.sin(index / 10);
const mass = massKg(pressure, temperature, 520);
systemMasses.push(mass);
const rawJson = JSON.stringify({
event_id: eventId,
vehicle_state: vehicleState,
charge_status: chargeState,
running_mode: runningMode,
fuel_cell_active: fuelCellActive,
hydrogen_max_pressure_mpa: Number(pressure.toFixed(6)),
hydrogen_max_temperature_c: Number(temperature.toFixed(6)),
soc_percent: Number(soc.toFixed(6)),
total_mileage_km: Number(mileage.toFixed(6)),
});
rawRows.push([index + 1, eventId, observedAt, "GB32960-audit-sample", vehicleState, chargeState, runningMode, fuelCellActive, pressure, temperature, 520, soc, mileage, rawJson]);
}
raw.getRange("A2:N121").values = rawRows;
styleBody(raw.getRange("A2:N121"));
raw.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
raw.getRange("I2:M121").format.numberFormat = "0.000000";
raw.getRange("N2:N121").format.wrapText = false;
raw.getRange("A123:N126").values = [
["人工核验说明", null, null, null, null, null, null, null, null, null, null, null, null, null],
["本页为验算构造的原始解析字段样本,不是生产车辆真实报文。生产复核时应按相同字段结构替换为中台导出的真实32960数据。", null, null, null, null, null, null, null, null, null, null, null, null, null],
["车辆状态:1=启动/运行,2=熄火;充电状态:1=停车充电,3=未充电,4=充电完成;运行模式:1=纯电,2=混动。", null, null, null, null, null, null, null, null, null, null, null, null, null],
["停车充电需由车辆状态与充电状态联合判断;充电区间不计算氢耗,充电完成重新建立SOC、压力、温度和里程基线。", null, null, null, null, null, null, null, null, null, null, null, null, null],
];
for (let row = 123; row <= 126; row++) raw.getRange(`A${row}:N${row}`).merge();
raw.getRange("A123:N123").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
raw.getRange("A124:N126").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
raw.tables.add("A1:N121", true, "RawTelemetry120").style = "TableStyleMedium2";
raw.freezePanes.freezeRows(1);
raw.freezePanes.freezeColumns(3);
setWidths(raw, [8, 24, 20, 24, 10, 10, 10, 14, 12, 12, 14, 12, 18, 90]);
// 逐条计算明细:全部中间状态和公式均保留。
const detailHeaders = ["序号", "报文ID", "上报时间", "压力P", "温度T", "容积V", "Tₖ", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "Z", "分子", "分母", "Excel复算质量", "系统质量", "差值", "车辆状态", "充电状态", "运行模式", "燃料电池", "SOC", "仪表里程", "阶段", "上电60s通过", "下电60s通过", "停车充电", "状态组合有效", "参与计算", "排除原因", "区间类型", "系统区间号", "端点角色"];
detail.getRange(`A1:${excelCol(detailHeaders.length)}1`).values = [detailHeaders];
styleHeader(detail.getRange(`A1:${excelCol(detailHeaders.length)}1`));
for (let row = 2; row <= 121; row++) {
const rawRow = row;
const index = row - 2;
detail.getRange(`A${row}:F${row}`).formulas = [[
`='原始报文120条'!A${rawRow}`,
`='原始报文120条'!B${rawRow}`,
`='原始报文120条'!C${rawRow}`,
`='原始报文120条'!I${rawRow}`,
`='原始报文120条'!J${rawRow}`,
`='原始报文120条'!K${rawRow}`,
]];
detail.getRange(`G${row}`).formulas = [[`=E${row}+273.15`]];
for (let term = 0; term < 9; term++) {
const col = excelCol(8 + term);
const coeffRow = 19 + term;
detail.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$G${row},'参数与系数'!$C$${coeffRow})*POWER($D${row},'参数与系数'!$D$${coeffRow})`]];
}
detail.getRange(`Q${row}`).formulas = [[`=1+SUM(H${row}:P${row})`]];
detail.getRange(`R${row}`).formulas = [[`=D${row}*1000*0.00201588*F${row}`]];
detail.getRange(`S${row}`).formulas = [[`=8.314472*G${row}*Q${row}`]];
detail.getRange(`T${row}`).formulas = [[`=R${row}/S${row}`]];
detail.getRange(`U${row}`).values = [[systemMasses[index]]];
detail.getRange(`V${row}`).formulas = [[`=ABS(T${row}-U${row})`]];
detail.getRange(`W${row}:AB${row}`).formulas = [[
`='原始报文120条'!E${rawRow}`,
`='原始报文120条'!F${rawRow}`,
`='原始报文120条'!G${rawRow}`,
`='原始报文120条'!H${rawRow}`,
`='原始报文120条'!L${rawRow}`,
`='原始报文120条'!M${rawRow}`,
]];
const phase = index < 40 ? "充电前运行" : index < 60 ? "停车充电" : index < 80 ? "充电后纯电" : "充电后混动";
detail.getRange(`AC${row}`).values = [[phase]];
detail.getRange(`AD${row}`).formulas = [[`=IF(OR(C${row}<'原始报文120条'!$C$2+TIME(0,0,'参数与系数'!$B$7),AND(C${row}>='原始报文120条'!$C$62,C${row}<'原始报文120条'!$C$62+TIME(0,0,'参数与系数'!$B$7))),"否","是")`]];
detail.getRange(`AE${row}`).formulas = [[`=IF(OR(AND(C${row}>'原始报文120条'!$C$42-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$42),AND(C${row}>'原始报文120条'!$C$121-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$121)),"否","是")`]];
detail.getRange(`AF${row}`).formulas = [[`=IF(AND(W${row}=2,X${row}=1),"是","否")`]];
detail.getRange(`AG${row}`).formulas = [[`=IF(OR(AND(Y${row}=1,Z${row}=0),AND(Y${row}=2,Z${row}=1)),"是","否")`]];
detail.getRange(`AH${row}`).formulas = [[`=IF(AND(AD${row}="是",AE${row}="是",AF${row}="否",AG${row}="是",W${row}=1),"是","否")`]];
detail.getRange(`AI${row}`).formulas = [[`=IF(AH${row}="是","",IF(AF${row}="是","停车充电区间",IF(AD${row}="否","上电后60秒稳定窗口",IF(AE${row}="否","下电前60秒稳定窗口",IF(W${row}<>1,"车辆非运行状态","运行模式与燃料电池状态不匹配")))))`]];
detail.getRange(`AJ${row}`).formulas = [[`=IF(AH${row}<>"是","",IF(AND(Y${row}=1,Z${row}=0),"PURE_ELECTRIC",IF(AND(Y${row}=2,Z${row}=1),"MIXED","")))`]];
const segment = index >= 6 && index <= 34 ? 1 : index >= 66 && index <= 79 ? 2 : index >= 80 && index <= 113 ? 3 : null;
detail.getRange(`AK${row}`).values = [[segment]];
let endpointRole = "";
if (index >= 6 && index <= 10) endpointRole = `区间1起始候选${index - 5}`;
else if (index >= 30 && index <= 34) endpointRole = `区间1结束候选${index - 29}`;
else if (index >= 66 && index <= 70) endpointRole = `区间2起始候选${index - 65}`;
else if (index >= 75 && index <= 79) endpointRole = `区间2结束候选${index - 74}`;
else if (index >= 80 && index <= 84) endpointRole = `区间3起始候选${index - 79}`;
else if (index >= 109 && index <= 113) endpointRole = `区间3结束候选${index - 108}`;
detail.getRange(`AL${row}`).values = [[endpointRole]];
}
styleBody(detail.getRange(`A2:AL121`));
detail.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
detail.getRange("D2:V121").format.numberFormat = "0.000000";
detail.getRange("AA2:AB121").format.numberFormat = "0.000000";
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "否", format: { fill: colors.gray, font: { color: colors.grayText } } });
detail.getRange("AF2:AF121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.orange, font: { color: colors.orangeText, bold: true } } });
detail.getRange("AL2:AL121").conditionalFormats.add("notContainsBlanks", { format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
detail.tables.add("A1:AL121", true, "TelemetryCalculationDetail").style = "TableStyleMedium2";
detail.freezePanes.freezeRows(1);
detail.freezePanes.freezeColumns(3);
setWidths(detail, [8, 24, 20, 11, 11, 11, 12, ...Array(9).fill(13), 12, 14, 14, 16, 16, 16, 10, 10, 10, 12, 11, 16, 16, 13, 13, 12, 14, 12, 28, 16, 12, 18]);
// 每日与区间汇总,公式均追溯至逐条明细。
styleTitle(summary, summary.getRange("A1:AB1"), "120条原始报文:每日氢耗与区间计算汇总");
summary.getRange("A3:H3").values = [["统计日期", "原始样本数", "参与计算样本", "停车充电样本", "有效区间", "混动区间", "纯电区间", "质量状态"]];
styleHeader(summary.getRange("A3:H3"));
summary.getRange("A4").values = [["2026-08-26"]];
summary.getRange("B4:H4").formulas = [[
`=COUNTA('原始报文120条'!$B$2:$B$121)`,
`=COUNTIF('样本计算明细'!$AH$2:$AH$121,"是")`,
`=COUNTIF('样本计算明细'!$AF$2:$AF$121,"是")`,
`=COUNTA($A$9:$A$11)`,
`=COUNTIF($B$9:$B$11,"MIXED")`,
`=COUNTIF($B$9:$B$11,"PURE_ELECTRIC")`,
`=IF(AND(B4=120,C4>=10,F4>=1),"OK","SUSPECT")`,
]];
styleBody(summary.getRange("A4:H4"));
summary.getRange("H4").conditionalFormats.add("containsText", { text: "OK", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
summary.getRange("J3:Q3").values = [["物理耗氢(kg)", "SOC差值(百分点)", "电池净放电(kWh)", "电量折氢(kg)", "SOC平衡氢耗(kg)", "混动里程(km)", "物理百公里氢耗", "SOC平衡百公里氢耗"]];
styleHeader(summary.getRange("J3:Q3"));
summary.getRange("J4:Q4").formulas = [[
`=SUMIF($B$9:$B$11,"MIXED",$P$9:$P$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$R$9:$R$11)-SUMIF($B$9:$B$11,"MIXED",$Q$9:$Q$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$T$9:$T$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$U$9:$U$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$V$9:$V$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$Y$9:$Y$11)`,
`=IF(O4>0,J4/O4*100,"")`,
`=IF(O4>0,N4/O4*100,"")`,
]];
styleBody(summary.getRange("J4:Q4"));
summary.getRange("J4:Q4").format.numberFormat = "0.000000";
summary.getRange("A7:AB7").values = [["区间", "类型", "有效样本", "起始候选行", "结束候选行", "起始报文ID", "结束报文ID", "起始时间", "结束时间", "起始压力", "结束压力", "起始温度", "结束温度", "起始质量", "结束质量", "物理耗氢", "起始SOC", "结束SOC", "SOC差值", "电池净放电", "电量折氢", "SOC平衡氢耗", "起始里程", "结束里程", "区间里程", "物理百公里", "SOC平衡百公里", "是否计入日氢耗"]];
styleHeader(summary.getRange("A7:AB7"));
const intervalSpecs = [
{ row: 9, index: 1, type: "MIXED", sampleCount: 29, startRows: [8, 12], endRows: [32, 36] },
{ row: 10, index: 2, type: "PURE_ELECTRIC", sampleCount: 14, startRows: [68, 72], endRows: [77, 81] },
{ row: 11, index: 3, type: "MIXED", sampleCount: 34, startRows: [82, 86], endRows: [111, 115] },
];
for (const spec of intervalSpecs) {
const r = spec.row;
const [ss, se] = spec.startRows;
const [es, ee] = spec.endRows;
summary.getRange(`A${r}:E${r}`).values = [[spec.index, spec.type, spec.sampleCount, `${ss}:${se}`, `${es}:${ee}`]];
summary.getRange(`F${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${ss}:$B$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
summary.getRange(`G${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${es}:$B$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
summary.getRange(`H${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${ss}:$C$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
summary.getRange(`I${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${es}:$C$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
const sourceCols = { J: "D", K: "D", L: "E", M: "E", N: "T", O: "T", Q: "AA", R: "AA", W: "AB", X: "AB" };
for (const [targetCol, sourceCol] of Object.entries(sourceCols)) {
const isStart = ["J", "L", "N", "Q", "W"].includes(targetCol);
const rs = isStart ? ss : es;
const re = isStart ? se : ee;
summary.getRange(`${targetCol}${r}`).formulas = [[`=INDEX('样本计算明细'!$${sourceCol}$${rs}:$${sourceCol}$${re},MATCH(MEDIAN('样本计算明细'!$T$${rs}:$T$${re}),'样本计算明细'!$T$${rs}:$T$${re},0))`]];
}
summary.getRange(`P${r}`).formulas = [[spec.type === "MIXED" ? `=MAX(0,N${r}-O${r})` : `=0`]];
summary.getRange(`S${r}`).formulas = [[`=R${r}-Q${r}`]];
summary.getRange(`T${r}`).formulas = [[spec.type === "MIXED" ? `='参数与系数'!$B$5*(Q${r}-R${r})/100` : `=""`]];
summary.getRange(`U${r}`).formulas = [[spec.type === "MIXED" ? `=T${r}/'参数与系数'!$B$6` : `=""`]];
summary.getRange(`V${r}`).formulas = [[spec.type === "MIXED" ? `=P${r}+U${r}` : `=""`]];
summary.getRange(`Y${r}`).formulas = [[`=MAX(0,X${r}-W${r})`]];
summary.getRange(`Z${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,P${r}/Y${r}*100,"")` : `=""`]];
summary.getRange(`AA${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,V${r}/Y${r}*100,"")` : `=""`]];
summary.getRange(`AB${r}`).values = [[spec.type === "MIXED" ? "是" : "否(仅计纯电里程)"]];
}
styleBody(summary.getRange("A9:AB11"));
summary.getRange("H9:I11").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
summary.getRange("J9:AA11").format.numberFormat = "0.000000";
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "MIXED", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "PURE_ELECTRIC", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
summary.getRange("A14:AB18").values = [
["人工核验顺序", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["1. 在“原始报文120条”核对每条车辆状态、充电状态、运行模式、压力、温度、SOC和仪表里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["2. 在“样本计算明细”逐项复核Tₖ、9个Z分项、压缩因子Z、分子、分母、质量及参与计算原因。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["3. 在本页核对每个区间首尾5条候选样本的中位质量、对应原始报文ID、物理耗氢、SOC修正和里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["4. 停车充电区间不计氢耗;充电结束后重新建立基线。纯电区间只累计纯电里程,不计入混动氢耗分母。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
];
for (let row = 14; row <= 18; row++) summary.getRange(`A${row}:AB${row}`).merge();
summary.getRange("A14:AB14").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
summary.getRange("A15:AB18").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
summary.freezePanes.freezeRows(7);
summary.freezePanes.freezeColumns(2);
setWidths(summary, [8, 18, 12, 14, 14, 24, 24, 20, 20, 13, 13, 13, 13, 15, 15, 15, 13, 13, 13, 16, 14, 17, 15, 15, 14, 15, 17, 20]);
// 说明首页,关键结果均跨表引用。
styleTitle(guide, guide.getRange("A1:H1"), "氢耗算法120组人工验算底稿");
guide.getRange("A3:H4").merge();
guide.getRange("A3").values = [["用途:供人工使用原始字段逐步复核压力—质量换算、充电剔除、充电后重建基线、纯电/混动分段、SOC能量修正及最终日氢耗。样本为验算构造数据,不代表生产车辆真实报文。"]];
guide.getRange("A3:H4").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true, verticalAlignment: "center" };
guide.getRange("A6:H6").values = [["核验项目", "数量/结果", "口径", "核验项目", "数量/结果", "口径", "状态", "备注"]];
styleHeader(guide.getRange("A6:H6"));
guide.getRange("A7:H10").values = [
["NIST压力质量样本", null, "12压力×10温度", "原始报文样本", null, "含停车充电和充电后重基线", null, ""],
["NIST通过数", null, "系统值与Excel公式差≤1e-10", "参与计算样本", null, "上/下电窗口及充电剔除后", null, ""],
["物理耗氢", null, "仅有效混动区间", "SOC平衡氢耗", null, "物理耗氢+电池净放电折氢", null, ""],
["混动里程", null, "仅混动区间", "纯电里程", null, "只统计,不进入混动氢耗分母", null, ""],
];
guide.getRange("B7").formulas = [[`=COUNTA('120组压力质量验算'!$A$2:$A$121)`]];
guide.getRange("E7").formulas = [[`=COUNTA('原始报文120条'!$A$2:$A$121)`]];
guide.getRange("G7").formulas = [[`=IF(AND(B7=120,E7=120),"完整","不完整")`]];
guide.getRange("B8").formulas = [[`=COUNTIF('120组压力质量验算'!$U$2:$U$121,"通过")`]];
guide.getRange("E8").formulas = [[`='每日与区间汇总'!$C$4`]];
guide.getRange("G8").formulas = [[`=IF(B8=120,"通过","不通过")`]];
guide.getRange("B9").formulas = [[`='每日与区间汇总'!$J$4`]];
guide.getRange("E9").formulas = [[`='每日与区间汇总'!$N$4`]];
guide.getRange("G9").formulas = [[`='每日与区间汇总'!$H$4`]];
guide.getRange("B10").formulas = [[`='每日与区间汇总'!$O$4`]];
guide.getRange("E10").formulas = [[`=SUMIF('每日与区间汇总'!$B$9:$B$11,"PURE_ELECTRIC",'每日与区间汇总'!$Y$9:$Y$11)`]];
guide.getRange("G10").values = [["可追溯"]];
styleBody(guide.getRange("A7:H10"));
guide.getRange("B9:B10").format.numberFormat = "0.000000";
guide.getRange("E9:E10").format.numberFormat = "0.000000";
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "完整", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "可追溯", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
guide.getRange("A13:H13").values = [["工作表", "核验内容", "人工操作", "", "", "", "", ""]];
styleHeader(guide.getRange("A13:H13"));
const guideRows = [
["参数与系数", "所有常量和9组NIST系数", "先确认车型参数,再复核公式引用"],
["120组压力质量验算", "120组压力/温度的完整中间值和最终差异", "逐行查看Z分项→Z→分子/分母→质量→通过结果"],
["原始报文120条", "车辆状态、充电状态、运行模式、压力、温度、SOC、里程及JSON", "核对原始字段,不修改计算列"],
["样本计算明细", "每条报文换算质量、边界过滤、充电识别和区间归属", "按报文ID追踪排除原因及端点候选"],
["每日与区间汇总", "端点中位数、分段耗氢、SOC修正、里程和百公里氢耗", "从最终结果反查起止报文ID"],
];
guide.getRange("A14:C18").values = guideRows;
for (let row = 14; row <= 18; row++) guide.getRange(`C${row}:H${row}`).merge();
styleBody(guide.getRange("A14:H18"));
guide.getRange("A21:H21").merge();
guide.getRange("A21").values = [["关键规则:停车充电 = 车辆状态为熄火(2) 且充电状态为停车充电(1)。充电期间不计算氢耗;充电结束后重新建立SOC、压力、温度和里程基线,仅对运行区间SOC变化进行氢气等效修正。"]];
guide.getRange("A21:H21").format = { fill: colors.orange, font: { bold: true, color: colors.orangeText }, wrapText: true };
guide.getRange("A21:H21").format.rowHeight = 48;
setWidths(guide, [24, 18, 34, 24, 18, 34, 14, 28]);
await fs.mkdir(previewDir, { recursive: true });
const checks = {};
for (const [name, range] of [
["guide", "验算说明!A1:H21"],
["parameters", "参数与系数!A1:F33"],
["grid", "120组压力质量验算!A1:U12"],
["raw", "原始报文120条!A1:N15"],
["detail", "样本计算明细!A1:AL15"],
["summary", "每日与区间汇总!A1:AB18"],
]) {
const [sheetName, a1] = range.split("!");
const inspection = await workbook.inspect({ kind: "table", sheetId: sheetName, range: a1, include: "values,formulas", tableMaxRows: 20, tableMaxCols: 40, maxChars: 12000 });
checks[name] = inspection.ndjson;
}
const formulaErrors = await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "final formula error scan",
maxChars: 10000,
});
for (const sheetName of ["验算说明", "参数与系数", "120组压力质量验算", "原始报文120条", "样本计算明细", "每日与区间汇总"]) {
const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 0.8, format: "png" });
await fs.writeFile(`${previewDir}/${sheetName}.png`, new Uint8Array(await preview.arrayBuffer()));
}
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
await xlsx.save(outputPath);
await fs.writeFile(`${outputDir}/verification.json`, JSON.stringify({ outputPath, checks, formulaErrors: formulaErrors.ndjson }, null, 2));
console.log(JSON.stringify({ outputPath, previewDir, formulaErrors: formulaErrors.ndjson }));
@@ -0,0 +1,446 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const evidencePath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
const outputPath = path.join(outputDir, "15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx");
const previewDir = path.join(outputDir, "preview");
const data = JSON.parse(await fs.readFile(evidencePath, "utf8"));
const workbook = Workbook.create();
const summary = workbook.worksheets.add("申诉总览");
const notes = workbook.worksheets.add("口径与证据说明");
const vehicleSheets = new Map();
for (const vehicle of data.vehicles) {
const sheetName = `${String(vehicle.seq).padStart(2, "0")}-${vehicle.plate}`;
vehicleSheets.set(vehicle.vin, { sheetName, sheet: workbook.worksheets.add(sheetName) });
}
const palette = {
navy: "#1F4E78",
blue: "#D9EAF7",
blue2: "#EAF3F8",
yellow: "#FFF2CC",
amber: "#FCE4D6",
mileageHeader: "#C65911",
mileageLight: "#FFF2CC",
mileageStrong: "#FFD966",
green: "#E2F0D9",
gray: "#E7E6E6",
dark: "#1F2937",
muted: "#5B6573",
border: "#B7C9D6",
white: "#FFFFFF",
};
const thinBorders = { preset: "all", style: "thin", color: palette.border };
const titleFormat = {
fill: palette.navy,
font: { bold: true, color: palette.white, fontSize: 16, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
};
const sectionFormat = {
fill: palette.blue,
font: { bold: true, color: palette.dark, fontSize: 11, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
borders: thinBorders,
};
const headerFormat = {
fill: palette.navy,
font: { bold: true, color: palette.white, fontSize: 9, typeface: "Microsoft YaHei" },
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
const labelFormat = {
fill: palette.blue2,
font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
const valueFormat = {
font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
function asDate(day) {
return new Date(`${day}T00:00:00Z`);
}
function addDays(day, count) {
const date = asDate(day);
date.setUTCDate(date.getUTCDate() + count);
return date.toISOString().slice(0, 10);
}
function calendarDays(start, end) {
const days = [];
for (let day = start; day <= end; day = addDays(day, 1)) days.push(day);
return days;
}
function absDelta(a, b) {
if (a == null || b == null || Number.isNaN(Number(a)) || Number.isNaN(Number(b))) return null;
return Math.abs(Number(a) - Number(b));
}
function rawRange(vehicle) {
const rows = vehicle.rawEvidence.filter((row) => row.status === "OK");
if (rows.length === 0) return "无";
return `${rows[0].day}${rows.at(-1).day}`;
}
function evidenceLevel(metric, raw) {
if (raw?.status === "OK") return "A-原始32960报文";
if (metric?.source_code === "legacy_lingniu_prod") return "C-历史日统计迁移(无RAW";
if (metric) return "B-中台32960日统计(无RAW";
return "D-无中台日记录";
}
function rowNote(metric, raw) {
const notes = [];
if (metric?.source_code === "legacy_lingniu_prod") notes.push("历史生产库 ln_vehicle_day_mileage 迁移记录,不是原始帧");
if (raw?.status === "OK" && !metric) notes.push("有原始报文,但中台日统计无记录");
if (raw?.status === "OK" && metric && absDelta(metric.latest_total_mileage_km, raw.parsedTotalMileageKm) > 0.1) {
notes.push("日统计日末值与本日最后可得RAW读数不一致,详见数据源/时间;可能涉及人工源选举或跨日延迟");
}
if (raw?.status !== "OK" && metric && metric.source_code !== "legacy_lingniu_prod") notes.push("有中台日统计,但当日未检出含仪表总里程的RAW帧");
if (!metric && raw?.status !== "OK") notes.push("当日无中台日记录,且现行RAW历史不可用或未检出");
return notes.join("");
}
function setStandardSheet(sheet) {
sheet.showGridLines = false;
sheet.freezePanes.freezeRows(13);
sheet.freezePanes.freezeColumns(1);
}
for (const vehicle of data.vehicles) {
const { sheetName, sheet } = vehicleSheets.get(vehicle.vin);
setStandardSheet(sheet);
sheet.getRange("A1:P1").merge();
sheet.getRange("A1").values = [[`${vehicle.plate} 里程核验申诉明细`]];
sheet.getRange("A1:P1").format = titleFormat;
sheet.getRange("A1:P1").format.rowHeight = 30;
sheet.getRange("A2:P2").merge();
sheet.getRange("A2").values = [["核验口径:GB/T 32960 车辆仪表累计总里程;原始报文列保存完整HEX,点击单元格可在公式栏复制。相邻自然日均有RAW读数时才计算RAW日差。"]];
sheet.getRange("A2:P2").format = { fill: palette.yellow, font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
sheet.getRange("A2:P2").format.rowHeight = 30;
sheet.getRange("A4:P6").format = valueFormat;
sheet.getRange("A4").values = [["车牌"]];
sheet.getRange("B4:C4").merge(); sheet.getRange("B4").values = [[vehicle.plate]];
sheet.getRange("D4").values = [["VIN"]];
sheet.getRange("E4:H4").merge(); sheet.getRange("E4").values = [[vehicle.vin]];
sheet.getRange("I4").values = [["车型"]];
sheet.getRange("J4:K4").merge(); sheet.getRange("J4").values = [[vehicle.model]];
sheet.getRange("L4").values = [["协议"]];
sheet.getRange("M4:P4").merge(); sheet.getRange("M4").values = [["GB32960(仪表累计总里程)"]];
sheet.getRange("A5").values = [["核验区间"]];
sheet.getRange("B5:C5").merge(); sheet.getRange("B5").values = [["2025-11-28 至 2026-07-31"]];
sheet.getRange("D5").values = [["原TBOX合计(km)"]];
sheet.getRange("E5:F5").merge(); sheet.getRange("E5").values = [[vehicle.sourceTboxKm]];
sheet.getRange("G5").values = [["第三方平台合计(km)"]];
sheet.getRange("H5:I5").merge(); sheet.getRange("H5").values = [[vehicle.platformKm]];
sheet.getRange("J5").values = [["原表损耗(km)"]];
sheet.getRange("K5:L5").merge(); sheet.getRange("K5").values = [[vehicle.lossKm]];
sheet.getRange("M5").values = [["原申诉里程(km)"]];
sheet.getRange("N5:P5").merge(); sheet.getRange("N5").values = [[vehicle.appealKm]];
sheet.getRange("E5:F5").format.numberFormat = "#,##0.0";
sheet.getRange("H5:I5").format.numberFormat = "#,##0.0";
sheet.getRange("K5:L5").format.numberFormat = "#,##0.0";
sheet.getRange("N5:P5").format.numberFormat = "#,##0.0";
sheet.getRange("K5:L5").format.fill = palette.yellow;
sheet.getRange("A6").values = [["异常标记"]];
sheet.getRange("B6:D6").merge(); sheet.getRange("B6").values = [[vehicle.anomaly]];
sheet.getRange("E6").values = [["原排查结论"]];
sheet.getRange("F6:P6").merge(); sheet.getRange("F6").values = [[vehicle.note]];
for (const address of ["A4", "A5", "A6", "D4", "D5", "E6", "G5", "I4", "J5", "L4", "M5"]) sheet.getRange(address).format = labelFormat;
sheet.getRange("A8:P8").format = valueFormat;
sheet.getRange("A8").values = [["原车辆统计合计(km)"]];
sheet.getRange("B8:C8").merge(); sheet.getRange("B8").values = [[vehicle.sourceTboxKm]];
sheet.getRange("D8").values = [["车辆数据中台统计合计(km)"]];
sheet.getRange("E8:F8").merge(); sheet.getRange("E8").formulas = [["=SUM(B14:B259)"]];
sheet.getRange("G8").values = [["中台日统计有值天数"]];
sheet.getRange("H8:I8").merge(); sheet.getRange("H8").formulas = [["=COUNT(B14:B259)"]];
sheet.getRange("J8").values = [["RAW仪表证据天数"]];
sheet.getRange("K8:L8").merge(); sheet.getRange("K8").formulas = [["=COUNT(F14:F259)"]];
sheet.getRange("M8").values = [["RAW证据日期范围"]];
sheet.getRange("N8:P8").merge(); sheet.getRange("N8").values = [[rawRange(vehicle)]];
for (const address of ["A8", "D8", "G8", "J8", "M8"]) sheet.getRange(address).format = labelFormat;
sheet.getRange("B8:C8").format.numberFormat = "#,##0.0";
sheet.getRange("E8:F8").format.numberFormat = "#,##0.0";
sheet.getRange("B8:C8").format.fill = palette.blue2;
sheet.getRange("E8:F8").format.fill = palette.mileageStrong;
sheet.getRange("E8:F8").format.font = { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" };
sheet.getRange("A8:P8").format.rowHeight = 38;
sheet.getRange("A9").values = [["证据限制"]];
sheet.getRange("B9:P9").merge();
sheet.getRange("B9").values = [["现行TDengine RAW历史自2026-07-01起可查询;此前仅有历史生产库日统计迁移记录。材料已按A/B/C/D分级,未将统计记录伪称为原始报文。"]];
sheet.getRange("A9").format = labelFormat;
sheet.getRange("B9:P9").format = { ...valueFormat, fill: palette.amber };
sheet.getRange("A9:P9").format.rowHeight = 30;
sheet.getRange("A11:P11").merge();
sheet.getRange("A11").values = [["每日核验明细(完整日历)"]];
sheet.getRange("A11:P11").format = sectionFormat;
sheet.getRange("A12:D12").merge();
sheet.getRange("A12").values = [["车辆数据中台区间里程总计"]];
sheet.getRange("E12:F12").merge();
sheet.getRange("E12").formulas = [["=SUM(B14:B259)"]];
sheet.getRange("G12:P12").merge();
sheet.getRange("G12").values = [["计算范围:B14:B259(下方“中台当日里程(km)”列)"]];
sheet.getRange("A12:D12").format = {
...headerFormat,
fill: palette.mileageHeader,
horizontalAlignment: "left",
};
sheet.getRange("E12:F12").format = {
...valueFormat,
fill: palette.mileageStrong,
font: { bold: true, color: "#7F6000", fontSize: 11, typeface: "Microsoft YaHei" },
numberFormat: "#,##0.0",
horizontalAlignment: "right",
};
sheet.getRange("G12:P12").format = {
...valueFormat,
fill: palette.mileageLight,
font: { color: palette.textMuted, fontSize: 9, typeface: "Microsoft YaHei" },
};
sheet.getRange("A12:P12").format.rowHeight = 30;
const headers = ["日期", "中台当日里程(km)", "中台日末仪表总里程(km)", "日统计数据源", "日统计来源端点", "RAW解析仪表总里程(km)", "RAW相邻日差(km)", "日统计-RAW差(km)", "证据等级", "报文事件时间", "中台接收时间", "RAW来源端点", "Event ID", "Frame ID", "原始报文大小(bytes)", "原始GB32960报文HEX(完整)"];
sheet.getRange("A13:P13").values = [headers];
sheet.getRange("A13:P13").format = headerFormat;
sheet.getRange("A13:P13").format.rowHeight = 42;
const metricByDay = new Map(vehicle.dailyMetrics.map((row) => [row.stat_date, row]));
const rawByDay = new Map(vehicle.rawEvidence.map((row) => [row.day, row]));
const days = calendarDays(data.period.start, data.period.end);
const rows = days.map((day) => {
const metric = metricByDay.get(day);
const raw = rawByDay.get(day);
return [
asDate(day),
metric?.daily_mileage_km ?? null,
metric?.latest_total_mileage_km ?? null,
metric ? `${metric.platform_name || ""} / ${metric.source_code || ""}` : "",
metric?.latest_source_endpoint ?? "",
raw?.status === "OK" ? raw.parsedTotalMileageKm : null,
null,
null,
evidenceLevel(metric, raw),
raw?.status === "OK" ? raw.eventTime : "",
raw?.status === "OK" ? raw.receivedAt : "",
raw?.status === "OK" ? raw.sourceEndpoint : "",
raw?.status === "OK" ? raw.eventId : "",
raw?.status === "OK" ? raw.frameId : "",
raw?.status === "OK" ? raw.rawSizeBytes : null,
raw?.status === "OK" ? raw.rawHex : "",
];
});
sheet.getRange("A14:P259").values = rows;
sheet.getRange("A14:A259").format.numberFormat = "yyyy-mm-dd";
sheet.getRange("B14:C259").format.numberFormat = "#,##0.0";
sheet.getRange("F14:H259").format.numberFormat = "#,##0.0";
sheet.getRange("O14:O259").format.numberFormat = "#,##0";
sheet.getRange("A14:P259").format = {
font: { color: palette.dark, fontSize: 8, typeface: "Microsoft YaHei" },
verticalAlignment: "center",
borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } },
};
sheet.getRange("A14:A259").format.horizontalAlignment = "center";
sheet.getRange("B14:C259").format.horizontalAlignment = "right";
sheet.getRange("F14:H259").format.horizontalAlignment = "right";
sheet.getRange("I14:O259").format.wrapText = true;
sheet.getRange("P14:P259").format = { font: { color: palette.muted, fontSize: 7, typeface: "Consolas" }, wrapText: true, verticalAlignment: "top" };
sheet.getRange("A14:P259").format.rowHeight = 34;
sheet.getRange("G14").values = [[null]];
for (let row = 15; row <= 259; row += 1) {
sheet.getRange(`G${row}`).formulas = [[`=IF(OR(F${row}="",F${row - 1}=""),"",F${row}-F${row - 1})`]];
}
for (let row = 14; row <= 259; row += 1) {
sheet.getRange(`H${row}`).formulas = [[`=IF(OR(C${row}="",F${row}=""),"",C${row}-F${row})`]];
}
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "A-", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "C-", format: { fill: palette.yellow, font: { color: "#7F6000" } } });
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "D-", format: { fill: palette.gray, font: { color: palette.muted } } });
sheet.getRange("H14:H259").conditionalFormats.addCustom("=ABS($H14)>0.1", { fill: palette.amber, font: { color: "#9C0006", bold: true } });
const table = sheet.tables.add("A13:P259", true, `Vehicle${String(vehicle.seq).padStart(2, "0")}Evidence`);
table.style = "TableStyleMedium2";
table.showBandedColumns = false;
table.showFilterButton = true;
sheet.getRange("B13").format = { ...headerFormat, fill: palette.mileageHeader };
sheet.getRange("B14:B259").format.fill = palette.mileageLight;
sheet.getRange("B14:B259").conditionalFormats.add("cellIs", {
operator: "greaterThan",
formula: 0,
format: { fill: palette.mileageStrong, font: { bold: true, color: "#7F6000" } },
});
const widths = [12, 17, 20, 24, 26, 20, 17, 18, 25, 20, 20, 22, 34, 34, 14, 72];
for (let col = 0; col < widths.length; col += 1) sheet.getRangeByIndexes(0, col, 259, 1).format.columnWidth = widths[col];
}
summary.showGridLines = false;
summary.freezePanes.freezeRows(4);
summary.getRange("A1:P1").merge();
summary.getRange("A1").values = [["15辆车里程核验申诉总览"]];
summary.getRange("A1:P1").format = titleFormat;
summary.getRange("A1:P1").format.rowHeight = 32;
summary.getRange("A2:P2").merge();
summary.getRange("A2").values = [["结论:车辆数据中台GB32960日里程复核合计与原异常表TBOX区间里程逐车一致;第三方平台合计显著偏低。RAW原始帧按现有可查历史提供,早期记录已明确标注证据限制。"]];
summary.getRange("A2:P2").format = { fill: palette.green, font: { bold: true, color: "#375623", fontSize: 10, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
summary.getRange("A2:P2").format.rowHeight = 34;
summary.getRange("A3:B3").merge(); summary.getRange("A3").values = [["全部15辆车·原车辆统计(km)"]];
summary.getRange("C3").formulas = [["=F20"]];
summary.getRange("D3:E3").merge(); summary.getRange("D3").values = [["车辆数据中台统计(km)"]];
summary.getRange("F3").formulas = [["=G20"]];
summary.getRange("G3:H3").merge(); summary.getRange("G3").values = [["第三方平台统计(km)"]];
summary.getRange("I3").formulas = [["=H20"]];
summary.getRange("J3:K3").merge(); summary.getRange("J3").values = [["平台漏计差异(km)"]];
summary.getRange("L3").formulas = [["=I20"]];
summary.getRange("M3:N3").merge(); summary.getRange("M3").values = [["RAW仪表证据(天)"]];
summary.getRange("O3:P3").merge(); summary.getRange("O3").formulas = [["=M20"]];
for (const address of ["A3:B3", "D3:E3", "G3:H3", "J3:K3", "M3:N3"]) summary.getRange(address).format = labelFormat;
for (const address of ["C3", "F3", "I3", "L3", "O3:P3"]) summary.getRange(address).format = { ...valueFormat, fill: palette.mileageLight, font: { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" }, horizontalAlignment: "right" };
summary.getRange("C3:L3").format.numberFormat = "#,##0.0";
summary.getRange("O3:P3").format.numberFormat = "#,##0";
summary.getRange("A3:P3").format.rowHeight = 34;
const summaryHeaders = ["序号", "车型", "车牌", "VIN", "核验区间", "原TBOX合计(km)", "中台复核合计(km)", "第三方平台合计(km)", "损耗(km)", "损耗率", "申诉里程(km)", "日统计天数", "RAW证据天数", "RAW证据范围", "核验结论", "原排查结论"];
summary.getRange("A4:P4").values = [summaryHeaders];
summary.getRange("A4:P4").format = headerFormat;
summary.getRange("A4:P4").format.rowHeight = 40;
const summaryRows = data.vehicles.map((vehicle) => [
vehicle.seq, vehicle.model, vehicle.plate, vehicle.vin, "2025-11-28 至 2026-07-31", vehicle.sourceTboxKm, null, vehicle.platformKm, null, null, vehicle.appealKm, null, null, rawRange(vehicle), null, vehicle.note,
]);
summary.getRange("A5:P19").values = summaryRows;
for (let index = 0; index < data.vehicles.length; index += 1) {
const row = 5 + index;
const { sheetName } = vehicleSheets.get(data.vehicles[index].vin);
summary.getRange(`G${row}`).formulas = [[`='${sheetName}'!E8`]];
summary.getRange(`I${row}`).formulas = [[`=F${row}-H${row}`]];
summary.getRange(`J${row}`).formulas = [[`=IF(F${row}=0,"",I${row}/F${row})`]];
summary.getRange(`L${row}`).formulas = [[`='${sheetName}'!H8`]];
summary.getRange(`M${row}`).formulas = [[`='${sheetName}'!K8`]];
summary.getRange(`O${row}`).formulas = [[`=IF(ABS(G${row}-F${row})<0.1,"中台复核合计与原TBOX一致","需复核中台合计")`]];
}
summary.getRange("A20:E20").merge(); summary.getRange("A20").values = [["合计"]];
summary.getRange("F20").formulas = [["=SUM(F5:F19)"]];
summary.getRange("G20").formulas = [["=SUM(G5:G19)"]];
summary.getRange("H20").formulas = [["=SUM(H5:H19)"]];
summary.getRange("I20").formulas = [["=SUM(I5:I19)"]];
summary.getRange("J20").formulas = [["=IF(F20=0,\"\",I20/F20)"]];
summary.getRange("K20").formulas = [["=SUM(K5:K19)"]];
summary.getRange("L20").formulas = [["=SUM(L5:L19)"]];
summary.getRange("M20").formulas = [["=SUM(M5:M19)"]];
summary.getRange("N20:P20").merge(); summary.getRange("N20").values = [["15车合计;RAW证据为现行历史可查询范围"]];
summary.getRange("A20:P20").format = { fill: palette.blue, font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, borders: thinBorders, verticalAlignment: "center", wrapText: true };
summary.getRange("A5:P19").format = { font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, verticalAlignment: "center", wrapText: true, borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } } };
summary.getRange("F5:I20").format.numberFormat = "#,##0.0";
summary.getRange("J5:J20").format.numberFormat = "0.00%";
summary.getRange("K5:K20").format.numberFormat = "#,##0.0";
summary.getRange("L5:M20").format.numberFormat = "#,##0";
summary.getRange("I5:I19").format.fill = palette.yellow;
summary.getRange("O5:O19").conditionalFormats.add("containsText", { text: "一致", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
const summaryTable = summary.tables.add("A4:P19", true, "AppealSummary");
summaryTable.style = "TableStyleMedium2";
summaryTable.showFilterButton = true;
const summaryWidths = [8, 9, 14, 22, 22, 16, 18, 18, 15, 12, 16, 13, 14, 24, 26, 54];
for (let col = 0; col < summaryWidths.length; col += 1) summary.getRangeByIndexes(0, col, 20, 1).format.columnWidth = summaryWidths[col];
summary.getRange("A5:P19").format.rowHeight = 42;
notes.showGridLines = false;
notes.getRange("A1:H1").merge();
notes.getRange("A1").values = [["核验口径与证据说明"]];
notes.getRange("A1:H1").format = titleFormat;
notes.getRange("A1:H1").format.rowHeight = 32;
notes.getRange("A3:H3").merge(); notes.getRange("A3").values = [["1. 核验口径"]]; notes.getRange("A3:H3").format = sectionFormat;
notes.getRange("A4:B8").values = [
["协议", "GB/T 32960"],
["仪表总里程字段", "gb32960.vehicle.total_mileage_km"],
["仪表总里程含义", "车辆仪表盘累计总里程,单位 km;不同协议累计值不可拼接。"],
["中台当日里程", "生产 vehicle_daily_mileage 的 GB32960 日统计值;本材料逐车求和后与原异常表TBOX合计核对。"],
["RAW相邻日差", "仅相邻两个自然日都存在RAW仪表读数时计算;缺任一日则留空,不把跨日缺口跳变塞进单日。"],
];
notes.getRange("A4:A8").format = labelFormat;
notes.getRange("B4:H8").merge(true);
notes.getRange("B4:H8").format = valueFormat;
notes.getRange("A10:H10").merge(); notes.getRange("A10").values = [["2. 证据分级"]]; notes.getRange("A10:H10").format = sectionFormat;
notes.getRange("A11:B14").values = [
["A-原始32960报文", "中台TDengine保存的完整GB32960十六进制报文,且解析结果明确包含仪表总里程字段。"],
["B-中台32960日统计", "中台日统计存在,但当日未检出含仪表总里程的RAW帧。"],
["C-历史日统计迁移", "来自历史生产库 lingniu_prod.ln_vehicle_day_mileage 的迁移记录,不是原始帧。"],
["D-无中台日记录", "当日中台日统计无记录,且现行RAW历史不可用或未检出。"],
];
notes.getRange("A11:A14").format = labelFormat;
notes.getRange("B11:H14").merge(true);
notes.getRange("B11:H14").format = valueFormat;
notes.getRange("A11:H11").format.fill = palette.green;
notes.getRange("A13:H13").format.fill = palette.yellow;
notes.getRange("A14:H14").format.fill = palette.gray;
notes.getRange("A16:H16").merge(); notes.getRange("A16").values = [["3. 数据来源与限制"]]; notes.getRange("A16:H16").format = sectionFormat;
notes.getRange("A17:B21").values = [
["原异常表", "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx"],
["中台日统计API", "http://115.29.187.205:20200/api/stats/daily-metrics"],
["中台RAW历史API", "http://115.29.187.205:20200/api/history/raw-frames"],
["RAW可查范围", "本次提取范围2026-07-01至2026-07-3115车共取得334个含仪表总里程字段的完整原始帧。"],
["重要限制", "2026-07-01之前现行TDengine没有可查询RAW历史;只能提供历史生产库日统计迁移记录。对外申诉时应如实说明,并向原TSP/车厂补调更早期原始报文归档。"],
];
notes.getRange("A17:A21").format = labelFormat;
notes.getRange("B17:H21").merge(true);
notes.getRange("B17:H21").format = valueFormat;
notes.getRange("A21:H21").format.fill = palette.amber;
notes.getRange("A23:H23").merge(); notes.getRange("A23").values = [["4. 使用建议"]]; notes.getRange("A23:H23").format = sectionFormat;
notes.getRange("A24:H27").values = [
["① 先提交“申诉总览”,证明15车中台GB32960日里程合计与原TBOX合计一致。", null, null, null, null, null, null, null],
["② 每车附对应Sheet,重点引用A类RAW证据、事件时间、接收时间和完整HEX。", null, null, null, null, null, null, null],
["③ 对第三方缺失日,用完整日历中的D级空缺、前后仪表累计变化和原排查结论说明漏传。", null, null, null, null, null, null, null],
["④ 申诉若要求全区间逐日原始报文,需由原TSP/车厂补调2026-07-01之前归档;本材料未虚构缺失证据。", null, null, null, null, null, null, null],
];
for (let row = 24; row <= 27; row += 1) notes.getRange(`A${row}:H${row}`).merge();
notes.getRange("A24:H27").format = { ...valueFormat, fill: palette.blue2 };
notes.getRange("A4:H27").format.rowHeight = 30;
notes.getRange("A1:A27").format.columnWidth = 22;
for (let col = 1; col < 8; col += 1) notes.getRangeByIndexes(0, col, 27, 1).format.columnWidth = 18;
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(previewDir, { recursive: true });
const inspection = {};
inspection.summary = (await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 16, maxChars: 22000 })).ndjson;
inspection.formulaErrors = (await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" })).ndjson;
await fs.writeFile(path.join(outputDir, "verification.ndjson"), `${inspection.summary}\n${inspection.formulaErrors}\n`, "utf8");
const previewSheets = ["申诉总览", "口径与证据说明", ...data.vehicles.map((vehicle) => vehicleSheets.get(vehicle.vin).sheetName)];
for (let index = 0; index < previewSheets.length; index += 1) {
const sheetName = previewSheets[index];
const range = sheetName === "申诉总览" ? "A1:P20" : sheetName === "口径与证据说明" ? "A1:H27" : "A1:P24";
const preview = await workbook.render({ sheetName, range, scale: 1, format: "png" });
const safeName = sheetName.replaceAll(/[\\/:*?\[\]]/g, "_");
await fs.writeFile(path.join(previewDir, `${String(index + 1).padStart(2, "0")}-${safeName}.png`), new Uint8Array(await preview.arrayBuffer()));
}
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputPath);
console.log(JSON.stringify({ outputPath, sheets: previewSheets.length, previewDir }, null, 2));
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
const baseUrl = "http://115.29.187.205:20200";
const outputPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
const vehicles = [
{ seq: 1, model: "18T", plate: "粤A05995F", vin: "LNXNEGRR7SR321370", sourceTboxKm: 26180.6, platformKm: 4208.9, lossKm: 21971.7, lossRatePct: 83.92, anomaly: "里程损失,tbox异常", appealKm: 41497.1, note: "区间内仪表盘开始里程是576.6,结束里程是41497.1,差值40920.5,小于申诉里程;2026-05-11到2026-07-28间的数据没上传,仪表盘里程差了36306.6,2025-12-25到2026-01-01间还存在里程跳变导致里程缺失" },
{ seq: 2, model: "18T", plate: "粤A05839F", vin: "LNXNEGRR8SR321376", sourceTboxKm: 41030.3, platformKm: 19763.6, lossKm: 21266.7, lossRatePct: 51.83, anomaly: "里程损失", appealKm: 41030.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,特别是7月份仪表里程从7月1号的16341.2到7月31号的41848.9,相差25507.7,计算的总里程只有8470" },
{ seq: 3, model: "18T", plate: "粤A09369F", vin: "LNXNEGRR6SR321473", sourceTboxKm: 40381.5, platformKm: 24994.0, lossKm: 15387.5, lossRatePct: 38.11, anomaly: "里程损失", appealKm: 40381.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 4, model: "18T", plate: "粤A06658F", vin: "LNXNEGRR5SR321397", sourceTboxKm: 52386.5, platformKm: 33064.5, lossKm: 19322.0, lossRatePct: 36.88, anomaly: "里程损失", appealKm: 52386.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 5, model: "18T", plate: "粤A05700F", vin: "LNXNEGRR8SR321393", sourceTboxKm: 60054.3, platformKm: 41352.9, lossKm: 18701.4, lossRatePct: 31.14, anomaly: "里程损失", appealKm: 60054.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 6, model: "18T", plate: "粤A05906F", vin: "LNXNEGRR2SR319462", sourceTboxKm: 37216.8, platformKm: 26711.4, lossKm: 10505.4, lossRatePct: 28.23, anomaly: "里程损失", appealKm: 37216.8, note: "最新数据只到2026-06-30,仪表盘最后里程为28638.5,没有7月份的数据;也存在很多数据缺失导致的里程缺失,例如2026-03-26到2026-04-01,2026-04-21到2026-05-01间没上传数据" },
{ seq: 7, model: "4.5T", plate: "粤AGP9346", vin: "LB9A32A28R0LS1722", sourceTboxKm: 32337.2, platformKm: 28685.4, lossKm: 3651.8, lossRatePct: 11.29, anomaly: "里程损失", appealKm: 32337.2, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 8, model: "4.5T", plate: "粤AGP2017", vin: "LB9A32A28R0LS1445", sourceTboxKm: 48164.9, platformKm: 44357.1, lossKm: 3807.8, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 48164.9, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-31的47337.5,小于申诉里程" },
{ seq: 9, model: "4.5T", plate: "粤AGP5165", vin: "LB9A32A25R0LS1385", sourceTboxKm: 32906.4, platformKm: 30302.3, lossKm: 2604.1, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 32906.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-30的31994.6,2026-07-31没有数据上传" },
{ seq: 10, model: "4.5T", plate: "粤AGP4355", vin: "LB9A32A26R0LS1606", sourceTboxKm: 55151.0, platformKm: 51525.8, lossKm: 3625.2, lossRatePct: 6.57, anomaly: "里程损失", appealKm: 55151.0, note: "区间内仪表盘开始里程是1898.6,结束里程是55229.6,差值53331,小于申诉里程,也存在很多数据缺失导致的里程缺失" },
{ seq: 11, model: "4.5T", plate: "粤AGP5646", vin: "LB9A32A22R0LS1702", sourceTboxKm: 39935.0, platformKm: 37319.8, lossKm: 2615.2, lossRatePct: 6.55, anomaly: "里程损失", appealKm: 39935.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 12, model: "4.5T", plate: "粤AGP3692", vin: "LB9A32A27R0LS1727", sourceTboxKm: 64195.0, platformKm: 60242.6, lossKm: 3952.4, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 64195.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 13, model: "4.5T", plate: "粤AGP9782", vin: "LB9A32A28R0LS1705", sourceTboxKm: 50216.4, platformKm: 47123.8, lossKm: 3092.6, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 50216.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,如:2026-05-21数据缺失导致里程少了1300多" },
{ seq: 14, model: "4.5T", plate: "粤AGP3027", vin: "LB9A32A22R0LS1599", sourceTboxKm: 31180.9, platformKm: 29260.1, lossKm: 1920.8, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 31180.9, note: "区间内仪表盘开始里程是933.5,结束里程是31245.3,差值30311.8,小于申诉里程,也存在一些数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 15, model: "4.5T", plate: "粤AGP9751", vin: "LB9A32A28R0LS1574", sourceTboxKm: 17944.6, platformKm: 16861.0, lossKm: 1083.6, lossRatePct: 6.04, anomaly: "里程损失", appealKm: 17944.6, note: "区间内仪表盘开始里程是205.2,结束里程是17796.8,差值17591.6,小于申诉里程,也存在一些数据缺失导致的里程缺失" },
];
function dateString(date) {
return date.toISOString().slice(0, 10);
}
function addDays(day, count) {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + count);
return dateString(date);
}
function dateRange(start, end) {
const out = [];
for (let day = start; day <= end; day = addDays(day, 1)) out.push(day);
return out;
}
async function fetchJson(url, attempts = 4) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return await response.json();
} catch (error) {
lastError = error;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
}
}
throw lastError;
}
async function mapConcurrent(items, concurrency, worker) {
const results = new Array(items.length);
let cursor = 0;
async function run() {
while (true) {
const index = cursor;
cursor += 1;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
return results;
}
async function fetchDaily(vehicle) {
const params = new URLSearchParams({
vin: vehicle.vin,
protocol: "GB32960",
dateFrom: "2025-11-28",
dateTo: "2026-07-31",
limit: "500",
includeTotal: "true",
});
const data = await fetchJson(`${baseUrl}/api/stats/daily-metrics?${params}`);
return data.items ?? [];
}
async function fetchRawEvidence({ vehicle, day }) {
const params = new URLSearchParams({
protocol: "GB32960",
vin: vehicle.vin,
dateFrom: `${day} 00:00:00`,
dateTo: `${addDays(day, 1)} 00:00:00`,
orderBy: "eventTime",
limit: "20",
includeFields: "true",
includePayload: "true",
fields: "gb32960.vehicle.total_mileage_km",
});
let data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
let evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
if (!evidence && (data.items ?? []).length === 20) {
params.set("limit", "100");
data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
}
if (!evidence) return { day, status: "NO_RAW_ODOMETER_FRAME" };
return {
day,
status: "OK",
ts: evidence.ts ?? "",
eventTime: evidence.event_time ?? "",
receivedAt: evidence.received_at ?? "",
frameId: evidence.frame_id ?? "",
eventId: evidence.event_id ?? "",
messageIdHex: evidence.message_id_hex ?? "",
rawSizeBytes: evidence.raw_size_bytes ?? null,
rawHex: evidence.raw_hex ?? "",
parseStatus: evidence.parse_status ?? "",
parseError: evidence.parse_error ?? "",
sourceEndpoint: evidence.source_endpoint ?? "",
parsedTotalMileageKm: Number(evidence.parsed_fields["gb32960.vehicle.total_mileage_km"]),
};
}
const dailyLists = await mapConcurrent(vehicles, 6, fetchDaily);
const rawDays = dateRange("2026-07-01", "2026-07-31");
const rawTasks = vehicles.flatMap((vehicle) => rawDays.map((day) => ({ vehicle, day })));
let completed = 0;
const rawResults = await mapConcurrent(rawTasks, 10, async (task) => {
const result = await fetchRawEvidence(task);
completed += 1;
if (completed % 50 === 0 || completed === rawTasks.length) console.log(`raw evidence ${completed}/${rawTasks.length}`);
return { vin: task.vehicle.vin, ...result };
});
const byVinRaw = new Map();
for (const item of rawResults) {
if (!byVinRaw.has(item.vin)) byVinRaw.set(item.vin, []);
byVinRaw.get(item.vin).push(item);
}
const result = {
generatedAt: new Date().toISOString(),
baseUrl,
period: { start: "2025-11-28", end: "2026-07-31" },
protocol: "GB32960",
parsedMileageField: "gb32960.vehicle.total_mileage_km",
rawEvidenceAvailability: { start: "2026-07-01", end: "2026-07-31", note: "现行TDengine RAW历史自2026-07-01起;此前中台仅保留历史生产库日统计迁移记录,不能伪称原始帧。" },
vehicles: vehicles.map((vehicle, index) => ({
...vehicle,
dailyMetrics: dailyLists[index],
rawEvidence: (byVinRaw.get(vehicle.vin) ?? []).sort((a, b) => a.day.localeCompare(b.day)),
})),
};
await fs.writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
console.log(JSON.stringify({
outputPath,
vehicles: result.vehicles.length,
dailyRows: result.vehicles.reduce((sum, item) => sum + item.dailyMetrics.length, 0),
rawEvidenceRows: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status === "OK").length, 0),
rawMissingDays: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status !== "OK").length, 0),
}, null, 2));
@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath = "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/source-preview";
await fs.mkdir(outputDir, { recursive: true });
const input = await FileBlob.load(inputPath);
const workbook = await SpreadsheetFile.importXlsx(input);
const overview = await workbook.inspect({
kind: "workbook,sheet,table,drawing",
maxChars: 20000,
tableMaxRows: 12,
tableMaxCols: 20,
tableMaxCellChars: 180,
});
await fs.writeFile(path.join(outputDir, "overview.ndjson"), overview.ndjson, "utf8");
const sheets = [];
for (let i = 0; ; i += 1) {
let sheet;
try {
sheet = workbook.worksheets.getItemAt(i);
} catch {
break;
}
if (!sheet) break;
const name = sheet.name;
const used = sheet.getUsedRange();
const usedAddress = used?.address ?? null;
const info = await workbook.inspect({
kind: "region,computedStyle,formula",
sheetId: name,
range: usedAddress ?? "A1:Z60",
maxChars: 30000,
tableMaxRows: 80,
tableMaxCols: 30,
tableMaxCellChars: 240,
options: { maxResults: 200 },
});
await fs.writeFile(path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.ndjson`), info.ndjson, "utf8");
const preview = await workbook.render({ sheetName: name, autoCrop: "all", scale: 1, format: "png" });
const previewPath = path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.png`);
await fs.writeFile(previewPath, new Uint8Array(await preview.arrayBuffer()));
sheets.push({ index: i, name, usedAddress, previewPath });
}
await fs.writeFile(path.join(outputDir, "sheets.json"), JSON.stringify(sheets, null, 2), "utf8");
console.log(JSON.stringify(sheets, null, 2));
@@ -0,0 +1,28 @@
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/preview")
output = source / "contact-sheet.jpg"
files = sorted(source.glob("*.png"))
thumb_w, thumb_h = 620, 360
label_h = 34
cols = 2
rows = (len(files) + cols - 1) // cols
canvas = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
draw = ImageDraw.Draw(canvas)
font = ImageFont.load_default()
for index, file in enumerate(files):
image = Image.open(file).convert("RGB")
image.thumbnail((thumb_w - 12, thumb_h - 12))
x0 = (index % cols) * thumb_w
y0 = (index // cols) * (thumb_h + label_h)
x = x0 + (thumb_w - image.width) // 2
y = y0 + 6
canvas.paste(image, (x, y))
draw.rectangle((x0, y0, x0 + thumb_w - 1, y0 + thumb_h + label_h - 1), outline="#B7C9D6", width=1)
draw.text((x0 + 8, y0 + thumb_h + 8), file.stem, fill="#1F2937", font=font)
canvas.save(output, quality=88)
print(output)
@@ -0,0 +1,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const workbookPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/final-qa";
await fs.mkdir(outputDir, { recursive: true });
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 8000 });
const summary = await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 22, tableMaxCols: 16, maxChars: 18000 });
const vehicleTop = await workbook.inspect({ kind: "table", range: "02-粤A05839F!A4:P14", include: "values,formulas", tableMaxRows: 14, tableMaxCols: 16, maxChars: 12000 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reimported final formula error scan" });
await fs.writeFile(path.join(outputDir, "final-inspect.ndjson"), `${sheets.ndjson}\n${summary.ndjson}\n${vehicleTop.ndjson}\n${errors.ndjson}\n`, "utf8");
const vehicleSheetNames = [
"01-粤A05995F", "02-粤A05839F", "03-粤A09369F", "04-粤A06658F", "05-粤A05700F",
"06-粤A05906F", "07-粤AGP9346", "08-粤AGP2017", "09-粤AGP5165", "10-粤AGP4355",
"11-粤AGP5646", "12-粤AGP3692", "13-粤AGP9782", "14-粤AGP3027", "15-粤AGP9751",
];
for (let index = 0; index < vehicleSheetNames.length; index += 1) {
const sheetName = vehicleSheetNames[index];
const preview = await workbook.render({ sheetName, range: "A232:P259", scale: 1, format: "png" });
await fs.writeFile(path.join(outputDir, `${String(index + 1).padStart(2, "0")}-${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
}
console.log(JSON.stringify({ workbookPath, outputDir, sheetCount: 17, formulaErrorScan: errors.ndjson }, null, 2));
+17
View File
@@ -0,0 +1,17 @@
import pymysql, pathlib, re, json
def connect():
env={}
for name in ['base.env','stat-writer.env']:
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);env[k]=v.strip().strip('\"').strip("'")
m=re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+).*',env['MYSQL_DSN'])
assert m, 'Unexpected DSN format'
return pymysql.connect(user=m[1],password=m[2],host=m[3],port=int(m[4]),database=m[5],charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False)
if __name__=='__main__':
c=connect()
with c.cursor() as q:
for table in ['vehicle_daily_mileage_source','vehicle_daily_mileage','vehicle_data_source','vehicle_identifier','vehicle']:
q.execute('SHOW COLUMNS FROM '+table); print(table,json.dumps(q.fetchall(),default=str,ensure_ascii=False))
q.execute("SELECT * FROM vehicle_data_source WHERE platform_name LIKE '%赛格%' OR source_code LIKE '%seg%' OR source_ip LIKE '%seg%'"); print('SEG_SOURCES',json.dumps(q.fetchall(),default=str,ensure_ascii=False))
c.close()
+80
View File
@@ -0,0 +1,80 @@
from db import connect
import json, pathlib, collections, sys, decimal
root=pathlib.Path(__file__).parent
rows=json.loads((root/'rows.json').read_text())
c=connect(); q=c.cursor()
def query(sql,args=()):
q.execute(sql,args);return q.fetchall()
maps=collections.defaultdict(set)
for r in query("SELECT vin,plate FROM vehicle UNION SELECT vin,plate FROM vehicle_identifier WHERE enabled=1 UNION SELECT vin,plate FROM vehicle_identity_binding"):
if r['plate'] and r['vin']:maps[r['plate'].strip()].add(r['vin'])
unmapped=[]; mapped=[]
for r in rows:
vins=maps[r['plate']]
if len(vins)!=1:unmapped.append(dict(r,matching_vins=sorted(vins)));continue
mapped.append(dict(r,vin=next(iter(vins))))
assert len({(r['vin'],r['date']) for r in mapped})==len(mapped),'VIN/date collision'
source_ids=[r['id'] for r in query("SELECT id FROM vehicle_data_source WHERE protocol='JT808' AND (source_code='saige' OR platform_name LIKE '%%赛格%%')")]
existing=query("SELECT s.* FROM vehicle_daily_mileage_source s LEFT JOIN vehicle_data_source d ON d.protocol=s.protocol AND d.source_ip=s.source_ip WHERE s.protocol='JT808' AND s.stat_date BETWEEN '2026-07-01' AND '2026-08-31' AND (d.source_code='saige' OR d.platform_name LIKE '%%赛格%%' OR s.platform_name LIKE '%%赛格%%' OR s.source_key LIKE '%%saige%%')")
finals=query("SELECT * FROM vehicle_daily_mileage WHERE protocol='JT808' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
keys={(r['vin'],str(r['stat_date'])) for r in existing}
keys.update((r['vin'],str(r['stat_date'])) for r in finals if r['source_id'] in source_ids)
pending=[r for r in mapped if (r['vin'],r['date']) not in keys]
summary=dict(input_rows=len(rows),mapped_rows=len(mapped),unmapped_rows=len(unmapped),unmapped_plates=sorted({r['plate'] for r in unmapped}),existing_saige_skipped=len(mapped)-len(pending),insert_candidates=len(pending),insert_vehicles=len({r['vin'] for r in pending}),insert_total_km=str(sum((decimal.Decimal(r['km']) for r in pending),decimal.Decimal(0))))
(root/'preflight.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
(root/'unmapped.json').write_text(json.dumps(unmapped,ensure_ascii=False,indent=2))
(root/'pending.json').write_text(json.dumps(pending,ensure_ascii=False,indent=2))
print(json.dumps(summary,ensure_ascii=False))
if '--apply' not in sys.argv:
c.rollback(); c.close(); sys.exit(0)
# Historical dates only. Lock every affected vehicle/day source range and result
# in deterministic order before rechecking existence. No UPDATE statements.
c.rollback()
pending.sort(key=lambda r:(r['vin'],r['date']))
before=[]; locked=[]; skipped_race=0
for r in pending:
args=(r['vin'],r['date'])
ss=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
ff=query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
before.append(dict(vin=r['vin'],date=r['date'],sources=ss,final=ff))
has=any(s['source_ip'] in ('222.66.200.68','58.33.87.196','manual-saige-excel') or 'saige' in s['source_key'].lower() or '赛格' in (s['platform_name'] or '') for s in ss) or any(f['source_id'] in source_ids for f in ff)
if has:skipped_race+=1;continue
locked.append((r,ff))
(root/'backup-before.json').write_text(json.dumps(before,default=str,ensure_ascii=False))
ds=query("SELECT * FROM vehicle_data_source WHERE protocol='JT808' AND source_ip='manual-saige-excel'")
if ds:
assert ds[0]['source_code']=='saige' and ds[0]['platform_name']=='赛格'
sid=ds[0]['id']
else:
q.execute("INSERT INTO vehicle_data_source (protocol,source_ip,platform_name,source_code,source_kind,trust_priority,enabled,remark) VALUES ('JT808','manual-saige-excel','赛格','saige','PLATFORM',100,1,%s)",('赛格Excel每日里程导入;仅补缺,不覆盖已有记录;报表未提供累计总里程',))
sid=q.lastrowid
inserted=[]; final_inserted=0
for r,ff in locked:
key='JT808:'+r['phone']+'@PLATFORM:saige_excel'
reason='manual_saige_excel_import_20260907;file='+r['file']+';row='+str(r['row'])
q.execute("""INSERT INTO vehicle_daily_mileage_source
(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected)
VALUES (%s,%s,'JT808',%s,'manual-saige-excel','manual-excel-import',%s,'赛格',%s,1,'OK',%s,%s)""",(r['vin'],r['date'],key,r['phone'],r['km'],reason,int(not ff)))
assert q.rowcount==1
if not ff:
q.execute("INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (%s,%s,'JT808',%s,%s)",(r['vin'],r['date'],sid,r['km']))
assert q.rowcount==1;final_inserted+=1
inserted.append(dict(r,source_key=key))
# Verify every old row is byte-for-byte equivalent as typed query results.
for b in before:
args=(b['vin'],b['date'])
now=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)
lookup={s['source_key']:s for s in now}
assert all(lookup[s['source_key']]==s for s in b['sources']), 'Existing source changed'
if b['final']:
assert query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)==b['final'],'Existing final changed'
for r in inserted:
v=query("SELECT daily_mileage_km,latest_total_mileage_km,first_total_mileage_km FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' AND source_key=%s",(r['vin'],r['date'],r['source_key']))
assert len(v)==1 and v[0]['daily_mileage_km']==decimal.Decimal(r['km']) and v[0]['latest_total_mileage_km'] is None and v[0]['first_total_mileage_km'] is None
result=dict(summary,inserted_sources=len(inserted),inserted_final_rows=final_inserted,preserved_existing_final_rows=len(locked)-final_inserted,concurrent_skipped=skipped_race,source_id=sid,existing_rows_verified_unchanged=True)
(root/'inserted.json').write_text(json.dumps(inserted,ensure_ascii=False))
c.commit()
result['committed']=True
(root/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps(result,ensure_ascii=False))
c.close()
@@ -0,0 +1,25 @@
import xlrd, json, pathlib, decimal, hashlib, re
out=pathlib.Path(__file__).parent
rows=[]; summary=[]; seen={}
for p in sorted(pathlib.Path('/Users/lingniu/Documents').glob('里程统计报表*.xls')):
s=xlrd.open_workbook(str(p)).sheet_by_index(0)
meta={'file':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'rows':s.nrows-1}
if s.cell_value(0,2)!='日期':
meta.update(excluded='No daily date column',nonzero=sum(decimal.Decimal(str(s.cell_value(i,3)))!=0 for i in range(1,s.nrows)))
summary.append(meta); continue
dates=[]
for i in range(1,s.nrows):
plate,phone,day,km=map(lambda x:str(x).strip(),s.row_values(i))
import datetime
datetime.date.fromisoformat(day)
assert re.fullmatch(r'(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?',km), km
amount=decimal.Decimal(km.replace(',','')); assert amount.is_finite() and amount>=0
key=(plate,day)
assert key not in seen, ('duplicate',key)
seen[key]=amount; dates.append(day)
rows.append(dict(plate=plate,phone=phone,date=day,km=str(amount),file=p.name,row=i+1))
meta.update(date_min=min(dates),date_max=max(dates),vehicles=len({r['plate'] for r in rows if r['file']==p.name}))
summary.append(meta)
(out/'rows.json').write_text(json.dumps(rows,ensure_ascii=False))
(out/'input-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print(json.dumps(summary,ensure_ascii=False,indent=2))
+15
View File
@@ -0,0 +1,15 @@
from db import connect
import pathlib,json,decimal,collections
p=pathlib.Path(__file__).parent
inserted=json.loads((p/'inserted.json').read_text())
c=connect();q=c.cursor()
q.execute("SELECT vin,stat_date,source_key,daily_mileage_km,latest_total_mileage_km FROM vehicle_daily_mileage_source WHERE source_ip='manual-saige-excel' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
actual={(r['vin'],str(r['stat_date']),r['source_key']):r for r in q.fetchall()}
months=collections.defaultdict(lambda:dict(rows=0,total_km=decimal.Decimal(0)))
for r in inserted:
a=actual[(r['vin'],r['date'],r['source_key'])]
assert a['daily_mileage_km']==decimal.Decimal(r['km']) and a['latest_total_mileage_km'] is None
m=months[r['date'][:7]];m['rows']+=1;m['total_km']+=a['daily_mileage_km']
v=dict(verified_rows=len(inserted),months=dict(months),verified_total_km=sum((m['total_km'] for m in months.values()),decimal.Decimal(0)))
(p/'verification.json').write_text(json.dumps(v,default=str,ensure_ascii=False,indent=2))
print(json.dumps(v,default=str,ensure_ascii=False));c.close()
@@ -0,0 +1,638 @@
import fs from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const workspaceRoot = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
const workDir = path.join(
workspaceRoot,
"tmp/vehicle-mileage-selection-019fa1ac-db39-7933-bb0e-30dd63cc25bd",
);
const outputDir = path.join(
workspaceRoot,
"outputs/019fa1ac-db39-7933-bb0e-30dd63cc25bd",
);
const outputPath = path.join(
outputDir,
"江浙沪四类车型近三个月GPS里程清单_20260427-20260726.xlsx",
);
const masterPath = path.join(
workspaceRoot,
"outputs/jt808-provider-audit-20260717/oneos-vehicle-master.json",
);
const dateFrom = "2026-04-27";
const dateTo = "2026-07-26";
const periodDays = 91;
const queryAsOf = "2026-07-28";
const mileageProtocol = "JT808";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function postProductionAPI(endpoint, body) {
const remoteCommand =
`. /opt/lingniu-vehicle-platform/env/access-tokens.env; ` +
`curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" ` +
`-H "Content-Type: application/json" --data-binary @- ` +
`http://127.0.0.1:20300${endpoint}`;
const result = spawnSync(
"ssh",
["-o", "BatchMode=yes", "root@115.29.187.205", remoteCommand],
{
input: JSON.stringify(body),
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
},
);
if (result.status !== 0) {
throw new Error(
`Production API failed for ${endpoint}: ${result.stderr || result.stdout}`,
);
}
const payload = JSON.parse(result.stdout);
if (!payload.data) {
throw new Error(`Production API returned no data for ${endpoint}`);
}
return payload.data;
}
function excelDate(isoDate) {
return new Date(`${isoDate}T00:00:00+08:00`);
}
function datesBetween(start, end) {
const result = [];
const cursor = new Date(`${start}T00:00:00+08:00`);
const last = new Date(`${end}T00:00:00+08:00`);
while (cursor <= last) {
result.push(cursor.toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }));
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
function residentLocation(rawCity) {
const parts = String(rawCity || "").split("-");
const province = parts[0] || "";
const city = parts.slice(1).join("-") || province;
return { province, city };
}
function vehicleCategory(model) {
const normalized = String(model || "");
if (normalized.includes("4.5吨") && !normalized.includes("冷链")) {
return "4.5T普货";
}
if (normalized.includes("冷链")) return "冷链";
if (normalized.includes("18吨")) return "18T";
if (normalized.includes("49吨")) return "49T";
return "";
}
function isJiangZheHu(city) {
return ["江苏省-", "浙江省-", "上海市-"].some((prefix) =>
String(city || "").startsWith(prefix),
);
}
async function loadMasterVehicles() {
const source = JSON.parse(await fs.readFile(masterPath, "utf8"));
const rows = source.sheets?.[0]?.rows || [];
assert(rows.length > 1, "OneOS vehicle master is empty");
const headers = rows[0].values;
const headerIndex = Object.fromEntries(headers.map((value, index) => [value, index]));
const requiredHeaders = [
"车牌号",
"VIN",
"运营城市",
"品牌",
"型号",
"运营状态",
"车辆状态",
"客户名称",
];
for (const header of requiredHeaders) {
assert(headerIndex[header] !== undefined, `Missing master header: ${header}`);
}
const result = [];
for (const row of rows.slice(1)) {
const values = [...row.values, ...Array(headers.length).fill("")];
const cityRaw = values[headerIndex["运营城市"]];
const model = values[headerIndex["型号"]];
const category = vehicleCategory(model);
if (!category || !isJiangZheHu(cityRaw)) continue;
const vin = String(values[headerIndex["VIN"]] || "").trim();
const plate = String(values[headerIndex["车牌号"]] || "").trim();
if (!vin || !plate) continue;
const location = residentLocation(cityRaw);
result.push({
category,
plate,
vin,
brand: String(values[headerIndex["品牌"]] || "").trim(),
model: String(model || "").trim(),
province: location.province,
city: location.city,
cityRaw: String(cityRaw || "").trim(),
operationStatus: String(values[headerIndex["运营状态"]] || "").trim(),
vehicleStatus: String(values[headerIndex["车辆状态"]] || "").trim(),
customerName: ["租赁", "自营"].includes(
String(values[headerIndex["运营状态"]] || "").trim(),
)
? String(values[headerIndex["客户名称"]] || "").trim()
: "",
});
}
return result;
}
function queryRanking(vins) {
if (vins.length === 0) return { ranking: [], asOf: "" };
return postProductionAPI("/api/v2/statistics/mileage", {
dateFrom,
dateTo,
vins,
protocol: mileageProtocol,
});
}
function rankingMap(data) {
return new Map((data.ranking || []).map((row) => [row.vin, row]));
}
function selectCategoryVehicles(category, candidates) {
const categoryCandidates = candidates.filter((row) => row.category === category);
assert(
categoryCandidates.length >= 10,
`${category} has only ${categoryCandidates.length} Jiang-Zhe-Hu candidates`,
);
const masterByVIN = new Map(categoryCandidates.map((row) => [row.vin, row]));
const suzhouVINs = categoryCandidates
.filter((row) => row.city === "苏州市")
.map((row) => row.vin);
const otherVINs = categoryCandidates
.filter((row) => row.city !== "苏州市")
.map((row) => row.vin);
const suzhouRanking = queryRanking(suzhouVINs);
const otherRanking = queryRanking(otherVINs);
const combined = [
...(suzhouRanking.ranking || []).map((row) => ({
...row,
suzhouPreferred: true,
})),
...(otherRanking.ranking || []).map((row) => ({
...row,
suzhouPreferred: false,
})),
];
assert(combined.length >= 10, `${category} has fewer than 10 vehicles with mileage`);
return combined.slice(0, 10).map((ranked, index) => ({
...masterByVIN.get(ranked.vin),
rank: index + 1,
sourcePeriodMileageKm: Number(ranked.mileageKm || 0),
sourceActiveDays: Number(ranked.activeDays || 0),
suzhouPreferred: ranked.suzhouPreferred,
statisticsAsOf: suzhouRanking.asOf || otherRanking.asOf || "",
}));
}
function queryDailyMileage(vins) {
const items = [];
let offset = 0;
const limit = 1000;
let total = Number.POSITIVE_INFINITY;
while (offset < total) {
const page = postProductionAPI("/api/mileage/daily", {
dateFrom,
dateTo,
vins,
protocol: mileageProtocol,
deduplicate: true,
limit,
offset,
});
const pageItems = page.items || [];
items.push(...pageItems);
total = Number(page.total || 0);
offset += pageItems.length;
if (pageItems.length === 0) break;
}
return items;
}
function styleTitle(sheet, rangeAddress, title) {
const range = sheet.getRange(rangeAddress);
range.merge();
range.values = [[title]];
range.format = {
fill: "#0F4C5C",
font: { bold: true, color: "#FFFFFF", size: 16 },
verticalAlignment: "center",
horizontalAlignment: "left",
};
range.format.rowHeight = 34;
}
function styleHeader(range) {
range.format = {
fill: "#DCEEF2",
font: { bold: true, color: "#12343B" },
verticalAlignment: "center",
horizontalAlignment: "center",
wrapText: true,
borders: {
bottom: { style: "medium", color: "#78A7B2" },
},
};
range.format.rowHeight = 30;
}
function styleNote(range) {
range.format = {
fill: "#F3F8FA",
font: { color: "#385A64", size: 10 },
wrapText: true,
verticalAlignment: "center",
};
}
function addCategoryConditionalFormatting(range) {
range.conditionalFormats.addCustom('=$A5="4.5T普货"', {
fill: "#FFF4CC",
font: { bold: true, color: "#7A5300" },
});
range.conditionalFormats.addCustom('=$A5="冷链"', {
fill: "#DDF3FF",
font: { bold: true, color: "#075985" },
});
range.conditionalFormats.addCustom('=$A5="18T"', {
fill: "#E8E1FF",
font: { bold: true, color: "#5B21B6" },
});
range.conditionalFormats.addCustom('=$A5="49T"', {
fill: "#DFF5E6",
font: { bold: true, color: "#166534" },
});
}
const masterVehicles = await loadMasterVehicles();
const categories = ["4.5T普货", "冷链", "18T", "49T"];
const selectedVehicles = categories.flatMap((category) =>
selectCategoryVehicles(category, masterVehicles),
);
assert(selectedVehicles.length === 40, "Expected exactly 40 selected vehicles");
assert(
new Set(selectedVehicles.map((row) => row.vin)).size === 40,
"Selected VINs are not unique",
);
const dailySourceRows = queryDailyMileage(selectedVehicles.map((row) => row.vin));
const dailyByVehicleDate = new Map();
for (const row of dailySourceRows) {
assert(
row.source === mileageProtocol,
`Unexpected mileage source for ${row.vin} on ${row.date}: ${row.source}`,
);
const key = `${row.vin}|${row.date}`;
assert(!dailyByVehicleDate.has(key), `Duplicate deduplicated daily row: ${key}`);
dailyByVehicleDate.set(key, row);
}
const dates = datesBetween(dateFrom, dateTo);
assert(dates.length === periodDays, `Expected ${periodDays} dates, got ${dates.length}`);
const dailyRows = [];
for (const vehicle of selectedVehicles) {
for (const date of dates) {
const source = dailyByVehicleDate.get(`${vehicle.vin}|${date}`);
dailyRows.push({
date,
...vehicle,
dailyMileageKm: source ? Number(source.dailyMileageKm || 0) : null,
source: source?.source || "",
recordStatus: source ? "有记录" : "缺失",
});
}
}
assert(dailyRows.length === 40 * periodDays, "Daily row count mismatch");
for (const vehicle of selectedVehicles) {
const computed = dailyRows
.filter((row) => row.vin === vehicle.vin)
.reduce((sum, row) => sum + (row.dailyMileageKm ?? 0), 0);
const difference = Math.abs(computed - vehicle.sourcePeriodMileageKm);
assert(
difference < 0.02,
`${vehicle.plate} total mismatch: daily=${computed}, stats=${vehicle.sourcePeriodMileageKm}`,
);
}
const workbook = Workbook.create();
const notesSheet = workbook.worksheets.add("口径说明");
const summarySheet = workbook.worksheets.add("车辆汇总");
const dailySheet = workbook.worksheets.add("每日里程");
for (const sheet of [notesSheet, summarySheet, dailySheet]) {
sheet.showGridLines = false;
}
// 口径说明
styleTitle(notesSheet, "A1:H1", "江浙沪四类车型近三个月 GPS 里程清单");
notesSheet.getRange("A3:B11").values = [
["项目", "口径"],
["统计区间", `${dateFrom}${dateTo}${periodDays} 个自然日,完整日)`],
["区域范围", "常驻市(OneOS 运营城市)属于江苏、浙江、上海"],
["选车规则", "每类先按苏州区间总里程降序选取,不足 10 辆时按江浙沪其他城市区间总里程降序补齐"],
["车型定义", "4.5T普货=型号含4.5吨且不含冷链;冷链=型号含冷链;18T=型号含18吨;49T=型号含49吨"],
["里程口径", "仅使用 JT808 定位终端/GPS 侧累计里程计算,不使用 GB32960、宇通 MQTT 等仪表/车端累计里程"],
["缺失处理", "逐日表保留全部车辆×日期组合;无生产记录的日期留空,并标记为“缺失”,不擅自按0计算"],
["主数据快照", "OneOS车辆信息快照:2026-07-17;常驻市取“运营城市”字段"],
["里程数据截至", `${queryAsOf} 查询,统计截止至 ${dateTo};来源协议固定为 ${mileageProtocol}`],
];
styleHeader(notesSheet.getRange("A3:B3"));
styleNote(notesSheet.getRange("A4:B11"));
notesSheet.getRange("A4:A11").format.font = { bold: true, color: "#12343B" };
notesSheet.getRange("A3:B11").format.borders = {
outside: { style: "thin", color: "#B9D3DA" },
insideHorizontal: { style: "thin", color: "#DCE8EC" },
};
notesSheet.getRange("A13:E18").values = [
["分类", "车辆数", "苏州优先数", "GPS区间总里程(km)", "GPS日均里程(km/天)"],
["4.5T普货", null, null, null, null],
["冷链", null, null, null, null],
["18T", null, null, null, null],
["49T", null, null, null, null],
["合计", null, null, null, null],
];
styleHeader(notesSheet.getRange("A13:E13"));
notesSheet.getRange("B14").formulas = [["=COUNTIF('车辆汇总'!$A$5:$A$44,A14)"]];
notesSheet.getRange("B14:B17").fillDown();
notesSheet.getRange("C14").formulas = [
["=COUNTIFS('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$I$5:$I$44,\"苏州市\")"],
];
notesSheet.getRange("C14:C17").fillDown();
notesSheet.getRange("D14").formulas = [
["=SUMIF('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$O$5:$O$44)"],
];
notesSheet.getRange("D14:D17").fillDown();
notesSheet.getRange("E14").formulas = [["=D14/(B14*'车辆汇总'!$N$5)"]];
notesSheet.getRange("E14:E17").fillDown();
notesSheet.getRange("B18").formulas = [["=SUM(B14:B17)"]];
notesSheet.getRange("C18").formulas = [["=SUM(C14:C17)"]];
notesSheet.getRange("D18").formulas = [["=SUM(D14:D17)"]];
notesSheet.getRange("E18").formulas = [["=D18/(B18*'车辆汇总'!$N$5)"]];
notesSheet.getRange("D14:E18").format.numberFormat = "#,##0.0";
notesSheet.getRange("A18:E18").format = {
fill: "#E8F1F4",
font: { bold: true, color: "#12343B" },
borders: { top: { style: "double", color: "#78A7B2" } },
};
notesSheet.getRange("A13:E18").format.borders = {
outside: { style: "thin", color: "#B9D3DA" },
insideHorizontal: { style: "thin", color: "#DCE8EC" },
};
notesSheet.getRange("A:A").format.columnWidth = 18;
notesSheet.getRange("B:B").format.columnWidth = 64;
notesSheet.getRange("C:E").format.columnWidth = 18;
notesSheet.getRange("A4:B11").format.rowHeight = 34;
notesSheet.freezePanes.freezeRows(3);
// 车辆汇总
styleTitle(summarySheet, "A1:Q1", "四类车型各10辆|近三个月 GPS 里程汇总");
summarySheet.getRange("A2:Q2").merge();
summarySheet.getRange("A2").values = [[
`范围:江浙沪常驻车辆;苏州优先;统计区间 ${dateFrom}${dateTo}。仅使用 JT808 GPS 里程,区间总里程由“每日里程”明细公式汇总。`,
]];
styleNote(summarySheet.getRange("A2:Q2"));
const summaryHeaders = [
"分类",
"类内排名",
"车牌",
"VIN",
"品牌",
"型号",
"常驻省",
"常驻城市原值",
"常驻市",
"苏州优先",
"运营状态",
"车辆状态",
"客户名称",
"区间天数",
"GPS区间总里程(km)",
"GPS日均里程(km/天)",
"有记录天数",
];
summarySheet.getRange("A4:Q4").values = [summaryHeaders];
styleHeader(summarySheet.getRange("A4:Q4"));
const summaryValues = selectedVehicles.map((vehicle) => [
vehicle.category,
vehicle.rank,
vehicle.plate,
vehicle.vin,
vehicle.brand,
vehicle.model,
vehicle.province,
vehicle.cityRaw,
vehicle.city,
vehicle.suzhouPreferred ? "是" : "否",
vehicle.operationStatus,
vehicle.vehicleStatus,
vehicle.customerName,
periodDays,
null,
null,
null,
]);
summarySheet.getRange(`A5:Q${4 + summaryValues.length}`).values = summaryValues;
const dailyEndRow = 4 + dailyRows.length;
summarySheet.getRange("O5").formulas = [[
`=SUMIFS('每日里程'!$J$5:$J$${dailyEndRow},'每日里程'!$E$5:$E$${dailyEndRow},D5)`,
]];
summarySheet.getRange("O5:O44").fillDown();
summarySheet.getRange("P5").formulas = [["=O5/N5"]];
summarySheet.getRange("P5:P44").fillDown();
summarySheet.getRange("Q5").formulas = [[
`=COUNTIFS('每日里程'!$E$5:$E$${dailyEndRow},D5,'每日里程'!$L$5:$L$${dailyEndRow},"有记录")`,
]];
summarySheet.getRange("Q5:Q44").fillDown();
summarySheet.getRange("B5:B44").format.numberFormat = "0";
summarySheet.getRange("N5:N44").format.numberFormat = "0";
summarySheet.getRange("O5:P44").format.numberFormat = "#,##0.0";
summarySheet.getRange("Q5:Q44").format.numberFormat = "0";
summarySheet.getRange("A5:Q44").format.verticalAlignment = "center";
summarySheet.getRange("A5:Q44").format.borders = {
insideHorizontal: { style: "thin", color: "#E2ECEF" },
};
addCategoryConditionalFormatting(summarySheet.getRange("A5:A44"));
summarySheet.getRange("O5:O44").conditionalFormats.add("dataBar", {
color: "#2A9D8F",
gradient: true,
});
summarySheet.getRange("J5:J44").conditionalFormats.add("containsText", {
text: "是",
format: {
fill: "#E5F7ED",
font: { bold: true, color: "#166534" },
},
});
const summaryTable = summarySheet.tables.add("A4:Q44", true, "VehicleMileageSummary");
summaryTable.style = "TableStyleMedium2";
summaryTable.showFilterButton = true;
summarySheet.freezePanes.freezeRows(4);
summarySheet.freezePanes.freezeColumns(4);
const summaryWidths = [
14, 10, 14, 22, 12, 30, 12, 20, 12, 10, 12, 12, 30, 10, 18, 18, 14,
];
summaryWidths.forEach((width, index) => {
summarySheet.getRangeByIndexes(0, index, 44, 1).format.columnWidth = width;
});
summarySheet.getRange("A5:Q44").format.rowHeight = 23;
// 每日里程
styleTitle(dailySheet, "A1:L1", "40辆车逐日 GPS 里程明细");
dailySheet.getRange("A2:L2").merge();
dailySheet.getRange("A2").values = [[
`${selectedVehicles.length}× ${periodDays} 天 = ${dailyRows.length.toLocaleString("zh-CN")} 行;仅统计 JT808 GPS 里程,缺失日期留空并单独标记。`,
]];
styleNote(dailySheet.getRange("A2:L2"));
const dailyHeaders = [
"日期",
"分类",
"类内排名",
"车牌",
"VIN",
"品牌",
"型号",
"常驻市",
"客户名称",
"GPS每日里程(km)",
"来源",
"记录状态",
];
dailySheet.getRange("A4:L4").values = [dailyHeaders];
styleHeader(dailySheet.getRange("A4:L4"));
const dailyValues = dailyRows.map((row) => [
excelDate(row.date),
row.category,
row.rank,
row.plate,
row.vin,
row.brand,
row.model,
row.city,
row.customerName,
row.dailyMileageKm,
row.source,
row.recordStatus,
]);
dailySheet.getRange(`A5:L${dailyEndRow}`).values = dailyValues;
dailySheet.getRange(`A5:A${dailyEndRow}`).format.numberFormat = "yyyy-mm-dd";
dailySheet.getRange(`C5:C${dailyEndRow}`).format.numberFormat = "0";
dailySheet.getRange(`J5:J${dailyEndRow}`).format.numberFormat = "#,##0.0";
dailySheet.getRange(`A5:L${dailyEndRow}`).format.borders = {
insideHorizontal: { style: "thin", color: "#EDF2F4" },
};
dailySheet.getRange(`L5:L${dailyEndRow}`).conditionalFormats.add("containsText", {
text: "缺失",
format: {
fill: "#FFF0F0",
font: { bold: true, color: "#B42318" },
},
});
dailySheet.getRange(`J5:J${dailyEndRow}`).conditionalFormats.add("dataBar", {
color: "#5CA4A9",
gradient: true,
});
const dailyTable = dailySheet.tables.add(
`A4:L${dailyEndRow}`,
true,
"VehicleDailyMileage",
);
dailyTable.style = "TableStyleMedium2";
dailyTable.showFilterButton = true;
dailySheet.freezePanes.freezeRows(4);
dailySheet.freezePanes.freezeColumns(5);
const dailyWidths = [14, 14, 10, 14, 22, 12, 30, 12, 30, 18, 16, 12];
dailyWidths.forEach((width, index) => {
dailySheet.getRangeByIndexes(0, index, dailyEndRow, 1).format.columnWidth = width;
});
dailySheet.getRange(`A5:L${dailyEndRow}`).format.rowHeight = 21;
// Compact verification before export.
const summaryInspect = await workbook.inspect({
kind: "table",
range: "车辆汇总!A1:Q16",
include: "values,formulas",
tableMaxRows: 16,
tableMaxCols: 17,
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "summary-inspect.ndjson"), summaryInspect.ndjson);
const dailyInspect = await workbook.inspect({
kind: "table",
range: "每日里程!A1:L20",
include: "values,formulas",
tableMaxRows: 20,
tableMaxCols: 12,
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "daily-inspect.ndjson"), dailyInspect.ndjson);
const formulaErrors = await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "final formula error scan",
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "formula-errors.ndjson"), formulaErrors.ndjson);
for (const [sheetName, range, fileName] of [
["口径说明", "A1:H19", "preview-notes.png"],
["车辆汇总", "A1:Q18", "preview-summary.png"],
["每日里程", "A1:L26", "preview-daily.png"],
]) {
const preview = await workbook.render({
sheetName,
range,
scale: 1.25,
format: "png",
});
await fs.writeFile(
path.join(workDir, fileName),
new Uint8Array(await preview.arrayBuffer()),
);
}
await fs.mkdir(outputDir, { recursive: true });
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputPath);
console.log(
JSON.stringify(
{
outputPath,
selected: selectedVehicles.map((vehicle) => ({
category: vehicle.category,
rank: vehicle.rank,
plate: vehicle.plate,
vin: vehicle.vin,
brand: vehicle.brand,
model: vehicle.model,
residentCity: vehicle.city,
suzhouPreferred: vehicle.suzhouPreferred,
periodMileageKm: vehicle.sourcePeriodMileageKm,
})),
sourceDailyRows: dailySourceRows.length,
expandedDailyRows: dailyRows.length,
missingDailyRows: dailyRows.filter((row) => row.recordStatus === "缺失").length,
customerFilledVehicles: selectedVehicles.filter((row) => row.customerName).length,
},
null,
2,
),
);
@@ -0,0 +1,133 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
if e := run(); e != nil {
log.Fatal(e)
}
}
func run() error {
mode := flag.String("mode", "status", "status, backfill, resolve, or live")
from := flag.String("from", "", "first historical date, defaults to first hydrogen day")
to := flag.String("to", "", "last historical date, defaults to yesterday")
concurrency := flag.Int("concurrency", 3, "maximum concurrent geocoding requests (1-8)")
flag.Parse()
cfg := config.Load()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
db, e := sql.Open("mysql", cfg.MySQLDSN)
if e != nil {
return e
}
defer db.Close()
db.SetMaxOpenConns(12)
if *from == "" {
if e = db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MIN(stat_date),'%Y-%m-%d'),'') FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN'`).Scan(from); e != nil {
return e
}
}
if *to == "" {
*to = time.Now().In(dailygeo.Shanghai).AddDate(0, 0, -1).Format("2006-01-02")
}
first, e := time.ParseInLocation("2006-01-02", *from, dailygeo.Shanghai)
if e != nil {
return e
}
last, e := time.ParseInLocation("2006-01-02", *to, dailygeo.Shanghai)
if e != nil {
return e
}
if first.After(last) {
return fmt.Errorf("invalid date range")
}
if *mode == "status" {
return printStatus(ctx, db, *from, *to)
}
if cfg.AMapAPIKey == "" {
return fmt.Errorf("AMAP_API_KEY required")
}
td, e := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
if e != nil {
return e
}
defer td.Close()
td.SetMaxOpenConns(2)
w := dailygeo.New(db, td, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
w.Concurrency = *concurrency
if *mode == "live" {
w.Run(ctx)
return ctx.Err()
}
if *mode != "backfill" && *mode != "resolve" {
return fmt.Errorf("invalid mode")
}
return w.WithLock(ctx, "vehicle_daily_geography_backfill", func() error {
if *mode == "backfill" {
for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
rctx, c := context.WithTimeout(ctx, 90*time.Second)
n, e := w.RefreshDay(rctx, d.Format("2006-01-02"))
c()
if e != nil {
return e
}
log.Printf("daily_geography_seed date=%s points=%d", d.Format("2006-01-02"), n)
}
}
for {
n, failed, e := w.ResolvePending(ctx, *from, *to, 1000)
log.Printf("daily_geography_batch resolved=%d failed=%d error=%v", n, failed, e)
if ctx.Err() != nil {
return ctx.Err()
}
if e != nil && failed == 0 {
return e
}
if n+failed == 0 {
break
}
}
return printStatus(ctx, db, *from, *to)
})
}
func printStatus(ctx context.Context, db *sql.DB, from, to string) error {
rows, e := db.QueryContext(ctx, `SELECT status,COUNT(*) FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? GROUP BY status`, from, to)
if e != nil {
return e
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var s string
var n int
if e = rows.Scan(&s, &n); e != nil {
return e
}
counts[s] = n
}
if e = rows.Err(); e != nil {
return e
}
rows.Close()
var missing int
e = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_daily_energy h LEFT JOIN vehicle_daily_geography g ON g.vin=h.vin COLLATE utf8mb4_unicode_ci AND g.stat_date=h.stat_date WHERE h.energy_type='HYDROGEN' AND h.stat_date BETWEEN ? AND ? AND g.vin IS NULL`, from, to).Scan(&missing)
if e != nil {
return e
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"from": from, "to": to, "counts": counts, "hydrogenDaysMissing": missing})
}
@@ -0,0 +1,93 @@
// Read-only production verification of the same repository used by HTTP APIs.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"math"
"os"
"reflect"
"strings"
"time"
)
func main() {
start := flag.String("start", "2026-09-11", "first date")
end := flag.String("end", "2026-09-15", "last date")
vin := flag.String("vins", "", "VINs, empty = all current vehicles")
flag.Parse()
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
must(err)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
vins := strings.Split(*vin, ",")
if *vin == "" {
vins = nil
rows, e := db.QueryContext(ctx, "SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin<>''")
must(e)
for rows.Next() {
var v string
must(rows.Scan(&v))
vins = append(vins, v)
}
must(rows.Err())
rows.Close()
}
r := openplatform.NewMySQLRepository(db)
began := time.Now()
priorities := []string{"GB32960", "YUTONG_MQTT"}
all, err := r.ReconciledMileageRange(ctx, vins, *start, *end, priorities)
must(err)
first, e := time.Parse("2006-01-02", *start)
must(e)
last, e := time.Parse("2006-01-02", *end)
must(e)
mismatches := 0
differences := 0
normal := 0
qualities := map[string]int{}
samples := map[string]openplatform.DailyMileage{}
for date := first; !date.After(last); date = date.AddDate(0, 0, 1) {
d := date.Format("2006-01-02")
single, e := r.ReconciledMileageRange(ctx, vins, d, d, priorities)
must(e)
for _, v := range vins {
key := v + "\x00" + d
value, ok := all[key]
one, exists := single[key]
if ok != exists || !reflect.DeepEqual(value, one) {
differences++
}
if !ok {
continue
}
qualities[value.DataQuality]++
prev, hasPrev := all[v+"\x00"+date.AddDate(0, 0, -1).Format("2006-01-02")]
if value.DataQuality == "" || value.DataQuality == "CARRIED_FORWARD" {
normal++
if hasPrev && math.Abs(value.TotalMileageKm-prev.TotalMileageKm-value.MileageKm) > 0.001 {
mismatches++
}
}
if len(vins) < 10 {
samples[key] = value
}
}
}
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"vehicles": len(vins), "rows": len(all), "normal": normal, "qualities": qualities, "reconciliationMismatches": mismatches, "singleRangeDifferences": differences, "samples": samples, "elapsed": time.Since(began).String()}))
if mismatches > 0 || differences > 0 {
os.Exit(2)
}
}
func must(e error) {
if e != nil {
fmt.Fprintln(os.Stderr, e)
os.Exit(1)
}
}
@@ -0,0 +1,174 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
apply := flag.Bool("apply", false, "apply corrections after writing a durable backup")
backup := flag.String("backup", "", "new backup JSON path, required with --apply")
flag.Parse()
if *apply && *backup == "" {
panic("--backup is required with --apply")
}
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
if err != nil {
panic("invalid database configuration")
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err = run(ctx, db, *apply, *backup); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func rows(ctx context.Context, tx *sql.Tx, query string) ([]map[string]any, error) {
r, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer r.Close()
columns, err := r.Columns()
if err != nil {
return nil, err
}
result := []map[string]any{}
for r.Next() {
values := make([]any, len(columns))
targets := make([]any, len(columns))
for i := range values {
targets[i] = &values[i]
}
if err = r.Scan(targets...); err != nil {
return nil, err
}
item := map[string]any{}
for i, k := range columns {
if b, ok := values[i].([]byte); ok {
item[k] = string(b)
} else {
item[k] = values[i]
}
}
result = append(result, item)
}
return result, r.Err()
}
type correction struct {
id, title, status string
remove bool
}
func run(ctx context.Context, db *sql.DB, apply bool, backup string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
archive := map[string]any{"createdAt": time.Now().UTC().Format(time.RFC3339), "reason": "decode GB32960 table 18; remove reserved-only false alarms"}
data := map[string][]map[string]any{}
// Consumer must be stopped for --apply. Lock event rows as well so a concurrent
// operator's action is serialized with the correction and fully backed up.
for _, q := range []struct{ key, sql string }{
{"events", `SELECT * FROM vehicle_alert_event WHERE rule_id='native-gb32960-alarm' ORDER BY id FOR UPDATE`},
{"evidence", `SELECT n.* FROM vehicle_native_alarm_evidence n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"actions", `SELECT a.* FROM vehicle_alert_event_action a JOIN vehicle_alert_event e ON e.id=a.event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"state", `SELECT s.* FROM vehicle_native_alarm_state s JOIN vehicle_alert_event e ON e.id=s.active_event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"notifications", `SELECT n.id,n.event_id FROM vehicle_alert_notification n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
} {
items, e := rows(ctx, tx, q.sql)
if e != nil {
return e
}
data[q.key] = items
archive[q.key] = items
}
evidence := map[string]string{}
for _, row := range data["evidence"] {
evidence[fmt.Sprint(row["event_id"])] = fmt.Sprint(row["fields_json"])
}
notified := map[string]bool{}
for _, row := range data["notifications"] {
notified[fmt.Sprint(row["event_id"])] = true
}
changes := []correction{}
renamed, removed := 0, 0
names := map[string]int{}
for _, row := range data["events"] {
id := fmt.Sprint(row["id"])
var fields map[string]json.RawMessage
if err = json.Unmarshal([]byte(evidence[id]), &fields); err != nil {
return fmt.Errorf("event %s has no valid evidence; no changes applied", id)
}
description, valid := platform.DescribeNativeAlarm(fields)
if !valid {
return fmt.Errorf("event %s has unsupported evidence; no changes applied", id)
}
names[description.Title]++
if !description.Active {
if notified[id] {
return fmt.Errorf("event %s has notifications; refusing automatic deletion", id)
}
changes = append(changes, correction{id: id, remove: true})
removed++
} else if fmt.Sprint(row["rule_name"]) != description.Title {
changes = append(changes, correction{id: id, title: description.Title, status: fmt.Sprint(row["status"])})
renamed++
}
}
if apply && len(changes) > 0 {
file, e := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return e
}
e = json.NewEncoder(file).Encode(archive)
if e == nil {
e = file.Sync()
}
closeErr := file.Close()
if e != nil {
return e
}
if closeErr != nil {
return closeErr
}
for _, change := range changes {
if change.remove {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET active_event_id='' WHERE active_event_id=?`, change.id); err != nil {
return err
}
for _, table := range []string{"vehicle_alert_event_action", "vehicle_native_alarm_evidence"} {
if _, err = tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE event_id=?", change.id); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_event WHERE id=? AND rule_id='native-gb32960-alarm'`, change.id); err != nil {
return err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET rule_name=?,version=version+1 WHERE id=? AND rule_id='native-gb32960-alarm'`, change.title, change.id); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'repair',?,?,'native-alarm-repair','按GB/T 32960.3-2016表18修正具体告警名称,保留原始证据')`, change.id, change.status, change.status); err != nil {
return err
}
}
}
if err = tx.Commit(); err != nil {
return err
}
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"applied": apply, "scanned": len(data["events"]), "renamed": renamed, "removed": removed, "descriptions": names})
}
@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func expectSnapshot(mock sqlmock.Sqlmock, notified bool) {
mock.ExpectBegin()
mock.ExpectQuery(`SELECT \* FROM vehicle_alert_event`).WillReturnRows(sqlmock.NewRows([]string{"id", "rule_name", "status"}).AddRow("false-event", "车辆原生告警", "unprocessed").AddRow("real-event", "车辆原生告警", "processing"))
payload := func(flag string) string {
data := map[string]any{"gb32960.alarm.max_alarm_level": "0", "gb32960.alarm.general_alarm_flag": flag}
for _, key := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
data["gb32960.alarm."+key] = []string{}
}
b, _ := json.Marshal(data)
return string(b)
}
mock.ExpectQuery(`SELECT n.\* FROM vehicle_native_alarm_evidence`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "fields_json"}).AddRow("false-event", payload("0x00300000")).AddRow("real-event", payload("0x00380800")))
mock.ExpectQuery(`SELECT a.\* FROM vehicle_alert_event_action`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "action"}).AddRow("false-event", "trigger"))
mock.ExpectQuery(`SELECT s.\* FROM vehicle_native_alarm_state`).WillReturnRows(sqlmock.NewRows([]string{"vin", "active_event_id"}).AddRow("VIN1", "false-event"))
notifications := sqlmock.NewRows([]string{"id", "event_id"})
if notified {
notifications.AddRow(1, "false-event")
}
mock.ExpectQuery(`SELECT n.id,n.event_id`).WillReturnRows(notifications)
}
func TestRepairDryRunNeverWrites(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectRollback()
if err := run(t.Context(), db, false, ""); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairBacksUpAndCorrectsOnlyNativeEvents(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_alert_event_action`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_native_alarm_evidence`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_alert_event WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_alert_event SET rule_name=\?,version=version\+1 WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("绝缘报警", "real-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WithArgs("real-event", "processing", "processing").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
path := filepath.Join(t.TempDir(), "backup.json")
if err := run(t.Context(), db, true, path); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var archive map[string]json.RawMessage
if err = json.Unmarshal(data, &archive); err != nil {
t.Fatal(err)
}
for _, key := range []string{"events", "evidence", "actions", "state"} {
if len(archive[key]) == 0 {
t.Fatalf("missing backup %s", key)
}
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0600 {
t.Fatal("backup permissions")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairRefusesToOverwriteBackup(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectRollback()
path := filepath.Join(t.TempDir(), "backup.json")
if err := os.WriteFile(path, []byte("previous backup"), 0600); err != nil {
t.Fatal(err)
}
if err := run(t.Context(), db, true, path); err == nil {
t.Fatal("overwrote backup")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairRefusesToOrphanNotifications(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, true)
mock.ExpectRollback()
if err := run(t.Context(), db, true, filepath.Join(t.TempDir(), "backup.json")); err == nil {
t.Fatal("deleted notified event")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -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"},
@@ -19,6 +19,7 @@ import (
"time" "time"
"lingniu/vehicle-data-platform/apps/api/internal/config" "lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
"lingniu/vehicle-data-platform/apps/api/internal/httpx" "lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform" "lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"lingniu/vehicle-data-platform/apps/api/internal/platform" "lingniu/vehicle-data-platform/apps/api/internal/platform"
@@ -69,6 +70,11 @@ func NewServer(cfg config.Config) http.Handler {
log.Printf("production capacity-check probe enabled") log.Printf("production capacity-check probe enabled")
} }
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup) productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
if cfg.DailyGeographyEnabled && tdengine != nil && cfg.AMapAPIKey != "" {
geographyWorker := dailygeo.New(db, tdengine, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
geographyWorker.RequestInterval = 100 * time.Millisecond
go geographyWorker.Run(context.Background())
}
store = productionStore store = productionStore
storeErr = nil storeErr = nil
log.Printf("production mysql store enabled") log.Printf("production mysql store enabled")
@@ -8,6 +8,7 @@ import (
) )
type Config struct { type Config struct {
DailyGeographyEnabled bool
HTTPAddr string HTTPAddr string
StaticDir string StaticDir string
MySQLDSN string MySQLDSN string
@@ -97,6 +98,7 @@ func Load() Config {
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"), AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
AMapAPIKey: os.Getenv("AMAP_API_KEY"), AMapAPIKey: os.Getenv("AMAP_API_KEY"),
DailyGeographyEnabled: envBool("DAILY_GEOGRAPHY_ENABLED", false),
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"), AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"), AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
PlatformRelease: os.Getenv("PLATFORM_RELEASE"), PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
@@ -0,0 +1,35 @@
package dailygeo
import "math"
func WGS84ToGCJ02(longitude float64, latitude float64) (float64, float64) {
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 float64, 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 float64, 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
}
@@ -0,0 +1,123 @@
package dailygeo
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Address struct{ Province, City, Region, Adcode string }
type Resolver interface {
Resolve(context.Context, float64, float64) (Address, error)
}
type AMap struct {
Key, BaseURL string
Client *http.Client
}
func (a *AMap) Resolve(ctx context.Context, lng, lat float64) (Address, error) {
if a.Key == "" {
return Address{}, fmt.Errorf("AMAP_API_KEY is not configured")
}
base := a.BaseURL
if base == "" {
base = "https://restapi.amap.com"
}
u, e := url.Parse(base + "/v3/geocode/regeo")
if e != nil {
return Address{}, fmt.Errorf("invalid geocoder URL")
}
x, y := WGS84ToGCJ02(lng, lat)
q := u.Query()
q.Set("key", a.Key)
q.Set("location", fmt.Sprintf("%.6f,%.6f", x, y))
q.Set("extensions", "base")
q.Set("radius", "1000")
u.RawQuery = q.Encode()
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if e != nil {
return Address{}, fmt.Errorf("invalid geocoder request")
}
client := a.Client
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, e := client.Do(req)
if e != nil {
return Address{}, fmt.Errorf("geocoder request failed")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Address{}, fmt.Errorf("geocoder HTTP %d", resp.StatusCode)
}
var body struct {
Status string
Infocode string
Regeocode struct {
AddressComponent struct{ Province, City, District, Adcode json.RawMessage } `json:"addressComponent"`
}
}
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); e != nil {
return Address{}, fmt.Errorf("invalid geocoder response")
}
if body.Status != "1" {
return Address{}, fmt.Errorf("geocoder rejected: %s", body.Infocode)
}
p, c := textValue(body.Regeocode.AddressComponent.Province), textValue(body.Regeocode.AddressComponent.City)
if c == "" {
switch p {
case "北京市", "上海市", "天津市", "重庆市", "香港特别行政区", "澳门特别行政区":
c = p
}
}
// Province-administered county-level cities (for example 潜江市) are
// returned in district, with city=[], by AMap.
if c == "" && p != "" {
district := textValue(body.Regeocode.AddressComponent.District)
if strings.HasSuffix(district, "市") {
c = district
}
}
if p == "" || c == "" {
return Address{}, fmt.Errorf("geocoder returned incomplete province/city")
}
return Address{p, c, Region(p), textValue(body.Regeocode.AddressComponent.Adcode)}, nil
}
func textValue(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var ss []string
if json.Unmarshal(raw, &ss) == nil {
return strings.Join(ss, "")
}
return ""
}
func Region(province string) string {
groups := []struct {
name string
provinces []string
}{
{"华东", []string{"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"}},
{"华北", []string{"北京", "天津", "河北", "山西", "内蒙古"}},
{"华中", []string{"河南", "湖北", "湖南"}},
{"华南", []string{"广东", "广西", "海南", "香港", "澳门"}},
{"东北", []string{"辽宁", "吉林", "黑龙江"}},
{"西南", []string{"重庆", "四川", "贵州", "云南", "西藏"}},
{"西北", []string{"陕西", "甘肃", "青海", "宁夏", "新疆"}},
}
for _, g := range groups {
for _, p := range g.provinces {
if strings.HasPrefix(province, p) {
return g.name
}
}
}
return ""
}
@@ -0,0 +1,297 @@
package dailygeo
import (
"context"
"database/sql"
"fmt"
"log"
"math"
"regexp"
"sync"
"time"
)
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type Point struct {
VIN, Protocol string
Time time.Time
Longitude, Latitude float64
}
type Worker struct {
RequestInterval time.Duration
DB, TD *sql.DB
Database string
Resolver Resolver
Concurrency int
requestMu sync.Mutex
nextRequest time.Time
}
func New(db, td *sql.DB, database string, r Resolver) *Worker {
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
}
func validPoint(p Point) bool {
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
}
func newer(a, b Point) bool {
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
}
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
// protocol. All selected columns therefore refer to the same final source row.
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
if e != nil {
return 0, e
}
if w.TD == nil || !identifier.MatchString(w.Database) {
return 0, fmt.Errorf("historical location store unavailable")
}
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
rows, e := w.TD.QueryContext(ctx, q)
if e != nil {
return 0, e
}
points := map[string]Point{}
for rows.Next() {
var p Point
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, e
}
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
continue
}
if old, ok := points[p.VIN]; !ok || newer(p, old) {
points[p.VIN] = p
}
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, e
}
// Repair the device's invalid-location sentinel before applying real points.
// Clearing its timestamp allows an earlier valid point from this day to win.
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
if e != nil {
return 0, e
}
for _, p := range points {
if e = w.SavePoint(ctx, p); e != nil {
return 0, e
}
}
// A successful historical lookup with no point is distinct from an unavailable
// history service. Never record NO_LOCATION when the lookup itself failed.
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
return len(points), e
}
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
if !validPoint(p) {
return fmt.Errorf("invalid historical location")
}
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
return e
}
type pending struct {
VIN, Date string
Longitude, Latitude float64
}
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
if e != nil {
return 0, 0, e
}
items := []pending{}
for rows.Next() {
var p pending
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, 0, e
}
items = append(items, p)
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, 0, e
}
jobs := make(chan pending, len(items))
for _, p := range items {
jobs <- p
}
close(jobs)
count := w.Concurrency
if count < 1 {
count = 1
}
if count > 8 {
count = 8
}
var mu sync.Mutex
var wg sync.WaitGroup
ok, failed := 0, 0
var first error
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if ctx.Err() != nil {
return
}
e := w.resolveOne(ctx, p)
mu.Lock()
if e == nil {
ok++
} else {
failed++
if first == nil {
first = e
}
}
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return ok, failed, ctx.Err()
}
return ok, failed, first
}
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
var a Address
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
if e != nil && e != sql.ErrNoRows {
return e
}
if e == sql.ErrNoRows {
// At most three external calls in flight by default; quota/network failures
// are persisted and retried with backoff, never exported as a guessed city.
w.requestMu.Lock()
delay := time.Until(w.nextRequest)
if delay > 0 {
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
w.requestMu.Unlock()
return ctx.Err()
case <-timer.C:
}
}
interval := w.RequestInterval
if interval <= 0 {
interval = 20 * time.Millisecond
}
w.nextRequest = time.Now().Add(interval)
w.requestMu.Unlock()
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
cancel()
if e != nil {
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
if saveErr != nil {
return saveErr
}
return e
}
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
if e != nil {
return e
}
}
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
return e
}
// An advisory lock is tied to this dedicated connection and released even on
// cancellation/crash, preventing multiple API replicas from running the loop.
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
conn, e := w.DB.Conn(ctx)
if e != nil {
return e
}
defer conn.Close()
var acquired int
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
return e
}
if acquired != 1 {
return fmt.Errorf("daily geography worker already running")
}
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
return fn()
}
func (w *Worker) Run(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
round := 0
for {
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
today := time.Now().In(Shanghai)
date := today.Format("2006-01-02")
rctx, c := context.WithTimeout(ctx, 50*time.Second)
defer c()
if _, e := w.RefreshDay(rctx, date); e != nil {
return e
}
if round%60 == 0 {
for d := 1; d <= 2; d++ {
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
return e
}
}
}
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
if e != nil {
return e
}
// Historical retry is independent of the live queue. The same lock used
// by the CLI prevents duplicate external calls during a bulk backfill.
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
if n+failed > 0 {
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
}
return e
})
return nil
})
if e != nil {
log.Printf("daily_geography: %v", e)
}
round++
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
@@ -0,0 +1,138 @@
package dailygeo
import (
"context"
"database/sql"
"errors"
"github.com/DATA-DOG/go-sqlmock"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
type resolverFunc func(context.Context, float64, float64) (Address, error)
func (f resolverFunc) Resolve(c context.Context, x, y float64) (Address, error) { return f(c, x, y) }
func TestAMapMunicipalityAndRegion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("key") != "test-key" {
t.Error("missing credential")
}
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"adcode":"310101"}}}`))
}))
defer server.Close()
a, e := (&AMap{Key: "test-key", BaseURL: server.URL}).Resolve(context.Background(), 121.47, 31.23)
if e != nil || a.Province != "上海市" || a.City != "上海市" || a.Region != "华东" {
t.Fatalf("address=%+v error=%v", a, e)
}
for p, want := range map[string]string{"广东省": "华南", "四川省": "西南", "陕西省": "西北", "山西省": "华北", "湖北省": "华中", "吉林省": "东北", "": ""} {
if Region(p) != want {
t.Fatal(p)
}
}
}
func TestAMapQuotaFailureDoesNotLeakKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"0","infocode":"10021"}`)) }))
defer server.Close()
_, e := (&AMap{Key: "secret-value", BaseURL: server.URL}).Resolve(context.Background(), 121, 31)
if e == nil || !strings.Contains(e.Error(), "10021") || strings.Contains(e.Error(), "secret-value") {
t.Fatal(e)
}
}
func TestCachedCoordinateNeedsNoGeocoding(t *testing.T) {
db, m, e := sqlmock.New()
if e != nil {
t.Fatal(e)
}
defer db.Close()
m.ExpectQuery("SELECT province,city,region,adcode").WithArgs(121.0, 31.0).WillReturnRows(sqlmock.NewRows([]string{"province", "city", "region", "adcode"}).AddRow("上海市", "上海市", "华东", "310000"))
m.ExpectExec("UPDATE vehicle_daily_geography SET province=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("上海市", "上海市", "华东", "310000", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) {
t.Fatal("cache hit must not call external API")
return Address{}, nil
}))
if e = w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e != nil {
t.Fatal(e)
}
if e = m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestResolverFailurePersistsRetryInsteadOfFalseUnknown(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
m.ExpectQuery("SELECT province,city,region,adcode").WillReturnError(sql.ErrNoRows)
m.ExpectExec("UPDATE vehicle_daily_geography SET status='ERROR'.*next_attempt_at=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("quota", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) { return Address{}, errors.New("quota") }))
if e := w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e == nil {
t.Fatal("expected failure")
}
if e := m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestHistoricalFailureDoesNotMarkMissingLocation(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
td, tm, _ := sqlmock.New()
defer td.Close()
tm.ExpectQuery("SELECT vin,protocol,LAST").WillReturnError(errors.New("historical store unavailable"))
w := New(db, td, "test_ts", nil)
if _, e := w.RefreshDay(context.Background(), "2026-08-31"); e == nil {
t.Fatal("expected history error")
}
if e := m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
if e := tm.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestHistoryUsesLatestSourceInsideBusinessDate(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
td, tm, _ := sqlmock.New()
defer td.Close()
early := time.Date(2026, 8, 31, 2, 0, 0, 0, Shanghai)
late := early.Add(20 * time.Hour)
tm.ExpectQuery("SELECT vin,protocol,LAST.*2026-08-31T00:00:00\\+08:00.*2026-09-01T00:00:00\\+08:00.*PARTITION BY vin,protocol").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "ts", "longitude", "latitude"}).AddRow("VIN1", "GB32960", late, 121.0, 31.0).AddRow("VIN1", "JT808", early, 113.0, 23.0).AddRow("VIN2", "JT808", late.Add(5*time.Hour), 110.0, 20.0))
m.ExpectExec("UPDATE vehicle_daily_geography SET longitude=NULL").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 1))
m.ExpectExec(regexp.QuoteMeta(savePointSQL)).WithArgs("VIN1", "2026-08-31", 121.0, 31.0, late, "GB32960").WillReturnResult(sqlmock.NewResult(0, 1))
m.ExpectExec("INSERT IGNORE INTO vehicle_daily_geography").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 0))
w := New(db, td, "test_ts", nil)
n, e := w.RefreshDay(context.Background(), "2026-08-31")
if e != nil || n != 1 {
t.Fatalf("n=%d error=%v", n, e)
}
if e = m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
if e = tm.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestProvinceAdministeredCity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"湖北省","city":[],"district":"潜江市","adcode":"429005"}}}`))
}))
defer server.Close()
a, e := (&AMap{Key: "test", BaseURL: server.URL}).Resolve(context.Background(), 112.807848, 30.381828)
if e != nil || a.City != "潜江市" || a.Region != "华中" {
t.Fatalf("address=%+v error=%v", a, e)
}
}
func TestInvalidLocationSentinel(t *testing.T) {
p := Point{VIN: "VIN1", Time: time.Now(), Longitude: -0.999999, Latitude: -0.999999}
if validPoint(p) {
t.Fatal("invalid device sentinel accepted")
}
p.Longitude, p.Latitude = 114.3, 30.5
if !validPoint(p) {
t.Fatal("valid location rejected")
}
}
File diff suppressed because one or more lines are too long
@@ -1,11 +1,11 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: 车辆数据开放平台 API title: 车辆数据开放平台 API
version: 1.5.0 version: 1.10.0
license: license:
name: Proprietary name: Proprietary
description: | description: |
向授权合作方开放车辆单日用氢量、单日里程、区间日里程指定时刻总里程。 向授权合作方开放车辆单日用氢量、单日里程、区间日里程指定时刻总里程、加氢站停留核验、实时位置状态及加氢站地图点位
appKey 和逐车授权必须完整覆盖查询自然日。 appKey 和逐车授权必须完整覆盖查询自然日。
servers: servers:
- url: / - url: /
@@ -16,11 +16,60 @@ tags:
- name: 开放平台管理 - name: 开放平台管理
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口 description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
paths: paths:
/api/v1/vehicles/hydrogen-remaining/history/query:
post:
tags: [合作方数据接口]
summary: 批量查询历史时刻剩余氢质量
description: |
按真实 event_time 查询不晚于指定时刻的最近氢相关帧,不跳过最新异常记录,不使用实时值或日用氢量填补。
历史支持从 2026-08-01 起;实际车辆/时间覆盖以保留的原始帧为准,日期可请求不代表每个时点均有有效氢量。
历史容量必须具有适用于历史时点的证据;不能用当前配置回套历史。当前缺少历史容量版本,只输出有证据的REPORTED质量,温压有效但无容量版本时 MISSING。
同批按规范化VIN+time+protocol去重,同一点共享一次查询/授权结果与来源版本,各requestId仍独立回显;不同点为非原子快照,不以同批证明两端可比。
200项仅为结构上限,不保证200个不同点能在9秒完成。建议先用20个不同点,ERROR超时时缩小批量并退避重试。
有效采样超容差为STALE,最新帧INVALID/MISSING优先保留原原因;分块原始帧重组失败或候选预算耗尽为ERROR,不伪装NO_DATA。
每项均返回 requestIdFORBIDDEN、ERROR 等失败不丢项,非 NORMAL 质量为 null。
appKey须当前有效,并逐项校验请求时刻与采样时刻授权(结束时间不含端点);FORBIDDEN不返回采样元数据。
每app每服务实例30批/60秒,最多2个并发批;429包含Retry-After。整批9秒预算、最多4个工作协程,超时项ERROR,可重试。
operationId: queryHistoricalHydrogenRemaining
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/HistoricalHydrogenQuery'
example:
queries:
- requestId: job-start
vin: LA9GG68L2PBAF4773
time: '2026-08-03 16:02:43'
protocol: GB32960
maxTimeDifferenceSeconds: 300
responses:
'200':
description: 批量处理完成;每项状态独立判断,包括 FORBIDDEN 或 ERROR
content:
application/json:
schema:
$ref: '#/components/schemas/HistoricalHydrogenQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: 超出历史查询限流;读取 Retry-After 秒数后重试
headers:
Retry-After:
schema: { type: integer, minimum: 1 }
description: 建议等待秒数
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/hydrogen-consumption/query: /api/v1/vehicles/hydrogen-consumption/query:
post: post:
tags: [合作方数据接口] tags: [合作方数据接口]
summary: 查询车辆单日用氢量 summary: 查询车辆单日用氢量
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。SUSPECT 计算结果保留用氢量并以 DATA_ANOMALY 输出,同时返回质量原因。
operationId: queryDailyHydrogenConsumption operationId: queryDailyHydrogenConsumption
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -53,9 +102,9 @@ paths:
summary: 查询车辆单日里程 summary: 查询车辆单日里程
description: | description: |
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。 protocolPriority 传入时,逐车按数组顺序选择截至查询日已有有效累计读数的第一个协议;高优先级协议缺报时沿用其历史累计值,不切换累计基准,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。 NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程按同来源相邻自然日累计读数之差计算,缺报期间增量计入恢复上报日;GPS 估算不参与本接口。首次基线、来源切换、累计回退返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality。
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间 当日缺报时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用同协议历史读数,dataQuality=CARRIED_FORWARD
operationId: queryDailyMileage operationId: queryDailyMileage
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -92,7 +141,7 @@ paths:
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。 首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。 快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。 protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。 某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程为相邻自然日累计差,GPS 估算不参与本接口。单日、区间和分页共用相同计算规则。
operationId: queryDailyMileageRange operationId: queryDailyMileageRange
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -160,6 +209,115 @@ 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:
post:
tags: [合作方数据接口]
summary: 查询授权车辆实时位置与状态
description: |
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。
满充容量采用 PressureHydrogenMassKg(35,15,VIN水容积)35 MPa 是本次业务基准,15°C 密度比参考来自 https://unece.org/sites/default/files/2024-07/ECE_TRANS_WP.29_2023_110E.pdf,不代表车型额定压力自动认证。
储氢优先终端上报质量 REPORTED;无质量时以同帧最大氢压/最大氢温与 VIN 全车水容积估算 ESTIMATED。百分比按 35 MPa、15°C 满充参考质量计算,不以 SOC 替代。
仅 GB 最新快照有引用且精确帧氢量 MISSING 时有界回补,按采集时间优先、接收时间次序检查最近5条候选;不完整温压可取次新完整帧,不回退覆盖 INVALID,离线回补保留真实 STALE。
GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。
补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。
operationId: queryRealtimeVehicles
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RealtimeVehicleQuery'
example: {}
responses:
'200':
description: 查询成功;没有实时记录的授权车辆以 NO_DATA 返回
content:
application/json:
schema:
$ref: '#/components/schemas/RealtimeVehicleQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/hydrogen-stations/query:
post:
tags: [合作方数据接口]
summary: 查询加氢站地图点位
description: 只读返回资产管理数据库内具有有效经纬度的加氢站,可按省、市和合作状态筛选;无需车辆授权但要求有效 appKey。
operationId: queryHydrogenStations
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/HydrogenStationQuery'
example: {}
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/HydrogenStationQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalError'
/api/v2/open-platform/apps: /api/v2/open-platform/apps:
get: get:
tags: [开放平台管理] tags: [开放平台管理]
@@ -420,7 +578,7 @@ components:
properties: properties:
vin: vin:
type: string type: string
pattern: '^[A-HJ-NPR-Z0-9]{17}$' pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$'
description: 已授权车辆 VIN description: 已授权车辆 VIN
time: time:
type: string type: string
@@ -430,10 +588,146 @@ 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:
type: object
additionalProperties: false
properties:
plateNumbers:
type: array
maxItems: 2000
uniqueItems: true
items:
type: string
minLength: 1
maxLength: 32
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
HistoricalHydrogenQuery:
type: object
additionalProperties: false
required: [queries]
properties:
queries:
type: array
minItems: 1
maxItems: 200
items:
type: object
additionalProperties: false
required: [requestId, vin, time]
properties:
requestId: { type: string, minLength: 1, maxLength: 128, description: 本批唯一非空关联标识,最多128字节,原样返回 }
vin: { type: string, minLength: 17, maxLength: 17, pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$', description: 查询VIN按大写规范化,禁止I/O/Q }
time: { type: string, description: '北京时间 yyyy-MM-dd HH:mm:ss;不得早于2026-08-01或晚于请求时刻' }
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808], default: GB32960, description: 省略固定GB32960;显式协议不切换,YUTONG_MQTT/JT808当前UNSUPPORTED }
maxTimeDifferenceSeconds:
type: integer
minimum: 0
maximum: 300
default: 300
description: 允许采样早于查询的最大秒数;0要求精确命中,不静默放宽
HistoricalHydrogenQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/HistoricalHydrogenResult' }
HistoricalHydrogenResult:
type: object
required: [requestId, vin, plateNumber, queryTime, remainingHydrogenKg, hydrogenRecordTime, timeDifferenceSeconds, remainingHydrogenKgStatus, hydrogenValueSource, hydrogenSourceProtocol, sourceRecordId, sourceDataVersion, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenTankCapacityL, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacityVersion, updatedAt, reasonCode, message]
properties:
requestId: { type: string, description: 本批对应请求标识 }
vin: { type: string }
plateNumber: { type: string, nullable: true, description: 无可证明历史车牌时为 null,不冒用当前车牌 }
queryTime: { type: string, description: 请求北京时间 }
remainingHydrogenKg: { type: number, nullable: true, minimum: 0, description: '全车剩余氢质量kg,保留源/模型计算精度;仅NORMAL可用,其他状态null。不得用缺值补0' }
hydrogenRecordTime: { type: string, format: date-time, nullable: true, description: 真实原始帧 event_timeRFC3339带时区 }
timeDifferenceSeconds: { type: number, nullable: true, minimum: 0, description: queryTime减真实采集时间的秒数 }
remainingHydrogenKgStatus:
type: string
enum: [NORMAL, NO_DATA, MISSING, STALE, UNSUPPORTED, INVALID, FORBIDDEN, ERROR]
description: 仅NORMAL参与业务计算;ERROR明确为处理失败,可按原因重试;FORBIDDEN不泄露采样
hydrogenValueSource: { type: string, nullable: true, enum: [REPORTED, ESTIMATED], description: 终端上报不等同于直接测量;估算需历史温压/容积证据 }
hydrogenSourceProtocol: { type: string, nullable: true, enum: [GB32960, YUTONG_MQTT, JT808] }
sourceRecordId: { type: string, nullable: true, description: raw-sha256采样身份指纹;两端相同需识别采样分辨率不足,内容修订另看sourceDataVersion }
sourceDataVersion: { type: string, nullable: true, description: 'sha256原始JSON和解析状态内容指纹;同采样被修订时变化,不是两端必须相等的口径版本' }
hydrogenEstimatePressureMPa: { type: number, nullable: true, description: 有效同帧最大氢压MPa证据,缺历史容量仍可诊断返回 }
hydrogenEstimateTemperatureC: { type: number, nullable: true, description: 有效同帧最大氢温摄氏度证据 }
hydrogenTankCapacityL: { type: number, nullable: true, description: 当前缺历史容量版本始终null,不能回套当前容积 }
hydrogenPressureTemperatureSource: { type: string, nullable: true, enum: [MAX_SENSOR_AGGREGATE], description: 最大温压聚合不保证同瓶 }
hydrogenCalculationVersion: { type: string, nullable: true, description: 当前REPORTED_HYDROGEN_KG_V1,质量不额外舍入;ESTIMATED预留但当前缺历史容积版本不输出估算值 }
hydrogenCapacityVersion: { type: string, nullable: true, description: 历史时点适用容积版本;估算必需,无证据不估算 }
updatedAt: { type: string, format: date-time, nullable: true, description: 原始记录received_atRFC3339带时区;不代替采集时间,同时间戳修订通过sourceDataVersion识别 }
reasonCode: { type: string, nullable: true, description: 可程序识别的状态原因 }
message: { type: string, nullable: true, description: 中文状态说明 }
HydrogenStationQuery:
type: object
additionalProperties: false
properties:
province:
type: string
maxLength: 32
city:
type: string
maxLength: 32
cooperateOnly:
type: boolean
description: true仅合作站,false仅外部站,省略则返回全部
HydrogenResult: HydrogenResult:
type: object type: object
required: [plateNumber, date, hydrogenConsumptionKg, status] required: [vin, plateNumber, date, hydrogenConsumptionKg, statisticsStartTime, statisticsEndTime, updatedAt, status]
properties: properties:
vin:
type: string
description: 授权车辆 VIN,供跨接口身份校验
plateNumber: plateNumber:
type: string type: string
date: date:
@@ -443,12 +737,41 @@ components:
type: number type: number
format: double format: double
nullable: true nullable: true
description: 单日用氢量,kg;无数据时为 null description: 单日用氢量,kgOK 和 SUSPECT 计算结果均返回数值,无数据时为 null
statisticsStartTime:
type: string
format: date-time
nullable: true
description: FINAL 证据区间最早开始时间;PRELIMINARY 仅有结束水位故为 null;缺失或异常证据为 null。边界是证据包络,不承诺连续覆盖
statisticsEndTime:
type: string
format: date-time
nullable: true
description: FINAL 证据区间最晚结束时间,PRELIMINARY 为真实 lastEventTime 水位;缺失或异常证据为 null;不是 API 查询时间
updatedAt:
type: string
format: date-time
nullable: true
description: 同一用氢统计行的数据库更新时间,RFC 3339 带时区;无统计行时为 null
calculationPhase:
type: string
enum: [PRELIMINARY, FINAL]
description: PRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻
algorithmVersion:
type: string
description: 氢耗计算算法版本
qualityStatus:
type: string
enum: [OK, SUSPECT, NO_DATA]
description: OK 对应 NORMALSUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计
qualityReason:
type: string
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageResult: MileageResult:
type: object type: object
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status] required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, statisticsStartTime, statisticsEndTime, status]
properties: properties:
vin: vin:
type: string type: string
@@ -461,12 +784,22 @@ components:
type: number type: number
format: double format: double
nullable: true nullable: true
description: 单日里程,km;当日无记录但存在历史累计里程时为 0 description: 相邻自然日同来源累计值之差,km;跨缺报期增量计入恢复日;缺报日为 0;首次基线、来源变化和累计异常时为 null
totalMileageKm: totalMileageKm:
type: number type: number
format: double format: double
nullable: true nullable: true
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null description: 当日所选协议最后有效终端累计总里程,kmGPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
statisticsStartTime:
type: string
format: date-time
nullable: true
description: 日里程起点累计读数的时间;可跨越多个缺报日;历史结转补零或无数据时为 null
statisticsEndTime:
type: string
format: date-time
nullable: true
description: 当日所选来源最晚 latest_event_time,等于 dataTime;历史结转补零或无数据时为 null。与用氢接口无共同快照保证
dataTime: dataTime:
type: string type: string
format: date-time format: date-time
@@ -482,6 +815,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, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageRangeResult: MileageRangeResult:
@@ -506,7 +844,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
@@ -521,11 +859,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, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
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'
@@ -606,6 +950,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'
@@ -614,6 +977,180 @@ 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:
type: object
required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, remainingHydrogenPercentSource, hydrogenFullCapacityKg, hydrogenTankCapacityL, hydrogenFullPressureMPa, hydrogenReferenceTemperatureC, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacitySource, hydrogenPercentReason, gpsFixStatus, locationRecordTime, coordinateSystem]
properties:
vin: { type: string }
plateNumber: { type: string }
remainingHydrogenKg:
type: number
nullable: true
minimum: 0
maximum: 500
description: 全车氢质量 kg;优先 GB32960 广东扩展终端上报(0–200 kg),缺质量时以同帧最大氢压/氢温和 VIN 全车水容积作实气估算(0–500 kg)。真实零保留,缺失/异常为 null,陈旧值保留并标 STALE
remainingHydrogenPercent:
type: number
nullable: true
minimum: 0
maximum: 100
description: 剩余质量除以 VIN 水容积在 35 MPa、15°C 的实气模型满充质量乘100,属于 ESTIMATED;缺容量为 null/MISSING。超过100返回 null/INVALID,不截断为100kg独立保留;不以动力电池SOC替代
hydrogenRecordTime:
type: string
format: date-time
nullable: true
description: 氢质量对应原始同帧实际采集时间,RFC 3339 带时区;不是请求时间或缓存更新时间
hydrogenDataStatus:
type: string
enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID]
description: NORMAL 两项有效(可包含估算,见来源);PARTIAL 部分字段有效;STALE 数据陈旧;MISSING 缺少数据或容量;UNSUPPORTED 不支持;INVALID 异常。数值和字段级状态独立判断
remainingHydrogenKgStatus:
type: string
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
remainingHydrogenPercentStatus:
type: string
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
description: 独立于 kg 状态;容量缺失 MISSING,比例超100 INVALID;有值时继承 kg 的 NORMAL 或 STALE
hydrogenValueSource:
type: string
nullable: true
enum: [REPORTED, ESTIMATED]
description: kg 来源;REPORTED 仅确认终端上报,无法判断终端内部测量或估算;ESTIMATED 为平台压力/温度/容积实气模型估算
remainingHydrogenPercentSource:
type: string
nullable: true
enum: [ESTIMATED]
description: 百分比始终为模型满充容量推算;与 kg 来源独立,计算前缺条件时为 null
hydrogenFullCapacityKg:
type: number
nullable: true
description: 实际用于比例分母的未舍入满充质量,kgPressureHydrogenMassKg(35,15,VIN水容积),不是扣除残余后的可用容量
hydrogenTankCapacityL:
type: number
nullable: true
description: VIN 配置的全车储氢水容积,L;必须 active 且大于0、不超过10000,不根据车型臆造默认值
hydrogenFullPressureMPa:
type: number
nullable: true
enum: [35]
description: 本次业务确认的满充参考压力,MPa;不代表自动核验每辆车铭牌额定压力
hydrogenReferenceTemperatureC:
type: number
nullable: true
enum: [15]
description: 满充参考温度,摄氏度
hydrogenEstimatePressureMPa:
type: number
nullable: true
description: 仅平台估算 kg 时使用的同帧最大氢压,MPa;支持0,不补默认值
hydrogenEstimateTemperatureC:
type: number
nullable: true
description: 仅平台估算 kg 时使用的同帧最大氢温,摄氏度;模型输入范围−40–726.85°C,不是车辆安全温度阈值;缺失不补默认温度
hydrogenPressureTemperatureSource:
type: string
nullable: true
enum: [MAX_SENSOR_AGGREGATE]
description: 最大氢压/最大氢温聚合读数近似;两者不保证来自同一瓶,不是逐瓶质量求和或全瓶完整性证明
hydrogenCalculationVersion:
type: string
nullable: true
enum: [REAL_GAS_35MPA_15C_V1]
description: 实时储氢模型版本;不修改日用氢算法
hydrogenCapacitySource:
type: string
nullable: true
enum: [vehicle_hydrogen_tank_capacity]
description: 满充容量采用的 VIN 配置数据表
hydrogenPercentReason:
type: string
nullable: true
enum: [MISSING_HYDROGEN_MEASUREMENT, INVALID_MASS_READING, INVALID_PRESSURE_TEMPERATURE, INCOMPLETE_PRESSURE_TEMPERATURE, MISSING_TANK_CAPACITY, MASS_CALCULATION_FAILED, EXCEEDS_NOMINAL_FULL_CAPACITY]
description: 缺少或拒绝百分比的原因;超出业务满充参考仅拒绝百分比,不伪装为100,也不据此判断车辆安全状态
hydrogenSourceProtocol:
type: string
nullable: true
enum: [GB32960, MQTT, JT808]
description: 储氢数据采用的协议,独立于位置的 protocol
hydrogenStaleAfterSeconds:
type: integer
nullable: true
description: GB32960 服务时效策略为 300 秒;不是协议采样周期承诺
hydrogenExpectedIntervalSeconds:
type: integer
nullable: true
description: 当前无已确认上报周期,固定 null
gpsFixStatus:
type: string
enum: [FIXED, NO_FIX, UNKNOWN]
description: 实际位置报文定位位;GB32960 bit0=0、JT808 bit1=1 表示 FIXEDMQTT 或缺少可信定位位为 UNKNOWN。不以在线或数据年龄推断
locationRecordTime:
type: string
format: date-time
nullable: true
description: 位置行对应实际采集时间,独立于主记录 recordTime;历史 FIXED 不因车辆离线改变
coordinateSystem:
type: string
enum: [WGS84, GCJ02, UNKNOWN]
description: 仅 GB2025 显式坐标类型 1=WGS84、2=GCJ02,其余 UNKNOWNGB2016/JT808/MQTT 当前无确证为 UNKNOWN,未知坐标系不得直接当高德坐标使用
protocol:
type: string
enum: [GB32960, MQTT, JT808]
description: 本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源
longitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效经度;无位置时为 null }
latitude: { type: number, format: double, nullable: true, description: 所选来源的最新有效纬度;无位置时为 null }
speedKmh: { type: number, format: double, nullable: true, description: 所选来源瞬时速度,单位 km/h }
socPercent: { type: number, format: double, minimum: 0, maximum: 100, description: 所选来源的动力电池荷电状态,单位 %;无有效采集值时字段省略 }
totalMileageKm: { type: number, format: double, nullable: true, description: 所选来源累计总里程,单位 km }
recordTime: { type: string, description: 所选来源实际记录时间,北京时间 }
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0, description: 当前查询时间减 recordTime,单位秒 }
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
activeToday: { type: boolean, description: 任一采集协议在当前自然日(Asia/Shanghai)内上报过 }
motionStatus: { type: string, enum: [driving, idle, offline], description: driving=在线且速度大于3km/hidle=在线且速度不大于3km/h;offline=当前不在线或无实时记录 }
locationAvailable: { type: boolean, description: true=longitude/latitude 有效;false=当前无有效位置 }
status: { $ref: '#/components/schemas/DataStatus' }
RealtimeVehicleQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/RealtimeVehicleResult' }
HydrogenStation:
type: object
required: [id, name, longitude, latitude, cooperative]
properties:
id: { type: string, description: 站点ID使用字符串避免JavaScript整数精度损失 }
name: { type: string }
shortName: { type: string }
address: { type: string }
longitude: { type: number, format: double }
latitude: { type: number, format: double }
province: { type: string }
city: { type: string }
district: { type: string }
cooperative: { type: boolean }
HydrogenStationQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/HydrogenStation' }
SuccessEnvelope: SuccessEnvelope:
type: object type: object
required: [code, message, traceId] required: [code, message, traceId]
@@ -0,0 +1,64 @@
package openplatform
import (
"encoding/json"
"time"
)
// Timestamps are evidence from the persisted result, never the API clock or
// separately evolving stream state. A missing boundary stays unknown.
func validStatisticsTimestamp(value string) *string {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil || t.IsZero() {
return nil
}
normalized := t.In(time.FixedZone("CST", 8*60*60)).Format(time.RFC3339Nano)
return &normalized
}
func validatedStatisticsInterval(start, end string) (*string, *string) {
a, b := validStatisticsTimestamp(start), validStatisticsTimestamp(end)
if a != nil && b != nil {
at, _ := time.Parse(time.RFC3339Nano, *a)
bt, _ := time.Parse(time.RFC3339Nano, *b)
if at.After(bt) {
return nil, nil
}
}
return a, b
}
// FINAL evidence contains an envelope of calculation segments, not a promise
// of uninterrupted coverage. PRELIMINARY evidence currently only stores a
// lastEventTime watermark, so its start is intentionally null.
func hydrogenStatisticsInterval(evidence string) (*string, *string) {
var live struct {
LastEventTime string `json:"lastEventTime"`
}
if json.Unmarshal([]byte(evidence), &live) == nil && live.LastEventTime != "" {
return nil, validStatisticsTimestamp(live.LastEventTime)
}
var segments []struct {
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
if json.Unmarshal([]byte(evidence), &segments) != nil || len(segments) == 0 {
return nil, nil
}
var earliest, latest time.Time
for _, segment := range segments {
a, b := validatedStatisticsInterval(segment.StartTime, segment.EndTime)
if a == nil || b == nil {
return nil, nil
}
at, _ := time.Parse(time.RFC3339Nano, *a)
bt, _ := time.Parse(time.RFC3339Nano, *b)
if earliest.IsZero() || at.Before(earliest) {
earliest = at
}
if latest.IsZero() || bt.After(latest) {
latest = bt
}
}
return validatedStatisticsInterval(earliest.Format(time.RFC3339Nano), latest.Format(time.RFC3339Nano))
}
@@ -0,0 +1,86 @@
package openplatform
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestHydrogenStatisticsInterval(t *testing.T) {
cases := []struct{ name, evidence, start, end string }{
{"live watermark", `{"lastEventTime":"2026-09-08T10:23:45.123Z"}`, "", "2026-09-08T18:23:45.123+08:00"},
{"final envelope", `[{"startTime":"2026-09-08T02:00:00+08:00","endTime":"2026-09-08T03:00:00+08:00"},{"startTime":"2026-09-08T01:00:00+08:00","endTime":"2026-09-08T01:30:00+08:00"}]`, "2026-09-08T01:00:00+08:00", "2026-09-08T03:00:00+08:00"},
{"missing", `null`, "", ""},
{"legacy empty", `[]`, "", ""},
{"malformed", `{`, "", ""},
{"reversed", `[{"startTime":"2026-09-08T03:00:00+08:00","endTime":"2026-09-08T02:00:00+08:00"}]`, "", ""},
{"incomplete", `[{"startTime":"2026-09-08T03:00:00+08:00"}]`, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a, b := hydrogenStatisticsInterval(tc.evidence)
if (a == nil) != (tc.start == "") || a != nil && *a != tc.start {
t.Fatalf("start=%v", a)
}
if (b == nil) != (tc.end == "") || b != nil && *b != tc.end {
t.Fatalf("end=%v", b)
}
})
}
}
func TestMileageStatisticsIntervalDoesNotRelabelCarryForward(t *testing.T) {
value := DailyMileage{Date: "2026-09-07", StatisticsStartTime: "2026-09-07T00:01:00+08:00", DataTime: "2026-09-07T23:01:00+08:00"}
carried := MileageResult{Date: "2026-09-08"}
fillMileageResult(&carried, value, 0)
if carried.StatisticsStartTime != nil || carried.StatisticsEndTime != nil {
t.Fatalf("fabricated carry interval: %+v", carried)
}
current := MileageResult{Date: value.Date}
fillMileageResult(&current, value, 10)
if current.StatisticsStartTime == nil || current.StatisticsEndTime == nil {
t.Fatalf("missing evidence: %+v", current)
}
}
func TestDailyHydrogenReadsEvidenceAndUpdateFromSameRow(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT vin,DATE_FORMAT.*COALESCE\\(evidence_json,'null'\\).*DATE_FORMAT\\(updated_at").WithArgs("2026-09-08", "VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "consumption", "samples", "quality", "reason", "phase", "algorithm", "evidence", "updated"}).AddRow("VIN1", "2026-09-08", 0, 2, "OK", "", "PRELIMINARY", "V3_5", `{"lastEventTime":"2026-09-08T10:00:00+08:00"}`, "2026-09-08T10:01:00.123000+08:00"))
values, err := NewMySQLRepository(db).DailyHydrogen(context.Background(), []string{"VIN1"}, "2026-09-08")
if err != nil {
t.Fatal(err)
}
if values["VIN1"].EvidenceJSON == "" || values["VIN1"].UpdatedAt == "" {
t.Fatal(values)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDailyQueryRejectsSameCountWrongAuthorizationMembership(t *testing.T) {
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}}}
_, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
if err != ErrForbidden {
t.Fatalf("authorization membership mismatch must fail closed: %v", err)
}
if len(repo.dailyVINs) != 0 {
t.Fatalf("queried data after failed authorization: %v", repo.dailyVINs)
}
}
func TestDailyQueryIgnoresUnrequestedVINResults(t *testing.T) {
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}}, hydrogen: map[string]DailyHydrogen{"LTEST32960VIN0002": {ConsumptionKg: 99, QualityStatus: "OK"}}}
rows, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].VIN != "LTEST32960VIN0001" || rows[0].HydrogenConsumptionKg != nil {
t.Fatalf("unexpected VIN leaked: %+v", rows)
}
}
@@ -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) {
@@ -74,3 +79,56 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
} }
} }
} }
func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T) {
// Public additions must be discoverable in both the machine contract and the
// human-readable documentation, including their null/quality companions.
for _, field := range []string{
"remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime",
"hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus",
"hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds",
"hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime",
"coordinateSystem", "statisticsStartTime", "statisticsEndTime",
"updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion",
"remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL",
"hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa",
"hydrogenEstimateTemperatureC", "hydrogenPressureTemperatureSource", "hydrogenCalculationVersion",
"hydrogenCapacitySource", "hydrogenPercentReason",
} {
if !strings.Contains(string(openAPISpec), field+":") {
t.Errorf("OpenAPI missing live contract field %s", field)
}
if !strings.Contains(string(simpleDocsHTML), field) {
t.Errorf("HTML missing live contract field %s", field)
}
}
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
for _, boundary := range []string{"PARTIAL", "UNSUPPORTED", "UNKNOWN", "PRELIMINARY", "FINAL", "SUSPECT", "REPORTED", "ESTIMATED", "MAX_SENSOR_AGGREGATE", "EXCEEDS_NOMINAL_FULL_CAPACITY", "REAL_GAS_35MPA_15C_V1", "共同快照", "null"} {
if !strings.Contains(doc, boundary) {
t.Errorf("%s missing availability/comparability boundary %q", name, boundary)
}
}
}
if strings.Contains(string(openAPISpec), "MEASURED") || strings.Contains(string(simpleDocsHTML), "MEASURED") {
t.Error("reported hydrogen must not promise a measured terminal value")
}
if strings.Contains(string(simpleDocsHTML), "新接入请使用 sourceProtocol") {
t.Error("HTML must not advertise sourceProtocol as realtime field")
}
}
func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *testing.T) {
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
for _, want := range []string{
"/api/v1/vehicles/hydrogen-remaining/history/query",
"requestId", "maxTimeDifferenceSeconds", "sourceRecordId",
"hydrogenCapacityVersion", "sourceDataVersion", "reasonCode", "2026-08-01", "FORBIDDEN", "ERROR",
"STALE", "429", "Retry-After", "event_time", "历史容量", "非原子",
"20个不同点", "去重",
} {
if !strings.Contains(doc, want) {
t.Errorf("%s missing history contract evidence/failure boundary %q", name, want)
}
}
}
}
@@ -17,10 +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"
StationaryVehicleQueryPath = "/api/v1/vehicles/stationary/query"
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
) )
type Handler struct { type Handler struct {
@@ -56,9 +59,13 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
func (h *Handler) registerExternalDataRoutes() { func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen) h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
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 "+HydrogenStationQueryPath, h.hydrogenStations)
} }
func (h *Handler) registerAdminAppRoutes() { func (h *Handler) registerAdminAppRoutes() {
@@ -103,9 +110,13 @@ func (h *Handler) registerPortalRoutes() {
func NewDataHandler(service *Service) *Handler { func NewDataHandler(service *Service) *Handler {
handler := &Handler{service: service, mux: http.NewServeMux()} handler := &Handler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen) handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
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 "+HydrogenStationQueryPath, handler.hydrogenStations)
return handler return handler
} }
@@ -114,7 +125,49 @@ 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 return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
}
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request StationaryVehicleQueryRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryStationaryVehicles(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request RealtimeVehicleRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryRealtimeVehicles(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) hydrogenStations(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request HydrogenStationRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryHydrogenStations(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
} }
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) { func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
@@ -650,7 +703,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 {
@@ -701,6 +756,7 @@ func requestRemoteAddress(r *http.Request) string {
func dataProducts() []DataProduct { func dataProducts() []DataProduct {
return []DataProduct{ return []DataProduct{
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
{ {
Code: "daily_hydrogen", Name: "单日用氢量", Code: "daily_hydrogen", Name: "单日用氢量",
Description: "按车牌和自然日查询授权车辆的氢气消耗量。", Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
@@ -725,5 +781,23 @@ 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: "车辆实时位置与状态",
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: RealtimeVehicleQueryPath, Unit: "",
},
{
Code: "hydrogen_station", Name: "加氢站地图点位",
Description: "只读查询资产管理库中的加氢站名称、经纬度、行政区划和合作状态。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: HydrogenStationQueryPath, Unit: "座",
},
} }
} }
@@ -0,0 +1,389 @@
package openplatform
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
)
const HistoricalHydrogenQueryPath = "/api/v1/vehicles/hydrogen-remaining/history/query"
const historicalHydrogenBudget = 9 * time.Second
var historicalVINPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
type HistoricalHydrogenQuery struct {
RequestID string `json:"requestId"`
VIN string `json:"vin"`
Time string `json:"time"`
Protocol string `json:"protocol,omitempty"`
}
type HistoricalHydrogenRequest struct {
Queries []HistoricalHydrogenQuery `json:"queries"`
MaxTimeDifferenceSeconds *int `json:"maxTimeDifferenceSeconds,omitempty"`
}
// Optional numeric/protocol fields may be omitted, but explicit JSON null is
// not a valid integer or protocol identifier. Preserve strict unknown-field
// rejection even though this request has a custom decoder.
func (r *HistoricalHydrogenRequest) UnmarshalJSON(data []byte) error {
type plain HistoricalHydrogenRequest
var decoded plain
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&decoded); err != nil {
return err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if value, exists := raw["maxTimeDifferenceSeconds"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return fmt.Errorf("maxTimeDifferenceSeconds must be an integer")
}
var queries []map[string]json.RawMessage
if err := json.Unmarshal(raw["queries"], &queries); err != nil {
return err
}
for _, query := range queries {
if value, exists := query["protocol"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return fmt.Errorf("protocol must be a string")
}
}
*r = HistoricalHydrogenRequest(decoded)
return nil
}
type HistoricalHydrogenResult struct {
RequestID string `json:"requestId"`
VIN string `json:"vin"`
PlateNumber *string `json:"plateNumber"`
QueryTime string `json:"queryTime"`
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
TimeDifferenceSeconds *float64 `json:"timeDifferenceSeconds"`
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
HydrogenValueSource *string `json:"hydrogenValueSource"`
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
SourceRecordID *string `json:"sourceRecordId"`
SourceDataVersion *string `json:"sourceDataVersion"`
HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
HydrogenCapacityVersion *string `json:"hydrogenCapacityVersion"`
UpdatedAt *string `json:"updatedAt"`
ReasonCode *string `json:"reasonCode"`
Message *string `json:"message"`
HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
}
type HistoricalHydrogenRepository interface {
HistoricalHydrogen(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
HistoricalHydrogenAuthorized(context.Context, uint64, string, time.Time, time.Time) (bool, error)
}
type parsedHistoricalHydrogenQuery struct {
HistoricalHydrogenQuery
at time.Time
}
func (s *Service) validateHistoricalHydrogen(request HistoricalHydrogenRequest, now time.Time) ([]parsedHistoricalHydrogenQuery, int, error) {
if len(request.Queries) < 1 || len(request.Queries) > 200 {
return nil, 0, fmt.Errorf("%w: queries must contain 1-200 items", ErrInvalidRequest)
}
tolerance := 300
if request.MaxTimeDifferenceSeconds != nil {
tolerance = *request.MaxTimeDifferenceSeconds
}
if tolerance < 0 || tolerance > 300 {
return nil, 0, fmt.Errorf("%w: tolerance must be 0-300", ErrInvalidRequest)
}
seen := make(map[string]bool, len(request.Queries))
parsed := make([]parsedHistoricalHydrogenQuery, 0, len(request.Queries))
lower := time.Date(2026, 8, 1, 0, 0, 0, 0, s.location)
for _, query := range request.Queries {
if strings.TrimSpace(query.RequestID) == "" || len(query.RequestID) > 128 || seen[query.RequestID] {
return nil, 0, fmt.Errorf("%w: requestId must be nonempty and unique", ErrInvalidRequest)
}
seen[query.RequestID] = true
query.VIN = strings.ToUpper(query.VIN)
if !historicalVINPattern.MatchString(query.VIN) {
return nil, 0, fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", query.Time, s.location)
if err != nil || at.Format("2006-01-02 15:04:05") != query.Time {
return nil, 0, fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
}
if at.After(now) || at.Before(lower) {
return nil, 0, fmt.Errorf("%w: time outside supported history", ErrInvalidRequest)
}
if query.Protocol == "" {
query.Protocol = vehicleprotocol.GB32960
} else if protocol, ok := vehicleprotocol.Canonical(query.Protocol); !ok || protocol != query.Protocol {
return nil, 0, fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
}
parsed = append(parsed, parsedHistoricalHydrogenQuery{HistoricalHydrogenQuery: query, at: at})
}
return parsed, tolerance, nil
}
func (s *Service) QueryHistoricalHydrogen(ctx context.Context, appKey, traceID string, request HistoricalHydrogenRequest) ([]HistoricalHydrogenResult, error) {
now := s.now().In(s.location)
queries, tolerance, err := s.validateHistoricalHydrogen(request, now)
if err != nil {
return nil, err
}
if !appKeyPattern.MatchString(appKey) {
return nil, ErrUnauthorized
}
bounded, cancel := context.WithTimeout(ctx, historicalHydrogenBudget)
defer cancel()
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
return nil, ErrUnauthorized
}
return nil, err
}
release, err := s.historicalLimiter.acquire(app.ID, now)
if err != nil {
return nil, err
}
defer release()
results := make([]HistoricalHydrogenResult, len(queries))
for i, query := range queries {
results[i] = historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
}
repository, ok := s.repository.(HistoricalHydrogenRepository)
if !ok {
for i, query := range queries {
results[i] = historicalHydrogenFailure(query, "ERROR", "HISTORY_UNAVAILABLE", "历史数据服务暂不可用")
}
return results, nil
}
// Deduplicate identical sampling points within this batch. Every requestId
// still receives its own result, while duplicate endpoints share one source
// revision and one pair of authorization checks.
var unique []parsedHistoricalHydrogenQuery
var destinations [][]int
indexes := make(map[string]int, len(queries))
for i, query := range queries {
key := query.VIN + "\x00" + query.Time + "\x00" + query.Protocol
index, exists := indexes[key]
if !exists {
index = len(unique)
indexes[key] = index
unique = append(unique, query)
destinations = append(destinations, nil)
}
destinations[index] = append(destinations[index], i)
}
type completed struct {
index int
result HistoricalHydrogenResult
}
jobs := make(chan int, len(unique))
out := make(chan completed, len(unique))
for i := range unique {
jobs <- i
}
close(jobs)
workers := 4
if len(unique) < workers {
workers = len(unique)
}
for worker := 0; worker < workers; worker++ {
go func() {
for i := range jobs {
if bounded.Err() != nil {
return
}
out <- completed{i, s.queryHistoricalHydrogenItem(bounded, repository, app.ID, unique[i], tolerance, now)}
}
}()
}
for count := 0; count < len(unique); count++ {
select {
case result := <-out:
for _, destination := range destinations[result.index] {
item := result.result
item.RequestID = queries[destination].RequestID
results[destination] = item
}
case <-bounded.Done():
return results, nil
}
}
// Use the remaining shared budget; avoid extending a timed-out batch for audit.
if bounded.Err() == nil {
_ = s.repository.Audit(bounded, app.ID, "historical_hydrogen_query", "success", traceID, len(results), "")
}
return results, nil
}
func historicalHydrogenFailure(query parsedHistoricalHydrogenQuery, status, reason, message string) HistoricalHydrogenResult {
return HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: status, ReasonCode: historicalString(reason), Message: historicalString(message)}
}
func historicalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func historicalTime(value time.Time, location *time.Location) *string {
if value.IsZero() {
return nil
}
text := value.In(location).Format(time.RFC3339Nano)
return &text
}
func (s *Service) queryHistoricalHydrogenItem(ctx context.Context, repository HistoricalHydrogenRepository, appID uint64, query parsedHistoricalHydrogenQuery, tolerance int, now time.Time) HistoricalHydrogenResult {
failure := func(err error) HistoricalHydrogenResult {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
}
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_FAILURE", "历史查询失败,请重试")
}
allowed, err := repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, query.at, now)
if err != nil {
return failure(err)
}
if !allowed {
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
}
if query.Protocol != vehicleprotocol.GB32960 {
return historicalHydrogenFailure(query, "UNSUPPORTED", "PROTOCOL_UNSUPPORTED", "该协议暂不支持历史储氢质量")
}
point, err := repository.HistoricalHydrogen(ctx, query.VIN, query.at, query.Protocol)
if err != nil {
return failure(err)
}
if point == nil {
return historicalHydrogenFailure(query, "NO_DATA", "NO_HISTORICAL_SAMPLE", "查询时刻前没有历史储氢采样")
}
if point.VIN != query.VIN || point.Protocol != query.Protocol {
return historicalHydrogenFailure(query, "ERROR", "SOURCE_IDENTITY_MISMATCH", "历史采样身份不一致")
}
if point.ObservedAt.IsZero() || point.ObservedAt.After(query.at) {
return historicalHydrogenFailure(query, "ERROR", "INVALID_SAMPLE_TIME", "历史采样时间不符合查询条件")
}
allowed, err = repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, point.ObservedAt, now)
if err != nil {
return failure(err)
}
if !allowed {
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
}
result := HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: point.Status, HydrogenRecordTime: historicalTime(point.ObservedAt, s.location), HydrogenValueSource: historicalString(point.ValueSource), HydrogenSourceProtocol: historicalString(point.Protocol), SourceRecordID: historicalString(point.RecordID), SourceDataVersion: historicalString(point.SourceDataVersion), HydrogenCalculationVersion: historicalString(point.CalculationVersion), HydrogenCapacityVersion: historicalString(point.CapacityVersion), UpdatedAt: historicalTime(point.UpdatedAt, s.location), ReasonCode: historicalString(point.ReasonCode), Message: historicalString(point.Message), HydrogenEstimatePressureMPa: point.EstimatePressureMPa, HydrogenEstimateTemperatureC: point.EstimateTemperatureC, HydrogenTankCapacityL: point.TankCapacityL, HydrogenPressureTemperatureSource: historicalString(point.PressureTemperatureSource)}
difference := query.at.Sub(point.ObservedAt).Seconds()
result.TimeDifferenceSeconds = &difference
switch result.RemainingHydrogenKgStatus {
case "NORMAL", "NO_DATA", "MISSING", "STALE", "UNSUPPORTED", "INVALID":
default:
return historicalHydrogenFailure(query, "ERROR", "INVALID_SOURCE_STATUS", "历史数据状态异常")
}
if result.RemainingHydrogenKgStatus == "NORMAL" {
if point.RemainingHydrogenKg == nil {
result.RemainingHydrogenKgStatus = "MISSING"
result.ReasonCode = historicalString("MISSING_MASS")
} else if math.IsNaN(*point.RemainingHydrogenKg) || math.IsInf(*point.RemainingHydrogenKg, 0) || *point.RemainingHydrogenKg < 0 {
result.RemainingHydrogenKgStatus = "INVALID"
result.ReasonCode = historicalString("INVALID_MASS")
} else if difference > float64(tolerance) {
result.RemainingHydrogenKgStatus = "STALE"
result.ReasonCode = historicalString("SAMPLE_OUTSIDE_TOLERANCE")
result.Message = historicalString("采样时间早于允许容差")
} else if point.ValueSource == "ESTIMATED" && (point.CalculationVersion == "" || point.CapacityVersion == "" || point.EstimatePressureMPa == nil || point.EstimateTemperatureC == nil || point.TankCapacityL == nil || point.PressureTemperatureSource == "") {
result.RemainingHydrogenKgStatus = "MISSING"
result.ReasonCode = historicalString("MISSING_ESTIMATE_EVIDENCE")
} else {
value := *point.RemainingHydrogenKg
result.RemainingHydrogenKg = &value
}
}
return result
}
func (r *MySQLRepository) HistoricalHydrogenAuthorized(ctx context.Context, appID uint64, vin string, at, now time.Time) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM vehicle_open_app a JOIN vehicle_open_app_vehicle g ON g.app_id=a.id WHERE a.id=? AND a.status='enabled' AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND BINARY g.vin=BINARY ? AND g.valid_from<=? AND (g.valid_to IS NULL OR g.valid_to>?))`, appID, now, now, at, at, vin, at, at).Scan(&exists)
return exists, err
}
type historicalHydrogenRateError struct{ RetryAfter int }
func (e *historicalHydrogenRateError) Error() string {
return "historical hydrogen rate limit exceeded"
}
type historicalHydrogenRateEntry struct {
start time.Time
count, active int
}
type historicalHydrogenLimiter struct {
mu sync.Mutex
entries map[uint64]*historicalHydrogenRateEntry
}
func (l *historicalHydrogenLimiter) acquire(appID uint64, now time.Time) (func(), error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.entries == nil {
l.entries = make(map[uint64]*historicalHydrogenRateEntry)
}
for id, entry := range l.entries {
if entry.active == 0 && now.Sub(entry.start) >= 2*time.Minute {
delete(l.entries, id)
}
}
entry := l.entries[appID]
if entry == nil {
entry = &historicalHydrogenRateEntry{start: now}
l.entries[appID] = entry
}
if now.Sub(entry.start) >= time.Minute {
entry.start = now
entry.count = 0
}
if entry.active >= 2 {
return nil, &historicalHydrogenRateError{RetryAfter: 1}
}
if entry.count >= 30 {
return nil, &historicalHydrogenRateError{RetryAfter: max(1, int(math.Ceil(time.Minute.Seconds()-now.Sub(entry.start).Seconds())))}
}
entry.count++
entry.active++
return func() { l.mu.Lock(); entry.active--; l.mu.Unlock() }, nil
}
func (h *Handler) historicalHydrogen(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request HistoricalHydrogenRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryHistoricalHydrogen(r.Context(), externalBearer(r), traceID, request)
if err != nil {
var limit *historicalHydrogenRateError
if errors.As(err, &limit) {
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
return
}
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
@@ -0,0 +1,39 @@
package openplatform
import (
"context"
"testing"
"time"
)
func TestHistoricalHydrogenCrossMidnightKeepsInstantAndRevisionEvidence(t *testing.T) {
service, repository, _ := historicalTestService()
start := time.Date(2026, 8, 3, 23, 59, 55, 0, service.location)
end := start.Add(20 * time.Second)
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
point := historicalTestPoint(at, 30.50012345)
point.RecordID = at.Format(time.RFC3339)
if at.Equal(end) {
point.RemainingHydrogenKg = realLiveFloat(29.80012345)
point.SourceDataVersion = "end-v2"
}
return point, nil
}
request := HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{
{RequestID: "job-end", VIN: historicalTestVIN, Time: end.Format("2006-01-02 15:04:05")},
{RequestID: "job-start", VIN: historicalTestVIN, Time: start.Format("2006-01-02 15:04:05")},
}}
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "cross-midnight", request)
if err != nil || len(results) != 2 {
t.Fatalf("%+v %v", results, err)
}
for i, at := range []time.Time{end, start} {
got := results[i]
if got.RequestID != request.Queries[i].RequestID || got.QueryTime != request.Queries[i].Time || got.HydrogenRecordTime == nil || *got.HydrogenRecordTime != at.Format(time.RFC3339) || got.RemainingHydrogenKgStatus != "NORMAL" || *got.TimeDifferenceSeconds != 0 {
t.Fatalf("midnight instant lost: %+v", got)
}
}
if *results[0].RemainingHydrogenKg != 29.80012345 || *results[1].RemainingHydrogenKg != 30.50012345 || *results[0].SourceRecordID == *results[1].SourceRecordID || *results[0].SourceDataVersion == *results[1].SourceDataVersion {
t.Fatal("endpoint precision or distinct provenance lost")
}
}
@@ -0,0 +1,239 @@
package openplatform
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// HistoricalHydrogenStart is the first supported event-time boundary. This is
// an API coverage boundary, not a claim that every VIN has retained records.
var HistoricalHydrogenStart = time.Date(2026, 8, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
type HistoricalHydrogenPoint struct {
VIN, Protocol, RecordID, SourceDataVersion string
ObservedAt, ReceivedAt, UpdatedAt time.Time
RemainingHydrogenKg *float64
Status, ValueSource, CalculationVersion, CapacityVersion, ReasonCode, Message string
EstimatePressureMPa, EstimateTemperatureC, TankCapacityL *float64
PressureTemperatureSource string
}
type historicalHydrogenFrame struct {
VIN, Protocol, EventID, FrameID, Parsed, ParseStatus string
EventMS, ReceivedMS, TimestampMS sql.NullInt64
}
func (r *MySQLRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
if protocol == "" {
protocol = "GB32960"
}
if protocol != "GB32960" {
return &HistoricalHydrogenPoint{VIN: vin, Protocol: protocol, Status: "UNSUPPORTED", ReasonCode: "UNSUPPORTED_HYDROGEN_PROTOCOL", Message: "该协议暂不支持历史剩余氢质量"}, nil
}
if r.tdengine == nil {
return nil, errors.New("TDengine is not configured for historical hydrogen query")
}
query, err := historicalHydrogenQuery(r.tdDatabase, vin, at, protocol)
if err != nil {
return nil, err
}
for candidate := 0; candidate < 32; candidate++ {
var frame historicalHydrogenFrame
frame, err = r.loadHistoricalHydrogenCandidate(ctx, query)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if frame.VIN != vin || frame.Protocol != protocol {
return nil, errors.New("historical hydrogen source identity mismatch")
}
if err := r.hydrateHistoricalHydrogenFrame(ctx, &frame); err != nil {
return nil, err
}
point := historicalHydrogenFromFrame(frame)
if point.ObservedAt.IsZero() || point.ObservedAt.After(at) || point.ObservedAt.Before(HistoricalHydrogenStart) {
return nil, errors.New("historical hydrogen source time out of bounds")
}
if point.ReasonCode != "MISSING_HYDROGEN_MEASUREMENT" {
return &point, nil
}
// A hydrated chunk can prove that a candidate has no hydrogen fields. Only
// in that case move to the next record; invalid/incomplete readings stop.
clause := fmt.Sprintf(" AND (event_time<%d OR (event_time=%d AND ts<%d))", frame.EventMS.Int64, frame.EventMS.Int64, frame.TimestampMS.Int64)
query = strings.Replace(query, " ORDER BY", clause+" ORDER BY", 1)
}
return nil, errors.New("historical hydrogen candidate budget exceeded")
}
func historicalHydrogenQuery(database, vin string, at time.Time, protocol string) (string, error) {
if !validTDIdentifier(database) || vin == "" || protocol != "GB32960" || at.Before(HistoricalHydrogenStart) {
return "", errors.New("invalid historical hydrogen query")
}
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
fields := []string{`parsed_json LIKE '%"chunked":true%'`}
for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) {
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
}
// Raw rows are JSON-marshaled by ingestion (compact key-colon syntax). Do not
// filter invalid values/parse status or restrict arrival <= query event time:
// a delayed upload remains valid historical evidence for its collection time.
return `SELECT vin,protocol,event_id,frame_id,CAST(event_time AS BIGINT),CAST(received_at AS BIGINT),CAST(ts AS BIGINT),parsed_json,parse_status FROM ` + database + `.raw_frames WHERE vin=` + quote(vin) + ` AND protocol=` + quote(protocol) + ` AND event_time>=` + strconv.FormatInt(HistoricalHydrogenStart.UnixMilli(), 10) + ` AND event_time<=` + strconv.FormatInt(at.UnixMilli(), 10) + ` AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 2`, nil
}
func historicalHydrogenFromFrame(frame historicalHydrogenFrame) HistoricalHydrogenPoint {
point := HistoricalHydrogenPoint{VIN: frame.VIN, Protocol: frame.Protocol, Status: "MISSING"}
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
point.ObservedAt = time.UnixMilli(frame.EventMS.Int64)
}
if frame.ReceivedMS.Valid && frame.ReceivedMS.Int64 > 0 {
point.ReceivedAt = time.UnixMilli(frame.ReceivedMS.Int64)
point.UpdatedAt = point.ReceivedAt
}
// The content fingerprint exposes corrections even if storage keeps the
// original arrival timestamp. It is not part of calculation comparability.
identity := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d", frame.VIN, frame.Protocol, frame.EventID, frame.FrameID, frame.EventMS.Int64, frame.TimestampMS.Int64)))
point.RecordID = "raw-sha256:" + hex.EncodeToString(identity[:])
content := sha256.Sum256([]byte(frame.Parsed + "\x00" + frame.ParseStatus))
point.SourceDataVersion = "sha256:" + hex.EncodeToString(content[:])
fail := func(status, reason, message string) HistoricalHydrogenPoint {
point.Status = status
point.ReasonCode = reason
point.Message = message
return point
}
if frame.ParseStatus != "OK" {
return fail("INVALID", "RAW_FRAME_PARSE_FAILED", "历史原始记录解析异常")
}
var fields map[string]any
if json.Unmarshal([]byte(frame.Parsed), &fields) != nil {
return fail("INVALID", "INVALID_RAW_JSON", "历史原始数据格式异常")
}
for _, key := range hydrogenMassFields {
value, exists := fields[key]
if !exists {
continue
}
mass, valid := numericValue(value)
point.ValueSource = "REPORTED"
point.CalculationVersion = "REPORTED_HYDROGEN_KG_V1"
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 {
return fail("INVALID", "INVALID_MASS_READING", "历史剩余氢质量读数无效")
}
point.RemainingHydrogenKg = &mass
point.Status = "NORMAL"
return point
}
pv, pExists := fields[realtimeHydrogenPressureField]
tv, tExists := fields[realtimeHydrogenTemperatureField]
if !pExists && !tExists {
return fail("MISSING", "MISSING_HYDROGEN_MEASUREMENT", "历史原始记录缺少氢质量及温压")
}
pressure, pOK := numericValue(pv)
temperature, tOK := numericValue(tv)
if (pExists && (!pOK || !finiteRealtime(pressure) || pressure < 0 || pressure > 70)) || (tExists && (!tOK || !finiteRealtime(temperature) || temperature < -40 || temperature > 726.85)) {
return fail("INVALID", "INVALID_PRESSURE_TEMPERATURE", "历史温度或压力读数无效")
}
if !pExists || !tExists {
return fail("MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", "历史同帧温度或压力缺失")
}
point.EstimatePressureMPa = &pressure
point.EstimateTemperatureC = &temperature
point.PressureTemperatureSource = "MAX_SENSOR_AGGREGATE"
// Current capacity, current stream state and daily aggregate parameters do
// not establish a versioned total tank volume valid at this historical event.
return fail("MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", "缺少适用于该历史时点的储氢容积版本证据")
}
func (r *MySQLRepository) hydrateHistoricalHydrogenFrame(ctx context.Context, frame *historicalHydrogenFrame) error {
var manifest struct {
Chunked bool `json:"chunked"`
PayloadKind string `json:"payload_kind"`
EventID string `json:"event_id"`
ChunkCount int `json:"chunk_count"`
}
if json.Unmarshal([]byte(frame.Parsed), &manifest) != nil || !manifest.Chunked {
return nil
}
if manifest.ChunkCount <= 0 || manifest.ChunkCount > 256 || (manifest.PayloadKind != "parsed_fields" && manifest.PayloadKind != "parsed_json") || (manifest.EventID != "" && manifest.EventID != frame.EventID) {
return errors.New("invalid historical hydrogen chunk manifest")
}
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
query := `SELECT chunk_index,chunk_count,chunk_text FROM ` + r.tdDatabase + `.raw_frame_payload_chunks WHERE vin=` + quote(frame.VIN) + ` AND protocol=` + quote(frame.Protocol) + ` AND event_id=` + quote(frame.EventID) + ` AND frame_id=` + quote(frame.FrameID) + ` AND payload_kind=` + quote(manifest.PayloadKind) + ` ORDER BY chunk_index LIMIT ` + strconv.Itoa(manifest.ChunkCount+1)
rows, err := r.tdengine.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()
parts := make([]string, manifest.ChunkCount)
seen := make([]bool, manifest.ChunkCount)
count := 0
for rows.Next() {
var index, total int
var part string
if err := rows.Scan(&index, &total, &part); err != nil {
return err
}
if index < 0 || index >= len(parts) || seen[index] || total != len(parts) {
return errors.New("inconsistent historical hydrogen chunks")
}
seen[index] = true
parts[index] = part
count++
}
if err := rows.Err(); err != nil {
return err
}
if count != len(parts) {
return errors.New("incomplete historical hydrogen chunks")
}
frame.Parsed = strings.Join(parts, "")
return nil
}
// Read a second candidate to detect ambiguous ordering across TDengine child
// tables. Never select arbitrarily or skip a tied hydrogen frame when advancing
// past a hydrated non-hydrogen candidate.
func (r *MySQLRepository) loadHistoricalHydrogenCandidate(ctx context.Context, query string) (historicalHydrogenFrame, error) {
rows, err := r.tdengine.QueryContext(ctx, query)
if err != nil {
return historicalHydrogenFrame{}, err
}
defer rows.Close()
var first historicalHydrogenFrame
scan := func(frame *historicalHydrogenFrame) error {
return rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.FrameID, &frame.EventMS, &frame.ReceivedMS, &frame.TimestampMS, &frame.Parsed, &frame.ParseStatus)
}
if !rows.Next() {
if err := rows.Err(); err != nil {
return first, err
}
return first, sql.ErrNoRows
}
if err := scan(&first); err != nil {
return first, err
}
if rows.Next() {
var second historicalHydrogenFrame
if err := scan(&second); err != nil {
return first, err
}
if first.EventMS == second.EventMS && first.TimestampMS == second.TimestampMS {
return first, errors.New("ambiguous historical hydrogen records at identical collection and storage timestamps")
}
}
if err := rows.Err(); err != nil {
return first, err
}
return first, nil
}
@@ -0,0 +1,146 @@
package openplatform
import (
"context"
"database/sql"
"github.com/DATA-DOG/go-sqlmock"
"strings"
"testing"
"time"
)
func historicalTestFrame(parsed string) historicalHydrogenFrame {
return historicalHydrogenFrame{VIN: "V1", Protocol: "GB32960", EventID: "E1", FrameID: "F1", Parsed: parsed, ParseStatus: "OK", EventMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(time.Hour).UnixMilli(), Valid: true}, ReceivedMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(2 * time.Hour).UnixMilli(), Valid: true}}
}
func TestHistoricalHydrogenRawPrecisionAndInvalidPriority(t *testing.T) {
for _, tc := range []struct {
parsed, status, reason string
mass *float64
}{
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "NORMAL", "", realLiveFloat(0)},
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":32.658123456}`, "NORMAL", "", realLiveFloat(32.658123456)},
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":-1}`, "INVALID", "INVALID_MASS_READING", nil},
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gd_fc_vehicle_hydrogen_mass_kg":10}`, "INVALID", "INVALID_MASS_READING", nil},
{fmtRealtimePT(35, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
{fmtRealtimePT(0, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null}`, "INVALID", "INVALID_PRESSURE_TEMPERATURE", nil},
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, "MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", nil},
} {
got := historicalHydrogenFromFrame(historicalTestFrame(tc.parsed))
if got.Status != tc.status || got.ReasonCode != tc.reason || (got.RemainingHydrogenKg == nil) != (tc.mass == nil) || tc.mass != nil && *got.RemainingHydrogenKg != *tc.mass {
t.Fatalf("%+v", got)
}
}
frame := historicalTestFrame(`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":1}`)
first := historicalHydrogenFromFrame(frame)
frame.Parsed = `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":2}`
second := historicalHydrogenFromFrame(frame)
if first.RecordID != second.RecordID || first.SourceDataVersion == second.SourceDataVersion || first.UpdatedAt != second.UpdatedAt {
t.Fatal("revision tracking invalid")
}
frame.ParseStatus = "ERROR"
if got := historicalHydrogenFromFrame(frame); got.Status != "INVALID" {
t.Fatal(got)
}
}
func TestHistoricalHydrogenQueryEventTimeAndSafeBounds(t *testing.T) {
q, err := historicalHydrogenQuery("vehicle_ts", "VIN'1", HistoricalHydrogenStart.Add(time.Hour), "GB32960")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"vin='VIN''1'", "event_time>=", "event_time<=", "ORDER BY event_time DESC,ts DESC LIMIT 2", `"chunked":true`} {
if !strings.Contains(q, want) {
t.Fatal(q)
}
}
for _, bad := range []string{"parse_status='OK'", "ts<=", "IS NOT NULL"} {
if strings.Contains(q, bad) {
t.Fatal(q)
}
}
if _, err := historicalHydrogenQuery("bad;sql", "V1", HistoricalHydrogenStart, "GB32960"); err == nil {
t.Fatal("unsafe db")
}
}
func TestHistoricalHydrogenRepositoryPreservesDelayedUpload(t *testing.T) {
td, mock, _ := sqlmock.New()
defer td.Close()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
at := HistoricalHydrogenStart.Add(time.Hour)
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.Add(time.Hour).UnixMilli(), at.Add(time.Hour).UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "OK"))
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
if err != nil || got == nil || got.Status != "NORMAL" || !got.ReceivedAt.After(at) {
t.Fatalf("%+v %v", got, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHistoricalHydrogenChunkMissingFailsInsteadOfFallback(t *testing.T) {
td, mock, _ := sqlmock.New()
defer td.Close()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"x":`))
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err == nil {
t.Fatal("missing chunk silently accepted")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHistoricalHydrogenChunkHydration(t *testing.T) {
td, mock, _ := sqlmock.New()
defer td.Close()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":`).AddRow(1, 2, `0}`))
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err != nil {
t.Fatal(err)
}
got := historicalHydrogenFromFrame(frame)
if got.Status != "NORMAL" || *got.RemainingHydrogenKg != 0 {
t.Fatal(got)
}
}
func TestHistoricalHydrogenNewestInvalidNeverQueriesOlder(t *testing.T) {
td, mock, _ := sqlmock.New()
defer td.Close()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
at := HistoricalHydrogenStart.Add(time.Hour)
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, "OK"))
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
if err != nil || got == nil || got.Status != "INVALID" {
t.Fatalf("%+v %v", got, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHistoricalHydrogenRejectsFutureReturnedRow(t *testing.T) {
td, mock, _ := sqlmock.New()
defer td.Close()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
at := HistoricalHydrogenStart.Add(time.Hour)
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.Add(time.Second).UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, "OK"))
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil {
t.Fatal("future row accepted")
}
}
func TestHistoricalHydrogenTiedCandidatesFailWithoutSkipping(t *testing.T) {
for _, parsed := range []string{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, `{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":1}`} {
td, mock, _ := sqlmock.New()
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
at := HistoricalHydrogenStart.Add(time.Hour)
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), parsed, "OK").AddRow("V1", "GB32960", "E2", "F2", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":20}`, "OK"))
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("expected ambiguity, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
td.Close()
}
}
@@ -0,0 +1,346 @@
package openplatform
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
const historicalTestVIN = "LA9GG68L2PBAF4773"
const historicalTestKey = "0123456789abcdef0123456789abcdef"
type historicalTestRepository struct {
*fakeRepository
lookup func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
allowed func(string, time.Time, time.Time) (bool, error)
lookups atomic.Int32
}
func (r *historicalTestRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
r.lookups.Add(1)
if r.lookup != nil {
return r.lookup(ctx, vin, at, protocol)
}
return nil, nil
}
func (r *historicalTestRepository) HistoricalHydrogenAuthorized(_ context.Context, _ uint64, vin string, at, now time.Time) (bool, error) {
if r.allowed != nil {
return r.allowed(vin, at, now)
}
return true, nil
}
func historicalTestService() (*Service, *historicalTestRepository, time.Time) {
repository := &historicalTestRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 9}}}
service := NewService(repository)
now := time.Date(2026, 9, 9, 12, 0, 0, 0, service.location)
service.now = func() time.Time { return now }
return service, repository, now
}
func historicalTestRequest(at time.Time) HistoricalHydrogenRequest {
return HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{{RequestID: "start", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05")}}}
}
func historicalTestPoint(at time.Time, kg float64) *HistoricalHydrogenPoint {
return &HistoricalHydrogenPoint{VIN: historicalTestVIN, Protocol: "GB32960", ObservedAt: at, RemainingHydrogenKg: &kg, Status: "NORMAL", ValueSource: "REPORTED", RecordID: "record", SourceDataVersion: "data-v1", CalculationVersion: "decode-v1", UpdatedAt: at}
}
func TestHistoricalHydrogenValidation(t *testing.T) {
service, _, now := historicalTestService()
for _, tc := range []struct {
name string
mutate func(*HistoricalHydrogenRequest)
}{
{"empty", func(r *HistoricalHydrogenRequest) { r.Queries = nil }},
{"too many", func(r *HistoricalHydrogenRequest) { r.Queries = make([]HistoricalHydrogenQuery, 201) }},
{"duplicate id", func(r *HistoricalHydrogenRequest) { r.Queries = append(r.Queries, r.Queries[0]) }},
{"blank id", func(r *HistoricalHydrogenRequest) { r.Queries[0].RequestID = " " }},
{"bad VIN", func(r *HistoricalHydrogenRequest) { r.Queries[0].VIN = "LA9GG68L2PBAI4773" }},
{"fractional time", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-09-08 12:00:00.1" }},
{"future", func(r *HistoricalHydrogenRequest) {
r.Queries[0].Time = now.Add(time.Second).Format("2006-01-02 15:04:05")
}},
{"before coverage", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-07-31 23:59:59" }},
{"protocol alias", func(r *HistoricalHydrogenRequest) { r.Queries[0].Protocol = "MQTT" }},
{"negative tolerance", func(r *HistoricalHydrogenRequest) { v := -1; r.MaxTimeDifferenceSeconds = &v }},
{"too large tolerance", func(r *HistoricalHydrogenRequest) { v := 301; r.MaxTimeDifferenceSeconds = &v }},
} {
t.Run(tc.name, func(t *testing.T) {
request := historicalTestRequest(now.Add(-time.Hour))
tc.mutate(&request)
if _, _, err := service.validateHistoricalHydrogen(request, now); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("err=%v", err)
}
})
}
request := historicalTestRequest(now)
request.Queries[0].RequestID = " original id "
parsed, tolerance, err := service.validateHistoricalHydrogen(request, now)
if err != nil || tolerance != 300 || parsed[0].Protocol != "GB32960" || parsed[0].RequestID != " original id " {
t.Fatalf("%+v %d %v", parsed, tolerance, err)
}
}
func TestHistoricalHydrogenNormalZeroPrecisionStaleAndInvalid(t *testing.T) {
for _, tc := range []struct {
name string
kg float64
age time.Duration
tolerance int
status string
}{
{"zero", 0, 0, 0, "NORMAL"}, {"precision", 12.3456789, time.Second, 300, "NORMAL"}, {"exact tolerance", 2, 300 * time.Second, 300, "NORMAL"}, {"stale", 2, 300*time.Second + time.Millisecond, 300, "STALE"}, {"not exact", 2, time.Millisecond, 0, "STALE"}, {"invalid negative", -1, 0, 300, "INVALID"},
} {
t.Run(tc.name, func(t *testing.T) {
service, repository, now := historicalTestService()
at := now.Add(-time.Hour)
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
return historicalTestPoint(at.Add(-tc.age), tc.kg), nil
}
request := historicalTestRequest(at)
request.MaxTimeDifferenceSeconds = &tc.tolerance
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
if err != nil {
t.Fatal(err)
}
got := results[0]
if got.RemainingHydrogenKgStatus != tc.status {
t.Fatalf("%+v", got)
}
if tc.status == "NORMAL" {
if got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != tc.kg {
t.Fatal("lost zero or precision")
}
} else if got.RemainingHydrogenKg != nil {
t.Fatal("non-normal quantity exposed")
}
if got.TimeDifferenceSeconds == nil || *got.TimeDifferenceSeconds != tc.age.Seconds() {
t.Fatalf("time delta=%v", got.TimeDifferenceSeconds)
}
})
}
}
func TestHistoricalHydrogenBatchForbiddenNeverLeaksSample(t *testing.T) {
service, repository, now := historicalTestService()
at := now.Add(-time.Hour)
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
return historicalTestPoint(at.Add(-time.Minute), 4), nil
}
repository.allowed = func(_ string, sample, _ time.Time) (bool, error) { return !sample.Before(at), nil }
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
if err != nil {
t.Fatal(err)
}
got := results[0]
if got.RemainingHydrogenKgStatus != "FORBIDDEN" || got.RemainingHydrogenKg != nil || got.HydrogenRecordTime != nil || got.SourceRecordID != nil || got.HydrogenSourceProtocol != nil || got.SourceDataVersion != nil || got.UpdatedAt != nil {
t.Fatalf("sample leaked: %+v", got)
}
repository.allowed = func(string, time.Time, time.Time) (bool, error) { return false, nil }
repository.lookups.Store(0)
results, err = service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
if err != nil || repository.lookups.Load() != 0 || results[0].RemainingHydrogenKgStatus != "FORBIDDEN" {
t.Fatalf("queried forbidden data: %+v %v", results, err)
}
}
func TestHistoricalHydrogenMixedBatchAndNoProtocolFallback(t *testing.T) {
service, repository, now := historicalTestService()
at := now.Add(-time.Hour)
repository.lookup = func(_ context.Context, _ string, sample time.Time, _ string) (*HistoricalHydrogenPoint, error) {
if sample.Equal(at) {
return nil, errors.New("private driver query")
}
return nil, nil
}
request := historicalTestRequest(at)
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: "none", VIN: historicalTestVIN, Time: at.Add(-time.Minute).Format("2006-01-02 15:04:05")}, HistoricalHydrogenQuery{RequestID: "unsupported", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05"), Protocol: "JT808"})
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
if err != nil {
t.Fatal(err)
}
for i, want := range []string{"ERROR", "NO_DATA", "UNSUPPORTED"} {
if results[i].RequestID != request.Queries[i].RequestID || results[i].RemainingHydrogenKgStatus != want {
t.Fatalf("%+v", results)
}
}
if repository.lookups.Load() != 2 {
t.Fatalf("fallback read count=%d", repository.lookups.Load())
}
encoded, _ := json.Marshal(results)
if strings.Contains(string(encoded), "private") {
t.Fatal("driver detail leaked")
}
}
func TestHistoricalHydrogenRejectsFutureOrWrongIdentitySample(t *testing.T) {
for _, kind := range []string{"future", "vin", "protocol"} {
t.Run(kind, func(t *testing.T) {
service, repository, now := historicalTestService()
at := now.Add(-time.Hour)
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
point := historicalTestPoint(at, 4)
switch kind {
case "future":
point.ObservedAt = at.Add(time.Second)
case "vin":
point.VIN = "OTHER"
case "protocol":
point.Protocol = "JT808"
}
return point, nil
}
result, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
if err != nil || result[0].RemainingHydrogenKgStatus != "ERROR" || result[0].SourceRecordID != nil {
t.Fatalf("%+v %v", result, err)
}
})
}
}
func TestHistoricalHydrogenDeadlineKeepsEveryRequestID(t *testing.T) {
service, repository, now := historicalTestService()
repository.lookup = func(ctx context.Context, _ string, _ time.Time, _ string) (*HistoricalHydrogenPoint, error) {
<-ctx.Done()
return nil, ctx.Err()
}
request := historicalTestRequest(now.Add(-time.Hour))
for i := 1; i < 8; i++ {
query := request.Queries[0]
query.RequestID = string(rune('a' + i))
request.Queries = append(request.Queries, query)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
results, err := service.QueryHistoricalHydrogen(ctx, historicalTestKey, "trace", request)
if err != nil || len(results) != 8 {
t.Fatalf("%+v %v", results, err)
}
for i, got := range results {
if got.RequestID != request.Queries[i].RequestID || got.RemainingHydrogenKgStatus != "ERROR" || got.RemainingHydrogenKg != nil {
t.Fatalf("%+v", got)
}
}
if repository.lookups.Load() > 4 {
t.Fatal("more than four workers queried")
}
}
func TestHistoricalHydrogenHandlerAuthenticationAndRateLimit(t *testing.T) {
service, repository, now := historicalTestService()
handler := NewDataHandler(service)
request := historicalTestRequest(now.Add(-time.Hour))
request.Queries[0].Protocol = "JT808"
body, _ := json.Marshal(request)
call := func() *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, HistoricalHydrogenQueryPath, strings.NewReader(string(body)))
r.Header.Set("Authorization", "Bearer "+historicalTestKey)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
repository.authErr = ErrUnauthorized
if w := call(); w.Code != 401 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
repository.authErr = nil
for i := 0; i < 30; i++ {
if w := call(); w.Code != 200 {
t.Fatalf("request %d status=%d body=%s", i, w.Code, w.Body.String())
}
}
w := call()
if w.Code != 429 || w.Header().Get("Retry-After") != "60" || !strings.Contains(w.Body.String(), "RATE_LIMITED") {
t.Fatalf("status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String())
}
if !IsPublicPath(HistoricalHydrogenQueryPath) {
t.Fatal("missing public route")
}
}
func TestHistoricalHydrogenConcurrentLimitAndWindowReset(t *testing.T) {
var limiter historicalHydrogenLimiter
now := time.Now()
release1, err := limiter.acquire(1, now)
if err != nil {
t.Fatal(err)
}
release2, err := limiter.acquire(1, now)
if err != nil {
t.Fatal(err)
}
if _, err := limiter.acquire(1, now); err == nil {
t.Fatal("third concurrent batch allowed")
}
release1()
release2()
for i := 2; i < 30; i++ {
release, err := limiter.acquire(1, now)
if err != nil {
t.Fatal(err)
}
release()
}
if _, err := limiter.acquire(1, now); err == nil {
t.Fatal("31st batch allowed")
}
release, err := limiter.acquire(1, now.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
release()
}
func TestHistoricalHydrogenAuthorizationChecksAppAndGrantAtBothTimes(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
now := time.Now()
at := now.Add(-time.Hour)
mock.ExpectQuery("SELECT EXISTS.*a.valid_to>\\?.*a.valid_to>\\?.*BINARY g.vin=BINARY \\?.*g.valid_to>\\?").WithArgs(uint64(9), now, now, at, at, historicalTestVIN, at, at).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
allowed, err := NewMySQLRepository(db).HistoricalHydrogenAuthorized(context.Background(), 9, historicalTestVIN, at, now)
if err != nil || allowed {
t.Fatalf("allowed=%v err=%v", allowed, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHistoricalHydrogenTwoHundredItemsShareEighteenLookups(t *testing.T) {
service, repository, now := historicalTestService()
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
return historicalTestPoint(at, 12.3456789), nil
}
var request HistoricalHydrogenRequest
for i := 0; i < 200; i++ {
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: strconv.Itoa(i), VIN: historicalTestVIN, Time: now.Add(-time.Duration(i%18) * time.Minute).Format("2006-01-02 15:04:05")})
}
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
if err != nil || len(results) != 200 || repository.lookups.Load() != 18 {
t.Fatalf("len=%d lookups=%d err=%v", len(results), repository.lookups.Load(), err)
}
for i, got := range results {
if got.RequestID != strconv.Itoa(i) || got.QueryTime != request.Queries[i].Time || got.RemainingHydrogenKgStatus != "NORMAL" || got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 12.3456789 {
t.Fatalf("index=%d result=%+v", i, got)
}
}
}
func TestHistoricalHydrogenDecoderRejectsNullAndUnknownFields(t *testing.T) {
for _, body := range []string{
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00"}],"maxTimeDifferenceSeconds":null}`,
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","protocol":null}]}`,
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","unexpected":true}]}`,
`{"queries":[],"unexpected":true}`,
} {
var request HistoricalHydrogenRequest
if err := json.Unmarshal([]byte(body), &request); err == nil {
t.Fatalf("accepted %s", body)
}
}
}
@@ -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
}
@@ -0,0 +1,171 @@
package openplatform
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
)
// ReconciledMileageRange uses terminal odometers, including one preceding
// observation per protocol. The same function serves single days and every
// range page: neither the requested start date nor page size changes a result.
func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
}
if len(protocols) == 0 {
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
}
placeholders := func(n int) string { return strings.TrimRight(strings.Repeat("?,", n), ",") }
// Unknown-protocol legacy imports and GPS distance are not terminal odometers.
eligible := func(alias string) string {
return fmt.Sprintf(`%[1]s.quality_status IN ('OK','INVALID_DELTA')
AND %[1]s.latest_total_mileage_km > 0
AND COALESCE(%[1]s.quality_reason,'') <> 'gps_coordinate_accumulation'
AND %[1]s.source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')
AND %[1]s.latest_event_time IS NOT NULL
AND %[1]s.latest_event_time < TIMESTAMP(%[1]s.stat_date)+INTERVAL 1 DAY`, alias)
}
filter := `vin IN (` + placeholders(len(vins)) + `) AND protocol IN (` + placeholders(len(protocols)) + `)`
query := `WITH wanted AS (
SELECT DISTINCT vin,protocol,stat_date FROM vehicle_daily_mileage_source p
WHERE ` + filter + ` AND stat_date BETWEEN ? AND ? AND ` + eligible("p") + `
UNION ALL
SELECT vin,protocol,MAX(stat_date) AS stat_date FROM vehicle_daily_mileage_source p
WHERE ` + filter + ` AND stat_date<? AND ` + eligible("p") + ` GROUP BY vin,protocol
)
SELECT s.vin,DATE_FORMAT(s.stat_date,'%Y-%m-%d'),s.protocol,s.source_key,
s.daily_mileage_km,s.latest_total_mileage_km,
DATE_FORMAT(s.latest_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),
DATE_FORMAT(s.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
COALESCE(DATE_FORMAT(s.first_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),''),
CASE WHEN s.quality_status='INVALID_DELTA' THEN COALESCE(s.quality_reason,'INVALID_DELTA') ELSE '' END
FROM wanted w JOIN vehicle_daily_mileage_source s
ON s.vin=w.vin AND s.protocol=w.protocol AND s.stat_date=w.stat_date
WHERE ` + eligible("s") + `
ORDER BY s.stat_date,s.vin,s.protocol,s.is_selected DESC,
CASE WHEN s.quality_status='OK' THEN 0 ELSE 1 END,s.latest_event_time DESC,s.sample_count DESC,s.source_key`
args := make([]any, 0, 2*(len(vins)+len(protocols))+3)
for _, v := range vins {
args = append(args, v)
}
for _, p := range protocols {
args = append(args, p)
}
args = append(args, start, end)
for _, v := range vins {
args = append(args, v)
}
for _, p := range protocols {
args = append(args, p)
}
args = append(args, start)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var points []DailyMileage
seen := map[string]bool{}
for rows.Next() {
var v DailyMileage
if err := rows.Scan(&v.VIN, &v.Date, &v.Protocol, &v.SourceKey, &v.MileageKm, &v.TotalMileageKm, &v.DataTime, &v.UpdatedAt, &v.StatisticsStartTime, &v.DataQuality); err != nil {
return nil, err
}
key := v.VIN + "|" + v.Date + "|" + v.Protocol
if !seen[key] {
points = append(points, v)
seen[key] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return reconcileMileage(points, vins, start, end, protocols)
}
func reconcileMileage(points []DailyMileage, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
first, err := time.ParseInLocation("2006-01-02", start, loc)
if err != nil {
return nil, err
}
last, err := time.ParseInLocation("2006-01-02", end, loc)
if err != nil {
return nil, err
}
if last.Before(first) {
return nil, fmt.Errorf("invalid mileage interval")
}
if len(protocols) == 0 {
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
}
sort.SliceStable(points, func(i, j int) bool { return points[i].Date < points[j].Date })
state := map[string]map[string]DailyMileage{}
pick := func(vin string) (DailyMileage, bool) {
for _, p := range protocols {
if v, ok := state[vin][p]; ok {
return v, true
}
}
return DailyMileage{}, false
}
put := func(v DailyMileage) {
if state[v.VIN] == nil {
state[v.VIN] = map[string]DailyMileage{}
}
state[v.VIN][v.Protocol] = v
}
i := 0
for i < len(points) && points[i].Date < start {
put(points[i])
i++
}
out := map[string]DailyMileage{}
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
before := map[string]DailyMileage{}
for _, vin := range vins {
if p, ok := pick(vin); ok {
before[vin] = p
}
}
for i < len(points) && points[i].Date == date {
put(points[i])
i++
}
for _, vin := range vins {
current, ok := pick(vin)
if !ok {
continue
}
result := current
previous, hasPrevious := before[vin]
if current.Date < date {
result.MileageKm = 0
result.StatisticsStartTime = ""
if result.DataQuality == "" {
result.DataQuality = "CARRIED_FORWARD"
}
} else if current.DataQuality != "" {
result.MileageKm = 0
} else if !hasPrevious {
result.DataQuality = "NO_PREVIOUS_BASELINE"
} else if current.Protocol != previous.Protocol || current.SourceKey != previous.SourceKey {
result.DataQuality = "ODOMETER_SOURCE_CHANGED"
} else if current.TotalMileageKm < previous.TotalMileageKm {
result.DataQuality = mileageTotalRollbackQuality
} else if previous.DataQuality != "" {
result.DataQuality = "PREVIOUS_ODOMETER_ANOMALY"
} else {
result.MileageKm = math.Round((current.TotalMileageKm-previous.TotalMileageKm)*1000) / 1000
result.StatisticsStartTime = previous.DataTime
}
out[dailyMileageKey(vin, date)] = result
}
}
return out, nil
}

Some files were not shown because too many files have changed in this diff Show More