功能:扩展开放平台氢耗溯源与合作站数据
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
@@ -19,24 +21,39 @@ var hydrogenMassFields = []string{
|
||||
"gd_fc_vehicle_hydrogen_mass_kg",
|
||||
}
|
||||
|
||||
const (
|
||||
hydrogenFuelCellActiveCurrentA = 1.0
|
||||
hydrogenRapidRecoveryWindow = time.Minute
|
||||
hydrogenRapidRecoveryPressureMPa = 8.0
|
||||
hydrogenSegmentEndpointWindow = 5
|
||||
hydrogenSegmentMaxGap = 5 * time.Minute
|
||||
)
|
||||
|
||||
func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat {
|
||||
return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, false, nil)
|
||||
}
|
||||
|
||||
// BuildHydrogenDailyStatsOrdered avoids sorting the same high-volume day again
|
||||
// when LoadHydrogenObservations has already returned VIN/event-time order.
|
||||
func BuildHydrogenDailyStatsOrdered(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat {
|
||||
return buildHydrogenDailyStatsWithParameters(observations, date, noiseKg, maxDropKg, true, nil)
|
||||
}
|
||||
|
||||
func buildHydrogenDailyStatsWithParameters(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64, alreadyOrdered bool, parameters map[string]HydrogenCalculationParameters) []HydrogenDailyStat {
|
||||
if noiseKg <= 0 {
|
||||
noiseKg = 0.05
|
||||
}
|
||||
if maxDropKg <= noiseKg {
|
||||
maxDropKg = 20
|
||||
}
|
||||
grouped := map[string]map[string][]HydrogenObservation{}
|
||||
grouped := 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)
|
||||
grouped[observation.VIN] = append(grouped[observation.VIN], observation)
|
||||
}
|
||||
vins := make([]string, 0, len(grouped))
|
||||
for vin := range grouped {
|
||||
@@ -45,81 +62,236 @@ func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, no
|
||||
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)
|
||||
}
|
||||
values, primarySource := mergeHydrogenObservations(grouped[vin], alreadyOrdered)
|
||||
stats = append(stats, buildTrustedHydrogenDailyStat(vin, primarySource, date, values, noiseKg, maxDropKg, parameters[vin]))
|
||||
}
|
||||
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",
|
||||
func mergeHydrogenObservations(values []HydrogenObservation, alreadyOrdered bool) ([]HydrogenObservation, string) {
|
||||
sourceCounts := make(map[string]int)
|
||||
for _, value := range values {
|
||||
sourceCounts[value.Source]++
|
||||
}
|
||||
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++
|
||||
primarySource := ""
|
||||
primaryCount := -1
|
||||
for source, count := range sourceCounts {
|
||||
if count > primaryCount || (count == primaryCount && source < primarySource) {
|
||||
primarySource = source
|
||||
primaryCount = count
|
||||
}
|
||||
}
|
||||
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)
|
||||
if !alreadyOrdered {
|
||||
sort.SliceStable(values, func(i, j int) bool {
|
||||
if !values[i].ObservedAt.Equal(values[j].ObservedAt) {
|
||||
return values[i].ObservedAt.Before(values[j].ObservedAt)
|
||||
}
|
||||
return betterHydrogenDuplicate(values[i], values[j], sourceCounts)
|
||||
})
|
||||
}
|
||||
return stat
|
||||
merged := make([]HydrogenObservation, 0, len(values))
|
||||
for first := 0; first < len(values); {
|
||||
last := first + 1
|
||||
best := values[first]
|
||||
for last < len(values) && values[last].ObservedAt.Equal(values[first].ObservedAt) {
|
||||
if betterHydrogenDuplicate(values[last], best, sourceCounts) {
|
||||
best = values[last]
|
||||
}
|
||||
last++
|
||||
}
|
||||
merged = append(merged, best)
|
||||
first = last
|
||||
}
|
||||
return merged, primarySource
|
||||
}
|
||||
|
||||
func betterHydrogenStat(candidate, current HydrogenDailyStat) bool {
|
||||
rank := func(status string) int {
|
||||
switch status {
|
||||
case "OK":
|
||||
return 0
|
||||
case "SUSPECT":
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
func betterHydrogenDuplicate(candidate, current HydrogenObservation, sourceCounts map[string]int) bool {
|
||||
if candidate.FuelCellStateKnown != current.FuelCellStateKnown {
|
||||
return candidate.FuelCellStateKnown
|
||||
}
|
||||
if sourceCounts[candidate.Source] != sourceCounts[current.Source] {
|
||||
return sourceCounts[candidate.Source] > sourceCounts[current.Source]
|
||||
}
|
||||
if candidate.Source != current.Source {
|
||||
return candidate.Source < current.Source
|
||||
}
|
||||
return candidate.MassKg < current.MassKg
|
||||
}
|
||||
|
||||
func hydrogenDailyMetadata(values []HydrogenObservation, noiseKg, maxDropKg float64) (float64, float64, int) {
|
||||
cycleMinimum := values[0]
|
||||
remainingMass := values[0].MassKg
|
||||
refuelCount := 0
|
||||
for index := 1; index < len(values); index++ {
|
||||
value := values[index]
|
||||
previous := values[index-1]
|
||||
intervalEligible := hydrogenConsumptionIntervalEligible(previous, value)
|
||||
sampleNoise := hydrogenObservationNoise(value, noiseKg)
|
||||
refuelThreshold := hydrogenObservationRefuelThreshold(value, cycleMinimum)
|
||||
if intervalEligible || (value.FuelCellStateKnown && value.FuelCellActive) || value.MassKg-remainingMass > sampleNoise {
|
||||
remainingMass = value.MassKg
|
||||
}
|
||||
delta := cycleMinimum.MassKg - value.MassKg
|
||||
switch {
|
||||
case value.MassKg-cycleMinimum.MassKg > refuelThreshold:
|
||||
if !rapidHydrogenPressureRecovery(previous, value) {
|
||||
refuelCount++
|
||||
}
|
||||
cycleMinimum = value
|
||||
case delta > sampleNoise && delta <= maxDropKg:
|
||||
cycleMinimum = value
|
||||
}
|
||||
}
|
||||
if rank(candidate.QualityStatus) != rank(current.QualityStatus) {
|
||||
return rank(candidate.QualityStatus) < rank(current.QualityStatus)
|
||||
return remainingMass, cycleMinimum.MassKg, refuelCount
|
||||
}
|
||||
|
||||
func hydrogenSegmentConsumption(values []HydrogenObservation, noiseKg, maxDropKg float64) (float64, int, int, int) {
|
||||
accumulator := newHydrogenSegmentAccumulator(noiseKg, maxDropKg)
|
||||
for _, value := range values {
|
||||
accumulator.Add(value)
|
||||
}
|
||||
if candidate.SampleCount != current.SampleCount {
|
||||
return candidate.SampleCount > current.SampleCount
|
||||
return accumulator.Finalize()
|
||||
}
|
||||
|
||||
type hydrogenSegmentAccumulator struct {
|
||||
noiseKg float64
|
||||
maxDropKg float64
|
||||
previous HydrogenObservation
|
||||
hasPrevious bool
|
||||
cycleMinimum HydrogenObservation
|
||||
segmentCount int
|
||||
segmentFirst []HydrogenObservation
|
||||
segmentTail []HydrogenObservation
|
||||
consumption float64
|
||||
qualifiedSegments int
|
||||
eligibleIntervals int
|
||||
abnormalDrops int
|
||||
}
|
||||
|
||||
func newHydrogenSegmentAccumulator(noiseKg, maxDropKg float64) *hydrogenSegmentAccumulator {
|
||||
return &hydrogenSegmentAccumulator{noiseKg: noiseKg, maxDropKg: maxDropKg}
|
||||
}
|
||||
|
||||
func (accumulator *hydrogenSegmentAccumulator) Add(value HydrogenObservation) {
|
||||
if !accumulator.hasPrevious {
|
||||
accumulator.hasPrevious = true
|
||||
accumulator.previous = value
|
||||
if hydrogenObservationCanStartSegment(value) {
|
||||
accumulator.start(value)
|
||||
}
|
||||
return
|
||||
}
|
||||
return candidate.Source < current.Source
|
||||
previous := accumulator.previous
|
||||
accumulator.previous = value
|
||||
intervalEligible := hydrogenConsumptionIntervalEligible(previous, value)
|
||||
if intervalEligible {
|
||||
accumulator.eligibleIntervals++
|
||||
}
|
||||
if accumulator.segmentCount == 0 || !intervalEligible {
|
||||
accumulator.flush()
|
||||
if hydrogenObservationCanStartSegment(value) {
|
||||
accumulator.start(value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if value.ObservedAt.Sub(previous.ObservedAt) > hydrogenSegmentMaxGap {
|
||||
accumulator.flush()
|
||||
accumulator.start(value)
|
||||
return
|
||||
}
|
||||
if previous.MassKg-value.MassKg > accumulator.maxDropKg {
|
||||
accumulator.abnormalDrops++
|
||||
accumulator.flush()
|
||||
accumulator.start(value)
|
||||
return
|
||||
}
|
||||
if value.MassKg-accumulator.cycleMinimum.MassKg > hydrogenObservationRefuelThreshold(value, accumulator.cycleMinimum) {
|
||||
accumulator.flush()
|
||||
accumulator.start(value)
|
||||
return
|
||||
}
|
||||
accumulator.append(value)
|
||||
if value.MassKg < accumulator.cycleMinimum.MassKg {
|
||||
accumulator.cycleMinimum = value
|
||||
}
|
||||
}
|
||||
|
||||
func (accumulator *hydrogenSegmentAccumulator) Finalize() (float64, int, int, int) {
|
||||
accumulator.flush()
|
||||
return accumulator.consumption, accumulator.qualifiedSegments, accumulator.eligibleIntervals, accumulator.abnormalDrops
|
||||
}
|
||||
|
||||
func (accumulator *hydrogenSegmentAccumulator) start(value HydrogenObservation) {
|
||||
accumulator.segmentCount = 0
|
||||
accumulator.segmentFirst = accumulator.segmentFirst[:0]
|
||||
accumulator.segmentTail = accumulator.segmentTail[:0]
|
||||
accumulator.cycleMinimum = value
|
||||
accumulator.append(value)
|
||||
}
|
||||
|
||||
func (accumulator *hydrogenSegmentAccumulator) append(value HydrogenObservation) {
|
||||
accumulator.segmentCount++
|
||||
if len(accumulator.segmentFirst) < hydrogenSegmentEndpointWindow {
|
||||
accumulator.segmentFirst = append(accumulator.segmentFirst, value)
|
||||
}
|
||||
if len(accumulator.segmentTail) == hydrogenSegmentEndpointWindow {
|
||||
copy(accumulator.segmentTail, accumulator.segmentTail[1:])
|
||||
accumulator.segmentTail[len(accumulator.segmentTail)-1] = value
|
||||
return
|
||||
}
|
||||
accumulator.segmentTail = append(accumulator.segmentTail, value)
|
||||
}
|
||||
|
||||
func (accumulator *hydrogenSegmentAccumulator) flush() {
|
||||
if accumulator.segmentCount >= hydrogenSegmentEndpointWindow*2 {
|
||||
accumulator.qualifiedSegments++
|
||||
accumulator.consumption += hydrogenEndpointDrop(accumulator.segmentFirst, accumulator.segmentTail, accumulator.noiseKg)
|
||||
}
|
||||
accumulator.segmentCount = 0
|
||||
accumulator.segmentFirst = accumulator.segmentFirst[:0]
|
||||
accumulator.segmentTail = accumulator.segmentTail[:0]
|
||||
}
|
||||
|
||||
func hydrogenObservationCanStartSegment(value HydrogenObservation) bool {
|
||||
return !value.FuelCellStateKnown || value.FuelCellActive
|
||||
}
|
||||
|
||||
func hydrogenEndpointDrop(start, end []HydrogenObservation, noiseKg float64) float64 {
|
||||
startMass := hydrogenMedianMass(start)
|
||||
endMass := hydrogenMedianMass(end)
|
||||
segmentNoise := noiseKg
|
||||
for _, value := range start {
|
||||
segmentNoise = math.Max(segmentNoise, value.NoiseKg)
|
||||
}
|
||||
for _, value := range end {
|
||||
segmentNoise = math.Max(segmentNoise, value.NoiseKg)
|
||||
}
|
||||
drop := startMass - endMass
|
||||
if drop <= segmentNoise {
|
||||
return 0
|
||||
}
|
||||
return drop
|
||||
}
|
||||
|
||||
func hydrogenMedianMass(values []HydrogenObservation) float64 {
|
||||
masses := make([]float64, len(values))
|
||||
for index, value := range values {
|
||||
masses[index] = value.MassKg
|
||||
}
|
||||
sort.Float64s(masses)
|
||||
return masses[len(masses)/2]
|
||||
}
|
||||
|
||||
func hydrogenObservationNoise(value HydrogenObservation, fallback float64) float64 {
|
||||
return math.Max(fallback, value.NoiseKg)
|
||||
}
|
||||
|
||||
func hydrogenObservationRefuelThreshold(value, cycleMinimum HydrogenObservation) float64 {
|
||||
threshold := math.Max(1, cycleMinimum.MassKg*0.05)
|
||||
if value.RefuelThresholdKg > 0 {
|
||||
threshold = value.RefuelThresholdKg
|
||||
}
|
||||
return threshold
|
||||
}
|
||||
|
||||
func ExtractHydrogenMass(parsedJSON string) (float64, bool) {
|
||||
@@ -215,18 +387,90 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
|
||||
FROM vehicle_hydrogen_energy_parameter
|
||||
WHERE active=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>=?)
|
||||
ORDER BY vin,effective_from DESC`, date.Format("2006-01-02"), date.Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := map[string]HydrogenCalculationParameters{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var batteryCapacity, conversion float64
|
||||
if err := rows.Scan(&vin, &batteryCapacity, &conversion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := result[vin]; exists {
|
||||
continue
|
||||
}
|
||||
result[vin] = HydrogenCalculationParameters{
|
||||
BatteryCapacityKWh: batteryCapacity, HydrogenEnergyKWhKg: conversion,
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) {
|
||||
return loadHydrogenObservations(ctx, tdengine, database, start, end, capacities, "")
|
||||
}
|
||||
|
||||
func LoadHydrogenObservationVINs(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]string, error) {
|
||||
query, err := buildHydrogenObservationVINQuery(database, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
vins := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
continue
|
||||
}
|
||||
if _, ok := capacities[vin]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[vin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func LoadHydrogenObservationsForVIN(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64, vin string) ([]HydrogenObservation, error) {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return nil, fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
return loadHydrogenObservations(ctx, tdengine, database, start, end, capacities, vin)
|
||||
}
|
||||
|
||||
func loadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64, vin string) ([]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`
|
||||
query, err := buildHydrogenObservationQuery(database, start, end, vin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -235,14 +479,14 @@ ORDER BY vin,source_endpoint,ts`
|
||||
observations := make([]HydrogenObservation, 0)
|
||||
for rows.Next() {
|
||||
var vin, parsed string
|
||||
var source sql.NullString
|
||||
var source, eventID sql.NullString
|
||||
var unixMS int64
|
||||
if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil {
|
||||
if err := rows.Scan(&vin, &source, &eventID, &unixMS, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
capacity, capacityOK := capacities[vin]
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed)
|
||||
pressure, temperature, fuelCellActive, fuelCellStateKnown, ok := extractHydrogenTelemetryFast(parsed)
|
||||
if !capacityOK || !ok {
|
||||
continue
|
||||
}
|
||||
@@ -252,23 +496,298 @@ ORDER BY vin,source_endpoint,ts`
|
||||
}
|
||||
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,
|
||||
observation := HydrogenObservation{
|
||||
VIN: vin, Source: source.String, EventID: eventID.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass,
|
||||
TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||
NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||
})
|
||||
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
|
||||
}
|
||||
populateHydrogenObservationStateFast([]byte(parsed), &observation)
|
||||
observations = append(observations, observation)
|
||||
}
|
||||
return observations, rows.Err()
|
||||
}
|
||||
|
||||
func buildHydrogenObservationQuery(database string, start, end time.Time, vin string) (string, error) {
|
||||
if !validTDIdentifier(database) {
|
||||
return "", fmt.Errorf("invalid TDengine database %q", database)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return "", errors.New("hydrogen observation end must be after start")
|
||||
}
|
||||
vinFilter := ""
|
||||
if vin != "" {
|
||||
if !validHydrogenVIN(vin) {
|
||||
return "", fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
vinFilter = "\n AND vin='" + vin + "'"
|
||||
}
|
||||
return `SELECT vin,source_endpoint,event_id,CAST(event_time AS BIGINT),parsed_json
|
||||
FROM ` + database + `.raw_frames
|
||||
WHERE protocol='GB32960'
|
||||
AND ts>='` + quoteTDTime(start) + `'
|
||||
AND ts<'` + quoteTDTime(end) + `'
|
||||
AND event_time>='` + quoteTDTime(start) + `'
|
||||
AND event_time<'` + quoteTDTime(end) + `'` + vinFilter + `
|
||||
AND parse_status='OK'
|
||||
ORDER BY vin,event_time,source_endpoint,ts`, nil
|
||||
}
|
||||
|
||||
func populateHydrogenObservationStateFast(data []byte, observation *HydrogenObservation) {
|
||||
if value, ok := jsonNumericField(data, "gb32960.vehicle.soc_percent"); ok && value >= 0 && value <= 100 {
|
||||
observation.SOCPercent, observation.SOCKnown = value, true
|
||||
}
|
||||
if value, ok := jsonNumericField(data, "gb32960.vehicle.total_mileage_km"); ok && value >= 0 {
|
||||
observation.MileageKm, observation.MileageKnown = value, true
|
||||
}
|
||||
if value, ok := jsonNumericField(data, "gb32960.vehicle.vehicle_status"); ok {
|
||||
observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := jsonNumericField(data, "gb32960.vehicle.charge_status"); ok {
|
||||
observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := jsonNumericField(data, "gb32960.vehicle.running_mode"); ok {
|
||||
observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
voltage, voltageOK := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_voltage_v")
|
||||
current, currentOK := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_current_a")
|
||||
if voltageOK && currentOK && voltage > 0 && voltage <= 1000 && current >= 0 && current <= 2000 {
|
||||
observation.FuelCellVoltageV = voltage
|
||||
observation.FuelCellCurrentA = current
|
||||
observation.FuelCellPowerKnown = true
|
||||
}
|
||||
}
|
||||
|
||||
func buildHydrogenObservationVINQuery(database string, start, end time.Time) (string, error) {
|
||||
database = strings.TrimSpace(database)
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
if !validTDIdentifier(database) {
|
||||
return "", fmt.Errorf("invalid TDengine database %q", database)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return "", errors.New("hydrogen observation end must be after start")
|
||||
}
|
||||
return `SELECT DISTINCT vin
|
||||
FROM ` + database + `.raw_frames
|
||||
WHERE protocol='GB32960'
|
||||
AND ts>='` + quoteTDTime(start) + `'
|
||||
AND ts<'` + quoteTDTime(end) + `'
|
||||
AND event_time>='` + quoteTDTime(start) + `'
|
||||
AND event_time<'` + quoteTDTime(end) + `'
|
||||
AND parse_status='OK'
|
||||
ORDER BY vin`, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return extractHydrogenPressureTemperatureFields(fields)
|
||||
}
|
||||
|
||||
func ExtractHydrogenTelemetry(parsedJSON string) (pressure, temperature float64, active, known, ok bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, 0, false, false, false
|
||||
}
|
||||
pressure, temperature, ok = extractHydrogenPressureTemperatureFields(fields)
|
||||
if !ok {
|
||||
return 0, 0, false, false, false
|
||||
}
|
||||
active, known = extractHydrogenFuelCellStateFields(fields)
|
||||
return pressure, temperature, active, known, true
|
||||
}
|
||||
|
||||
func extractHydrogenTelemetryFast(parsedJSON string) (pressure, temperature float64, active, known, ok bool) {
|
||||
data := []byte(parsedJSON)
|
||||
pressure, pressureOK := jsonNumericField(data, "gb32960.fuel_cell.max_hydrogen_pressure_mpa")
|
||||
temperature, temperatureOK := jsonNumericField(data, "gb32960.fuel_cell.max_hydrogen_temperature_c")
|
||||
if !pressureOK || !temperatureOK || !validHydrogenPressureTemperature(pressure, temperature) {
|
||||
return 0, 0, false, false, false
|
||||
}
|
||||
if current, exists := jsonNumericField(data, "gb32960.fuel_cell.fuel_cell_current_a"); exists && current >= 0 {
|
||||
return pressure, temperature, current > hydrogenFuelCellActiveCurrentA, true, true
|
||||
}
|
||||
if state, exists := jsonNumericField(data, "gb32960.gd_fc_stack.engine_work_state"); exists {
|
||||
switch state {
|
||||
case 2:
|
||||
return pressure, temperature, true, true, true
|
||||
case 0:
|
||||
return pressure, temperature, false, true, true
|
||||
}
|
||||
}
|
||||
return pressure, temperature, false, false, true
|
||||
}
|
||||
|
||||
func jsonNumericField(data []byte, key string) (float64, bool) {
|
||||
needle := []byte(`"` + key + `"`)
|
||||
for offset := 0; offset < len(data); {
|
||||
match := bytes.Index(data[offset:], needle)
|
||||
if match < 0 {
|
||||
return 0, false
|
||||
}
|
||||
cursor := offset + match + len(needle)
|
||||
for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') {
|
||||
cursor++
|
||||
}
|
||||
if cursor >= len(data) || data[cursor] != ':' {
|
||||
offset = cursor
|
||||
continue
|
||||
}
|
||||
cursor++
|
||||
for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') {
|
||||
cursor++
|
||||
}
|
||||
quoted := cursor < len(data) && data[cursor] == '"'
|
||||
if quoted {
|
||||
cursor++
|
||||
}
|
||||
start := cursor
|
||||
for cursor < len(data) && jsonNumberByte(data[cursor]) {
|
||||
cursor++
|
||||
}
|
||||
if start == cursor || (quoted && (cursor >= len(data) || data[cursor] != '"')) {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseFloat(string(data[start:cursor]), 64)
|
||||
return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func jsonNumberByte(value byte) bool {
|
||||
return value == '+' || value == '-' || value == '.' || value == 'e' || value == 'E' ||
|
||||
(value >= '0' && value <= '9')
|
||||
}
|
||||
|
||||
func extractHydrogenPressureTemperatureFields(fields map[string]any) (float64, float64, bool) {
|
||||
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
|
||||
return pressure, temperature, pressureOK && temperatureOK && validHydrogenPressureTemperature(pressure, temperature)
|
||||
}
|
||||
|
||||
func ExtractHydrogenFuelCellState(parsedJSON string) (active, known bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return false, false
|
||||
}
|
||||
return extractHydrogenFuelCellStateFields(fields)
|
||||
}
|
||||
|
||||
func extractHydrogenFuelCellStateFields(fields map[string]any) (active, known bool) {
|
||||
if current, ok := numericValue(fields["gb32960.fuel_cell.fuel_cell_current_a"]); ok && current >= 0 {
|
||||
return current > hydrogenFuelCellActiveCurrentA, true
|
||||
}
|
||||
if state, ok := numericValue(fields["gb32960.gd_fc_stack.engine_work_state"]); ok {
|
||||
switch state {
|
||||
case 2:
|
||||
return true, true
|
||||
case 0:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func hydrogenConsumptionIntervalEligible(previous, current HydrogenObservation) bool {
|
||||
if previous.FuelCellStateKnown || current.FuelCellStateKnown {
|
||||
return previous.FuelCellStateKnown && previous.FuelCellActive &&
|
||||
current.FuelCellStateKnown && current.FuelCellActive
|
||||
}
|
||||
// Preserve compatibility for protocols or historical rows without a known
|
||||
// fuel-cell state. Those intervals still rely on pressure hysteresis alone.
|
||||
return true
|
||||
}
|
||||
|
||||
func rapidHydrogenPressureRecovery(previous, current HydrogenObservation) bool {
|
||||
elapsed := current.ObservedAt.Sub(previous.ObservedAt)
|
||||
return elapsed > 0 && elapsed <= hydrogenRapidRecoveryWindow &&
|
||||
current.PressureMPa-previous.PressureMPa >= hydrogenRapidRecoveryPressureMPa
|
||||
}
|
||||
|
||||
func validHydrogenPressureTemperature(pressureMPa, temperatureC float64) bool {
|
||||
return pressureMPa > 0 && pressureMPa <= 70 && temperatureC > -40 && temperatureC <= 726.85 &&
|
||||
!math.IsNaN(pressureMPa) && !math.IsInf(pressureMPa, 0) &&
|
||||
!math.IsNaN(temperatureC) && !math.IsInf(temperatureC, 0)
|
||||
}
|
||||
|
||||
func validHydrogenVIN(vin string) bool {
|
||||
if len(vin) != 17 {
|
||||
return false
|
||||
}
|
||||
for _, character := range vin {
|
||||
if character < '0' || character > '9' {
|
||||
if character < 'A' || character > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validTDIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character != '_' && (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func HydrogenObservationDateRange(ctx context.Context, tdengine *sql.DB, database, vin string) (time.Time, time.Time, bool, error) {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return time.Time{}, time.Time{}, false, errors.New("invalid VIN")
|
||||
}
|
||||
return hydrogenObservationDateRange(ctx, tdengine, database, vin)
|
||||
}
|
||||
|
||||
func HydrogenObservationDateRangeForAll(ctx context.Context, tdengine *sql.DB, database string) (time.Time, time.Time, bool, error) {
|
||||
return hydrogenObservationDateRange(ctx, tdengine, database, "")
|
||||
}
|
||||
|
||||
func hydrogenObservationDateRange(ctx context.Context, tdengine *sql.DB, database, vin string) (time.Time, time.Time, bool, error) {
|
||||
database = strings.TrimSpace(database)
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
if !validTDIdentifier(database) {
|
||||
return time.Time{}, time.Time{}, false, errors.New("invalid database")
|
||||
}
|
||||
vinFilter := ""
|
||||
if vin != "" {
|
||||
vinFilter = " AND vin='" + vin + "'"
|
||||
}
|
||||
loadBoundary := func(direction string) (time.Time, bool, error) {
|
||||
query := `SELECT event_time
|
||||
FROM ` + database + `.raw_frames
|
||||
WHERE protocol='GB32960'` + vinFilter + ` AND parse_status='OK' AND event_time IS NOT NULL
|
||||
ORDER BY event_time ` + direction + ` LIMIT 1`
|
||||
row := tdengine.QueryRowContext(ctx, query)
|
||||
var observed time.Time
|
||||
if err := row.Scan(&observed); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return observed, true, nil
|
||||
}
|
||||
first, found, err := loadBoundary("ASC")
|
||||
if err != nil || !found {
|
||||
return time.Time{}, time.Time{}, false, err
|
||||
}
|
||||
last, found, err := loadBoundary("DESC")
|
||||
if err != nil || !found {
|
||||
return time.Time{}, time.Time{}, false, err
|
||||
}
|
||||
return first, last, true, nil
|
||||
}
|
||||
|
||||
func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) {
|
||||
@@ -292,6 +811,26 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, false)
|
||||
}
|
||||
|
||||
// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment
|
||||
// handoff atomically. The stream resumes after the exact final observation
|
||||
// included in the rebuild, so Kafka backlog already covered by the rebuild is
|
||||
// ignored instead of counted twice.
|
||||
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, true)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -300,24 +839,122 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta
|
||||
// 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 {
|
||||
if vin == "" {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if seedStream {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) {
|
||||
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin)
|
||||
}
|
||||
parameterJSON, err := json.Marshal(stat.CalculationParameters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hydrogen calculation parameters for %s: %w", stat.VIN, err)
|
||||
}
|
||||
evidenceJSON, err := json.Marshal(stat.Intervals)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hydrogen interval evidence for %s: %w", stat.VIN, err)
|
||||
}
|
||||
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,
|
||||
raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
|
||||
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
|
||||
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
|
||||
invalid_segment_count,algorithm_version,parameter_json,evidence_json
|
||||
) 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,
|
||||
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
|
||||
stat.SOCBalancedConsumptionKg, stat.MixedMileageKm, stat.PureElectricMileageKm,
|
||||
stat.ConsumptionKgPer100Km, stat.SOCBalancedKgPer100Km, stat.ChargeCount, stat.QualifiedSegmentCount,
|
||||
stat.InvalidSegmentCount, stat.CalculationParameters.AlgorithmVersion, parameterJSON, evidenceJSON,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if seedStream {
|
||||
stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount,
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "",
|
||||
stateJSON, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
|
||||
type segmentWindow struct {
|
||||
Count int `json:"count"`
|
||||
First []any `json:"first"`
|
||||
Tail []any `json:"tail"`
|
||||
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"`
|
||||
}
|
||||
state := struct {
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
EligibleIntervalCount int `json:"eligibleIntervalCount"`
|
||||
QualifiedSegmentCount int `json:"qualifiedSegmentCount"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
RemainingMassKg float64 `json:"remainingMassKg"`
|
||||
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"`
|
||||
LastObservedMassKg float64 `json:"lastObservedMassKg"`
|
||||
TankCapacityLiters float64 `json:"tankCapacityLiters"`
|
||||
FirstPressureMpa float64 `json:"firstPressureMpa"`
|
||||
LastPressureMpa float64 `json:"lastPressureMpa"`
|
||||
FirstTemperatureC float64 `json:"firstTemperatureC"`
|
||||
LastTemperatureC float64 `json:"lastTemperatureC"`
|
||||
FirstEventTime time.Time `json:"firstEventTime"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastFuelCellActive bool `json:"lastFuelCellActive"`
|
||||
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"`
|
||||
Segment segmentWindow `json:"segment"`
|
||||
}{
|
||||
SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg,
|
||||
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount,
|
||||
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount,
|
||||
QualifiedSegmentCount: stat.QualifiedSegmentCount,
|
||||
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg,
|
||||
MetadataCycleMinimumKg: stat.CycleMinimumMassKg,
|
||||
LastObservedMassKg: stat.LastObservation.MassKg,
|
||||
TankCapacityLiters: stat.LastObservation.TankCapacityLiter,
|
||||
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa,
|
||||
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC,
|
||||
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
LastFuelCellActive: stat.LastObservation.FuelCellActive,
|
||||
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown,
|
||||
Segment: segmentWindow{First: []any{}, Tail: []any{}},
|
||||
}
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
Reference in New Issue
Block a user