661 lines
27 KiB
Go
661 lines
27 KiB
Go
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 <metadata.json> <start-date> <end-date> <output-dir>")
|
|
}
|
|
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) }
|