feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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), "'", "''")
|
||||
}
|
||||
Reference in New Issue
Block a user