Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/gps_mileage.go

443 lines
16 KiB
Go

package stats
import (
"context"
"database/sql"
"errors"
"math"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
QualityReasonGPSCoordinate = "gps_coordinate_accumulation"
gpsCoordinateSourceSuffix = "#GPS_COORDINATE"
gpsMaxSegmentGap = 10 * time.Minute
gpsMaxImpliedSpeedKMH = 220.0
GPSMinimumDailyDistanceKM = 0.1
)
const GPSMileageStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_gps_mileage_state (
vin VARCHAR(32) NOT NULL,
stat_date DATE NOT NULL,
protocol VARCHAR(32) NOT NULL,
source_key VARCHAR(256) NOT NULL,
source_ip VARCHAR(64) NOT NULL,
source_endpoint VARCHAR(128) NULL,
phone VARCHAR(32) NULL,
device_id VARCHAR(64) NULL,
first_event_time DATETIME(3) NOT NULL,
latest_event_time DATETIME(3) NOT NULL,
latest_event_id VARCHAR(128) NULL,
latest_longitude DOUBLE NOT NULL,
latest_latitude DOUBLE NOT NULL,
daily_mileage_km DECIMAL(18,6) NOT NULL DEFAULT 0,
point_count BIGINT NOT NULL DEFAULT 1,
usable_segment_count BIGINT NOT NULL DEFAULT 0,
bad_jump_count BIGINT NOT NULL DEFAULT 0,
long_gap_count BIGINT NOT NULL DEFAULT 0,
out_of_order_count BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (vin, stat_date, protocol, source_key),
KEY idx_gps_mileage_date (stat_date, protocol, vin)
)`
type GPSMileagePoint struct {
VIN string
StatDate string
Protocol envelope.Protocol
SourceKey string
SourceIP string
SourceEndpoint string
Phone string
DeviceID string
EventID string
EventTime time.Time
Longitude float64
Latitude float64
}
type GPSMileageState struct {
FirstEventTime time.Time
LatestEventTime time.Time
LatestEventID string
LatestLongitude float64
LatestLatitude float64
DailyMileageKM float64
PointCount int64
UsableSegmentCount int64
BadJumpCount int64
LongGapCount int64
OutOfOrderCount int64
}
type GPSMileageSegment struct {
DistanceKM float64
Usable bool
LongGap bool
BadJump bool
OutOfOrder bool
}
func CalculateGPSMileageSegment(previous, current GPSMileagePoint) GPSMileageSegment {
if current.EventTime.IsZero() || previous.EventTime.IsZero() || !current.EventTime.After(previous.EventTime) {
return GPSMileageSegment{OutOfOrder: true}
}
elapsed := current.EventTime.Sub(previous.EventTime)
if elapsed > gpsMaxSegmentGap {
return GPSMileageSegment{LongGap: true}
}
distanceKM := haversineDistanceKM(previous.Latitude, previous.Longitude, current.Latitude, current.Longitude)
impliedSpeed := distanceKM / elapsed.Hours()
if !isFiniteNonNegative(distanceKM) || !isFiniteNonNegative(impliedSpeed) || impliedSpeed > gpsMaxImpliedSpeedKMH {
return GPSMileageSegment{BadJump: true}
}
return GPSMileageSegment{DistanceKM: distanceKM, Usable: true}
}
func GPSMileagePointFromEnvelope(env envelope.FrameEnvelope, identity SourceIdentity, loc *time.Location) (GPSMileagePoint, bool) {
if !supportsGPSMileageFallback(env.Protocol) || strings.TrimSpace(env.VIN) == "" {
return GPSMileagePoint{}, false
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.Fields)
if !ok || !validGPSCoordinate(location.Longitude, location.Latitude) {
return GPSMileagePoint{}, false
}
// JT808 terminals often omit speed together with odometer data, so keep the
// established coordinate-only fallback. GB32960 and Yutong report much more
// frequently; require positive device speed to prevent stationary GPS jitter
// from becoming mileage and to keep the MySQL state path inexpensive.
if env.Protocol != envelope.ProtocolJT808 && (location.SpeedKMH == nil || *location.SpeedKMH <= 0) {
return GPSMileagePoint{}, false
}
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
return GPSMileagePoint{}, false
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventTime := time.UnixMilli(eventMS).In(loc)
sourceKey := SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode)
return GPSMileagePoint{
VIN: strings.TrimSpace(env.VIN),
StatDate: eventTime.Format("2006-01-02"),
Protocol: env.Protocol,
SourceKey: sourceKey,
SourceIP: strings.TrimSpace(identity.SourceIP),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
EventID: strings.TrimSpace(env.EventID),
EventTime: eventTime,
Longitude: location.Longitude,
Latitude: location.Latitude,
}, true
}
func supportsGPSMileageFallback(protocol envelope.Protocol) bool {
switch protocol {
case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT:
return true
default:
return false
}
}
func AccumulateGPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) {
beginner, ok := exec.(txBeginner)
if !ok || strings.TrimSpace(point.VIN) == "" || strings.TrimSpace(point.SourceKey) == "" || point.EventTime.IsZero() {
return GPSMileageState{}, false, nil
}
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return GPSMileageState{}, false, err
}
state, found, err := selectGPSMileageState(ctx, tx, point)
if err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if !found {
baselineMileage, baselineFirstEvent, baselinePointCount, baselineSegmentCount, err := selectExistingGPSMileageBaseline(ctx, tx, point)
if err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
state = GPSMileageState{
FirstEventTime: point.EventTime,
LatestEventTime: point.EventTime,
LatestEventID: point.EventID,
LatestLongitude: point.Longitude,
LatestLatitude: point.Latitude,
DailyMileageKM: baselineMileage,
PointCount: baselinePointCount + 1,
UsableSegmentCount: baselineSegmentCount,
}
if !baselineFirstEvent.IsZero() && baselineFirstEvent.Before(point.EventTime) {
state.FirstEventTime = baselineFirstEvent
}
if _, err := tx.ExecContext(ctx, insertGPSMileageStateSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID,
state.FirstEventTime, point.EventTime, point.EventID, point.Longitude, point.Latitude,
state.DailyMileageKM, state.PointCount, state.UsableSegmentCount,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
segment := CalculateGPSMileageSegment(GPSMileagePoint{
EventTime: state.LatestEventTime,
Longitude: state.LatestLongitude,
Latitude: state.LatestLatitude,
}, point)
if segment.OutOfOrder {
state.OutOfOrderCount++
if _, err := tx.ExecContext(ctx, incrementGPSMileageOutOfOrderSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
state.LatestEventTime = point.EventTime
state.LatestEventID = point.EventID
state.LatestLongitude = point.Longitude
state.LatestLatitude = point.Latitude
state.PointCount++
if segment.Usable {
state.DailyMileageKM += segment.DistanceKM
state.UsableSegmentCount++
}
if segment.BadJump {
state.BadJumpCount++
}
if segment.LongGap {
state.LongGapCount++
}
if _, err := tx.ExecContext(ctx, updateGPSMileageStateSQL,
point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID,
state.LatestEventTime, state.LatestEventID, state.LatestLongitude, state.LatestLatitude,
state.DailyMileageKM, state.PointCount, state.UsableSegmentCount,
state.BadJumpCount, state.LongGapCount, state.OutOfOrderCount,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if !GPSMileageCandidateEligible(state.DailyMileageKM, state.UsableSegmentCount) {
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
if err := upsertGPSMileageCandidate(ctx, tx, point, state); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := projectDailyMileageWithExec(ctx, tx, point.VIN, point.StatDate, point.Protocol); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, true, nil
}
// GPSMileageCandidateEligible keeps sub-100-metre stationary drift and
// momentary speed noise in the durable accumulation state without exposing it
// as a business daily-mileage candidate. Real movement continues accumulating
// and becomes eligible once the evidence crosses the minimum distance.
func GPSMileageCandidateEligible(distanceKM float64, usableSegmentCount int64) bool {
return usableSegmentCount > 0 &&
isFiniteNonNegative(distanceKM) &&
distanceKM >= GPSMinimumDailyDistanceKM
}
// AccumulateJT808GPSMileage remains as a compatibility wrapper for callers and
// operational tooling compiled against the original JT808-only fallback.
func AccumulateJT808GPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) {
return AccumulateGPSMileage(ctx, exec, point)
}
func selectExistingGPSMileageBaseline(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (float64, time.Time, int64, int64, error) {
var mileage sql.NullFloat64
var firstEvent sql.NullTime
var pointCount sql.NullInt64
err := tx.QueryRowContext(ctx, selectExistingGPSMileageBaselineSQL,
point.VIN, point.StatDate, string(point.Protocol), QualityReasonGPSCoordinate,
).Scan(&mileage, &firstEvent, &pointCount)
if errors.Is(err, sql.ErrNoRows) {
return 0, time.Time{}, 0, 0, nil
}
if err != nil {
return 0, time.Time{}, 0, 0, err
}
segments := pointCount.Int64 - 1
if segments < 0 {
segments = 0
}
return mileage.Float64, firstEvent.Time, pointCount.Int64, segments, nil
}
func selectGPSMileageState(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (GPSMileageState, bool, error) {
var state GPSMileageState
err := tx.QueryRowContext(ctx, selectGPSMileageStateSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
).Scan(
&state.FirstEventTime, &state.LatestEventTime, &state.LatestEventID,
&state.LatestLongitude, &state.LatestLatitude, &state.DailyMileageKM,
&state.PointCount, &state.UsableSegmentCount, &state.BadJumpCount,
&state.LongGapCount, &state.OutOfOrderCount,
)
if errors.Is(err, sql.ErrNoRows) {
return GPSMileageState{}, false, nil
}
return state, err == nil, err
}
func upsertGPSMileageCandidate(ctx context.Context, exec Execer, point GPSMileagePoint, state GPSMileageState) error {
sourceIP := strings.TrimSpace(point.SourceIP)
if len(sourceIP) > 56 {
sourceIP = sourceIP[:56]
}
sourceIP += "#gps"
_, err := exec.ExecContext(ctx, upsertGPSMileageCandidateSQL,
point.VIN,
point.StatDate,
string(point.Protocol),
gpsMileageCandidateSourceKey(point),
sourceIP,
point.SourceEndpoint,
point.Phone,
point.DeviceID,
gpsMileagePlatformName(point.Protocol),
0,
state.DailyMileageKM,
state.DailyMileageKM,
state.PointCount,
state.FirstEventTime,
state.LatestEventTime,
QualityOK,
QualityReasonGPSCoordinate,
)
return err
}
func gpsMileagePlatformName(protocol envelope.Protocol) string {
switch protocol {
case envelope.ProtocolGB32960:
return "GB32960 GPS轨迹估算"
case envelope.ProtocolYutongMQTT:
return "宇通 GPS轨迹估算"
default:
return "JT808 GPS轨迹估算"
}
}
func gpsMileageCandidateSourceKey(point GPSMileagePoint) string {
identity := strings.TrimSpace(point.Phone)
if identity == "" {
identity = strings.TrimSpace(point.DeviceID)
}
if identity == "" {
identity = strings.TrimSpace(point.VIN)
}
sourceIP := strings.TrimSpace(point.SourceIP)
if sourceIP != "" {
return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix + ":" + sourceIP
}
return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix
}
func validGPSCoordinate(longitude, latitude float64) bool {
return longitude >= -180 && longitude <= 180 &&
latitude >= -90 && latitude <= 90 &&
(longitude != 0 || latitude != 0) &&
!math.IsNaN(longitude) && !math.IsNaN(latitude) &&
!math.IsInf(longitude, 0) && !math.IsInf(latitude, 0)
}
func isFiniteNonNegative(value float64) bool {
return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func haversineDistanceKM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKM = 6371.0088
toRadians := math.Pi / 180
lat1Rad := lat1 * toRadians
lat2Rad := lat2 * toRadians
dLat := (lat2 - lat1) * toRadians
dLon := (lon2 - lon1) * toRadians
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
const selectGPSMileageStateSQL = `SELECT first_event_time, latest_event_time, COALESCE(latest_event_id, ''),
latest_longitude, latest_latitude, daily_mileage_km, point_count, usable_segment_count,
bad_jump_count, long_gap_count, out_of_order_count
FROM vehicle_daily_gps_mileage_state
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?
FOR UPDATE`
const insertGPSMileageStateSQL = `INSERT INTO vehicle_daily_gps_mileage_state
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id,
first_event_time, latest_event_time, latest_event_id, latest_longitude, latest_latitude,
daily_mileage_km, point_count, usable_segment_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const selectExistingGPSMileageBaselineSQL = `SELECT daily_mileage_km, first_event_time, sample_count
FROM vehicle_daily_mileage_source
WHERE vin = ? AND stat_date = ? AND protocol = ? AND quality_reason = ?
ORDER BY latest_event_time DESC, sample_count DESC
LIMIT 1`
const incrementGPSMileageOutOfOrderSQL = `UPDATE vehicle_daily_gps_mileage_state
SET out_of_order_count = out_of_order_count + 1, updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?`
const updateGPSMileageStateSQL = `UPDATE vehicle_daily_gps_mileage_state
SET source_ip = ?, source_endpoint = ?, phone = ?, device_id = ?,
latest_event_time = ?, latest_event_id = ?, latest_longitude = ?, latest_latitude = ?,
daily_mileage_km = ?, point_count = ?, usable_segment_count = ?,
bad_jump_count = ?, long_gap_count = ?, out_of_order_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?`
const upsertGPSMileageCandidateSQL = `INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
phone = VALUES(phone),
device_id = VALUES(device_id),
platform_name = VALUES(platform_name),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
updated_at = CURRENT_TIMESTAMP`