Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime.go
T

781 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)`