package main import ( "bytes" "compress/gzip" "crypto/sha256" "encoding/csv" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "lingniu/vehicle-data-platform/apps/api/internal/openplatform" ) const ( baseURL = "http://115.29.187.205:20200" hydrogenKWh = 16.0 ) type candidate struct { VIN string `json:"vin"` StatDate string `json:"stat_date"` PlatformName string `json:"platform_name"` DailyMileageKm float64 `json:"daily_mileage_km"` RawTotal int `json:"raw_total"` Plate string `json:"plate,omitempty"` SampleNo int `json:"sample_no,omitempty"` MileageBin int `json:"mileage_bin,omitempty"` Model string `json:"model,omitempty"` TankCapacityL float64 `json:"tank_capacity_l,omitempty"` BatteryKWh float64 `json:"battery_capacity_kwh,omitempty"` } type capacityRecord struct { VIN, Plate, Model string TankCapacityL float64 Active int } type rawFrame struct { TS string `json:"ts"` FrameID string `json:"frame_id"` EventID string `json:"event_id"` MessageID int `json:"message_id"` MessageIDHex string `json:"message_id_hex"` EventTime string `json:"event_time"` ReceivedAt string `json:"received_at"` RawSizeBytes int `json:"raw_size_bytes"` RawHex string `json:"raw_hex"` ParsedFields map[string]any `json:"parsed_fields"` ParseStatus string `json:"parse_status"` SourceEndpoint string `json:"source_endpoint"` Protocol string `json:"protocol"` VIN string `json:"vin"` } type rawResponse struct { Items []rawFrame `json:"items"` Total int `json:"total"` } type dayOutput struct { Candidate candidate `json:"candidate"` APIRawFrameCount int `json:"apiRawFrameCount"` UniqueFrameCount int `json:"uniqueFrameCount"` DuplicateFrameCount int `json:"duplicateFrameCount"` AlgorithmSamples int `json:"algorithmSamples"` CriticalFieldRows int `json:"criticalFieldRows"` EarliestEventTime string `json:"earliestEventTime"` LatestEventTime string `json:"latestEventTime"` RawArchiveFile string `json:"rawArchiveFile"` RawArchiveSHA256 string `json:"rawArchiveSha256"` RawArchiveBytes int64 `json:"rawArchiveBytes"` Stat openplatform.HydrogenDailyStat `json:"stat"` } func main() { if len(os.Args) != 5 && len(os.Args) != 6 { panic("usage: audit [source-archive-dir]") } metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4] sourceArchiveDir := "" if len(os.Args) == 6 { sourceArchiveDir = os.Args[5] } start, err := time.Parse("2006-01-02", startDate) if err != nil { panic(err) } end, err := time.Parse("2006-01-02", endDate) if err != nil || end.Before(start) { panic("invalid date range") } var metadata []struct { VIN string `json:"vin"` Plate string `json:"plate"` Model string `json:"model"` TankCapacityL float64 `json:"tank_capacity_l"` BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` } mustReadJSON(metadataPath, &metadata) if len(metadata) == 0 { panic("vehicle metadata is empty") } selected := make([]candidate, 0, len(metadata)*int(end.Sub(start).Hours()/24+1)) for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { statDate := day.Format("2006-01-02") for _, item := range metadata { if len(strings.TrimSpace(item.VIN)) != 17 || item.TankCapacityL <= 0 || item.BatteryCapacityKWh <= 0 { continue } selected = append(selected, candidate{ VIN: strings.ToUpper(strings.TrimSpace(item.VIN)), StatDate: statDate, Plate: item.Plate, Model: item.Model, TankCapacityL: item.TankCapacityL, BatteryKWh: item.BatteryCapacityKWh, }) } } sort.Slice(selected, func(i, j int) bool { if selected[i].StatDate != selected[j].StatDate { return selected[i].StatDate < selected[j].StatDate } if selected[i].Plate != selected[j].Plate { return selected[i].Plate < selected[j].Plate } return selected[i].VIN < selected[j].VIN }) for index := range selected { selected[index].SampleNo = index + 1 } if sourceArchiveDir == "" { for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { if err := os.MkdirAll(filepath.Join(outputDir, "raw", day.Format("2006-01-02")), 0o755); err != nil { panic(err) } } } writeJSON(filepath.Join(outputDir, "selected_vehicles.json"), selected) jobs := make(chan candidate) results := make(chan dayOutput) errs := make(chan error, len(selected)) var wg sync.WaitGroup for i := 0; i < 12; i++ { wg.Add(1) go func() { defer wg.Done() for c := range jobs { out, err := processDay(c, outputDir, sourceArchiveDir) if err != nil { errs <- err continue } results <- out } }() } go func() { for _, c := range selected { jobs <- c } close(jobs) wg.Wait() close(results) close(errs) }() outputs := make([]dayOutput, 0, len(selected)) for out := range results { if out.APIRawFrameCount == 0 { continue } outputs = append(outputs, out) fmt.Fprintf(os.Stderr, "completed %03d %s %s raw=%d samples=%d quality=%s\n", out.Candidate.SampleNo, out.Candidate.Plate, out.Candidate.VIN, out.APIRawFrameCount, out.AlgorithmSamples, out.Stat.QualityStatus) } var allErrs []string for err := range errs { allErrs = append(allErrs, err.Error()) } if len(allErrs) > 0 { panic(strings.Join(allErrs, "\n")) } sort.Slice(outputs, func(i, j int) bool { return outputs[i].Candidate.SampleNo < outputs[j].Candidate.SampleNo }) writeJSON(filepath.Join(outputDir, "daily_results.json"), outputs) writeManifest(filepath.Join(outputDir, "raw_manifest.csv"), outputs) fmt.Printf("processed=%d raw_frames=%d algorithm_samples=%d intervals=%d\n", len(outputs), sumRaw(outputs), sumSamples(outputs), sumIntervals(outputs)) } func stratifiedSelect(all []candidate, plateByVIN map[string]string, capacityByVIN map[string]capacityRecord) []candidate { general, cold := make([]candidate, 0), make([]candidate, 0) for _, c := range all { cap, ok := capacityByVIN[strings.ToUpper(c.VIN)] if !ok || cap.Active != 1 || c.DailyMileageKm < 10 || c.DailyMileageKm > 600 || c.RawTotal < 300 { continue } if cap.Model != "4.5吨货车" && cap.Model != "帕力安牌4.5吨冷链车" { continue } c.Plate = cap.Plate if c.Plate == "" { c.Plate = plateByVIN[strings.ToUpper(c.VIN)] } c.Model = cap.Model c.TankCapacityL = cap.TankCapacityL if cap.Model == "4.5吨货车" { general = append(general, c) } else { cold = append(cold, c) } } if len(general) < 41 || len(cold) < 59 { panic(fmt.Sprintf("insufficient pools general=%d cold=%d", len(general), len(cold))) } selected := append(selectEvenly(general, 41), selectEvenly(cold, 59)...) sort.Slice(selected, func(i, j int) bool { if selected[i].DailyMileageKm != selected[j].DailyMileageKm { return selected[i].DailyMileageKm < selected[j].DailyMileageKm } return selected[i].VIN < selected[j].VIN }) for i := range selected { selected[i].SampleNo = i + 1 selected[i].MileageBin = i/10 + 1 } return selected } func selectEvenly(values []candidate, count int) []candidate { sort.Slice(values, func(i, j int) bool { if values[i].DailyMileageKm != values[j].DailyMileageKm { return values[i].DailyMileageKm < values[j].DailyMileageKm } return values[i].VIN < values[j].VIN }) out := make([]candidate, 0, count) for i := 0; i < count; i++ { idx := 0 if count > 1 { idx = int(math.Round(float64(i) * float64(len(values)-1) / float64(count-1))) } out = append(out, values[idx]) } return out } func processDay(c candidate, outputDir, sourceArchiveDir string) (dayOutput, error) { archiveName := rawArchiveName(c) archivePath := filepath.Join(outputDir, "raw", c.StatDate, archiveName) readArchivePath := archivePath if sourceArchiveDir != "" { readArchivePath = filepath.Join(sourceArchiveDir, "raw", c.StatDate, archiveName) if _, statErr := os.Stat(readArchivePath); os.IsNotExist(statErr) { // Sample numbers depend on the selected vehicle/date set. Reuse an // existing archive by its stable plate/VIN/date suffix when rerunning // only a small subset for validation. pattern := filepath.Join(sourceArchiveDir, "raw", c.StatDate, "*_"+c.Plate+"_"+c.VIN+"_"+c.StatDate+".csv.gz") if matches, _ := filepath.Glob(pattern); len(matches) == 1 { readArchivePath = matches[0] } } } frames := []rawFrame(nil) total := 0 var err error if _, statErr := os.Stat(readArchivePath); statErr == nil { frames, err = readRawArchive(readArchivePath) total = len(frames) } else if sourceArchiveDir != "" && os.IsNotExist(statErr) { return dayOutput{Candidate: c}, nil } else { for attempt := 1; attempt <= 3; attempt++ { frames, total, err = fetchFrames(c.VIN, c.StatDate) if err == nil { break } if attempt < 3 { time.Sleep(time.Duration(attempt) * time.Second) } } } if err != nil { return dayOutput{}, fmt.Errorf("%s %s: %w", c.StatDate, c.VIN, err) } if total == 0 { return dayOutput{Candidate: c}, nil } sort.SliceStable(frames, func(i, j int) bool { if frames[i].EventTime != frames[j].EventTime { return frames[i].EventTime < frames[j].EventTime } if frames[i].SourceEndpoint != frames[j].SourceEndpoint { return frames[i].SourceEndpoint < frames[j].SourceEndpoint } return frames[i].TS < frames[j].TS }) seen := map[string]bool{} duplicateFrames := 0 observations := make([]openplatform.HydrogenObservation, 0, len(frames)) criticalRows := 0 for _, f := range frames { if f.FrameID != "" { if seen[f.FrameID] { duplicateFrames++ } seen[f.FrameID] = true } obs, ok, critical := observationFromFrame(f, c.TankCapacityL, c.StatDate) if critical { criticalRows++ } if ok { observations = append(observations, obs) } } params := map[string]openplatform.HydrogenCalculationParameters{c.VIN: {BatteryCapacityKWh: c.BatteryKWh, HydrogenEnergyKWhKg: hydrogenKWh}} stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, c.StatDate, 0.05, 20, params) var stat openplatform.HydrogenDailyStat if len(stats) == 1 { stat = stats[0] } else { stat = openplatform.HydrogenDailyStat{VIN: c.VIN, Date: c.StatDate, SampleCount: len(observations), QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"} } roles := map[string]string{} for _, interval := range stat.Intervals { appendRole(roles, interval.StartEventID, fmt.Sprintf("运行区间%d(%s)起点", interval.Index, interval.Type)) appendRole(roles, interval.EndEventID, fmt.Sprintf("运行区间%d(%s)终点", interval.Index, interval.Type)) } for _, interval := range stat.HydrogenIntervals { appendRole(roles, interval.StartEventID, fmt.Sprintf("氢量分段%d起点", interval.Index)) appendRole(roles, interval.EndEventID, fmt.Sprintf("氢量分段%d终点", interval.Index)) } // Even when the original frames are reused, rewrite the audit CSV so its // “计算角色/排除原因” column matches the current algorithm version and // interval boundaries. The original HEX and parsed fields remain unchanged. if sourceArchiveDir == "" { if err := writeRawCSV(archivePath, c, frames, roles); err != nil { return dayOutput{}, err } } checksum, size, err := fileSHA256(readArchivePath) if err != nil { return dayOutput{}, err } out := dayOutput{Candidate: c, APIRawFrameCount: total, UniqueFrameCount: len(seen), DuplicateFrameCount: duplicateFrames, AlgorithmSamples: len(observations), CriticalFieldRows: criticalRows, RawArchiveFile: filepath.ToSlash(filepath.Join("raw", c.StatDate, archiveName)), RawArchiveSHA256: checksum, RawArchiveBytes: size, Stat: stat} if len(frames) > 0 { out.EarliestEventTime = frames[0].EventTime out.LatestEventTime = frames[len(frames)-1].EventTime } return out, nil } func rawArchiveName(c candidate) string { return fmt.Sprintf("%04d_%s_%s_%s.csv.gz", c.SampleNo, safeName(c.Plate), c.VIN, c.StatDate) } func appendRole(roles map[string]string, eventID, role string) { if eventID == "" { return } if roles[eventID] == "" { roles[eventID] = role return } roles[eventID] += ";" + role } func fileSHA256(path string) (string, int64, error) { file, err := os.Open(path) if err != nil { return "", 0, err } defer file.Close() hash := sha256.New() size, err := io.Copy(hash, file) if err != nil { return "", 0, err } return fmt.Sprintf("%x", hash.Sum(nil)), size, nil } func readRawArchive(path string) ([]rawFrame, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() gz, err := gzip.NewReader(file) if err != nil { return nil, err } defer gz.Close() reader := csv.NewReader(gz) rows, err := reader.ReadAll() if err != nil { return nil, err } if len(rows) == 0 { return nil, fmt.Errorf("empty raw archive %s", path) } columns := map[string]int{} for index, name := range rows[0] { columns[name] = index } cell := func(row []string, name string) string { index, ok := columns[name] if !ok || index >= len(row) { return "" } return row[index] } frames := make([]rawFrame, 0, len(rows)-1) for _, row := range rows[1:] { fields := map[string]any{} if rawFields := cell(row, "完整解析字段JSON"); rawFields != "" { _ = json.Unmarshal([]byte(rawFields), &fields) } messageID, _ := strconv.Atoi(cell(row, "消息ID")) rawSize, _ := strconv.Atoi(cell(row, "原始字节数")) frames = append(frames, rawFrame{ FrameID: cell(row, "帧ID"), EventID: cell(row, "事件ID"), MessageID: messageID, MessageIDHex: cell(row, "消息ID_HEX"), EventTime: cell(row, "事件时间"), ReceivedAt: cell(row, "接收时间"), RawSizeBytes: rawSize, RawHex: cell(row, "原始报文HEX"), ParsedFields: fields, ParseStatus: cell(row, "解析状态"), SourceEndpoint: cell(row, "源端点"), Protocol: "GB32960", VIN: cell(row, "VIN"), }) } return frames, nil } func fetchFrames(vin, statDate string) ([]rawFrame, int, error) { const limit = 500 first, err := fetchPage(vin, statDate, 0, limit, true) if err != nil { return nil, 0, err } frames := append([]rawFrame(nil), first.Items...) for offset := limit; offset < first.Total; offset += limit { page, err := fetchPage(vin, statDate, offset, limit, false) if err != nil { return nil, 0, err } frames = append(frames, page.Items...) } if len(frames) != first.Total { return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames)) } return frames, first.Total, nil } func fetchPage(vin, statDate string, offset, limit int, includeTotal bool) (rawResponse, error) { q := url.Values{"protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {statDate + " 00:00:00"}, "dateTo": {statDate + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"true"}, "includeTotal": {strconv.FormatBool(includeTotal)}} req, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+q.Encode(), nil) req.Header.Set("Accept-Encoding", "gzip") client := &http.Client{Timeout: 90 * time.Second} res, err := client.Do(req) if err != nil { return rawResponse{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) if res.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) { return rawResponse{}, nil } return rawResponse{}, fmt.Errorf("HTTP %d: %s", res.StatusCode, body) } var reader io.Reader = res.Body if res.Header.Get("Content-Encoding") == "gzip" { gz, err := gzip.NewReader(res.Body) if err != nil { return rawResponse{}, err } defer gz.Close() reader = gz } var out rawResponse if err := json.NewDecoder(reader).Decode(&out); err != nil { return rawResponse{}, err } return out, nil } func observationFromFrame(f rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool, bool) { if f.MessageID != 2 || f.ParseStatus != "OK" { return openplatform.HydrogenObservation{}, false, false } pressure, pok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"]) temp, tok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) critical := pok && tok if !pok || !tok || pressure <= 0 || pressure > 70 || temp <= -40 || temp > 726.85 { return openplatform.HydrogenObservation{}, false, critical } mass, ok := openplatform.PressureHydrogenMassKg(pressure, temp, tankCapacity) if !ok { return openplatform.HydrogenObservation{}, false, critical } step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temp, tankCapacity) parsed, _ := json.Marshal(f.ParsedFields) _, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed)) t, err := parseEventTime(f.EventTime) if err != nil || t.Format("2006-01-02") != statDate { return openplatform.HydrogenObservation{}, false, critical } o := openplatform.HydrogenObservation{VIN: f.VIN, Source: f.SourceEndpoint, EventID: f.EventID, ObservedAt: t, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temp, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known} if voltage, vok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]); vok && voltage > 0 && voltage <= 1000 { if current, cok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]); cok && current >= 0 && current <= 2000 { o.FuelCellVoltageV = voltage o.FuelCellCurrentA = current o.FuelCellPowerKnown = true } } if v, ok := num(f.ParsedFields["gb32960.vehicle.soc_percent"]); ok && v >= 0 && v <= 100 { o.SOCPercent = v o.SOCKnown = true } if v, ok := num(f.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && v >= 0 { o.MileageKm = v o.MileageKnown = true } if v, ok := num(f.ParsedFields["gb32960.vehicle.vehicle_status"]); ok { o.VehicleState = int(v) o.VehicleStateKnown = v >= 0 && v <= 255 } if v, ok := num(f.ParsedFields["gb32960.vehicle.charge_status"]); ok { o.ChargeState = int(v) o.ChargeStateKnown = v >= 0 && v <= 255 } if v, ok := num(f.ParsedFields["gb32960.vehicle.running_mode"]); ok { o.RunningMode = int(v) o.RunningModeKnown = v >= 0 && v <= 255 } return o, true, critical } var rawHeaders = []string{"序号", "车牌", "VIN", "统计日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "事件时间", "接收时间", "帧ID", "事件ID", "消息ID", "消息ID_HEX", "源端点", "解析状态", "原始字节数", "原始报文HEX", "完整解析字段JSON", "最高氢压(MPa)", "最高氢温(℃)", "电池SOC(%)", "仪表总里程(km)", "车辆状态", "充电状态", "运行模式", "燃料电池工作状态", "燃料电池电流(A)", "车端氢气质量(kg)", "系统压力换算剩余氢量(kg)", "噪声阈值(kg)", "是否算法有效样本", "计算角色/排除原因"} func writeRawCSV(path string, c candidate, frames []rawFrame, roles map[string]string) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() gz := gzip.NewWriter(f) defer gz.Close() w := csv.NewWriter(gz) defer w.Flush() if err := w.Write(rawHeaders); err != nil { return err } for _, rf := range frames { o, valid, _ := observationFromFrame(rf, c.TankCapacityL, c.StatDate) role := roles[rf.EventID] if !valid { if t, err := parseEventTime(rf.EventTime); err == nil && t.Format("2006-01-02") != c.StatDate { role = "事件时间不在统计日,不进入计算" } else if rf.MessageID != 2 { role = "非实时信息上报帧,不进入计算" } else if rf.ParseStatus != "OK" { role = "解析状态非OK,不进入计算" } else { role = "压力/温度无效或缺失,不进入计算" } } else if role == "" { role = "有效候选帧(由分段规则判定)" } parsedJSON, _ := json.Marshal(rf.ParsedFields) row := []string{fmt.Sprintf("%04d", c.SampleNo), c.Plate, c.VIN, c.StatDate, c.Model, strconv.FormatFloat(c.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(c.BatteryKWh, 'f', 2, 64), rf.EventTime, rf.ReceivedAt, rf.FrameID, rf.EventID, strconv.Itoa(rf.MessageID), rf.MessageIDHex, rf.SourceEndpoint, rf.ParseStatus, strconv.Itoa(rf.RawSizeBytes), rf.RawHex, string(parsedJSON), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa"), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_temperature_c"), val(rf.ParsedFields, "gb32960.vehicle.soc_percent"), val(rf.ParsedFields, "gb32960.vehicle.total_mileage_km"), val(rf.ParsedFields, "gb32960.vehicle.vehicle_status"), val(rf.ParsedFields, "gb32960.vehicle.charge_status"), val(rf.ParsedFields, "gb32960.vehicle.running_mode"), val(rf.ParsedFields, "gb32960.gd_fc_stack.engine_work_state"), val(rf.ParsedFields, "gb32960.fuel_cell.fuel_cell_current_a"), val(rf.ParsedFields, "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"), blankFloat(valid, o.MassKg), blankFloat(valid, o.NoiseKg), yesNo(valid), role} if err := w.Write(row); err != nil { return err } } return w.Error() } func writeManifest(path string, outputs []dayOutput) { f, err := os.Create(path) if err != nil { panic(err) } defer f.Close() w := csv.NewWriter(f) defer w.Flush() _ = w.Write([]string{"序号", "车牌", "VIN", "日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "API原始帧数", "唯一帧数", "重复帧数", "算法样本数", "关键字段帧数", "最早事件时间", "最晚事件时间", "原始文件", "压缩包SHA256", "压缩字节数", "质量状态", "质量原因"}) for _, o := range outputs { _ = w.Write([]string{fmt.Sprintf("%04d", o.Candidate.SampleNo), o.Candidate.Plate, o.Candidate.VIN, o.Candidate.StatDate, o.Candidate.Model, strconv.FormatFloat(o.Candidate.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(o.Candidate.BatteryKWh, 'f', 2, 64), strconv.Itoa(o.APIRawFrameCount), strconv.Itoa(o.UniqueFrameCount), strconv.Itoa(o.DuplicateFrameCount), strconv.Itoa(o.AlgorithmSamples), strconv.Itoa(o.CriticalFieldRows), o.EarliestEventTime, o.LatestEventTime, o.RawArchiveFile, o.RawArchiveSHA256, strconv.FormatInt(o.RawArchiveBytes, 10), o.Stat.QualityStatus, o.Stat.QualityReason}) } } func num(v any) (float64, bool) { switch x := v.(type) { case float64: return x, true case string: n, e := strconv.ParseFloat(strings.TrimSpace(x), 64) return n, e == nil case json.Number: n, e := x.Float64() return n, e == nil default: return 0, false } } func val(m map[string]any, k string) string { if v, ok := m[k]; ok { return fmt.Sprint(v) } return "" } func blankFloat(ok bool, v float64) string { if !ok { return "" } return strconv.FormatFloat(v, 'f', 6, 64) } func yesNo(v bool) string { if v { return "是" } return "否" } func safeName(v string) string { v = strings.TrimSpace(v) if v == "" { return "无车牌" } return strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(v) } func parseEventTime(value string) (time.Time, error) { loc := time.FixedZone("CST", 8*3600) if t, e := time.ParseInLocation("2006-01-02 15:04:05.000", value, loc); e == nil { return t, nil } return time.ParseInLocation("2006-01-02 15:04:05", value, loc) } func mustReadJSON(path string, v any) { b, e := os.ReadFile(path) if e != nil { panic(e) } if e = json.Unmarshal(b, v); e != nil { panic(e) } } func writeJSON(path string, v any) { b, e := json.MarshalIndent(v, "", " ") if e != nil { panic(e) } if e = os.WriteFile(path, b, 0o644); e != nil { panic(e) } } func sumRaw(v []dayOutput) int { n := 0 for _, x := range v { n += x.APIRawFrameCount } return n } func sumSamples(v []dayOutput) int { n := 0 for _, x := range v { n += x.AlgorithmSamples } return n } func sumIntervals(v []dayOutput) int { n := 0 for _, x := range v { n += len(x.Stat.Intervals) } return n }