339 lines
12 KiB
Go
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 = ©
|
|
}
|
|
}
|
|
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), "'", "''")
|
|
}
|