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)`