706 lines
27 KiB
Go
706 lines
27 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
|
|
)
|
|
|
|
const (
|
|
defaultHydrogenNoiseKG = 0.05
|
|
defaultHydrogenMaxDropKG = 20.0
|
|
hydrogenActiveCurrentA = 1.0
|
|
hydrogenMolarMassKGPerMol = 0.00201588
|
|
universalGasConstant = 8.314472
|
|
hydrogenSegmentWindowSize = 5
|
|
hydrogenSegmentMinSamples = hydrogenSegmentWindowSize * 2
|
|
hydrogenSegmentMaxGap = 5 * time.Minute
|
|
hydrogenRecoveryWindow = time.Minute
|
|
hydrogenRecoveryPressure = 8.0
|
|
)
|
|
|
|
var hydrogenPressureFieldKeys = []string{
|
|
"gb32960.fuel_cell.max_hydrogen_pressure_mpa",
|
|
"fuel_cell_max_hydrogen_pressure_mpa",
|
|
}
|
|
|
|
var hydrogenTemperatureFieldKeys = []string{
|
|
"gb32960.fuel_cell.max_hydrogen_temperature_c",
|
|
"fuel_cell_max_hydrogen_temperature_c",
|
|
}
|
|
|
|
var hydrogenCurrentFieldKeys = []string{
|
|
"gb32960.fuel_cell.fuel_cell_current_a",
|
|
"fuel_cell_current_a",
|
|
}
|
|
|
|
var hydrogenDensityA = [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
|
|
var hydrogenDensityB = [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
|
|
var hydrogenDensityC = [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
|
|
|
|
const HydrogenStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_stream_state (
|
|
vin VARCHAR(64) NOT NULL,
|
|
stat_date DATE NOT NULL,
|
|
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
|
|
first_mass_kg DECIMAL(18,3) NOT NULL,
|
|
last_mass_kg DECIMAL(18,3) NOT NULL,
|
|
cycle_min_mass_kg DECIMAL(18,3) NOT NULL,
|
|
consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
|
|
sample_count BIGINT UNSIGNED NOT NULL DEFAULT 1,
|
|
refuel_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
abnormal_drop_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
tank_capacity_l DECIMAL(12,2) NOT NULL,
|
|
first_pressure_mpa DECIMAL(10,3) NOT NULL,
|
|
last_pressure_mpa DECIMAL(10,3) NOT NULL,
|
|
first_temperature_c DECIMAL(10,3) NOT NULL,
|
|
last_temperature_c DECIMAL(10,3) NOT NULL,
|
|
calculation_method VARCHAR(32) NOT NULL DEFAULT 'PRESSURE_NIST',
|
|
first_event_time DATETIME(3) NOT NULL,
|
|
last_event_time DATETIME(3) NOT NULL,
|
|
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
|
last_fuel_cell_active TINYINT(1) NOT NULL DEFAULT 0,
|
|
last_fuel_cell_state_known TINYINT(1) NOT NULL DEFAULT 0,
|
|
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
|
|
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效压力质量样本不足2条',
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
PRIMARY KEY (vin, stat_date, source_endpoint),
|
|
KEY idx_hydrogen_stream_date_quality (stat_date, quality_status, vin)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
|
|
|
const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state (
|
|
vin VARCHAR(64) NOT NULL,
|
|
stat_date DATE NOT NULL,
|
|
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
|
|
finalized_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
|
|
projected_consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
|
|
sample_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
refuel_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
abnormal_drop_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
eligible_interval_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
qualified_segment_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
last_mass_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
|
|
last_event_time DATETIME(3) NOT NULL,
|
|
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
|
state_json JSON NOT NULL,
|
|
calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',
|
|
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
|
|
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
PRIMARY KEY (vin, stat_date),
|
|
KEY idx_hydrogen_segment_stream_date_quality (stat_date, quality_status, vin),
|
|
CONSTRAINT chk_hydrogen_segment_stream_quality CHECK (quality_status IN ('OK', 'NO_DATA', 'SUSPECT'))
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
|
|
|
type HydrogenStreamSample struct {
|
|
VIN string
|
|
Date string
|
|
SourceEndpoint string
|
|
EventID string
|
|
EventTime time.Time
|
|
MassKG float64
|
|
TankCapacityLiters float64
|
|
PressureMPa float64
|
|
TemperatureC float64
|
|
NoiseKG float64
|
|
RefuelThresholdKG float64
|
|
FuelCellActive bool
|
|
FuelCellStateKnown bool
|
|
ConsumptionEligible bool
|
|
MileageKM float64
|
|
MileageKnown bool
|
|
SOCPercent float64
|
|
SOCKnown bool
|
|
VehicleState int
|
|
VehicleStateKnown bool
|
|
ChargeState int
|
|
ChargeStateKnown bool
|
|
RunningMode int
|
|
RunningModeKnown bool
|
|
FuelCellPowerKW float64
|
|
FuelCellPowerKnown bool
|
|
}
|
|
|
|
type HydrogenStreamResult struct {
|
|
Found int
|
|
Written int
|
|
Duplicate int
|
|
Invalid int
|
|
NotCurrent int
|
|
}
|
|
|
|
type hydrogenStreamEndpoint struct {
|
|
MassKG float64 `json:"massKg"`
|
|
NoiseKG float64 `json:"noiseKg"`
|
|
}
|
|
|
|
type hydrogenStreamSegmentWindow struct {
|
|
Count int64 `json:"count"`
|
|
First []hydrogenStreamEndpoint `json:"first"`
|
|
Tail []hydrogenStreamEndpoint `json:"tail"`
|
|
CycleMinimumMassKG float64 `json:"cycleMinimumMassKg"`
|
|
}
|
|
|
|
type hydrogenSegmentStreamState struct {
|
|
SourceEndpoint string `json:"sourceEndpoint"`
|
|
FinalizedConsumptionKG float64 `json:"finalizedConsumptionKg"`
|
|
SampleCount int64 `json:"sampleCount"`
|
|
RefuelCount int64 `json:"refuelCount"`
|
|
AbnormalDropCount int64 `json:"abnormalDropCount"`
|
|
EligibleIntervalCount int64 `json:"eligibleIntervalCount"`
|
|
QualifiedSegmentCount int64 `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 hydrogenStreamSegmentWindow `json:"segment"`
|
|
}
|
|
|
|
type hydrogenStreamBaseline struct {
|
|
SourceEndpoint string
|
|
ConsumptionKG float64
|
|
FirstMassKG float64
|
|
LastMassKG float64
|
|
SampleCount int64
|
|
RefuelCount int64
|
|
QualityStatus string
|
|
}
|
|
|
|
func newHydrogenSegmentStreamState(sample HydrogenStreamSample, baseline *hydrogenStreamBaseline) hydrogenSegmentStreamState {
|
|
state := hydrogenSegmentStreamState{
|
|
SourceEndpoint: sample.SourceEndpoint,
|
|
FirstMassKG: sample.MassKG, RemainingMassKG: sample.MassKG,
|
|
MetadataCycleMinimumKG: sample.MassKG, LastObservedMassKG: sample.MassKG,
|
|
TankCapacityLiters: sample.TankCapacityLiters,
|
|
FirstPressureMPa: sample.PressureMPa, LastPressureMPa: sample.PressureMPa,
|
|
FirstTemperatureC: sample.TemperatureC, LastTemperatureC: sample.TemperatureC,
|
|
FirstEventTime: sample.EventTime, LastEventTime: sample.EventTime, LastEventID: sample.EventID,
|
|
LastFuelCellActive: sample.FuelCellActive, LastFuelCellStateKnown: sample.FuelCellStateKnown,
|
|
SampleCount: 1,
|
|
}
|
|
if baseline != nil {
|
|
state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, baseline.SourceEndpoint)
|
|
state.FinalizedConsumptionKG = baseline.ConsumptionKG
|
|
state.SampleCount += baseline.SampleCount
|
|
state.RefuelCount = baseline.RefuelCount
|
|
if baseline.FirstMassKG >= 0 {
|
|
state.FirstMassKG = baseline.FirstMassKG
|
|
}
|
|
if baseline.LastMassKG >= 0 {
|
|
state.RemainingMassKG = baseline.LastMassKG
|
|
}
|
|
if baseline.QualityStatus == "OK" || baseline.QualityStatus == "SUSPECT" {
|
|
state.EligibleIntervalCount = 1
|
|
state.QualifiedSegmentCount = 1
|
|
}
|
|
if baseline.QualityStatus == "SUSPECT" {
|
|
state.AbnormalDropCount = 1
|
|
}
|
|
}
|
|
if hydrogenStreamCanStartSegment(sample) {
|
|
state.startSegment(sample)
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (state *hydrogenSegmentStreamState) add(sample HydrogenStreamSample) bool {
|
|
if !sample.EventTime.After(state.LastEventTime) {
|
|
return false
|
|
}
|
|
previousTime := state.LastEventTime
|
|
previousMass := state.LastObservedMassKG
|
|
previousPressure := state.LastPressureMPa
|
|
intervalEligible := hydrogenStreamIntervalEligible(
|
|
state.LastFuelCellActive, state.LastFuelCellStateKnown,
|
|
sample.FuelCellActive, sample.FuelCellStateKnown,
|
|
)
|
|
if intervalEligible || (sample.FuelCellStateKnown && sample.FuelCellActive) ||
|
|
sample.MassKG-state.RemainingMassKG > sample.NoiseKG {
|
|
state.RemainingMassKG = sample.MassKG
|
|
}
|
|
delta := state.MetadataCycleMinimumKG - sample.MassKG
|
|
switch {
|
|
case sample.MassKG-state.MetadataCycleMinimumKG > sample.RefuelThresholdKG:
|
|
if !hydrogenStreamRapidRecovery(previousTime, previousPressure, sample) {
|
|
state.RefuelCount++
|
|
}
|
|
state.MetadataCycleMinimumKG = sample.MassKG
|
|
case delta > sample.NoiseKG && delta <= defaultHydrogenMaxDropKG:
|
|
state.MetadataCycleMinimumKG = sample.MassKG
|
|
}
|
|
|
|
if intervalEligible {
|
|
state.EligibleIntervalCount++
|
|
}
|
|
switch {
|
|
case state.Segment.Count == 0 || !intervalEligible:
|
|
state.flushSegment()
|
|
if hydrogenStreamCanStartSegment(sample) {
|
|
state.startSegment(sample)
|
|
}
|
|
case sample.EventTime.Sub(previousTime) > hydrogenSegmentMaxGap:
|
|
state.flushSegment()
|
|
state.startSegment(sample)
|
|
case previousMass-sample.MassKG > defaultHydrogenMaxDropKG:
|
|
state.AbnormalDropCount++
|
|
state.flushSegment()
|
|
state.startSegment(sample)
|
|
case sample.MassKG-state.Segment.CycleMinimumMassKG > sample.RefuelThresholdKG:
|
|
state.flushSegment()
|
|
state.startSegment(sample)
|
|
default:
|
|
state.appendSegmentSample(sample)
|
|
if sample.MassKG < state.Segment.CycleMinimumMassKG {
|
|
state.Segment.CycleMinimumMassKG = sample.MassKG
|
|
}
|
|
}
|
|
|
|
state.SourceEndpoint = sample.SourceEndpoint
|
|
state.SampleCount++
|
|
state.LastObservedMassKG = sample.MassKG
|
|
state.TankCapacityLiters = sample.TankCapacityLiters
|
|
state.LastPressureMPa = sample.PressureMPa
|
|
state.LastTemperatureC = sample.TemperatureC
|
|
state.LastEventTime = sample.EventTime
|
|
state.LastEventID = sample.EventID
|
|
state.LastFuelCellActive = sample.FuelCellActive
|
|
state.LastFuelCellStateKnown = sample.FuelCellStateKnown
|
|
return true
|
|
}
|
|
|
|
func (state *hydrogenSegmentStreamState) startSegment(sample HydrogenStreamSample) {
|
|
state.Segment = hydrogenStreamSegmentWindow{CycleMinimumMassKG: sample.MassKG}
|
|
state.appendSegmentSample(sample)
|
|
}
|
|
|
|
func (state *hydrogenSegmentStreamState) appendSegmentSample(sample HydrogenStreamSample) {
|
|
endpoint := hydrogenStreamEndpoint{MassKG: sample.MassKG, NoiseKG: sample.NoiseKG}
|
|
state.Segment.Count++
|
|
if len(state.Segment.First) < hydrogenSegmentWindowSize {
|
|
state.Segment.First = append(state.Segment.First, endpoint)
|
|
}
|
|
if len(state.Segment.Tail) == hydrogenSegmentWindowSize {
|
|
copy(state.Segment.Tail, state.Segment.Tail[1:])
|
|
state.Segment.Tail[len(state.Segment.Tail)-1] = endpoint
|
|
return
|
|
}
|
|
state.Segment.Tail = append(state.Segment.Tail, endpoint)
|
|
}
|
|
|
|
func (state *hydrogenSegmentStreamState) flushSegment() {
|
|
if state.Segment.Count >= hydrogenSegmentMinSamples {
|
|
state.QualifiedSegmentCount++
|
|
state.FinalizedConsumptionKG += hydrogenStreamEndpointDrop(state.Segment.First, state.Segment.Tail)
|
|
}
|
|
state.Segment = hydrogenStreamSegmentWindow{}
|
|
}
|
|
|
|
func (state hydrogenSegmentStreamState) projectedConsumptionKG() float64 {
|
|
consumption := state.FinalizedConsumptionKG
|
|
if state.Segment.Count >= hydrogenSegmentMinSamples {
|
|
consumption += hydrogenStreamEndpointDrop(state.Segment.First, state.Segment.Tail)
|
|
}
|
|
return roundHydrogenKG(consumption)
|
|
}
|
|
|
|
func (state hydrogenSegmentStreamState) quality() (string, string) {
|
|
qualified := state.QualifiedSegmentCount
|
|
if state.Segment.Count >= hydrogenSegmentMinSamples {
|
|
qualified++
|
|
}
|
|
switch {
|
|
case state.SampleCount < 2:
|
|
return "NO_DATA", "有效车载氢量样本不足2条"
|
|
case state.EligibleIntervalCount == 0:
|
|
return "NO_DATA", "无燃料电池工作状态下的有效压力区间"
|
|
case qualified == 0:
|
|
return "NO_DATA", "有效工作段关键帧不足10条"
|
|
case state.AbnormalDropCount > 0:
|
|
return "SUSPECT", "存在超过阈值的异常下降"
|
|
default:
|
|
return "OK", ""
|
|
}
|
|
}
|
|
|
|
func hydrogenStreamCanStartSegment(sample HydrogenStreamSample) bool {
|
|
return !sample.FuelCellStateKnown || sample.FuelCellActive
|
|
}
|
|
|
|
func hydrogenStreamIntervalEligible(previousActive, previousKnown, currentActive, currentKnown bool) bool {
|
|
if previousKnown || currentKnown {
|
|
return previousKnown && previousActive && currentKnown && currentActive
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hydrogenStreamRapidRecovery(previousTime time.Time, previousPressure float64, sample HydrogenStreamSample) bool {
|
|
elapsed := sample.EventTime.Sub(previousTime)
|
|
return elapsed > 0 && elapsed <= hydrogenRecoveryWindow &&
|
|
sample.PressureMPa-previousPressure >= hydrogenRecoveryPressure
|
|
}
|
|
|
|
func hydrogenStreamEndpointDrop(first, tail []hydrogenStreamEndpoint) float64 {
|
|
if len(first) < hydrogenSegmentWindowSize || len(tail) < hydrogenSegmentWindowSize {
|
|
return 0
|
|
}
|
|
startMass := hydrogenStreamMedianMass(first)
|
|
endMass := hydrogenStreamMedianMass(tail)
|
|
noise := defaultHydrogenNoiseKG
|
|
for _, endpoint := range first {
|
|
noise = math.Max(noise, endpoint.NoiseKG)
|
|
}
|
|
for _, endpoint := range tail {
|
|
noise = math.Max(noise, endpoint.NoiseKG)
|
|
}
|
|
drop := startMass - endMass
|
|
if drop <= noise {
|
|
return 0
|
|
}
|
|
return drop
|
|
}
|
|
|
|
func hydrogenStreamMedianMass(values []hydrogenStreamEndpoint) float64 {
|
|
masses := make([]float64, len(values))
|
|
for index, value := range values {
|
|
masses[index] = value.MassKG
|
|
}
|
|
sort.Float64s(masses)
|
|
return masses[len(masses)/2]
|
|
}
|
|
|
|
func roundHydrogenKG(value float64) float64 {
|
|
return math.Round(value*1000) / 1000
|
|
}
|
|
|
|
func firstNonEmptyHydrogen(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func HydrogenDensityKGPerM3(pressureMPa, temperatureC float64) (float64, bool) {
|
|
temperatureK := temperatureC + 273.15
|
|
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 {
|
|
return 0, false
|
|
}
|
|
z := 1.0
|
|
for i := range hydrogenDensityA {
|
|
z += hydrogenDensityA[i] * math.Pow(100/temperatureK, hydrogenDensityB[i]) * math.Pow(pressureMPa, hydrogenDensityC[i])
|
|
}
|
|
if z <= 0 {
|
|
return 0, false
|
|
}
|
|
molPerLiter := pressureMPa * 1000 / (universalGasConstant * temperatureK * z)
|
|
return molPerLiter * hydrogenMolarMassKGPerMol * 1000, true
|
|
}
|
|
|
|
func PressureHydrogenMassKG(pressureMPa, temperatureC, tankCapacityLiters float64) (float64, bool) {
|
|
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
|
|
return 0, false
|
|
}
|
|
density, ok := HydrogenDensityKGPerM3(pressureMPa, temperatureC)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
mass := density * tankCapacityLiters / 1000
|
|
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
|
|
}
|
|
|
|
func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64) (HydrogenStreamSample, string, bool) {
|
|
if env.Protocol != envelope.ProtocolGB32960 {
|
|
return HydrogenStreamSample{}, "unsupported_protocol", false
|
|
}
|
|
vin := strings.ToUpper(strings.TrimSpace(env.VIN))
|
|
if len(vin) != 17 {
|
|
return HydrogenStreamSample{}, "invalid_vin", false
|
|
}
|
|
pressure, pressureOK := firstHydrogenNumber(env.Fields, hydrogenPressureFieldKeys)
|
|
if !pressureOK {
|
|
return HydrogenStreamSample{}, "missing_pressure", false
|
|
}
|
|
temperature, temperatureOK := firstHydrogenNumber(env.Fields, hydrogenTemperatureFieldKeys)
|
|
if !temperatureOK {
|
|
return HydrogenStreamSample{}, "missing_temperature", false
|
|
}
|
|
if !validHydrogenPressureTemperature(pressure, temperature) {
|
|
return HydrogenStreamSample{}, "invalid_pressure_temperature", false
|
|
}
|
|
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
|
|
return HydrogenStreamSample{}, "missing_tank_capacity", false
|
|
}
|
|
mass, ok := PressureHydrogenMassKG(pressure, temperature, tankCapacityLiters)
|
|
if !ok {
|
|
return HydrogenStreamSample{}, "invalid_pressure_mass", false
|
|
}
|
|
pressureStepMass, _ := PressureHydrogenMassKG(math.Max(0, pressure-0.2), temperature, tankCapacityLiters)
|
|
noiseKG := math.Max(defaultHydrogenNoiseKG, mass-pressureStepMass)
|
|
noiseKG = math.Min(noiseKG, 1.0)
|
|
refuelThresholdKG := math.Max(1.0, mass*0.05)
|
|
fuelCellActive, fuelCellStateKnown := FuelCellActiveModeFromEnvelope(env)
|
|
if !fuelCellStateKnown {
|
|
if currentA, currentOK := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys); currentOK && currentA >= 0 {
|
|
fuelCellActive = currentA > hydrogenActiveCurrentA
|
|
fuelCellStateKnown = true
|
|
}
|
|
}
|
|
mileageKM, mileageKnown := firstHydrogenNumber(env.Fields, []string{
|
|
envelope.FieldTotalMileageKM, "gb32960.vehicle.total_mileage_km",
|
|
})
|
|
socPercent, socKnown := firstHydrogenNumber(env.Fields, []string{
|
|
envelope.FieldSOCPercent, "gb32960.vehicle.soc_percent",
|
|
})
|
|
vehicleStateValue, vehicleStateKnown := firstHydrogenNumber(env.Fields, []string{
|
|
"vehicle_status", "gb32960.vehicle.vehicle_status",
|
|
})
|
|
chargeStateValue, chargeStateKnown := firstHydrogenNumber(env.Fields, []string{
|
|
"charge_status", "gb32960.vehicle.charge_status",
|
|
})
|
|
runningModeValue, runningModeKnown := firstHydrogenNumber(env.Fields, []string{
|
|
envelope.FieldVehicleRunningMode, "gb32960.vehicle.running_mode",
|
|
})
|
|
fuelCellVoltage, voltageKnown := firstHydrogenNumber(env.Fields, []string{
|
|
"fuel_cell_voltage_v", "gb32960.fuel_cell.fuel_cell_voltage_v",
|
|
})
|
|
fuelCellCurrent, currentKnown := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys)
|
|
fuelCellPowerKnown := voltageKnown && currentKnown && fuelCellVoltage >= 0 && fuelCellCurrent >= 0
|
|
if loc == nil {
|
|
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
|
}
|
|
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
|
|
if !ok {
|
|
return HydrogenStreamSample{}, "missing_event_time", false
|
|
}
|
|
eventTime := time.UnixMilli(eventMS).In(loc)
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
date := eventTime.Format("2006-01-02")
|
|
if date != now.In(loc).Format("2006-01-02") {
|
|
return HydrogenStreamSample{}, "not_current_date", false
|
|
}
|
|
return HydrogenStreamSample{
|
|
VIN: vin, Date: date, SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
|
|
EventID: env.StableEventID(), EventTime: eventTime, MassKG: mass,
|
|
TankCapacityLiters: tankCapacityLiters, PressureMPa: pressure, TemperatureC: temperature,
|
|
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
|
|
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
|
|
ConsumptionEligible: !fuelCellStateKnown || fuelCellActive,
|
|
MileageKM: mileageKM, MileageKnown: mileageKnown,
|
|
SOCPercent: socPercent, SOCKnown: socKnown && socPercent >= 0 && socPercent <= 100,
|
|
VehicleState: int(vehicleStateValue), VehicleStateKnown: vehicleStateKnown,
|
|
ChargeState: int(chargeStateValue), ChargeStateKnown: chargeStateKnown,
|
|
RunningMode: int(runningModeValue), RunningModeKnown: runningModeKnown,
|
|
FuelCellPowerKW: fuelCellVoltage * fuelCellCurrent / 1000, FuelCellPowerKnown: fuelCellPowerKnown,
|
|
}, "", true
|
|
}
|
|
|
|
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 firstHydrogenNumber(fields map[string]any, keys []string) (float64, bool) {
|
|
for _, key := range keys {
|
|
if value, ok := telemetry.Number(fields, key); ok {
|
|
return value, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func AppendHydrogenStream(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64) (HydrogenStreamResult, error) {
|
|
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, now, tankCapacityLiters)
|
|
if !ok {
|
|
result := HydrogenStreamResult{}
|
|
switch reason {
|
|
case "unsupported_protocol", "missing_pressure", "missing_temperature":
|
|
return result, nil
|
|
case "not_current_date":
|
|
result.NotCurrent = 1
|
|
default:
|
|
result.Invalid = 1
|
|
}
|
|
return result, nil
|
|
}
|
|
result := HydrogenStreamResult{Found: 1}
|
|
beginner, ok := exec.(txBeginner)
|
|
if !ok {
|
|
return result, sql.ErrTxDone
|
|
}
|
|
tx, err := beginner.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer tx.Rollback()
|
|
state, found, err := selectHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if found {
|
|
if !state.add(sample) {
|
|
result.Duplicate = 1
|
|
return result, tx.Commit()
|
|
}
|
|
if err := updateHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
|
return result, err
|
|
}
|
|
} else {
|
|
baseline, baselineFound, err := selectHydrogenStreamBaseline(ctx, tx, sample.VIN, sample.Date)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !baselineFound {
|
|
baseline = nil
|
|
}
|
|
state = newHydrogenSegmentStreamState(sample, baseline)
|
|
if err := insertHydrogenSegmentStreamState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
if err := projectHydrogenSegmentStreamDaily(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
|
return result, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return result, err
|
|
}
|
|
result.Written = 1
|
|
return result, nil
|
|
}
|
|
|
|
func selectHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenSegmentStreamState, bool, error) {
|
|
var encoded []byte
|
|
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return hydrogenSegmentStreamState{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return hydrogenSegmentStreamState{}, false, err
|
|
}
|
|
var state hydrogenSegmentStreamState
|
|
if err := json.Unmarshal(encoded, &state); err != nil {
|
|
return hydrogenSegmentStreamState{}, false, err
|
|
}
|
|
return state, true, nil
|
|
}
|
|
|
|
func selectHydrogenStreamBaseline(ctx context.Context, tx *sql.Tx, vin, date string) (*hydrogenStreamBaseline, bool, error) {
|
|
var baseline hydrogenStreamBaseline
|
|
var firstMass, lastMass sql.NullFloat64
|
|
err := tx.QueryRowContext(ctx, selectHydrogenStreamBaselineSQL, vin, date).Scan(
|
|
&baseline.SourceEndpoint, &baseline.ConsumptionKG, &firstMass, &lastMass,
|
|
&baseline.SampleCount, &baseline.RefuelCount, &baseline.QualityStatus,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
baseline.FirstMassKG = -1
|
|
baseline.LastMassKG = -1
|
|
if firstMass.Valid {
|
|
baseline.FirstMassKG = firstMass.Float64
|
|
}
|
|
if lastMass.Valid {
|
|
baseline.LastMassKG = lastMass.Float64
|
|
}
|
|
return &baseline, true, nil
|
|
}
|
|
|
|
func insertHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
|
|
encoded, err := json.Marshal(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qualityStatus, qualityReason := state.quality()
|
|
_, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL,
|
|
vin, date, state.SourceEndpoint, state.FinalizedConsumptionKG, state.projectedConsumptionKG(),
|
|
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EligibleIntervalCount,
|
|
state.QualifiedSegmentCount, state.RemainingMassKG, state.LastEventTime, state.LastEventID,
|
|
encoded, qualityStatus, qualityReason,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func updateHydrogenSegmentStreamState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
|
|
encoded, err := json.Marshal(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qualityStatus, qualityReason := state.quality()
|
|
_, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL,
|
|
state.SourceEndpoint, state.FinalizedConsumptionKG, state.projectedConsumptionKG(),
|
|
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EligibleIntervalCount,
|
|
state.QualifiedSegmentCount, state.RemainingMassKG, state.LastEventTime, state.LastEventID,
|
|
encoded, qualityStatus, qualityReason, vin, date,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func projectHydrogenSegmentStreamDaily(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenSegmentStreamState) error {
|
|
qualityStatus, qualityReason := state.quality()
|
|
_, err := tx.ExecContext(ctx, projectHydrogenSegmentStreamDailySQL,
|
|
vin, date, state.SourceEndpoint, state.projectedConsumptionKG(),
|
|
state.FirstMassKG, state.RemainingMassKG, state.SampleCount, state.RefuelCount,
|
|
qualityStatus, qualityReason,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const selectHydrogenSegmentStreamStateSQL = `SELECT state_json
|
|
FROM vehicle_open_hydrogen_segment_stream_state
|
|
WHERE vin=? AND stat_date=?
|
|
FOR UPDATE`
|
|
|
|
const selectHydrogenStreamBaselineSQL = `SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,
|
|
sample_count,refuel_count,quality_status
|
|
FROM vehicle_open_daily_energy
|
|
WHERE vin=? AND stat_date=? AND energy_type='HYDROGEN'
|
|
LIMIT 1`
|
|
|
|
const insertHydrogenSegmentStreamStateSQL = `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_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`
|
|
|
|
const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state
|
|
SET 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='PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',quality_status=?,quality_reason=?
|
|
WHERE vin=? AND stat_date=?`
|
|
|
|
const projectHydrogenSegmentStreamDailySQL = `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))
|
|
ON DUPLICATE KEY UPDATE
|
|
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),unit='kg',
|
|
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),
|
|
sample_count=VALUES(sample_count),refuel_count=VALUES(refuel_count),
|
|
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)`
|