package main import ( "bytes" "compress/gzip" "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" hydrogenEnergyKWh = 16.0 maxIntegrationGap = 30 * time.Second ) type vehicleMetadata struct { VIN string `json:"vin"` Plate string `json:"plate"` Model string `json:"model"` BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` TankCapacityL float64 `json:"tank_capacity_l"` } type rawFrame struct { TS string `json:"ts"` FrameID string `json:"frame_id"` EventID string `json:"event_id"` MessageID int `json:"message_id"` EventTime string `json:"event_time"` ParseStatus string `json:"parse_status"` SourceEndpoint string `json:"source_endpoint"` VIN string `json:"vin"` ParsedFields map[string]any `json:"parsed_fields"` } type rawResponse struct { Items []rawFrame `json:"items"` Total int `json:"total"` } type energySample struct { At time.Time VehicleStatus int ChargeStatus int SOC float64 Mileage float64 BatteryVoltageV float64 BatteryCurrentA float64 FuelCellVoltageV float64 FuelCellCurrentA float64 HasSOC bool HasMileage bool HasBatteryPower bool HasFuelCellPower bool HasVehicleStatus bool HasChargeStatus bool } type energyResult struct { FirstTime string `json:"firstTime"` LastTime string `json:"lastTime"` StartSOC float64 `json:"startSoc"` EndSOC float64 `json:"endSoc"` SOCChangePctEndMinusStart float64 `json:"socChangePctEndMinusStart"` StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"` BatteryNetOutputKWhSOC float64 `json:"batteryNetOutputKWhSoc"` BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"` FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"` BatteryCoverageSeconds float64 `json:"batteryCoverageSeconds"` FuelCellCoverageSeconds float64 `json:"fuelCellCoverageSeconds"` OperatingSpanSeconds float64 `json:"operatingSpanSeconds"` BatteryCoverageRatio float64 `json:"batteryCoverageRatio"` FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"` ExternalChargeFrameCount int `json:"externalChargeFrameCount"` EnergySampleCount int `json:"energySampleCount"` } type row struct { Date string `json:"date"` Plate string `json:"plate"` VIN string `json:"vin"` Model string `json:"model"` RawFrameCount int `json:"rawFrameCount"` CurrentQuality string `json:"currentQuality"` CurrentReason string `json:"currentReason"` RefuelCount int `json:"refuelCount"` ChargeCount int `json:"chargeCount"` TotalMileageKm float64 `json:"totalMileageKm"` PureElectricMileageKm float64 `json:"pureElectricMileageKm"` CurrentMixedMileageKm float64 `json:"currentMixedMileageKm"` PhysicalHydrogenKg float64 `json:"physicalHydrogenKg"` CurrentSOCBalancedKg float64 `json:"currentSocBalancedKg"` CurrentRatePerMixedKm float64 `json:"currentRatePerMixedKm"` PhysicalRatePerTotalKm float64 `json:"physicalRatePerTotalKm"` FullDayStartSOC float64 `json:"fullDayStartSoc"` FullDayEndSOC float64 `json:"fullDayEndSoc"` FullDaySOCChangePct float64 `json:"fullDaySocChangePct"` StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"` BatteryEquivalentKgFixed16 float64 `json:"batteryEquivalentKgFixed16"` StandardLikeBalancedKg float64 `json:"standardLikeBalancedKg"` StandardLikeRatePerTotalKm float64 `json:"standardLikeRatePerTotalKm"` StandardLikeApplicable bool `json:"standardLikeApplicable"` StandardLikeReason string `json:"standardLikeReason"` BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"` FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"` BatteryEnergyShare float64 `json:"batteryEnergyShare"` FuelCellEnergyShare float64 `json:"fuelCellEnergyShare"` BatteryContributionKm float64 `json:"batteryContributionKm"` FuelCellContributionKm float64 `json:"fuelCellContributionKm"` HydrogenRatePerFCContributionKm float64 `json:"hydrogenRatePerFcContributionKm"` BatteryCoverageRatio float64 `json:"batteryCoverageRatio"` FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"` ExternalChargeFrameCount int `json:"externalChargeFrameCount"` PowerIntegrationUsable bool `json:"powerIntegrationUsable"` } type job struct { Vehicle vehicleMetadata Date string } func main() { if len(os.Args) != 5 { panic("usage: standard-model-trial ") } metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4] start := mustDate(startDate) end := mustDate(endDate) if end.Before(start) { panic("end date is before start date") } var vehicles []vehicleMetadata mustReadJSON(metadataPath, &vehicles) if len(vehicles) == 0 { panic("metadata is empty") } if err := os.MkdirAll(outputDir, 0o755); err != nil { panic(err) } jobs := make(chan job) results := make(chan row) errs := make(chan error, 128) var wg sync.WaitGroup for worker := 0; worker < 16; worker++ { wg.Add(1) go func() { defer wg.Done() for item := range jobs { result, err := process(item) if err != nil { errs <- fmt.Errorf("%s %s %s: %w", item.Date, item.Vehicle.Plate, item.Vehicle.VIN, err) continue } if result.RawFrameCount > 0 { results <- result } } }() } go func() { for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { for _, vehicle := range vehicles { if len(strings.TrimSpace(vehicle.VIN)) != 17 || vehicle.TankCapacityL <= 0 || vehicle.BatteryCapacityKWh <= 0 { continue } jobs <- job{Vehicle: vehicle, Date: day.Format("2006-01-02")} } } close(jobs) wg.Wait() close(results) close(errs) }() var rows []row completed := 0 for result := range results { rows = append(rows, result) completed++ if completed%25 == 0 { fmt.Fprintf(os.Stderr, "completed=%d latest=%s %s quality=%s raw=%d\n", completed, result.Date, result.Plate, result.CurrentQuality, result.RawFrameCount) } } var errorMessages []string for err := range errs { errorMessages = append(errorMessages, err.Error()) } if len(errorMessages) > 0 { panic(strings.Join(errorMessages, "\n")) } sort.Slice(rows, func(i, j int) bool { if rows[i].Date != rows[j].Date { return rows[i].Date < rows[j].Date } if rows[i].Plate != rows[j].Plate { return rows[i].Plate < rows[j].Plate } return rows[i].VIN < rows[j].VIN }) writeJSON(filepath.Join(outputDir, "results.json"), rows) writeCSV(filepath.Join(outputDir, "results.csv"), rows) writeJSON(filepath.Join(outputDir, "summary.json"), summarize(rows)) fmt.Printf("vehicle_days=%d output=%s\n", len(rows), outputDir) } func process(item job) (row, error) { frames, total, err := fetchFrames(item.Vehicle.VIN, item.Date) if err != nil || total == 0 { return row{Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total}, err } sort.SliceStable(frames, func(i, j int) bool { if frames[i].EventTime != frames[j].EventTime { return frames[i].EventTime < frames[j].EventTime } return frames[i].EventID < frames[j].EventID }) observations := make([]openplatform.HydrogenObservation, 0, len(frames)) energySamples := make([]energySample, 0, len(frames)) for _, frame := range frames { if observation, ok := observationFromFrame(frame, item.Vehicle.TankCapacityL, item.Date); ok { observations = append(observations, observation) } if sample, ok := energySampleFromFrame(frame, item.Date); ok { energySamples = append(energySamples, sample) } } params := map[string]openplatform.HydrogenCalculationParameters{ item.Vehicle.VIN: {BatteryCapacityKWh: item.Vehicle.BatteryCapacityKWh, HydrogenEnergyKWhKg: hydrogenEnergyKWh}, } stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, item.Date, 0.05, 20, params) stat := openplatform.HydrogenDailyStat{VIN: item.Vehicle.VIN, Date: item.Date, QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"} if len(stats) == 1 { stat = stats[0] } energy := calculateEnergy(energySamples, item.Vehicle.BatteryCapacityKWh) totalMileage := stat.MixedMileageKm + stat.PureElectricMileageKm result := row{ Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total, CurrentQuality: stat.QualityStatus, CurrentReason: stat.QualityReason, RefuelCount: stat.RefuelCount, ChargeCount: stat.ChargeCount, TotalMileageKm: totalMileage, PureElectricMileageKm: stat.PureElectricMileageKm, CurrentMixedMileageKm: stat.MixedMileageKm, PhysicalHydrogenKg: stat.ConsumptionKg, FullDayStartSOC: energy.StartSOC, FullDayEndSOC: energy.EndSOC, FullDaySOCChangePct: energy.SOCChangePctEndMinusStart, StoredEnergyChangeKWhSOC: energy.StoredEnergyChangeKWhSOC, BatteryNetOutputKWhIntegrated: energy.BatteryNetOutputKWhIntegrated, FuelCellOutputKWhIntegrated: energy.FuelCellOutputKWhIntegrated, BatteryCoverageRatio: energy.BatteryCoverageRatio, FuelCellCoverageRatio: energy.FuelCellCoverageRatio, ExternalChargeFrameCount: energy.ExternalChargeFrameCount, } if stat.SOCBalancedConsumptionKg != nil { result.CurrentSOCBalancedKg = *stat.SOCBalancedConsumptionKg } if stat.SOCBalancedKgPer100Km != nil { result.CurrentRatePerMixedKm = *stat.SOCBalancedKgPer100Km } if totalMileage > 0 { result.PhysicalRatePerTotalKm = round(stat.ConsumptionKg * 100 / totalMileage) result.BatteryEquivalentKgFixed16 = round(-energy.StoredEnergyChangeKWhSOC / hydrogenEnergyKWh) result.StandardLikeBalancedKg = round(stat.ConsumptionKg + result.BatteryEquivalentKgFixed16) result.StandardLikeRatePerTotalKm = round(result.StandardLikeBalancedKg * 100 / totalMileage) switch { case energy.ExternalChargeFrameCount > 0: result.StandardLikeReason = "检测到外部充电,整日SOC平衡修正不适用,应按CD/CS分段" case result.StandardLikeBalancedKg < 0: result.StandardLikeReason = "SOC平衡氢量为负,端点、SOC或换算系数需复核" default: result.StandardLikeApplicable = true } } else { result.StandardLikeReason = "无有效总里程" } // The GB/T 43252 contribution trial uses positive battery net output and // fuel-cell output measured over non-external-charge vehicle-on intervals. // Coverage gates prevent sparse power fields from being treated as precise. if totalMileage >= 10 && stat.ConsumptionKg > 0 && energy.ExternalChargeFrameCount == 0 && energy.BatteryCoverageRatio >= 0.98 && energy.FuelCellCoverageRatio >= 0.98 && energy.FuelCellOutputKWhIntegrated > 0 { batteryOutput := math.Max(0, energy.BatteryNetOutputKWhIntegrated) totalOutput := batteryOutput + energy.FuelCellOutputKWhIntegrated if totalOutput > 0 { result.PowerIntegrationUsable = true result.BatteryEnergyShare = round(batteryOutput / totalOutput) result.FuelCellEnergyShare = round(energy.FuelCellOutputKWhIntegrated / totalOutput) result.BatteryContributionKm = round(totalMileage * result.BatteryEnergyShare) result.FuelCellContributionKm = round(totalMileage * result.FuelCellEnergyShare) if result.FuelCellContributionKm > 0 { result.HydrogenRatePerFCContributionKm = round(stat.ConsumptionKg * 100 / result.FuelCellContributionKm) } } } return result, nil } func calculateEnergy(samples []energySample, batteryCapacityKWh float64) energyResult { result := energyResult{EnergySampleCount: len(samples)} if len(samples) == 0 { return result } for _, sample := range samples { if sample.HasChargeStatus && sample.ChargeStatus == 1 { result.ExternalChargeFrameCount++ } } operating := make([]energySample, 0, len(samples)) for _, sample := range samples { if sample.HasVehicleStatus && sample.VehicleStatus == 1 && (!sample.HasChargeStatus || sample.ChargeStatus != 1) { operating = append(operating, sample) } } if len(operating) == 0 { return result } result.FirstTime = operating[0].At.Format(time.RFC3339) result.LastTime = operating[len(operating)-1].At.Format(time.RFC3339) result.OperatingSpanSeconds = operating[len(operating)-1].At.Sub(operating[0].At).Seconds() for _, sample := range operating { if sample.HasSOC { result.StartSOC = sample.SOC break } } for index := len(operating) - 1; index >= 0; index-- { if operating[index].HasSOC { result.EndSOC = operating[index].SOC break } } result.SOCChangePctEndMinusStart = round(result.EndSOC - result.StartSOC) result.StoredEnergyChangeKWhSOC = round(batteryCapacityKWh * result.SOCChangePctEndMinusStart / 100) result.BatteryNetOutputKWhSOC = round(-result.StoredEnergyChangeKWhSOC) for index := 1; index < len(operating); index++ { previous, current := operating[index-1], operating[index] delta := current.At.Sub(previous.At) if delta <= 0 || delta > maxIntegrationGap { continue } hours := delta.Hours() if previous.HasBatteryPower && current.HasBatteryPower { power0 := previous.BatteryVoltageV * previous.BatteryCurrentA / 1000 power1 := current.BatteryVoltageV * current.BatteryCurrentA / 1000 result.BatteryNetOutputKWhIntegrated += (power0 + power1) * 0.5 * hours result.BatteryCoverageSeconds += delta.Seconds() } if previous.HasFuelCellPower && current.HasFuelCellPower { power0 := previous.FuelCellVoltageV * previous.FuelCellCurrentA / 1000 power1 := current.FuelCellVoltageV * current.FuelCellCurrentA / 1000 result.FuelCellOutputKWhIntegrated += (power0 + power1) * 0.5 * hours result.FuelCellCoverageSeconds += delta.Seconds() } } result.BatteryNetOutputKWhIntegrated = round(result.BatteryNetOutputKWhIntegrated) result.FuelCellOutputKWhIntegrated = round(result.FuelCellOutputKWhIntegrated) if result.OperatingSpanSeconds > 0 { result.BatteryCoverageRatio = round(result.BatteryCoverageSeconds / result.OperatingSpanSeconds) result.FuelCellCoverageRatio = round(result.FuelCellCoverageSeconds / result.OperatingSpanSeconds) } return result } func observationFromFrame(frame rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool) { if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") { return openplatform.HydrogenObservation{}, false } pressure, pressureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"]) temperature, temperatureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) if !pressureOK || !temperatureOK || pressure <= 0 || pressure > 70 || temperature <= -40 || temperature > 726.85 { return openplatform.HydrogenObservation{}, false } mass, ok := openplatform.PressureHydrogenMassKg(pressure, temperature, tankCapacity) if !ok { return openplatform.HydrogenObservation{}, false } step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, tankCapacity) at, err := parseEventTime(frame.EventTime) if err != nil || at.Format("2006-01-02") != statDate { return openplatform.HydrogenObservation{}, false } parsed, _ := json.Marshal(frame.ParsedFields) _, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed)) observation := openplatform.HydrogenObservation{ VIN: frame.VIN, Source: frame.SourceEndpoint, EventID: frame.EventID, ObservedAt: at, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temperature, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known, } if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 { observation.SOCPercent, observation.SOCKnown = value, true } if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 { observation.MileageKm, observation.MileageKnown = value, true } if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok { observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255 } if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok { observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255 } if value, ok := number(frame.ParsedFields["gb32960.vehicle.running_mode"]); ok { observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255 } return observation, true } func energySampleFromFrame(frame rawFrame, statDate string) (energySample, bool) { if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") { return energySample{}, false } at, err := parseEventTime(frame.EventTime) if err != nil || at.Format("2006-01-02") != statDate { return energySample{}, false } sample := energySample{At: at} if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok { sample.VehicleStatus, sample.HasVehicleStatus = int(value), true } if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok { sample.ChargeStatus, sample.HasChargeStatus = int(value), true } if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 { sample.SOC, sample.HasSOC = value, true } if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 { sample.Mileage, sample.HasMileage = value, true } batteryVoltage, batteryVoltageOK := number(frame.ParsedFields["gb32960.vehicle.total_voltage_v"]) batteryCurrent, batteryCurrentOK := number(frame.ParsedFields["gb32960.vehicle.total_current_a"]) if batteryVoltageOK && batteryCurrentOK && batteryVoltage > 0 && batteryVoltage <= 1000 && batteryCurrent >= -1000 && batteryCurrent <= 1000 { sample.BatteryVoltageV, sample.BatteryCurrentA, sample.HasBatteryPower = batteryVoltage, batteryCurrent, true } fuelCellVoltage, fuelCellVoltageOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]) fuelCellCurrent, fuelCellCurrentOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]) if fuelCellVoltageOK && fuelCellCurrentOK && fuelCellVoltage >= 0 && fuelCellVoltage <= 2000 && fuelCellCurrent >= 0 && fuelCellCurrent <= 2000 { sample.FuelCellVoltageV, sample.FuelCellCurrentA, sample.HasFuelCellPower = fuelCellVoltage, fuelCellCurrent, true } return sample, sample.HasVehicleStatus || sample.HasSOC || sample.HasBatteryPower || sample.HasFuelCellPower } func fetchFrames(vin, date string) ([]rawFrame, int, error) { const limit = 500 first, err := fetchPage(vin, date, 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, date, offset, limit, false) if err != nil { return nil, first.Total, 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, date string, offset, limit int, includeTotal bool) (rawResponse, error) { query := url.Values{ "protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {date + " 00:00:00"}, "dateTo": {date + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"false"}, "includeTotal": {strconv.FormatBool(includeTotal)}, } var response *http.Response var err error for attempt := 1; attempt <= 4; attempt++ { request, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+query.Encode(), nil) request.Header.Set("Accept-Encoding", "gzip") response, err = (&http.Client{Timeout: 90 * time.Second}).Do(request) if err == nil { break } if attempt < 4 { time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) } } if err != nil { return rawResponse{}, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { body, _ := io.ReadAll(response.Body) if response.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) { return rawResponse{}, nil } return rawResponse{}, fmt.Errorf("HTTP %d: %s", response.StatusCode, body) } var reader io.Reader = response.Body if response.Header.Get("Content-Encoding") == "gzip" { gzipReader, err := gzip.NewReader(response.Body) if err != nil { return rawResponse{}, err } defer gzipReader.Close() reader = gzipReader } var result rawResponse if err := json.NewDecoder(reader).Decode(&result); err != nil { return rawResponse{}, err } return result, nil } func summarize(rows []row) map[string]any { byDate := map[string]map[string]any{} for _, date := range uniqueDates(rows) { var dateRows []row for _, value := range rows { if value.Date == date { dateRows = append(dateRows, value) } } quality := map[string]int{} valid := 0 standardApplicable := 0 powerUsable := 0 physicalTotal, balancedTotal, mileageTotal, balancedMileageTotal := 0.0, 0.0, 0.0, 0.0 for _, value := range dateRows { quality[value.CurrentQuality]++ if value.CurrentQuality != "NO_DATA" && value.TotalMileageKm > 0 { valid++ physicalTotal += value.PhysicalHydrogenKg mileageTotal += value.TotalMileageKm } if value.StandardLikeApplicable { standardApplicable++ balancedTotal += value.StandardLikeBalancedKg balancedMileageTotal += value.TotalMileageKm } if value.PowerIntegrationUsable { powerUsable++ } } physicalRate, balancedRate := 0.0, 0.0 if mileageTotal > 0 { physicalRate = round(physicalTotal * 100 / mileageTotal) } if balancedMileageTotal > 0 { balancedRate = round(balancedTotal * 100 / balancedMileageTotal) } byDate[date] = map[string]any{ "vehicleDaysWithFrames": len(dateRows), "validVehicleDays": valid, "standardLikeApplicableVehicleDays": standardApplicable, "qualityCounts": quality, "powerIntegrationUsableVehicleDays": powerUsable, "totalMileageKm": round(mileageTotal), "physicalHydrogenKg": round(physicalTotal), "standardLikeBalancedHydrogenKg": round(balancedTotal), "standardLikeMileageKm": round(balancedMileageTotal), "fleetPhysicalRateKgPer100Km": physicalRate, "fleetStandardLikeRateKgPer100Km": balancedRate, } } return map[string]any{"vehicleDays": len(rows), "byDate": byDate} } func writeCSV(path string, rows []row) { file, err := os.Create(path) if err != nil { panic(err) } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() headers := []string{"日期", "车牌", "VIN", "车型", "原始帧数", "当前质量状态", "当前质量原因", "加氢次数", "充电次数", "总里程km", "当前纯电里程km", "当前混动里程km", "物理耗氢kg", "当前SOC平衡氢量kg", "当前修正氢耗_按混动里程", "物理氢耗_按总里程", "全日起始SOC", "全日终止SOC", "全日SOC变化_末减初", "全日电池储能变化kWh", "电池等效氢量_16kWhkg", "标准式平衡氢量kg", "标准式平衡氢耗_按总里程", "标准式修正可用", "标准式修正说明", "电流积分电池净输出kWh", "燃料电池输出积分kWh", "电池能量贡献占比", "燃料电池能量贡献占比", "电池贡献里程km", "燃料电池贡献里程km", "按燃料电池贡献里程氢耗", "电池功率覆盖率", "燃料电池功率覆盖率", "外充状态帧数", "功率积分可用"} _ = writer.Write(headers) for _, value := range rows { _ = writer.Write([]string{ value.Date, value.Plate, value.VIN, value.Model, integer(value.RawFrameCount), value.CurrentQuality, value.CurrentReason, integer(value.RefuelCount), integer(value.ChargeCount), decimal(value.TotalMileageKm), decimal(value.PureElectricMileageKm), decimal(value.CurrentMixedMileageKm), decimal(value.PhysicalHydrogenKg), decimal(value.CurrentSOCBalancedKg), decimal(value.CurrentRatePerMixedKm), decimal(value.PhysicalRatePerTotalKm), decimal(value.FullDayStartSOC), decimal(value.FullDayEndSOC), decimal(value.FullDaySOCChangePct), decimal(value.StoredEnergyChangeKWhSOC), decimal(value.BatteryEquivalentKgFixed16), decimal(value.StandardLikeBalancedKg), decimal(value.StandardLikeRatePerTotalKm), strconv.FormatBool(value.StandardLikeApplicable), value.StandardLikeReason, decimal(value.BatteryNetOutputKWhIntegrated), decimal(value.FuelCellOutputKWhIntegrated), decimal(value.BatteryEnergyShare), decimal(value.FuelCellEnergyShare), decimal(value.BatteryContributionKm), decimal(value.FuelCellContributionKm), decimal(value.HydrogenRatePerFCContributionKm), decimal(value.BatteryCoverageRatio), decimal(value.FuelCellCoverageRatio), integer(value.ExternalChargeFrameCount), strconv.FormatBool(value.PowerIntegrationUsable), }) } } func uniqueDates(rows []row) []string { seen := map[string]bool{} for _, value := range rows { seen[value.Date] = true } dates := make([]string, 0, len(seen)) for date := range seen { dates = append(dates, date) } sort.Strings(dates) return dates } func number(value any) (float64, bool) { switch typed := value.(type) { case float64: return typed, true case json.Number: parsed, err := typed.Float64() return parsed, err == nil case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) return parsed, err == nil default: return 0, false } } func parseEventTime(value string) (time.Time, error) { location := time.FixedZone("CST", 8*3600) if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.000", value, location); err == nil { return parsed, nil } return time.ParseInLocation("2006-01-02 15:04:05", value, location) } func mustDate(value string) time.Time { parsed, err := time.Parse("2006-01-02", value) if err != nil { panic(err) } return parsed } func mustReadJSON(path string, target any) { data, err := os.ReadFile(path) if err != nil { panic(err) } if err := json.Unmarshal(data, target); err != nil { panic(err) } } func writeJSON(path string, value any) { data, err := json.MarshalIndent(value, "", " ") if err != nil { panic(err) } if err := os.WriteFile(path, data, 0o644); err != nil { panic(err) } } func round(value float64) float64 { return math.Round(value*1000) / 1000 } func decimal(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) } func integer(value int) string { return strconv.Itoa(value) }