refactor: simplify jt808 mileage statistics
This commit is contained in:
@@ -85,10 +85,6 @@ lingniu:
|
||||
state-store: ${VEHICLE_STAT_JT808_STATE_STORE:redis}
|
||||
redis-key-prefix: ${VEHICLE_STAT_JT808_REDIS_KEY_PREFIX:vehicle:mileage:jt808:daily:}
|
||||
state-ttl-days: ${VEHICLE_STAT_JT808_STATE_TTL_DAYS:3}
|
||||
max-segment-gap-seconds: ${VEHICLE_STAT_JT808_MAX_SEGMENT_GAP_SECONDS:300}
|
||||
max-implied-speed-kmh: ${VEHICLE_STAT_JT808_MAX_IMPLIED_SPEED_KMH:200}
|
||||
short-gap-seconds: ${VEHICLE_STAT_JT808_SHORT_GAP_SECONDS:10}
|
||||
short-gap-jump-meters: ${VEHICLE_STAT_JT808_SHORT_GAP_JUMP_METERS:300}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -14,9 +14,6 @@ import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRule;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.InMemoryJt808MileageStateStore;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.JdbcJt808DailyMileageRepository;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808DailyMileageRepository;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808GpsMileageCalculator;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808LocationPointExtractor;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStateStore;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStreamProcessor;
|
||||
@@ -33,7 +30,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
@@ -99,26 +95,6 @@ public class VehicleStatAutoConfiguration {
|
||||
return new Jt808LocationPointExtractor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808GpsMileageCalculator jt808GpsMileageCalculator(VehicleStatProperties props) {
|
||||
VehicleStatProperties.Jt808 jt808 = props.getJt808();
|
||||
return new Jt808GpsMileageCalculator(
|
||||
Duration.ofSeconds(jt808.getMaxSegmentGapSeconds()),
|
||||
jt808.getMaxImpliedSpeedKmh(),
|
||||
Duration.ofSeconds(jt808.getShortGapSeconds()),
|
||||
jt808.getShortGapJumpMeters());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808DailyMileageRepository jt808DailyMileageRepository(JdbcTemplate jdbcTemplate) {
|
||||
return new JdbcJt808DailyMileageRepository(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({StringRedisTemplate.class, ObjectMapper.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
|
||||
@@ -140,15 +116,14 @@ public class VehicleStatAutoConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({Jt808LocationPointExtractor.class, Jt808GpsMileageCalculator.class,
|
||||
Jt808MileageStateStore.class, Jt808DailyMileageRepository.class})
|
||||
@ConditionalOnBean({Jt808LocationPointExtractor.class, Jt808MileageStateStore.class,
|
||||
VehicleStatRepository.class})
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808MileageStreamProcessor jt808MileageStreamProcessor(Jt808LocationPointExtractor extractor,
|
||||
Jt808GpsMileageCalculator calculator,
|
||||
Jt808MileageStateStore stateStore,
|
||||
Jt808DailyMileageRepository repository,
|
||||
VehicleStatRepository repository,
|
||||
VehicleStatProperties props) {
|
||||
return new Jt808MileageStreamProcessor(extractor, calculator, stateStore, repository,
|
||||
return new Jt808MileageStreamProcessor(extractor, stateStore, repository,
|
||||
ZoneId.of(props.getZoneId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@ public class VehicleStatProperties {
|
||||
private String stateStore = "memory";
|
||||
private String redisKeyPrefix = "vehicle:mileage:jt808:daily:";
|
||||
private long stateTtlDays = 3;
|
||||
private long maxSegmentGapSeconds = 300;
|
||||
private double maxImpliedSpeedKmh = 200.0;
|
||||
private long shortGapSeconds = 10;
|
||||
private double shortGapJumpMeters = 300.0;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
@@ -79,36 +75,5 @@ public class VehicleStatProperties {
|
||||
this.stateTtlDays = stateTtlDays;
|
||||
}
|
||||
|
||||
public long getMaxSegmentGapSeconds() {
|
||||
return maxSegmentGapSeconds;
|
||||
}
|
||||
|
||||
public void setMaxSegmentGapSeconds(long maxSegmentGapSeconds) {
|
||||
this.maxSegmentGapSeconds = maxSegmentGapSeconds;
|
||||
}
|
||||
|
||||
public double getMaxImpliedSpeedKmh() {
|
||||
return maxImpliedSpeedKmh;
|
||||
}
|
||||
|
||||
public void setMaxImpliedSpeedKmh(double maxImpliedSpeedKmh) {
|
||||
this.maxImpliedSpeedKmh = maxImpliedSpeedKmh;
|
||||
}
|
||||
|
||||
public long getShortGapSeconds() {
|
||||
return shortGapSeconds;
|
||||
}
|
||||
|
||||
public void setShortGapSeconds(long shortGapSeconds) {
|
||||
this.shortGapSeconds = shortGapSeconds;
|
||||
}
|
||||
|
||||
public double getShortGapJumpMeters() {
|
||||
return shortGapJumpMeters;
|
||||
}
|
||||
|
||||
public void setShortGapJumpMeters(double shortGapJumpMeters) {
|
||||
this.shortGapJumpMeters = shortGapJumpMeters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class JdbcJt808DailyMileageRepository implements Jt808DailyMileageRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public JdbcJt808DailyMileageRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc must not be null");
|
||||
initializeSchema();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void upsert(Jt808DailyMileageResult result) {
|
||||
jdbc.update("""
|
||||
insert into vehicle_daily_mileage_jt808 (
|
||||
stat_date, vehicle_key, vin, phone, first_event_time, last_event_time,
|
||||
daily_mileage_km, mileage_source, gps_mileage_km, speed_integral_km,
|
||||
odometer_mileage_km, first_total_mileage_km, last_total_mileage_km,
|
||||
accepted_points, bad_jump_segments, long_gap_segments, out_of_order_points,
|
||||
odometer_anomalies, data_quality
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on duplicate key update
|
||||
vin = values(vin),
|
||||
phone = values(phone),
|
||||
first_event_time = values(first_event_time),
|
||||
last_event_time = values(last_event_time),
|
||||
daily_mileage_km = values(daily_mileage_km),
|
||||
mileage_source = values(mileage_source),
|
||||
gps_mileage_km = values(gps_mileage_km),
|
||||
speed_integral_km = values(speed_integral_km),
|
||||
odometer_mileage_km = values(odometer_mileage_km),
|
||||
first_total_mileage_km = values(first_total_mileage_km),
|
||||
last_total_mileage_km = values(last_total_mileage_km),
|
||||
accepted_points = values(accepted_points),
|
||||
bad_jump_segments = values(bad_jump_segments),
|
||||
long_gap_segments = values(long_gap_segments),
|
||||
out_of_order_points = values(out_of_order_points),
|
||||
odometer_anomalies = values(odometer_anomalies),
|
||||
data_quality = values(data_quality),
|
||||
updated_at = current_timestamp
|
||||
""",
|
||||
result.statDate(),
|
||||
result.vehicleKey(),
|
||||
result.vin(),
|
||||
result.phone(),
|
||||
timestamp(result.firstEventTime()),
|
||||
timestamp(result.lastEventTime()),
|
||||
result.dailyMileageKm(),
|
||||
result.mileageSource(),
|
||||
result.gpsMileageKm(),
|
||||
result.speedIntegralKm(),
|
||||
result.odometerMileageKm(),
|
||||
result.firstTotalMileageKm(),
|
||||
result.lastTotalMileageKm(),
|
||||
result.acceptedPoints(),
|
||||
result.badJumpSegments(),
|
||||
result.longGapSegments(),
|
||||
result.outOfOrderPoints(),
|
||||
result.odometerAnomalies(),
|
||||
result.dataQuality());
|
||||
}
|
||||
|
||||
private void initializeSchema() {
|
||||
jdbc.execute("""
|
||||
create table if not exists vehicle_daily_mileage_jt808 (
|
||||
stat_date date not null,
|
||||
vehicle_key varchar(64) not null,
|
||||
vin varchar(32) null,
|
||||
phone varchar(32) null,
|
||||
first_event_time timestamp null,
|
||||
last_event_time timestamp null,
|
||||
daily_mileage_km decimal(12,3) not null default 0,
|
||||
mileage_source varchar(32) not null default 'INSUFFICIENT',
|
||||
gps_mileage_km decimal(12,3) not null,
|
||||
speed_integral_km decimal(12,3) not null,
|
||||
odometer_mileage_km decimal(12,3) null,
|
||||
first_total_mileage_km decimal(12,3) null,
|
||||
last_total_mileage_km decimal(12,3) null,
|
||||
accepted_points int not null,
|
||||
bad_jump_segments int not null,
|
||||
long_gap_segments int not null,
|
||||
out_of_order_points int not null,
|
||||
odometer_anomalies int not null,
|
||||
data_quality varchar(16) not null,
|
||||
updated_at timestamp not null default current_timestamp on update current_timestamp,
|
||||
primary key (stat_date, vehicle_key),
|
||||
key idx_vehicle_daily_mileage_jt808_vehicle_date (vehicle_key, stat_date),
|
||||
key idx_vehicle_daily_mileage_jt808_vin_date (vin, stat_date),
|
||||
key idx_vehicle_daily_mileage_jt808_phone_date (phone, stat_date)
|
||||
)
|
||||
""");
|
||||
ensureColumn("daily_mileage_km", "daily_mileage_km decimal(12,3) not null default 0");
|
||||
ensureColumn("mileage_source", "mileage_source varchar(32) not null default 'INSUFFICIENT'");
|
||||
ensureColumn("first_total_mileage_km", "first_total_mileage_km decimal(12,3) null");
|
||||
ensureColumn("last_total_mileage_km", "last_total_mileage_km decimal(12,3) null");
|
||||
}
|
||||
|
||||
private void ensureColumn(String columnName, String definition) {
|
||||
if (!columnExists(columnName)) {
|
||||
jdbc.execute("alter table vehicle_daily_mileage_jt808 add column " + definition);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean columnExists(String columnName) {
|
||||
Integer count = jdbc.queryForObject("""
|
||||
select count(*)
|
||||
from information_schema.columns
|
||||
where lower(table_name) = ?
|
||||
and lower(column_name) = ?
|
||||
""", Integer.class,
|
||||
"vehicle_daily_mileage_jt808",
|
||||
columnName.toLowerCase(Locale.ROOT));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private static Timestamp timestamp(Instant instant) {
|
||||
return instant == null ? null : Timestamp.from(instant);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
public interface Jt808DailyMileageRepository {
|
||||
|
||||
void upsert(Jt808DailyMileageResult result);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record Jt808DailyMileageResult(
|
||||
LocalDate statDate,
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
Instant firstEventTime,
|
||||
Instant lastEventTime,
|
||||
double dailyMileageKm,
|
||||
String mileageSource,
|
||||
double gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
Double odometerMileageKm,
|
||||
Double firstTotalMileageKm,
|
||||
Double lastTotalMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies,
|
||||
String dataQuality
|
||||
) {}
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public final class Jt808DailyMileageState {
|
||||
|
||||
@@ -11,15 +16,11 @@ public final class Jt808DailyMileageState {
|
||||
private final LocalDate statDate;
|
||||
private Instant firstEventTime;
|
||||
private Instant lastEventTime;
|
||||
private Jt808LocationPoint lastPoint;
|
||||
private double firstTotalMileageKm = Double.NaN;
|
||||
private double lastTotalMileageKm = Double.NaN;
|
||||
private double gpsMileageKm;
|
||||
private double speedIntegralKm;
|
||||
private double odometerMileageKm;
|
||||
private int acceptedPoints;
|
||||
private int badJumpSegments;
|
||||
private int longGapSegments;
|
||||
private int totalMileageSamples;
|
||||
private int outOfOrderPoints;
|
||||
private int odometerAnomalies;
|
||||
|
||||
@@ -47,100 +48,60 @@ public final class Jt808DailyMileageState {
|
||||
LocalDate statDate,
|
||||
Instant firstEventTime,
|
||||
Instant lastEventTime,
|
||||
Jt808LocationPoint lastPoint,
|
||||
double firstTotalMileageKm,
|
||||
double lastTotalMileageKm,
|
||||
double gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
double odometerMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
int totalMileageSamples,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies) {
|
||||
Jt808DailyMileageState state = new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
|
||||
state.firstEventTime = firstEventTime;
|
||||
state.lastEventTime = lastEventTime;
|
||||
state.lastPoint = lastPoint;
|
||||
state.firstTotalMileageKm = firstTotalMileageKm;
|
||||
state.lastTotalMileageKm = lastTotalMileageKm;
|
||||
state.gpsMileageKm = gpsMileageKm;
|
||||
state.speedIntegralKm = speedIntegralKm;
|
||||
state.odometerMileageKm = odometerMileageKm;
|
||||
state.acceptedPoints = acceptedPoints;
|
||||
state.badJumpSegments = badJumpSegments;
|
||||
state.longGapSegments = longGapSegments;
|
||||
state.totalMileageSamples = totalMileageSamples;
|
||||
state.outOfOrderPoints = outOfOrderPoints;
|
||||
state.odometerAnomalies = odometerAnomalies;
|
||||
return state;
|
||||
}
|
||||
|
||||
public void apply(Jt808LocationPoint point, Jt808GpsMileageCalculator calculator) {
|
||||
if (point == null || calculator == null) {
|
||||
public void apply(Jt808LocationPoint point) {
|
||||
if (point == null) {
|
||||
return;
|
||||
}
|
||||
if (lastPoint != null && !point.eventTime().isAfter(lastPoint.eventTime())) {
|
||||
if (lastEventTime != null && !point.eventTime().isAfter(lastEventTime)) {
|
||||
outOfOrderPoints++;
|
||||
return;
|
||||
}
|
||||
if (lastPoint == null) {
|
||||
if (firstEventTime == null) {
|
||||
firstEventTime = point.eventTime();
|
||||
updateOdometer(point, calculator);
|
||||
accept(point);
|
||||
return;
|
||||
}
|
||||
|
||||
Jt808GpsMileageCalculator.SegmentResult segment = calculator.calculateSegment(lastPoint, point);
|
||||
if (segment.outOfOrder()) {
|
||||
outOfOrderPoints++;
|
||||
return;
|
||||
}
|
||||
if (segment.longGap()) {
|
||||
longGapSegments++;
|
||||
} else if (segment.badJump()) {
|
||||
badJumpSegments++;
|
||||
} else if (segment.used()) {
|
||||
gpsMileageKm += segment.gpsKm();
|
||||
speedIntegralKm += segment.speedIntegralKm();
|
||||
}
|
||||
updateOdometer(point, calculator);
|
||||
accept(point);
|
||||
}
|
||||
|
||||
public Jt808DailyMileageResult toResult() {
|
||||
return new Jt808DailyMileageResult(
|
||||
statDate,
|
||||
vehicleKey,
|
||||
vin,
|
||||
phone,
|
||||
firstEventTime,
|
||||
lastEventTime,
|
||||
dailyMileageKm(),
|
||||
mileageSource(),
|
||||
gpsMileageKm,
|
||||
speedIntegralKm,
|
||||
odometerMileageKm > 0.0 ? odometerMileageKm : null,
|
||||
nullable(firstTotalMileageKm),
|
||||
nullable(lastTotalMileageKm),
|
||||
acceptedPoints,
|
||||
badJumpSegments,
|
||||
longGapSegments,
|
||||
outOfOrderPoints,
|
||||
odometerAnomalies,
|
||||
dataQuality());
|
||||
}
|
||||
|
||||
private void accept(Jt808LocationPoint point) {
|
||||
lastPoint = point;
|
||||
updateOdometer(point);
|
||||
lastEventTime = point.eventTime();
|
||||
acceptedPoints++;
|
||||
}
|
||||
|
||||
private void updateOdometer(Jt808LocationPoint point, Jt808GpsMileageCalculator calculator) {
|
||||
public Optional<VehicleDailyStatResult> toVehicleDailyStatResult() {
|
||||
OptionalDouble dailyMileage = totalMileageDifference();
|
||||
if (dailyMileage.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new VehicleDailyStatResult(
|
||||
statVehicleId(),
|
||||
statDate,
|
||||
dailyMileage,
|
||||
DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
|
||||
}
|
||||
|
||||
private void updateOdometer(Jt808LocationPoint point) {
|
||||
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
|
||||
return;
|
||||
}
|
||||
double current = point.totalMileageKm();
|
||||
totalMileageSamples++;
|
||||
if (!Double.isFinite(firstTotalMileageKm)) {
|
||||
firstTotalMileageKm = current;
|
||||
}
|
||||
@@ -151,61 +112,28 @@ public final class Jt808DailyMileageState {
|
||||
if (current < lastTotalMileageKm) {
|
||||
odometerAnomalies++;
|
||||
lastTotalMileageKm = current;
|
||||
odometerMileageKm = 0.0;
|
||||
return;
|
||||
}
|
||||
double delta = current - lastTotalMileageKm;
|
||||
if (lastPoint != null && !calculator.plausibleOdometerDelta(lastPoint, point, delta)) {
|
||||
odometerAnomalies++;
|
||||
lastTotalMileageKm = current;
|
||||
return;
|
||||
}
|
||||
odometerMileageKm += delta;
|
||||
lastTotalMileageKm = current;
|
||||
odometerMileageKm = current - firstTotalMileageKm;
|
||||
}
|
||||
|
||||
private double dailyMileageKm() {
|
||||
if (hasUsableTotalMileage()) {
|
||||
return odometerMileageKm;
|
||||
private OptionalDouble totalMileageDifference() {
|
||||
if (totalMileageSamples < 2
|
||||
|| !Double.isFinite(firstTotalMileageKm)
|
||||
|| !Double.isFinite(lastTotalMileageKm)
|
||||
|| lastTotalMileageKm < firstTotalMileageKm) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
if (gpsMileageKm > 0.0) {
|
||||
return gpsMileageKm;
|
||||
}
|
||||
return 0.0;
|
||||
return OptionalDouble.of(lastTotalMileageKm - firstTotalMileageKm);
|
||||
}
|
||||
|
||||
private String mileageSource() {
|
||||
if (hasUsableTotalMileage()) {
|
||||
return odometerAnomalies > 0 ? "JT808_TOTAL_MILEAGE_PARTIAL" : "JT808_TOTAL_MILEAGE";
|
||||
private String statVehicleId() {
|
||||
if (!vin.isBlank() && !"unknown".equalsIgnoreCase(vin)) {
|
||||
return vin;
|
||||
}
|
||||
if (gpsMileageKm > 0.0) {
|
||||
return "GPS_DISTANCE";
|
||||
}
|
||||
return "INSUFFICIENT";
|
||||
}
|
||||
|
||||
private boolean hasUsableTotalMileage() {
|
||||
if (odometerMileageKm > 0.0) {
|
||||
return true;
|
||||
}
|
||||
return acceptedPoints >= 2
|
||||
&& odometerAnomalies == 0
|
||||
&& Double.isFinite(firstTotalMileageKm)
|
||||
&& Double.isFinite(lastTotalMileageKm)
|
||||
&& lastTotalMileageKm >= firstTotalMileageKm;
|
||||
}
|
||||
|
||||
private static Double nullable(double value) {
|
||||
return Double.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
private String dataQuality() {
|
||||
if (acceptedPoints < 2) {
|
||||
return "PARTIAL";
|
||||
}
|
||||
if (badJumpSegments > 0 || longGapSegments > 0 || odometerAnomalies > 0 || vehicleKey.startsWith("jt808:")) {
|
||||
return "PARTIAL";
|
||||
}
|
||||
return "GOOD";
|
||||
return vehicleKey;
|
||||
}
|
||||
|
||||
public String vehicleKey() {
|
||||
@@ -232,18 +160,6 @@ public final class Jt808DailyMileageState {
|
||||
return lastEventTime;
|
||||
}
|
||||
|
||||
public Jt808LocationPoint lastPoint() {
|
||||
return lastPoint;
|
||||
}
|
||||
|
||||
public double gpsMileageKm() {
|
||||
return gpsMileageKm;
|
||||
}
|
||||
|
||||
public double speedIntegralKm() {
|
||||
return speedIntegralKm;
|
||||
}
|
||||
|
||||
public double odometerMileageKm() {
|
||||
return odometerMileageKm;
|
||||
}
|
||||
@@ -260,12 +176,8 @@ public final class Jt808DailyMileageState {
|
||||
return acceptedPoints;
|
||||
}
|
||||
|
||||
public int badJumpSegments() {
|
||||
return badJumpSegments;
|
||||
}
|
||||
|
||||
public int longGapSegments() {
|
||||
return longGapSegments;
|
||||
public int totalMileageSamples() {
|
||||
return totalMileageSamples;
|
||||
}
|
||||
|
||||
public int outOfOrderPoints() {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public final class Jt808GpsMileageCalculator {
|
||||
|
||||
private static final double EARTH_RADIUS_KM = 6371.0088;
|
||||
private static final double ODOMETER_DELTA_TOLERANCE_KM = 0.1;
|
||||
|
||||
private final Duration maxSegmentGap;
|
||||
private final double maxImpliedSpeedKmh;
|
||||
private final Duration shortGap;
|
||||
private final double shortGapJumpMeters;
|
||||
|
||||
public Jt808GpsMileageCalculator(Duration maxSegmentGap,
|
||||
double maxImpliedSpeedKmh,
|
||||
Duration shortGap,
|
||||
double shortGapJumpMeters) {
|
||||
if (maxSegmentGap == null || maxSegmentGap.isNegative() || maxSegmentGap.isZero()) {
|
||||
throw new IllegalArgumentException("maxSegmentGap must be positive");
|
||||
}
|
||||
if (shortGap == null || shortGap.isNegative() || shortGap.isZero()) {
|
||||
throw new IllegalArgumentException("shortGap must be positive");
|
||||
}
|
||||
if (!Double.isFinite(maxImpliedSpeedKmh) || maxImpliedSpeedKmh <= 0) {
|
||||
throw new IllegalArgumentException("maxImpliedSpeedKmh must be positive");
|
||||
}
|
||||
if (!Double.isFinite(shortGapJumpMeters) || shortGapJumpMeters <= 0) {
|
||||
throw new IllegalArgumentException("shortGapJumpMeters must be positive");
|
||||
}
|
||||
this.maxSegmentGap = maxSegmentGap;
|
||||
this.maxImpliedSpeedKmh = maxImpliedSpeedKmh;
|
||||
this.shortGap = shortGap;
|
||||
this.shortGapJumpMeters = shortGapJumpMeters;
|
||||
}
|
||||
|
||||
public SegmentResult calculateSegment(Jt808LocationPoint previous, Jt808LocationPoint current) {
|
||||
if (previous == null || current == null) {
|
||||
return SegmentResult.rejectedOutOfOrder();
|
||||
}
|
||||
Duration elapsed = Duration.between(previous.eventTime(), current.eventTime());
|
||||
if (elapsed.isZero() || elapsed.isNegative()) {
|
||||
return SegmentResult.rejectedOutOfOrder();
|
||||
}
|
||||
if (elapsed.compareTo(maxSegmentGap) > 0) {
|
||||
return SegmentResult.rejectedLongGap();
|
||||
}
|
||||
|
||||
double gpsKm = haversineKm(previous.longitude(), previous.latitude(), current.longitude(), current.latitude());
|
||||
double hours = elapsed.toMillis() / 3_600_000.0;
|
||||
double impliedSpeed = gpsKm / hours;
|
||||
if (impliedSpeed > maxImpliedSpeedKmh
|
||||
|| (elapsed.compareTo(shortGap) <= 0 && gpsKm * 1000.0 > shortGapJumpMeters)) {
|
||||
return SegmentResult.rejectedBadJump();
|
||||
}
|
||||
|
||||
double speedIntegralKm = 0.0;
|
||||
if (previous.speedKmh() != null && current.speedKmh() != null
|
||||
&& Double.isFinite(previous.speedKmh()) && Double.isFinite(current.speedKmh())) {
|
||||
speedIntegralKm = ((previous.speedKmh() + current.speedKmh()) / 2.0) * hours;
|
||||
}
|
||||
return new SegmentResult(gpsKm, speedIntegralKm, true, false, false, false);
|
||||
}
|
||||
|
||||
public boolean plausibleOdometerDelta(Jt808LocationPoint previous,
|
||||
Jt808LocationPoint current,
|
||||
double deltaKm) {
|
||||
if (previous == null || current == null || !Double.isFinite(deltaKm) || deltaKm < 0.0) {
|
||||
return false;
|
||||
}
|
||||
Duration elapsed = Duration.between(previous.eventTime(), current.eventTime());
|
||||
if (elapsed.isZero() || elapsed.isNegative()) {
|
||||
return false;
|
||||
}
|
||||
double hours = elapsed.toMillis() / 3_600_000.0;
|
||||
return deltaKm <= maxImpliedSpeedKmh * hours + ODOMETER_DELTA_TOLERANCE_KM;
|
||||
}
|
||||
|
||||
private static double haversineKm(double lon1, double lat1, double lon2, double lat2) {
|
||||
double latRad1 = Math.toRadians(lat1);
|
||||
double latRad2 = Math.toRadians(lat2);
|
||||
double deltaLat = Math.toRadians(lat2 - lat1);
|
||||
double deltaLon = Math.toRadians(lon2 - lon1);
|
||||
double a = Math.sin(deltaLat / 2.0) * Math.sin(deltaLat / 2.0)
|
||||
+ Math.cos(latRad1) * Math.cos(latRad2)
|
||||
* Math.sin(deltaLon / 2.0) * Math.sin(deltaLon / 2.0);
|
||||
return 2.0 * EARTH_RADIUS_KM * Math.asin(Math.min(1.0, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
public record SegmentResult(double gpsKm,
|
||||
double speedIntegralKm,
|
||||
boolean used,
|
||||
boolean badJump,
|
||||
boolean longGap,
|
||||
boolean outOfOrder) {
|
||||
private static SegmentResult rejectedBadJump() {
|
||||
return new SegmentResult(0.0, 0.0, false, true, false, false);
|
||||
}
|
||||
|
||||
private static SegmentResult rejectedLongGap() {
|
||||
return new SegmentResult(0.0, 0.0, false, false, true, false);
|
||||
}
|
||||
|
||||
private static SegmentResult rejectedOutOfOrder() {
|
||||
return new SegmentResult(0.0, 0.0, false, false, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
@@ -8,19 +9,16 @@ import java.time.ZoneId;
|
||||
public final class Jt808MileageStreamProcessor {
|
||||
|
||||
private final Jt808LocationPointExtractor extractor;
|
||||
private final Jt808GpsMileageCalculator calculator;
|
||||
private final Jt808MileageStateStore stateStore;
|
||||
private final Jt808DailyMileageRepository repository;
|
||||
private final VehicleStatRepository repository;
|
||||
private final ZoneId zoneId;
|
||||
|
||||
public Jt808MileageStreamProcessor(
|
||||
Jt808LocationPointExtractor extractor,
|
||||
Jt808GpsMileageCalculator calculator,
|
||||
Jt808MileageStateStore stateStore,
|
||||
Jt808DailyMileageRepository repository,
|
||||
VehicleStatRepository repository,
|
||||
ZoneId zoneId) {
|
||||
this.extractor = extractor;
|
||||
this.calculator = calculator;
|
||||
this.stateStore = stateStore;
|
||||
this.repository = repository;
|
||||
this.zoneId = zoneId;
|
||||
@@ -34,8 +32,8 @@ public final class Jt808MileageStreamProcessor {
|
||||
LocalDate statDate = LocalDate.ofInstant(point.eventTime(), zoneId);
|
||||
Jt808DailyMileageState state = stateStore.load(point.vehicleKey(), statDate)
|
||||
.orElseGet(() -> Jt808DailyMileageState.empty(point.vehicleKey(), point.vin(), point.phone(), statDate));
|
||||
state.apply(point, calculator);
|
||||
state.apply(point);
|
||||
stateStore.save(state);
|
||||
repository.upsert(state.toResult());
|
||||
state.toVehicleDailyStatResult().ifPresent(repository::saveDailyStat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,9 @@ public final class RedisJt808MileageStateStore implements Jt808MileageStateStore
|
||||
PointSnapshot lastPoint,
|
||||
Double firstTotalMileageKm,
|
||||
Double lastTotalMileageKm,
|
||||
double gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
double odometerMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
Integer totalMileageSamples,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies) {
|
||||
|
||||
@@ -83,15 +80,12 @@ public final class RedisJt808MileageStateStore implements Jt808MileageStateStore
|
||||
state.statDate().toString(),
|
||||
format(state.firstEventTime()),
|
||||
format(state.lastEventTime()),
|
||||
PointSnapshot.from(state.lastPoint()),
|
||||
null,
|
||||
finiteOrNull(state.firstTotalMileageKm()),
|
||||
finiteOrNull(state.lastTotalMileageKm()),
|
||||
state.gpsMileageKm(),
|
||||
state.speedIntegralKm(),
|
||||
state.odometerMileageKm(),
|
||||
state.acceptedPoints(),
|
||||
state.badJumpSegments(),
|
||||
state.longGapSegments(),
|
||||
state.totalMileageSamples(),
|
||||
state.outOfOrderPoints(),
|
||||
state.odometerAnomalies());
|
||||
}
|
||||
@@ -103,19 +97,33 @@ public final class RedisJt808MileageStateStore implements Jt808MileageStateStore
|
||||
phone,
|
||||
LocalDate.parse(statDate),
|
||||
parseInstant(firstEventTime),
|
||||
parseInstant(lastEventTime),
|
||||
lastPoint == null ? null : lastPoint.toPoint(),
|
||||
restoredLastEventTime(),
|
||||
nanIfNull(firstTotalMileageKm),
|
||||
nanIfNull(lastTotalMileageKm),
|
||||
gpsMileageKm,
|
||||
speedIntegralKm,
|
||||
odometerMileageKm,
|
||||
acceptedPoints,
|
||||
badJumpSegments,
|
||||
longGapSegments,
|
||||
restoredTotalMileageSamples(),
|
||||
outOfOrderPoints,
|
||||
odometerAnomalies);
|
||||
}
|
||||
|
||||
private Instant restoredLastEventTime() {
|
||||
Instant parsedLastEventTime = parseInstant(lastEventTime);
|
||||
if (parsedLastEventTime != null) {
|
||||
return parsedLastEventTime;
|
||||
}
|
||||
return lastPoint == null ? null : parseInstant(lastPoint.eventTime());
|
||||
}
|
||||
|
||||
private int restoredTotalMileageSamples() {
|
||||
if (totalMileageSamples != null) {
|
||||
return totalMileageSamples;
|
||||
}
|
||||
if (firstTotalMileageKm != null && lastTotalMileageKm != null) {
|
||||
return acceptedPoints >= 2 || !lastTotalMileageKm.equals(firstTotalMileageKm) ? 2 : 1;
|
||||
}
|
||||
return firstTotalMileageKm != null || lastTotalMileageKm != null ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -130,34 +138,6 @@ public final class RedisJt808MileageStateStore implements Jt808MileageStateStore
|
||||
Long statusFlag,
|
||||
Double totalMileageKm) {
|
||||
|
||||
static PointSnapshot from(Jt808LocationPoint point) {
|
||||
if (point == null) {
|
||||
return null;
|
||||
}
|
||||
return new PointSnapshot(
|
||||
point.vehicleKey(),
|
||||
point.vin(),
|
||||
point.phone(),
|
||||
format(point.eventTime()),
|
||||
point.longitude(),
|
||||
point.latitude(),
|
||||
point.speedKmh(),
|
||||
point.statusFlag(),
|
||||
point.totalMileageKm());
|
||||
}
|
||||
|
||||
Jt808LocationPoint toPoint() {
|
||||
return new Jt808LocationPoint(
|
||||
vehicleKey,
|
||||
vin,
|
||||
phone,
|
||||
Instant.parse(eventTime),
|
||||
longitude,
|
||||
latitude,
|
||||
speedKmh,
|
||||
statusFlag,
|
||||
totalMileageKm);
|
||||
}
|
||||
}
|
||||
|
||||
private static String format(Instant instant) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.lingniu.ingest.vehiclestat.VehicleStatEventSink;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808DailyMileageRepository;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStateStore;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStreamProcessor;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.RedisJt808MileageStateStore;
|
||||
@@ -86,7 +85,7 @@ class VehicleStatAutoConfigurationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsJt808MileageBeansWhenEnabledWithJdbcAndRedis() {
|
||||
void createsJt808MileageBeansWhenEnabledWithRedis() {
|
||||
contextRunner
|
||||
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
|
||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
|
||||
@@ -96,7 +95,6 @@ class VehicleStatAutoConfigurationTest {
|
||||
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
|
||||
"lingniu.ingest.vehicle-stat.jt808.state-store=redis")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(Jt808DailyMileageRepository.class);
|
||||
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
|
||||
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
|
||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||
@@ -113,7 +111,6 @@ class VehicleStatAutoConfigurationTest {
|
||||
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
|
||||
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(Jt808DailyMileageRepository.class);
|
||||
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
|
||||
assertThat(context).doesNotHaveBean(RedisJt808MileageStateStore.class);
|
||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||
@@ -137,7 +134,6 @@ class VehicleStatAutoConfigurationTest {
|
||||
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(JdbcTemplate.class);
|
||||
assertThat(context).hasSingleBean(Jt808DailyMileageRepository.class);
|
||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
||||
});
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class JdbcJt808DailyMileageRepositoryTest {
|
||||
|
||||
private JdbcTemplate jdbc;
|
||||
private JdbcJt808DailyMileageRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:jt808_mileage;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
|
||||
"sa",
|
||||
"");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("drop table if exists vehicle_daily_mileage_jt808");
|
||||
repository = new JdbcJt808DailyMileageRepository(jdbc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializesTableWhenMissing() {
|
||||
Integer count = jdbc.queryForObject("""
|
||||
select count(*)
|
||||
from information_schema.tables
|
||||
where table_name = 'vehicle_daily_mileage_jt808'
|
||||
""", Integer.class);
|
||||
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertsDailyMileageResult() {
|
||||
Jt808DailyMileageResult first = new Jt808DailyMileageResult(
|
||||
LocalDate.of(2026, 6, 30),
|
||||
"VIN001",
|
||||
"VIN001",
|
||||
"13900000001",
|
||||
Instant.parse("2026-06-30T00:00:00Z"),
|
||||
Instant.parse("2026-06-30T00:01:00Z"),
|
||||
1.23456,
|
||||
"GPS_DISTANCE",
|
||||
1.23456,
|
||||
1.2,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"GOOD");
|
||||
Jt808DailyMileageResult updated = new Jt808DailyMileageResult(
|
||||
LocalDate.of(2026, 6, 30),
|
||||
"VIN001",
|
||||
"VIN001",
|
||||
"13900000001",
|
||||
Instant.parse("2026-06-30T00:00:00Z"),
|
||||
Instant.parse("2026-06-30T00:02:00Z"),
|
||||
2.0,
|
||||
"JT808_TOTAL_MILEAGE",
|
||||
2.34567,
|
||||
2.3,
|
||||
2.0,
|
||||
100.0,
|
||||
102.0,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"PARTIAL");
|
||||
|
||||
repository.upsert(first);
|
||||
repository.upsert(updated);
|
||||
|
||||
Map<String, Object> row = jdbc.queryForMap("""
|
||||
select vehicle_key, daily_mileage_km, mileage_source, gps_mileage_km,
|
||||
odometer_mileage_km, first_total_mileage_km, last_total_mileage_km,
|
||||
accepted_points, bad_jump_segments, data_quality
|
||||
from vehicle_daily_mileage_jt808
|
||||
where stat_date = '2026-06-30' and vehicle_key = 'VIN001'
|
||||
""");
|
||||
assertThat(row.get("vehicle_key")).isEqualTo("VIN001");
|
||||
assertThat(row.get("daily_mileage_km").toString()).isEqualTo("2.000");
|
||||
assertThat(row.get("mileage_source")).isEqualTo("JT808_TOTAL_MILEAGE");
|
||||
assertThat(row.get("gps_mileage_km").toString()).isEqualTo("2.346");
|
||||
assertThat(row.get("odometer_mileage_km").toString()).isEqualTo("2.000");
|
||||
assertThat(row.get("first_total_mileage_km").toString()).isEqualTo("100.000");
|
||||
assertThat(row.get("last_total_mileage_km").toString()).isEqualTo("102.000");
|
||||
assertThat(row.get("accepted_points")).isEqualTo(3);
|
||||
assertThat(row.get("bad_jump_segments")).isEqualTo(1);
|
||||
assertThat(row.get("data_quality")).isEqualTo("PARTIAL");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@@ -11,24 +10,19 @@ import static org.assertj.core.data.Offset.offset;
|
||||
|
||||
class Jt808DailyMileageStateTest {
|
||||
|
||||
private final Jt808GpsMileageCalculator calculator =
|
||||
new Jt808GpsMileageCalculator(Duration.ofMinutes(5), 200.0, Duration.ofSeconds(10), 300.0);
|
||||
|
||||
@Test
|
||||
void firstPointInitializesStateWithoutDistance() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0), calculator);
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
|
||||
|
||||
assertThat(state.acceptedPoints()).isEqualTo(1);
|
||||
assertThat(state.gpsMileageKm()).isZero();
|
||||
assertThat(state.speedIntegralKm()).isZero();
|
||||
assertThat(state.totalMileageSamples()).isEqualTo(1);
|
||||
assertThat(state.odometerMileageKm()).isZero();
|
||||
assertThat(state.firstTotalMileageKm()).isEqualTo(100.0);
|
||||
assertThat(state.lastTotalMileageKm()).isEqualTo(100.0);
|
||||
assertThat(state.toResult().dailyMileageKm()).isZero();
|
||||
assertThat(state.toResult().mileageSource()).isEqualTo("INSUFFICIENT");
|
||||
assertThat(state.toVehicleDailyStatResult()).isEmpty();
|
||||
assertThat(state.firstEventTime()).isEqualTo(Instant.parse("2026-06-30T00:00:00Z"));
|
||||
assertThat(state.lastEventTime()).isEqualTo(Instant.parse("2026-06-30T00:00:00Z"));
|
||||
}
|
||||
@@ -37,77 +31,84 @@ class Jt808DailyMileageStateTest {
|
||||
void orderedPointUpdatesGpsSpeedAndOdometerMileage() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0), calculator);
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
|
||||
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 101.2), calculator);
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 101.2));
|
||||
|
||||
assertThat(state.acceptedPoints()).isEqualTo(2);
|
||||
assertThat(state.gpsMileageKm()).isBetween(0.99, 1.01);
|
||||
assertThat(state.speedIntegralKm()).isBetween(0.99, 1.01);
|
||||
assertThat(state.totalMileageSamples()).isEqualTo(2);
|
||||
assertThat(state.odometerMileageKm()).isCloseTo(1.2, offset(0.000001));
|
||||
assertThat(state.firstTotalMileageKm()).isEqualTo(100.0);
|
||||
assertThat(state.lastTotalMileageKm()).isEqualTo(101.2);
|
||||
assertThat(state.toResult().dailyMileageKm()).isCloseTo(1.2, offset(0.000001));
|
||||
assertThat(state.toResult().mileageSource()).isEqualTo("JT808_TOTAL_MILEAGE");
|
||||
assertThat(state.toResult().firstTotalMileageKm()).isEqualTo(100.0);
|
||||
assertThat(state.toResult().lastTotalMileageKm()).isEqualTo(101.2);
|
||||
assertThat(state.badJumpSegments()).isZero();
|
||||
assertThat(state.longGapSegments()).isZero();
|
||||
assertThat(state.toVehicleDailyStatResult()).isPresent();
|
||||
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm().getAsDouble())
|
||||
.isCloseTo(1.2, offset(0.000001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateOrOlderPointIsIgnored() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:01:00Z", 120.000000, 30.000000, 36.0, 100.0), calculator);
|
||||
state.apply(point("2026-06-30T00:01:00Z", 120.000000, 30.000000, 36.0, 100.0));
|
||||
|
||||
state.apply(point("2026-06-30T00:00:59Z", 120.010370, 30.000000, 36.0, 101.0), calculator);
|
||||
state.apply(point("2026-06-30T00:00:59Z", 120.010370, 30.000000, 36.0, 101.0));
|
||||
|
||||
assertThat(state.acceptedPoints()).isEqualTo(1);
|
||||
assertThat(state.outOfOrderPoints()).isEqualTo(1);
|
||||
assertThat(state.gpsMileageKm()).isZero();
|
||||
assertThat(state.totalMileageSamples()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void odometerRollbackIsIgnoredAndCounted() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0), calculator);
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
|
||||
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 99.5), calculator);
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 99.5));
|
||||
|
||||
assertThat(state.odometerMileageKm()).isZero();
|
||||
assertThat(state.odometerAnomalies()).isEqualTo(1);
|
||||
assertThat(state.toResult().dailyMileageKm()).isGreaterThan(0.9);
|
||||
assertThat(state.toResult().mileageSource()).isEqualTo("GPS_DISTANCE");
|
||||
assertThat(state.toVehicleDailyStatResult()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void odometerJumpAboveConfiguredSpeedIsIgnoredAndCounted() {
|
||||
void odometerJumpAboveConfiguredSpeedUsesSimpleDifference() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0), calculator);
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
|
||||
|
||||
state.apply(point("2026-06-30T00:01:00Z", 120.000100, 30.000000, 36.0, 200.0), calculator);
|
||||
state.apply(point("2026-06-30T00:01:00Z", 120.000100, 30.000000, 36.0, 200.0));
|
||||
|
||||
assertThat(state.odometerMileageKm()).isZero();
|
||||
assertThat(state.odometerAnomalies()).isEqualTo(1);
|
||||
assertThat(state.toResult().dataQuality()).isEqualTo("PARTIAL");
|
||||
assertThat(state.odometerMileageKm()).isEqualTo(100.0);
|
||||
assertThat(state.odometerAnomalies()).isZero();
|
||||
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gpsDistanceIsFallbackWhenTotalMileageIsMissing() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, null), calculator);
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, null));
|
||||
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, null), calculator);
|
||||
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, null));
|
||||
|
||||
assertThat(state.toResult().dailyMileageKm()).isBetween(0.99, 1.01);
|
||||
assertThat(state.toResult().mileageSource()).isEqualTo("GPS_DISTANCE");
|
||||
assertThat(state.toResult().odometerMileageKm()).isNull();
|
||||
assertThat(state.toResult().firstTotalMileageKm()).isNull();
|
||||
assertThat(state.toResult().lastTotalMileageKm()).isNull();
|
||||
assertThat(state.totalMileageSamples()).isZero();
|
||||
assertThat(state.toVehicleDailyStatResult()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresTwoTotalMileageSamplesBeforeSavingMetric() {
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
|
||||
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, null));
|
||||
state.apply(point("2026-06-30T00:01:00Z", 120.000100, 30.000000, 36.0, 100.0));
|
||||
|
||||
assertThat(state.toVehicleDailyStatResult()).isEmpty();
|
||||
|
||||
state.apply(point("2026-06-30T00:02:00Z", 120.000200, 30.000000, 36.0, 101.0));
|
||||
|
||||
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(1.0);
|
||||
}
|
||||
|
||||
private static Jt808LocationPoint point(String time,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.data.Offset.offset;
|
||||
|
||||
class Jt808GpsMileageCalculatorTest {
|
||||
|
||||
private final Jt808GpsMileageCalculator calculator =
|
||||
new Jt808GpsMileageCalculator(Duration.ofMinutes(5), 200.0, Duration.ofSeconds(10), 300.0);
|
||||
|
||||
@Test
|
||||
void accumulatesValidHaversineDistanceAndSpeedIntegral() {
|
||||
var result = calculator.calculateSegment(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0));
|
||||
|
||||
assertThat(result.used()).isTrue();
|
||||
assertThat(result.gpsKm()).isCloseTo(1.0, offset(0.01));
|
||||
assertThat(result.speedIntegralKm()).isCloseTo(1.0, offset(0.001));
|
||||
assertThat(result.badJump()).isFalse();
|
||||
assertThat(result.longGap()).isFalse();
|
||||
assertThat(result.outOfOrder()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPositiveTime() {
|
||||
var result = calculator.calculateSegment(
|
||||
point("2026-06-30T00:01:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:01:00Z", 120.001000, 30.000000, 36.0));
|
||||
|
||||
assertThat(result.used()).isFalse();
|
||||
assertThat(result.outOfOrder()).isTrue();
|
||||
assertThat(result.gpsKm()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLongGap() {
|
||||
var result = calculator.calculateSegment(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:06:00Z", 120.001000, 30.000000, 36.0));
|
||||
|
||||
assertThat(result.used()).isFalse();
|
||||
assertThat(result.longGap()).isTrue();
|
||||
assertThat(result.gpsKm()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBadJumpByImpliedSpeed() {
|
||||
var result = calculator.calculateSegment(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:01:00Z", 120.100000, 30.000000, 36.0));
|
||||
|
||||
assertThat(result.used()).isFalse();
|
||||
assertThat(result.badJump()).isTrue();
|
||||
assertThat(result.gpsKm()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsShortGapJumpByDistance() {
|
||||
var result = calculator.calculateSegment(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:00:05Z", 120.004000, 30.000000, 36.0));
|
||||
|
||||
assertThat(result.used()).isFalse();
|
||||
assertThat(result.badJump()).isTrue();
|
||||
assertThat(result.gpsKm()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesPlausibleOdometerDeltaByConfiguredSpeed() {
|
||||
assertThat(calculator.plausibleOdometerDelta(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:01:00Z", 120.001000, 30.000000, 36.0),
|
||||
2.0)).isTrue();
|
||||
assertThat(calculator.plausibleOdometerDelta(
|
||||
point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0),
|
||||
point("2026-06-30T00:01:00Z", 120.001000, 30.000000, 36.0),
|
||||
20.0)).isFalse();
|
||||
}
|
||||
|
||||
private static Jt808LocationPoint point(String time, double longitude, double latitude, Double speedKmh) {
|
||||
return new Jt808LocationPoint(
|
||||
"VIN001",
|
||||
"VIN001",
|
||||
"13900000001",
|
||||
Instant.parse(time),
|
||||
longitude,
|
||||
latitude,
|
||||
speedKmh,
|
||||
3L,
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,25 @@ package com.lingniu.ingest.vehiclestat.jt808;
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
|
||||
import com.lingniu.ingest.vehiclestat.MileagePoint;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808MileageStreamProcessorTest {
|
||||
|
||||
@Test
|
||||
void updatesDailyStateAndUpsertsResultForJt808Points() {
|
||||
void updatesDailyStateAndSavesResultToVehicleStatRepository() {
|
||||
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
|
||||
CapturingRepository repository = new CapturingRepository();
|
||||
Jt808MileageStreamProcessor processor = processor(stateStore, repository);
|
||||
@@ -27,12 +31,26 @@ class Jt808MileageStreamProcessorTest {
|
||||
|
||||
Jt808DailyMileageState state = stateStore.load("VIN001", LocalDate.of(2026, 6, 30)).orElseThrow();
|
||||
assertThat(state.acceptedPoints()).isEqualTo(2);
|
||||
assertThat(state.gpsMileageKm()).isGreaterThan(0.9);
|
||||
assertThat(repository.results).hasSize(2);
|
||||
assertThat(repository.results.getLast().vehicleKey()).isEqualTo("VIN001");
|
||||
assertThat(repository.results.getLast().dailyMileageKm()).isEqualTo(1.0);
|
||||
assertThat(repository.results.getLast().mileageSource()).isEqualTo("JT808_TOTAL_MILEAGE");
|
||||
assertThat(repository.results.getLast().odometerMileageKm()).isEqualTo(1.0);
|
||||
assertThat(state.totalMileageSamples()).isEqualTo(2);
|
||||
assertThat(repository.results).hasSize(1);
|
||||
VehicleDailyStatResult latest = repository.results.getLast();
|
||||
assertThat(latest.vin()).isEqualTo("VIN001");
|
||||
assertThat(latest.statDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||
assertThat(latest.dailyMileageKm()).hasValue(1.0);
|
||||
assertThat(latest.dailyMileageStrategy()).isEqualTo(DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesSimpleTotalMileageDifferenceInsteadOfAccumulatedPlausibilityDeltas() {
|
||||
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
|
||||
CapturingRepository repository = new CapturingRepository();
|
||||
Jt808MileageStreamProcessor processor = processor(stateStore, repository);
|
||||
|
||||
processor.process(envelope("2026-06-30T00:00:00Z", 120.0, 30.0, 100.0));
|
||||
processor.process(envelope("2026-06-30T00:01:00Z", 120.0001, 30.0, 200.0));
|
||||
|
||||
VehicleDailyStatResult latest = repository.results.getLast();
|
||||
assertThat(latest.dailyMileageKm()).hasValue(100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,10 +69,9 @@ class Jt808MileageStreamProcessorTest {
|
||||
|
||||
private static Jt808MileageStreamProcessor processor(
|
||||
Jt808MileageStateStore stateStore,
|
||||
Jt808DailyMileageRepository repository) {
|
||||
VehicleStatRepository repository) {
|
||||
return new Jt808MileageStreamProcessor(
|
||||
new Jt808LocationPointExtractor(),
|
||||
new Jt808GpsMileageCalculator(Duration.ofMinutes(5), 200.0, Duration.ofSeconds(10), 300.0),
|
||||
stateStore,
|
||||
repository,
|
||||
ZoneId.of("Asia/Shanghai"));
|
||||
@@ -86,12 +103,26 @@ class Jt808MileageStreamProcessorTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class CapturingRepository implements Jt808DailyMileageRepository {
|
||||
private final List<Jt808DailyMileageResult> results = new ArrayList<>();
|
||||
private static final class CapturingRepository implements VehicleStatRepository {
|
||||
private final List<VehicleDailyStatResult> results = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void upsert(Jt808DailyMileageResult result) {
|
||||
public void appendMileagePoint(String vin, MileagePoint point) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||
results.add(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class RedisJt808MileageStateStoreTest {
|
||||
redis, new ObjectMapper(), "vehicle:mileage:jt808:daily:", Duration.ofDays(3));
|
||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
||||
LocalDate.of(2026, 6, 30));
|
||||
state.apply(point("2026-06-30T00:00:00Z"), calculator());
|
||||
state.apply(point("2026-06-30T00:00:00Z"));
|
||||
|
||||
store.save(state);
|
||||
|
||||
@@ -47,6 +47,7 @@ class RedisJt808MileageStateStoreTest {
|
||||
.thenReturn("""
|
||||
{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001","statDate":"2026-06-30",
|
||||
"firstEventTime":"2026-06-30T00:00:00Z","lastEventTime":"2026-06-30T00:01:00Z",
|
||||
"firstTotalMileageKm":100.0,
|
||||
"lastPoint":{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001",
|
||||
"eventTime":"2026-06-30T00:01:00Z","longitude":120.0,"latitude":30.0,
|
||||
"speedKmh":36.0,"statusFlag":3,"totalMileageKm":101.0},
|
||||
@@ -62,15 +63,12 @@ class RedisJt808MileageStateStoreTest {
|
||||
assertThat(loaded).hasValueSatisfying(state -> {
|
||||
assertThat(state.vehicleKey()).isEqualTo("VIN001");
|
||||
assertThat(state.acceptedPoints()).isEqualTo(2);
|
||||
assertThat(state.gpsMileageKm()).isEqualTo(1.0);
|
||||
assertThat(state.totalMileageSamples()).isEqualTo(2);
|
||||
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(1.0);
|
||||
assertThat(state.lastEventTime()).isEqualTo(Instant.parse("2026-06-30T00:01:00Z"));
|
||||
});
|
||||
}
|
||||
|
||||
private static Jt808GpsMileageCalculator calculator() {
|
||||
return new Jt808GpsMileageCalculator(Duration.ofMinutes(5), 200.0, Duration.ofSeconds(10), 300.0);
|
||||
}
|
||||
|
||||
private static Jt808LocationPoint point(String time) {
|
||||
return new Jt808LocationPoint(
|
||||
"VIN001",
|
||||
|
||||
Reference in New Issue
Block a user