432 lines
15 KiB
Go
432 lines
15 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
|
|
)
|
|
|
|
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 state.UsableSegmentCount == 0 {
|
|
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
|
|
}
|
|
|
|
// 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`
|