feat: 推广 V3.5 实时氢耗并完善导出

This commit is contained in:
lingniu
2026-09-04 15:42:02 +08:00
parent a26179c8fe
commit e402eb5e60
53 changed files with 2855 additions and 320 deletions
@@ -0,0 +1,780 @@
package stats
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
HydrogenV35RealtimeAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"
hydrogenV35EnergyKWhPerKG = 16.0
hydrogenV35MixedMinimumKM = 10.0
hydrogenV35MixedConfirmSamples = 3
hydrogenV35MixedConfirmWindow = 30 * time.Second
hydrogenV35ContinuityGap = 5 * time.Minute
hydrogenV35PowerIntegrationGap = 2 * time.Minute
hydrogenV35RefuelObservationWindow = 10 * time.Minute
hydrogenV35RefuelThermalWindow = 30 * time.Minute
hydrogenV35RefuelSustain = 60 * time.Second
hydrogenV35RefuelRiseMPa = 3.0
hydrogenV35RefuelTemperatureRiseC = 3.0
)
// HydrogenRealtimeParameters are the effective-dated vehicle parameters used by
// the V3.5 preview. The physical pressure/temperature result remains available
// without a battery capacity, but SOC-balanced results are then marked suspect.
type HydrogenRealtimeParameters struct {
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
HydrogenEnergyKWhKG float64 `json:"hydrogenEnergyKWhPerKg"`
}
func normalizeHydrogenRealtimeParameters(value HydrogenRealtimeParameters) HydrogenRealtimeParameters {
if value.HydrogenEnergyKWhKG <= 0 {
value.HydrogenEnergyKWhKG = hydrogenV35EnergyKWhPerKG
}
return value
}
// LoadHydrogenRealtimeParameters loads the currently effective parameter row
// for every VIN. Rows are ordered newest-first so a business override wins over
// the oldest automatically synchronized model value.
func LoadHydrogenRealtimeParameters(ctx context.Context, query Queryer, date string) (map[string]HydrogenRealtimeParameters, error) {
result := map[string]HydrogenRealtimeParameters{}
if query == nil {
return result, errors.New("hydrogen parameter query is unavailable")
}
rows, err := query.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, date)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin string
var value HydrogenRealtimeParameters
if err := rows.Scan(&vin, &value.BatteryCapacityKWh, &value.HydrogenEnergyKWhKG); err != nil {
return nil, err
}
vin = strings.ToUpper(strings.TrimSpace(vin))
if len(vin) != 17 {
continue
}
if _, exists := result[vin]; !exists {
result[vin] = normalizeHydrogenRealtimeParameters(value)
}
}
return result, rows.Err()
}
type hydrogenV35Point struct {
EventID string `json:"eventId"`
ObservedAt time.Time `json:"observedAt"`
MassKG float64 `json:"massKg"`
PressureMPa float64 `json:"pressureMpa"`
TemperatureC float64 `json:"temperatureC"`
NoiseKG float64 `json:"noiseKg"`
MileageKM float64 `json:"mileageKm"`
MileageKnown bool `json:"mileageKnown"`
SOCPercent float64 `json:"socPercent"`
SOCKnown bool `json:"socKnown"`
RunningMode int `json:"runningMode"`
FuelCellActive bool `json:"fuelCellActive"`
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
FuelCellPowerKW float64 `json:"fuelCellPowerKw"`
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
}
func hydrogenV35PointFromSample(sample HydrogenStreamSample) hydrogenV35Point {
return hydrogenV35Point{
EventID: sample.EventID, ObservedAt: sample.EventTime, MassKG: sample.MassKG,
PressureMPa: sample.PressureMPa, TemperatureC: sample.TemperatureC, NoiseKG: sample.NoiseKG,
MileageKM: sample.MileageKM, MileageKnown: sample.MileageKnown,
SOCPercent: sample.SOCPercent, SOCKnown: sample.SOCKnown, RunningMode: sample.RunningMode,
FuelCellActive: sample.FuelCellActive, FuelCellStateKnown: sample.FuelCellStateKnown,
FuelCellPowerKW: sample.FuelCellPowerKW, FuelCellPowerKnown: sample.FuelCellPowerKnown,
}
}
type hydrogenV35CycleState struct {
First hydrogenV35Point `json:"first"`
Last hydrogenV35Point `json:"last"`
HasRun bool `json:"hasRun"`
StartsPure bool `json:"startsPure"`
MixedLocked bool `json:"mixedLocked"`
MixedConfirmed bool `json:"mixedConfirmed"`
MixedStart hydrogenV35Point `json:"mixedStart"`
MixedCandidate hydrogenV35Point `json:"mixedCandidate"`
MixedCandidateSet bool `json:"mixedCandidateSet"`
MixedCandidateN int `json:"mixedCandidateCount"`
MixedCandidateAt time.Time `json:"mixedCandidateLastAt"`
MixedEnergyKWh float64 `json:"mixedEnergyKWh"`
CandidateEnergyKWh float64 `json:"candidateEnergyKWh"`
}
type hydrogenV35RefuelCandidate struct {
Set bool `json:"set"`
Boundary hydrogenV35Point `json:"boundary"`
BaselinePressureMPa float64 `json:"baselinePressureMpa"`
BaselineMassKG float64 `json:"baselineMassKg"`
HighSamples int `json:"highSamples"`
PreviousHydrogen hydrogenV35Point `json:"previousHydrogen"`
PreviousHydrogenExists bool `json:"previousHydrogenExists"`
FirstHydrogenAfter hydrogenV35Point `json:"firstHydrogenAfter"`
HydrogenRunsAfter int64 `json:"hydrogenRunsAfter"`
}
type hydrogenV35RealtimeState struct {
AlgorithmVersion string `json:"algorithmVersion"`
SourceEndpoint string `json:"sourceEndpoint"`
SampleCount int64 `json:"sampleCount"`
LastEventID string `json:"lastEventId"`
LastEventTime time.Time `json:"lastEventTime"`
Parameters HydrogenRealtimeParameters `json:"parameters"`
FirstMassKG float64 `json:"firstMassKg"`
LastMassKG float64 `json:"lastMassKg"`
FirstEffective hydrogenV35Point `json:"firstEffective"`
LastEffective hydrogenV35Point `json:"lastEffective"`
HasEffective bool `json:"hasEffective"`
EffectiveRunCount int64 `json:"effectiveRunCount"`
LastBoundaryWasEffective bool `json:"lastBoundaryWasEffective"`
Cycle hydrogenV35CycleState `json:"cycle"`
InitialStateUsed bool `json:"initialStateUsed"`
Charging bool `json:"charging"`
ChargeCount int64 `json:"chargeCount"`
ChargeStartSOC float64 `json:"chargeStartSoc"`
ChargeLastSOC float64 `json:"chargeLastSoc"`
ChargeSOCKnown bool `json:"chargeSocKnown"`
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
CompletedPureKM float64 `json:"completedPureKm"`
CompletedMixedKM float64 `json:"completedMixedKm"`
CompletedSOCDelta float64 `json:"completedSocDelta"`
CompletedMixedEnergyKWh float64 `json:"completedMixedEnergyKWh"`
CompletedMixedCycles int64 `json:"completedMixedCycles"`
HydrogenSegmentStart hydrogenV35Point `json:"hydrogenSegmentStart"`
LastHydrogenRun hydrogenV35Point `json:"lastHydrogenRun"`
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
HydrogenSegmentRuns int64 `json:"hydrogenSegmentRuns"`
FinalizedHydrogenKG float64 `json:"finalizedHydrogenKg"`
HydrogenSegments int64 `json:"hydrogenSegments"`
RefuelCount int64 `json:"refuelCount"`
RefuelAmountKG float64 `json:"refuelAmountKg"`
RefuelCandidate hydrogenV35RefuelCandidate `json:"refuelCandidate"`
MinimumPressureMPa float64 `json:"minimumPressureMpa"`
MinimumMassKG float64 `json:"minimumMassKg"`
MinimumObservedAt time.Time `json:"minimumObservedAt"`
PreviousSample hydrogenV35Point `json:"previousSample"`
HasPreviousSample bool `json:"hasPreviousSample"`
AbnormalDropCount int64 `json:"abnormalDropCount"`
InvalidSampleCount int64 `json:"invalidSampleCount"`
PurePressurePeakMPa float64 `json:"purePressurePeakMpa"`
PurePressurePeakAt time.Time `json:"purePressurePeakAt"`
HasPurePressurePeak bool `json:"hasPurePressurePeak"`
InactiveStart hydrogenV35Point `json:"inactiveStart"`
InactiveLast hydrogenV35Point `json:"inactiveLast"`
InactiveCount int `json:"inactiveCount"`
SuspectedLeakCount int64 `json:"suspectedLeakCount"`
SuspectedLeakMaxMPa float64 `json:"suspectedLeakMaxMpa"`
}
type hydrogenV35Projection struct {
ConsumptionKG float64
SOCDeltaPercent *float64
BatteryEnergyChangeKWh *float64
ElectricEquivalentKG *float64
CorrectedConsumptionKG *float64
PureMileageKM float64
MixedMileageKM float64
ConsumptionPer100KM *float64
CorrectedPer100KM *float64
QualityStatus string
QualityReason string
FuelCellEnergyKG float64
ValidSegmentCount int64
}
func newHydrogenV35RealtimeState(sample HydrogenStreamSample, params HydrogenRealtimeParameters) hydrogenV35RealtimeState {
params = normalizeHydrogenRealtimeParameters(params)
state := hydrogenV35RealtimeState{
AlgorithmVersion: HydrogenV35RealtimeAlgorithmVersion,
SourceEndpoint: sample.SourceEndpoint, Parameters: params,
FirstMassKG: sample.MassKG, LastMassKG: sample.MassKG,
}
state.add(sample)
return state
}
func (state *hydrogenV35RealtimeState) add(sample HydrogenStreamSample) bool {
if !state.LastEventTime.IsZero() && !sample.EventTime.After(state.LastEventTime) {
return false
}
state.AlgorithmVersion = HydrogenV35RealtimeAlgorithmVersion
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, state.SourceEndpoint)
state.SampleCount++
state.LastEventID, state.LastEventTime = sample.EventID, sample.EventTime
point := hydrogenV35PointFromSample(sample)
pressureInvalid := state.updatePurePressureValidity(sample)
if pressureInvalid {
state.InvalidSampleCount++
}
state.updateLeak(point)
state.updateRefuel(sample, point)
effective := sample.VehicleStateKnown && sample.VehicleState == 1 &&
!(sample.ChargeStateKnown && sample.ChargeState == 1) && !pressureInvalid &&
sample.MileageKnown && sample.SOCKnown && sample.RunningModeKnown
externalCharging := sample.ChargeStateKnown && sample.ChargeState == 1 && sample.VehicleStateKnown &&
(sample.VehicleState == 1 || sample.VehicleState == 2)
if externalCharging {
state.startOrContinueCharge(sample)
state.LastBoundaryWasEffective = false
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
if state.Charging {
state.finishCharge()
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
}
if !effective {
state.LastBoundaryWasEffective = false
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
previousEffective, compareAbnormalDrop := state.LastEffective, state.LastBoundaryWasEffective
if state.HasEffective && sample.EventTime.Sub(state.LastEffective.ObservedAt) > hydrogenV35ContinuityGap {
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
}
if !state.HasEffective {
state.FirstEffective = point
state.FirstMassKG = point.MassKG
state.HasEffective = true
}
state.LastEffective = point
state.LastMassKG = point.MassKG
state.EffectiveRunCount++
state.addCycleRun(point)
if !sample.FuelCellStateKnown || sample.FuelCellActive {
state.addHydrogenRun(point)
}
if compareAbnormalDrop && previousEffective.MassKG-point.MassKG > defaultHydrogenMaxDropKG {
state.AbnormalDropCount++
}
state.LastBoundaryWasEffective = true
state.PreviousSample, state.HasPreviousSample = point, true
return true
}
func (state *hydrogenV35RealtimeState) updatePurePressureValidity(sample HydrogenStreamSample) bool {
if !sample.RunningModeKnown || sample.RunningMode != 1 {
state.HasPurePressurePeak = false
return false
}
if !state.HasPurePressurePeak || sample.EventTime.Sub(state.PurePressurePeakAt) > 30*time.Minute {
state.PurePressurePeakMPa, state.PurePressurePeakAt, state.HasPurePressurePeak = sample.PressureMPa, sample.EventTime, true
return false
}
invalid := state.PurePressurePeakMPa-sample.PressureMPa > 5
if sample.PressureMPa > state.PurePressurePeakMPa {
state.PurePressurePeakMPa, state.PurePressurePeakAt = sample.PressureMPa, sample.EventTime
}
return invalid
}
func (state *hydrogenV35RealtimeState) startOrContinueCharge(sample HydrogenStreamSample) {
if !state.Charging {
state.finishCycle()
state.Cycle = hydrogenV35CycleState{}
state.Charging = true
state.ChargeCount++
state.ChargeSOCKnown = sample.SOCKnown
state.ChargeStartSOC, state.ChargeLastSOC = sample.SOCPercent, sample.SOCPercent
return
}
if sample.SOCKnown {
if !state.ChargeSOCKnown {
state.ChargeStartSOC = sample.SOCPercent
state.ChargeSOCKnown = true
}
state.ChargeLastSOC = sample.SOCPercent
}
}
func (state *hydrogenV35RealtimeState) finishCharge() {
if state.ChargeSOCKnown && state.Parameters.BatteryCapacityKWh > 0 && state.ChargeLastSOC > state.ChargeStartSOC {
state.ChargeEnergyKWh += state.Parameters.BatteryCapacityKWh * (state.ChargeLastSOC - state.ChargeStartSOC) / 100
}
state.Charging, state.ChargeSOCKnown = false, false
state.InitialStateUsed = true
}
func (state *hydrogenV35RealtimeState) addCycleRun(point hydrogenV35Point) {
cycle := &state.Cycle
if !cycle.HasRun {
cycle.HasRun, cycle.First, cycle.Last = true, point, point
cycle.MixedLocked = !state.InitialStateUsed
cycle.StartsPure = point.RunningMode == 1 && !cycle.MixedLocked
if cycle.MixedLocked || !cycle.StartsPure {
cycle.MixedConfirmed, cycle.MixedStart = true, point
}
state.InitialStateUsed = true
return
}
previous := cycle.Last
cycle.Last = point
if cycle.MixedConfirmed {
cycle.MixedEnergyKWh += hydrogenV35PowerBetween(previous, point)
return
}
if point.RunningMode != 2 || (cycle.MixedCandidateSet && point.ObservedAt.Sub(cycle.MixedCandidateAt) > hydrogenV35MixedConfirmWindow) {
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
}
if point.RunningMode != 2 {
return
}
if !cycle.MixedCandidateSet {
cycle.MixedCandidate, cycle.MixedCandidateSet = point, true
cycle.MixedCandidateN = 1
cycle.CandidateEnergyKWh = 0
} else {
cycle.MixedCandidateN++
cycle.CandidateEnergyKWh += hydrogenV35PowerBetween(previous, point)
}
cycle.MixedCandidateAt = point.ObservedAt
if cycle.MixedCandidateN >= hydrogenV35MixedConfirmSamples {
cycle.MixedConfirmed = true
cycle.MixedStart = cycle.MixedCandidate
cycle.MixedEnergyKWh = cycle.CandidateEnergyKWh
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
}
}
func hydrogenV35PowerBetween(previous, current hydrogenV35Point) float64 {
gap := current.ObservedAt.Sub(previous.ObservedAt)
if gap <= 0 || gap > hydrogenV35PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown {
return 0
}
return (math.Max(0, previous.FuelCellPowerKW) + math.Max(0, current.FuelCellPowerKW)) / 2 * gap.Hours()
}
func hydrogenV35CycleTotals(cycle hydrogenV35CycleState) (pureKM, mixedKM, socDelta, energyKWh float64, mixed bool) {
if !cycle.HasRun {
return 0, 0, 0, 0, false
}
if !cycle.MixedConfirmed {
if cycle.StartsPure {
return math.Max(0, cycle.Last.MileageKM-cycle.First.MileageKM), 0, 0, 0, false
}
return 0, 0, 0, 0, false
}
if cycle.StartsPure {
pureKM = math.Max(0, cycle.MixedStart.MileageKM-cycle.First.MileageKM)
}
mixedKM = math.Max(0, cycle.Last.MileageKM-cycle.MixedStart.MileageKM)
socDelta = cycle.Last.SOCPercent - cycle.MixedStart.SOCPercent
return pureKM, mixedKM, socDelta, cycle.MixedEnergyKWh, true
}
func (state *hydrogenV35RealtimeState) finishCycle() {
pureKM, mixedKM, socDelta, energyKWh, mixed := hydrogenV35CycleTotals(state.Cycle)
state.CompletedPureKM += pureKM
state.CompletedMixedKM += mixedKM
state.CompletedSOCDelta += socDelta
state.CompletedMixedEnergyKWh += energyKWh
if mixed {
state.CompletedMixedCycles++
}
}
func (state *hydrogenV35RealtimeState) addHydrogenRun(point hydrogenV35Point) {
if state.RefuelCandidate.Set && !point.ObservedAt.Before(state.RefuelCandidate.Boundary.ObservedAt) {
if state.RefuelCandidate.HydrogenRunsAfter == 0 {
state.RefuelCandidate.FirstHydrogenAfter = point
}
state.RefuelCandidate.HydrogenRunsAfter++
}
if !state.HasHydrogenSegment {
state.HydrogenSegmentStart, state.LastHydrogenRun, state.HasHydrogenSegment = point, point, true
state.HydrogenSegmentRuns = 1
return
}
state.LastHydrogenRun = point
state.HydrogenSegmentRuns++
}
func (state *hydrogenV35RealtimeState) updateRefuel(sample HydrogenStreamSample, point hydrogenV35Point) {
if state.MinimumObservedAt.IsZero() || sample.EventTime.Sub(state.MinimumObservedAt) > hydrogenV35RefuelObservationWindow {
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
}
if state.RefuelCandidate.Set {
age := sample.EventTime.Sub(state.RefuelCandidate.Boundary.ObservedAt)
if age > hydrogenV35RefuelObservationWindow || sample.PressureMPa-state.RefuelCandidate.BaselinePressureMPa < hydrogenV35RefuelRiseMPa {
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
} else {
state.RefuelCandidate.HighSamples++
if age >= hydrogenV35RefuelSustain || state.RefuelCandidate.HighSamples >= hydrogenV35MixedConfirmSamples {
state.confirmRefuel(point)
}
}
}
if sample.PressureMPa < state.MinimumPressureMPa {
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
}
thermalRefuel := state.thermalRefuelContext(sample)
ordinaryRefuel := sample.PressureMPa-state.MinimumPressureMPa >= hydrogenV35RefuelRiseMPa && state.refuelContext(sample)
if state.RefuelCandidate.Set || (!ordinaryRefuel && !thermalRefuel) {
return
}
baselinePressure, baselineMass := state.MinimumPressureMPa, state.MinimumMassKG
if thermalRefuel {
baselinePressure, baselineMass = state.PreviousSample.PressureMPa, state.PreviousSample.MassKG
}
candidate := hydrogenV35RefuelCandidate{
Set: true, Boundary: point, BaselinePressureMPa: baselinePressure,
BaselineMassKG: baselineMass, HighSamples: 1,
}
if state.HasHydrogenSegment {
candidate.PreviousHydrogen, candidate.PreviousHydrogenExists = state.LastHydrogenRun, true
}
state.RefuelCandidate = candidate
}
func (state *hydrogenV35RealtimeState) thermalRefuelContext(sample HydrogenStreamSample) bool {
if !state.HasPreviousSample || !sample.MileageKnown || !state.PreviousSample.MileageKnown {
return false
}
gap := sample.EventTime.Sub(state.PreviousSample.ObservedAt)
return gap > 0 && gap <= hydrogenV35RefuelThermalWindow &&
sample.PressureMPa-state.PreviousSample.PressureMPa >= hydrogenV35RefuelRiseMPa &&
sample.TemperatureC-state.PreviousSample.TemperatureC >= hydrogenV35RefuelTemperatureRiseC &&
math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 5
}
func (state *hydrogenV35RealtimeState) refuelContext(sample HydrogenStreamSample) bool {
if !state.HasPreviousSample {
return false
}
if sample.MileageKnown && state.PreviousSample.MileageKnown {
return math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 0.2
}
return (sample.VehicleStateKnown && sample.VehicleState == 2) || (sample.FuelCellStateKnown && !sample.FuelCellActive)
}
func (state *hydrogenV35RealtimeState) confirmRefuel(current hydrogenV35Point) {
candidate := state.RefuelCandidate
if candidate.PreviousHydrogenExists && state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
drop := state.HydrogenSegmentStart.MassKG - candidate.PreviousHydrogen.MassKG
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, candidate.PreviousHydrogen.NoiseKG) {
drop = 0
}
state.FinalizedHydrogenKG += drop
state.HydrogenSegments++
}
state.RefuelCount++
state.RefuelAmountKG += math.Max(0, current.MassKG-candidate.BaselineMassKG)
if candidate.HydrogenRunsAfter > 0 {
state.HydrogenSegmentStart = candidate.FirstHydrogenAfter
state.HasHydrogenSegment = true
state.HydrogenSegmentRuns = candidate.HydrogenRunsAfter
} else {
state.HasHydrogenSegment = false
state.HydrogenSegmentRuns = 0
}
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = current.PressureMPa, current.MassKG, current.ObservedAt
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
}
func (state *hydrogenV35RealtimeState) updateLeak(point hydrogenV35Point) {
inactive := point.FuelCellStateKnown && !point.FuelCellActive
if !inactive {
state.finishInactiveLeak()
return
}
if state.InactiveCount > 0 && point.ObservedAt.Sub(state.InactiveLast.ObservedAt) > hydrogenV35ContinuityGap {
state.finishInactiveLeak()
}
if state.InactiveCount == 0 {
state.InactiveStart = point
}
state.InactiveLast = point
state.InactiveCount++
}
func (state *hydrogenV35RealtimeState) finishInactiveLeak() {
if state.InactiveCount >= 3 && state.InactiveLast.ObservedAt.Sub(state.InactiveStart.ObservedAt) >= 5*time.Minute {
pressureDrop := state.InactiveStart.PressureMPa - state.InactiveLast.PressureMPa
massDrop := state.InactiveStart.MassKG - state.InactiveLast.MassKG
if pressureDrop >= 0.5 && massDrop >= math.Max(0.05, math.Max(state.InactiveStart.NoiseKG, state.InactiveLast.NoiseKG)) {
state.SuspectedLeakCount++
state.SuspectedLeakMaxMPa = math.Max(state.SuspectedLeakMaxMPa, pressureDrop)
}
}
state.InactiveCount = 0
}
func (state hydrogenV35RealtimeState) projection() hydrogenV35Projection {
// Evaluate an open inactive segment at the current watermark on this copy.
// Persisted state remains open so later frames can continue the same segment.
state.finishInactiveLeak()
pureKM, mixedKM, socDelta, mixedEnergyKWh, currentMixed := hydrogenV35CycleTotals(state.Cycle)
pureKM += state.CompletedPureKM
mixedKM += state.CompletedMixedKM
socDelta += state.CompletedSOCDelta
mixedEnergyKWh += state.CompletedMixedEnergyKWh
mixedCycles := state.CompletedMixedCycles
if currentMixed {
mixedCycles++
}
physical := state.FinalizedHydrogenKG
hydrogenSegments := state.HydrogenSegments
if state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
drop := state.HydrogenSegmentStart.MassKG - state.LastHydrogenRun.MassKG
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, state.LastHydrogenRun.NoiseKG) {
drop = 0
}
physical += drop
hydrogenSegments++
}
params := normalizeHydrogenRealtimeParameters(state.Parameters)
fuelCellEnergyKG := mixedEnergyKWh / params.HydrogenEnergyKWhKG
electricEquivalentKG := 0.0
if params.BatteryCapacityKWh > 0 {
electricEquivalentKG = params.BatteryCapacityKWh * socDelta / 100 / params.HydrogenEnergyKWhKG
}
usedEnergyFallback := mixedKM >= hydrogenV35MixedMinimumKM && fuelCellEnergyKG > 0 && (physical <= 0 || physical-electricEquivalentKG <= 0)
if usedEnergyFallback {
physical = math.Max(physical, fuelCellEnergyKG)
}
corrected := math.Max(0, physical-electricEquivalentKG)
result := hydrogenV35Projection{
ConsumptionKG: roundHydrogenKG(physical), PureMileageKM: roundHydrogenKG(pureKM), MixedMileageKM: roundHydrogenKG(mixedKM),
FuelCellEnergyKG: roundHydrogenKG(fuelCellEnergyKG), ValidSegmentCount: hydrogenSegments, QualityStatus: "OK",
}
if params.BatteryCapacityKWh > 0 {
soc, battery, equivalent, balanced := roundHydrogenKG(socDelta), roundHydrogenKG(params.BatteryCapacityKWh*socDelta/100), roundHydrogenKG(electricEquivalentKG), roundHydrogenKG(corrected)
result.SOCDeltaPercent, result.BatteryEnergyChangeKWh = &soc, &battery
result.ElectricEquivalentKG, result.CorrectedConsumptionKG = &equivalent, &balanced
}
if mixedKM > 0 {
physicalRate := roundHydrogenKG(physical * 100 / mixedKM)
result.ConsumptionPer100KM = &physicalRate
if result.CorrectedConsumptionKG != nil {
correctedRate := roundHydrogenKG(corrected * 100 / mixedKM)
result.CorrectedPer100KM = &correctedRate
}
}
reasons := make([]string, 0, 5)
totalMileage := 0.0
if state.HasEffective {
totalMileage = state.LastEffective.MileageKM - state.FirstEffective.MileageKM
}
switch {
case state.EffectiveRunCount < 2:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "有效运行分界点不足2条")
case totalMileage < 1:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前有效总里程不足1km")
case mixedCycles == 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "尚未形成确认的用氢混动周期")
case mixedKM < hydrogenV35MixedMinimumKM:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前混动里程不足10km")
case hydrogenSegments == 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "燃料电池有效工作氢量边界不足")
case corrected <= 0:
result.QualityStatus, reasons = "NO_DATA", append(reasons, "现有报文不足以形成可信的正耗氢结果")
}
if result.QualityStatus != "NO_DATA" && params.BatteryCapacityKWh <= 0 {
result.QualityStatus, reasons = "SUSPECT", append(reasons, "车型动力电池容量未确认")
}
if state.AbnormalDropCount > 0 {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "检测到氢量异常大幅下降")
}
if state.SuspectedLeakCount > 0 {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "检测到燃料电池未工作期间持续压降")
}
if usedEnergyFallback {
if result.QualityStatus == "OK" {
result.QualityStatus = "SUSPECT"
}
reasons = append(reasons, "压力边界不足,暂用燃料电池功率积分折算")
}
if result.QualityStatus == "NO_DATA" {
result.ConsumptionPer100KM = nil
result.CorrectedPer100KM = nil
}
result.QualityReason = strings.Join(reasons, "")
return result
}
func AppendHydrogenV35Realtime(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64, params HydrogenRealtimeParameters) (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 := selectHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date)
if err != nil {
return result, err
}
if !found || state.AlgorithmVersion != HydrogenV35RealtimeAlgorithmVersion {
state = newHydrogenV35RealtimeState(sample, params)
if err := insertHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
} else {
if params.BatteryCapacityKWh > 0 {
state.Parameters = normalizeHydrogenRealtimeParameters(params)
} else {
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
}
if !state.add(sample) {
result.Duplicate = 1
return result, tx.Commit()
}
if err := updateHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
return result, err
}
}
if err := projectHydrogenV35Realtime(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 selectHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenV35RealtimeState, bool, error) {
var encoded []byte
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
if errors.Is(err, sql.ErrNoRows) {
return hydrogenV35RealtimeState{}, false, nil
}
if err != nil {
return hydrogenV35RealtimeState{}, false, err
}
var state hydrogenV35RealtimeState
if err := json.Unmarshal(encoded, &state); err != nil {
return hydrogenV35RealtimeState{}, false, nil
}
return state, true, nil
}
func insertHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
projection := state.projection()
_, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL,
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded, projection.QualityStatus, projection.QualityReason)
return err
}
func updateHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
encoded, err := json.Marshal(state)
if err != nil {
return err
}
projection := state.projection()
_, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL,
state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded,
projection.QualityStatus, projection.QualityReason, vin, date)
return err
}
func projectHydrogenV35Realtime(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
projection := state.projection()
parameters, err := json.Marshal(map[string]any{
"batteryCapacityKWh": state.Parameters.BatteryCapacityKWh,
"hydrogenEnergyKWhPerKg": normalizeHydrogenRealtimeParameters(state.Parameters).HydrogenEnergyKWhKG,
"algorithmVersion": HydrogenV35RealtimeAlgorithmVersion,
})
if err != nil {
return err
}
evidence, err := json.Marshal(map[string]any{
"calculationPhase": "PRELIMINARY", "lastEventId": state.LastEventID,
"lastEventTime": state.LastEventTime, "refuelAmountKg": roundHydrogenKG(state.RefuelAmountKG),
"suspectedLeakCount": state.SuspectedLeakCount, "fuelCellEnergyHydrogenKg": projection.FuelCellEnergyKG,
})
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, projectHydrogenV35RealtimeSQL,
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
projection.SOCDeltaPercent, projection.BatteryEnergyChangeKWh, projection.ElectricEquivalentKG,
projection.CorrectedConsumptionKG, projection.MixedMileageKM, projection.PureMileageKM,
projection.ConsumptionPer100KM, projection.CorrectedPer100KM,
state.FirstMassKG, state.LastMassKG, state.SampleCount, state.RefuelCount, state.ChargeCount,
projection.ValidSegmentCount, state.InvalidSampleCount, HydrogenV35RealtimeAlgorithmVersion,
parameters, evidence, projection.QualityStatus, projection.QualityReason)
return err
}
const projectHydrogenV35RealtimeSQL = `INSERT INTO vehicle_open_daily_energy(
vin,stat_date,energy_type,source_endpoint,consumption_kg,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,
unit,first_mass_kg,last_mass_kg,sample_count,refuel_count,charge_count,valid_segment_count,
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json,
quality_status,quality_reason,calculated_at
) VALUES(?,?,'HYDROGEN',?,?,?,?,?,?,?,?,?,?,?,'kg',?,?,?,?,?,?,?,?,'PRELIMINARY',?,?,?,?,NOW(3))
ON DUPLICATE KEY UPDATE
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),raw_consumption_kg=VALUES(raw_consumption_kg),
battery_soc_delta_pct=VALUES(battery_soc_delta_pct),battery_discharge_kwh=VALUES(battery_discharge_kwh),
battery_equivalent_kg=VALUES(battery_equivalent_kg),soc_balanced_consumption_kg=VALUES(soc_balanced_consumption_kg),
mixed_mileage_km=VALUES(mixed_mileage_km),pure_electric_mileage_km=VALUES(pure_electric_mileage_km),
consumption_kg_per_100km=VALUES(consumption_kg_per_100km),soc_balanced_kg_per_100km=VALUES(soc_balanced_kg_per_100km),
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),sample_count=VALUES(sample_count),
refuel_count=VALUES(refuel_count),charge_count=VALUES(charge_count),valid_segment_count=VALUES(valid_segment_count),
invalid_segment_count=VALUES(invalid_segment_count),algorithm_version=VALUES(algorithm_version),
calculation_phase='PRELIMINARY',parameter_json=VALUES(parameter_json),evidence_json=VALUES(evidence_json),
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)`