Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/statistics.go
2026-07-27 16:46:15 +08:00

339 lines
12 KiB
Go

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 = &copy
}
}
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 = &copy
}
}
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), "'", "''")
}