package openplatform import ( "context" "database/sql" "encoding/json" "fmt" "math" "sort" "strconv" "strings" "time" ) var hydrogenMassFields = []string{ "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg", "gb32960.gd_fc_vehicle.hydrogen_mass_kg", "gb32960.gd_fc_vehicle_info.gd_fc_vehicle_hydrogen_mass_kg", "gd_fc_vehicle_hydrogen_mass_kg", } func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat { if noiseKg <= 0 { noiseKg = 0.05 } if maxDropKg <= noiseKg { maxDropKg = 20 } grouped := map[string]map[string][]HydrogenObservation{} for _, observation := range observations { observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN)) observation.Source = strings.TrimSpace(observation.Source) if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 { continue } if grouped[observation.VIN] == nil { grouped[observation.VIN] = map[string][]HydrogenObservation{} } grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation) } vins := make([]string, 0, len(grouped)) for vin := range grouped { vins = append(vins, vin) } sort.Strings(vins) stats := make([]HydrogenDailyStat, 0, len(vins)) for _, vin := range vins { var selected *HydrogenDailyStat for source, values := range grouped[vin] { candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg) if selected == nil || betterHydrogenStat(candidate, *selected) { copy := candidate selected = © } } if selected != nil { stats = append(stats, *selected) } } return stats } func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat { sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) stat := HydrogenDailyStat{ VIN: vin, Source: source, Date: date, FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg, SampleCount: len(values), QualityStatus: "OK", } abnormalDrops := 0 cycleMinimum := values[0].MassKg for index := 1; index < len(values); index++ { value := values[index] sampleNoise := noiseKg if value.NoiseKg > sampleNoise { sampleNoise = value.NoiseKg } refuelThreshold := math.Max(1, cycleMinimum*0.05) if value.RefuelThresholdKg > 0 { refuelThreshold = value.RefuelThresholdKg } delta := cycleMinimum - value.MassKg switch { case value.MassKg-cycleMinimum > refuelThreshold: stat.RefuelCount++ cycleMinimum = value.MassKg case delta > sampleNoise && delta <= maxDropKg: stat.ConsumptionKg += delta cycleMinimum = value.MassKg case delta > maxDropKg: abnormalDrops++ } } stat.ConsumptionKg = round3(stat.ConsumptionKg) if stat.SampleCount < 2 { stat.QualityStatus = "NO_DATA" stat.QualityReason = "有效车载氢量样本不足2条" } else if abnormalDrops > 0 { stat.QualityStatus = "SUSPECT" stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg) } return stat } func betterHydrogenStat(candidate, current HydrogenDailyStat) bool { rank := func(status string) int { switch status { case "OK": return 0 case "SUSPECT": return 1 default: return 2 } } if rank(candidate.QualityStatus) != rank(current.QualityStatus) { return rank(candidate.QualityStatus) < rank(current.QualityStatus) } if candidate.SampleCount != current.SampleCount { return candidate.SampleCount > current.SampleCount } return candidate.Source < current.Source } func ExtractHydrogenMass(parsedJSON string) (float64, bool) { var fields map[string]any if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { return 0, false } for _, key := range hydrogenMassFields { if value, ok := numericValue(fields[key]); ok && value >= 0 && value <= 200 { return value, true } } return 0, false } func ExtractHydrogenRateAndMileage(parsedJSON string) (float64, float64, bool) { var fields map[string]any if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { return 0, 0, false } rate, rateOK := numericValue(fields["gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"]) mileage, mileageOK := numericValue(fields["gb32960.vehicle.total_mileage_km"]) return rate, mileage, rateOK && mileageOK && rate >= 0 && rate <= 50 && mileage > 0 } func BuildHydrogenRateDailyStats(observations []HydrogenRateObservation, date string, maxDeltaKm float64) []HydrogenRateDailyStat { if maxDeltaKm <= 0 { maxDeltaKm = 10 } grouped := map[string]map[string][]HydrogenRateObservation{} for _, value := range observations { value.VIN = strings.ToUpper(strings.TrimSpace(value.VIN)) if len(value.VIN) != 17 || value.Rate < 0 || value.Rate > 50 || value.MileageKm <= 0 { continue } if grouped[value.VIN] == nil { grouped[value.VIN] = map[string][]HydrogenRateObservation{} } grouped[value.VIN][strings.TrimSpace(value.Source)] = append(grouped[value.VIN][strings.TrimSpace(value.Source)], value) } result := make([]HydrogenRateDailyStat, 0, len(grouped)) for vin, sources := range grouped { var best *HydrogenRateDailyStat for source, values := range sources { sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) stat := HydrogenRateDailyStat{VIN: vin, Source: source, Date: date, SampleCount: len(values), QualityStatus: "NO_DATA", QualityReason: "尚无有效行驶里程区间"} movement, abnormal := 0, 0 for i := 1; i < len(values); i++ { delta := values[i].MileageKm - values[i-1].MileageKm if delta > 0 && delta <= maxDeltaKm { stat.ConsumptionKg += delta * (values[i-1].Rate + values[i].Rate) / 200 movement++ } else if delta < 0 || delta > maxDeltaKm { abnormal++ } } stat.ConsumptionKg = round3(stat.ConsumptionKg) if abnormal > 0 { stat.QualityStatus, stat.QualityReason = "SUSPECT", "存在异常里程跳变" } else if movement > 0 { stat.QualityStatus, stat.QualityReason = "OK", "按里程区间积分百公里氢耗" } if best == nil || (stat.QualityStatus == "OK" && best.QualityStatus != "OK") || (stat.QualityStatus == best.QualityStatus && stat.SampleCount > best.SampleCount) { copy := stat best = © } } if best != nil { result = append(result, *best) } } sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN }) return result } func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64, error) { rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`) if err != nil { return nil, err } defer rows.Close() capacities := map[string]float64{} for rows.Next() { var vin string var capacity float64 if err := rows.Scan(&vin, &capacity); err != nil { return nil, err } if len(vin) == 17 && capacity > 0 && capacity <= 10000 { capacities[vin] = capacity } } return capacities, rows.Err() } func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) { database = strings.TrimSpace(database) if database == "" { database = "lingniu_vehicle_ts" } query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>='` + quoteTDTime(start) + `' AND ts<'` + quoteTDTime(end) + `' AND parse_status='OK' ORDER BY vin,source_endpoint,ts` rows, err := tdengine.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() observations := make([]HydrogenObservation, 0) for rows.Next() { var vin, parsed string var source sql.NullString var unixMS int64 if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil { return nil, err } vin = strings.ToUpper(strings.TrimSpace(vin)) capacity, capacityOK := capacities[vin] pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed) if !capacityOK || !ok { continue } mass, ok := PressureHydrogenMassKg(pressure, temperature, capacity) if !ok { continue } stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity) noise := math.Min(1, math.Max(0.05, mass-stepMass)) observations = append(observations, HydrogenObservation{ VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass, TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature, NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05), }) } return observations, rows.Err() } func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) { var fields map[string]any if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { return 0, 0, false } pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"]) temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) return pressure, temperature, pressureOK && temperatureOK } func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) { temperatureK := temperatureC + 273.15 if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 { return 0, false } 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} z := 1.0 for index := range a { z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index]) } if z <= 0 { return 0, false } density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000 mass := density * capacityLiter / 1000 return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500 } func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // A pressure-based rebuild is authoritative for the whole day. Delete every // previous hydrogen row first so legacy rate/direct-mass results cannot remain // for vehicles without valid pressure observations in this run. if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil { return err } for _, stat := range stats { if _, err := tx.ExecContext(ctx, ` INSERT INTO vehicle_open_daily_energy( 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 ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`, stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, ); err != nil { return err } } return tx.Commit() } func numericValue(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 quoteTDTime(value time.Time) string { return strings.ReplaceAll(value.Format(time.RFC3339Nano), "'", "''") }