功能:完善里程边界与氢耗流式统计

This commit is contained in:
lingniu
2026-09-02 14:05:04 +08:00
parent 4201878c35
commit 8759668d39
23 changed files with 1638 additions and 159 deletions
@@ -13,6 +13,7 @@ const (
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
QualityReasonTotalRollback = "TOTAL_MILEAGE_ROLLBACK"
QualityReasonHistorical = "historical_source_baseline"
QualityReasonCurrentDayFirst = "current_day_first_baseline"
QualityReasonStationaryCarry = "stationary_carry_forward"
@@ -136,6 +137,26 @@ func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM
return currentDayLatestKM - previousBaselineKM
}
// IsUsableDailyMileageBoundary reports whether a persisted baseline belongs to
// the current natural day or its immediately preceding natural day. A baseline
// from an older day represents an offline gap; using it would attribute the
// whole gap to the day on which the vehicle came back online.
func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *time.Location) bool {
if strings.TrimSpace(statDate) == "" || baselineTime.IsZero() {
return false
}
if loc == nil {
loc = baselineTime.Location()
}
day, err := time.ParseInLocation("2006-01-02", statDate, loc)
if err != nil {
return false
}
baseline := baselineTime.In(loc)
baselineDay := time.Date(baseline.Year(), baseline.Month(), baseline.Day(), 0, 0, 0, 0, loc)
return !baselineDay.Before(day.AddDate(0, 0, -1)) && !baselineDay.After(day)
}
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
return 0, true, "negative_jitter_clamped"
@@ -444,8 +465,6 @@ const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL +
const upsertSourcePureHydrogenIncrementSQL = `CASE
WHEN latest_event_time IS NOT NULL
AND VALUES(latest_event_time) > latest_event_time
AND latest_pure_hydrogen_mode_known = 1
AND latest_pure_hydrogen_active = 1
AND VALUES(latest_pure_hydrogen_mode_known) = 1
AND VALUES(latest_pure_hydrogen_active) = 1
AND VALUES(latest_total_mileage_km) >= latest_total_mileage_km
@@ -731,9 +750,32 @@ const selectedSourceIdentitySampleCountSQL = `(
= COALESCE(NULLIF(TRIM(s2.phone), ''), NULLIF(TRIM(s2.device_id), ''), s2.source_key)
)`
// projectDayEndTotalMileageSQL intentionally uses an independent source choice
// from the daily-mileage projection. GPS coordinate accumulation can be the
// best evidence for distance travelled during the day, but it must never
// replace a terminal-reported odometer as the day-end cumulative mileage.
const projectDayEndTotalMileageSQL = `COALESCE((
SELECT total_candidate.latest_total_mileage_km
FROM vehicle_daily_mileage_source total_candidate
WHERE total_candidate.vin = s.vin
AND total_candidate.stat_date = s.stat_date
AND total_candidate.protocol = s.protocol
AND total_candidate.quality_status = '` + QualityOK + `'
AND total_candidate.latest_total_mileage_km IS NOT NULL
AND total_candidate.latest_total_mileage_km >= 0
ORDER BY
CASE WHEN COALESCE(total_candidate.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1 ELSE 0 END,
CASE WHEN total_candidate.latest_total_mileage_km > total_candidate.daily_mileage_km THEN 0 ELSE 1 END,
total_candidate.is_selected DESC,
total_candidate.latest_event_time DESC,
total_candidate.sample_count DESC,
total_candidate.source_key ASC
LIMIT 1
), s.latest_total_mileage_km)`
const projectDailyMileageSQL = `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km)
(vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km, day_end_total_mileage_km)
SELECT
s.vin,
s.stat_date,
@@ -741,7 +783,8 @@ SELECT
ds.id,
s.daily_mileage_km,
LEAST(s.pure_hydrogen_mileage_km, s.daily_mileage_km),
s.latest_total_mileage_km
s.latest_total_mileage_km,
` + projectDayEndTotalMileageSQL + `
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
@@ -774,6 +817,7 @@ ON DUPLICATE KEY UPDATE
daily_mileage_km = VALUES(daily_mileage_km),
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
day_end_total_mileage_km = VALUES(day_end_total_mileage_km),
updated_at = CURRENT_TIMESTAMP
`
@@ -866,6 +910,35 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
}, latestTotal.Valid, nil
}
// lookupPreviousProtocolBaseline is deliberately source-key independent. A
// device replacement must not allow a near-zero odometer to become a normal
// continuation of the same vehicle/protocol history.
func lookupPreviousProtocolBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol) (sourceBaseline, bool, error) {
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return sourceBaseline{}, false, nil
}
rows, err := query.QueryContext(ctx, previousProtocolBaselineSQL, vin, statDate, string(protocol))
if err != nil {
return sourceBaseline{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{}, false, nil
}
var total sql.NullFloat64
var event sql.NullTime
if err := rows.Scan(&total, &event); err != nil {
return sourceBaseline{}, false, err
}
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{LatestTotalKM: total.Float64, LatestEventTime: event.Time, QualityReason: QualityReasonHistorical}, total.Valid, nil
}
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
// same VIN, protocol and source before statDate. Missing calendar days are
// skipped automatically.
@@ -889,3 +962,14 @@ WHERE vin = ?
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`
const previousProtocolBaselineSQL = `
SELECT latest_total_mileage_km, latest_event_time
FROM vehicle_daily_mileage_source
WHERE vin=? AND stat_date<? AND protocol=?
AND quality_status='OK'
AND latest_total_mileage_km IS NOT NULL AND latest_total_mileage_km>0
AND latest_event_time IS NOT NULL
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`