feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenBrandVINs(ctx context.Context, db *sql.DB, brand string) ([]string, error) {
|
||||
brand = strings.TrimSpace(brand)
|
||||
if brand == "" {
|
||||
return nil, errors.New("hydrogen vehicle brand is required")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin))
|
||||
FROM vehicle_profile
|
||||
WHERE TRIM(brand_name)=?
|
||||
ORDER BY vin`, brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
vins := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if validHydrogenVIN(vin) {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
return vins, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
|
||||
FROM vehicle_hydrogen_energy_parameter
|
||||
@@ -811,7 +838,7 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, false)
|
||||
}
|
||||
|
||||
// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment
|
||||
@@ -819,7 +846,7 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta
|
||||
// included in the rebuild, so Kafka backlog already covered by the rebuild is
|
||||
// ignored instead of counted twice.
|
||||
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, true)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, true)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error {
|
||||
@@ -827,10 +854,30 @@ func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, []string{vin}, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
func ReplaceHydrogenDailyStatsForVINs(ctx context.Context, db *sql.DB, date string, vins []string, stats []HydrogenDailyStat) error {
|
||||
normalized := make([]string, 0, len(vins))
|
||||
seen := make(map[string]struct{}, len(vins))
|
||||
for _, vin := range vins {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
normalized = append(normalized, vin)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return errors.New("at least one scoped VIN is required")
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, normalized, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, scopedVINs []string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -839,12 +886,26 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
// A pressure-based rebuild is authoritative for the whole day. Delete every
|
||||
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
|
||||
// for vehicles without valid pressure observations in this run.
|
||||
if vin == "" {
|
||||
allowedVINs := make(map[string]struct{}, len(scopedVINs))
|
||||
for _, vin := range scopedVINs {
|
||||
allowedVINs[vin] = struct{}{}
|
||||
}
|
||||
if len(scopedVINs) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(scopedVINs) == 1 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, scopedVINs[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(scopedVINs)), ",")
|
||||
args := make([]any, 0, len(scopedVINs)+1)
|
||||
args = append(args, date)
|
||||
for _, vin := range scopedVINs {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (`+placeholders+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
}
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) {
|
||||
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin)
|
||||
statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN))
|
||||
if len(allowedVINs) > 0 {
|
||||
if _, allowed := allowedVINs[statVIN]; !allowed {
|
||||
return fmt.Errorf("refusing to write VIN %q outside scoped rebuild", stat.VIN)
|
||||
}
|
||||
}
|
||||
parameterJSON, err := json.Marshal(stat.CalculationParameters)
|
||||
if err != nil {
|
||||
@@ -872,8 +936,8 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
|
||||
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
|
||||
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
|
||||
invalid_segment_count,algorithm_version,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
|
||||
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
|
||||
@@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if seedStream {
|
||||
// A NO_DATA row can legitimately have no effective boundary observation.
|
||||
// Keep its daily result, but do not write a zero timestamp into the stream
|
||||
// watermark table; the next valid realtime frame will create the state.
|
||||
if seedStream && !stat.LastObservation.ObservedAt.IsZero() {
|
||||
stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -893,10 +960,10 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`,
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount,
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "",
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, stat.LastObservation.EventID,
|
||||
stateJSON, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
@@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
}
|
||||
|
||||
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
|
||||
type segmentWindow struct {
|
||||
Count int `json:"count"`
|
||||
First []any `json:"first"`
|
||||
Tail []any `json:"tail"`
|
||||
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"`
|
||||
type point 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"`
|
||||
}
|
||||
toPoint := func(value HydrogenObservation) point {
|
||||
return point{
|
||||
EventID: value.EventID, ObservedAt: value.ObservedAt, MassKg: value.MassKg,
|
||||
PressureMpa: value.PressureMPa, TemperatureC: value.TemperatureC, NoiseKg: value.NoiseKg,
|
||||
MileageKm: value.MileageKm, MileageKnown: value.MileageKnown,
|
||||
SocPercent: value.SOCPercent, SocKnown: value.SOCKnown, RunningMode: value.RunningMode,
|
||||
FuelCellActive: value.FuelCellActive, FuelCellStateKnown: value.FuelCellStateKnown,
|
||||
FuelCellPowerKw: value.FuelCellVoltageV * value.FuelCellCurrentA / 1000,
|
||||
FuelCellPowerKnown: value.FuelCellPowerKnown,
|
||||
}
|
||||
}
|
||||
first, last := toPoint(stat.FirstObservation), toPoint(stat.LastObservation)
|
||||
lastIntervalType := ""
|
||||
mixedCycles := 0
|
||||
for _, interval := range stat.Intervals {
|
||||
if interval.Type == "MIXED" {
|
||||
mixedCycles++
|
||||
}
|
||||
lastIntervalType = interval.Type
|
||||
}
|
||||
cycle := map[string]any{
|
||||
"first": last, "last": last, "hasRun": !last.ObservedAt.IsZero(),
|
||||
"startsPure": lastIntervalType == "PURE_ELECTRIC", "mixedLocked": lastIntervalType == "MIXED",
|
||||
"mixedConfirmed": lastIntervalType == "MIXED", "mixedStart": last,
|
||||
}
|
||||
state := struct {
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
EligibleIntervalCount int `json:"eligibleIntervalCount"`
|
||||
QualifiedSegmentCount int `json:"qualifiedSegmentCount"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
RemainingMassKg float64 `json:"remainingMassKg"`
|
||||
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"`
|
||||
LastObservedMassKg float64 `json:"lastObservedMassKg"`
|
||||
TankCapacityLiters float64 `json:"tankCapacityLiters"`
|
||||
FirstPressureMpa float64 `json:"firstPressureMpa"`
|
||||
LastPressureMpa float64 `json:"lastPressureMpa"`
|
||||
FirstTemperatureC float64 `json:"firstTemperatureC"`
|
||||
LastTemperatureC float64 `json:"lastTemperatureC"`
|
||||
FirstEventTime time.Time `json:"firstEventTime"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastFuelCellActive bool `json:"lastFuelCellActive"`
|
||||
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"`
|
||||
Segment segmentWindow `json:"segment"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
Parameters map[string]float64 `json:"parameters"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
LastMassKg float64 `json:"lastMassKg"`
|
||||
FirstEffective point `json:"firstEffective"`
|
||||
LastEffective point `json:"lastEffective"`
|
||||
HasEffective bool `json:"hasEffective"`
|
||||
EffectiveRunCount int `json:"effectiveRunCount"`
|
||||
Cycle map[string]any `json:"cycle"`
|
||||
InitialStateUsed bool `json:"initialStateUsed"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
|
||||
CompletedPureKm float64 `json:"completedPureKm"`
|
||||
CompletedMixedKm float64 `json:"completedMixedKm"`
|
||||
CompletedSOCDelta float64 `json:"completedSocDelta"`
|
||||
CompletedMixedCycles int `json:"completedMixedCycles"`
|
||||
HydrogenSegmentStart point `json:"hydrogenSegmentStart"`
|
||||
LastHydrogenRun point `json:"lastHydrogenRun"`
|
||||
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
|
||||
HydrogenSegmentRuns int `json:"hydrogenSegmentRuns"`
|
||||
FinalizedHydrogenKg float64 `json:"finalizedHydrogenKg"`
|
||||
HydrogenSegments int `json:"hydrogenSegments"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
RefuelAmountKg float64 `json:"refuelAmountKg"`
|
||||
MinimumPressureMpa float64 `json:"minimumPressureMpa"`
|
||||
MinimumMassKg float64 `json:"minimumMassKg"`
|
||||
MinimumObservedAt time.Time `json:"minimumObservedAt"`
|
||||
PreviousSample point `json:"previousSample"`
|
||||
HasPreviousSample bool `json:"hasPreviousSample"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
InvalidSampleCount int `json:"invalidSampleCount"`
|
||||
SuspectedLeakCount int `json:"suspectedLeakCount"`
|
||||
SuspectedLeakMaxMpa float64 `json:"suspectedLeakMaxMpa"`
|
||||
}{
|
||||
SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg,
|
||||
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount,
|
||||
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount,
|
||||
QualifiedSegmentCount: stat.QualifiedSegmentCount,
|
||||
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg,
|
||||
MetadataCycleMinimumKg: stat.CycleMinimumMassKg,
|
||||
LastObservedMassKg: stat.LastObservation.MassKg,
|
||||
TankCapacityLiters: stat.LastObservation.TankCapacityLiter,
|
||||
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa,
|
||||
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC,
|
||||
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
LastFuelCellActive: stat.LastObservation.FuelCellActive,
|
||||
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown,
|
||||
Segment: segmentWindow{First: []any{}, Tail: []any{}},
|
||||
AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source,
|
||||
SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg},
|
||||
FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg,
|
||||
FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount,
|
||||
Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh,
|
||||
CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm,
|
||||
CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles,
|
||||
HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1,
|
||||
FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount,
|
||||
RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg,
|
||||
MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt,
|
||||
PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(),
|
||||
AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount,
|
||||
SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa,
|
||||
}
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
func valueOrZero(value *float64) float64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
Reference in New Issue
Block a user