feat: add jt808 streaming daily mileage
This commit is contained in:
@@ -4,16 +4,24 @@ import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStreamProcessor;
|
||||
|
||||
public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
private final VehicleStatEventProcessor processor;
|
||||
private final Jt808MileageStreamProcessor jt808MileageProcessor;
|
||||
|
||||
public VehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) {
|
||||
this(processor, null);
|
||||
}
|
||||
|
||||
public VehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor,
|
||||
Jt808MileageStreamProcessor jt808MileageProcessor) {
|
||||
if (processor == null) {
|
||||
throw new IllegalArgumentException("processor must not be null");
|
||||
}
|
||||
this.processor = processor;
|
||||
this.jt808MileageProcessor = jt808MileageProcessor;
|
||||
}
|
||||
|
||||
public void ingest(byte[] kafkaValue) {
|
||||
@@ -22,6 +30,7 @@ public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor {
|
||||
return;
|
||||
}
|
||||
processor.process(envelope);
|
||||
processJt808Mileage(envelope);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -34,6 +43,7 @@ public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor {
|
||||
envelope.getEventId(), envelope.getVin(), "envelope telemetry_snapshot is required");
|
||||
}
|
||||
processor.process(envelope);
|
||||
processJt808Mileage(envelope);
|
||||
return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return envelope == null
|
||||
@@ -42,6 +52,12 @@ public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor {
|
||||
}
|
||||
}
|
||||
|
||||
private void processJt808Mileage(VehicleEnvelope envelope) {
|
||||
if (jt808MileageProcessor != null) {
|
||||
jt808MileageProcessor.process(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
private static VehicleEnvelope parse(byte[] kafkaValue) {
|
||||
if (kafkaValue == null || kafkaValue.length == 0) {
|
||||
throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty");
|
||||
|
||||
@@ -13,14 +13,27 @@ import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor;
|
||||
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;
|
||||
import com.lingniu.ingest.vehiclestat.jt808.RedisJt808MileageStateStore;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@AutoConfiguration
|
||||
@@ -66,8 +79,70 @@ public class VehicleStatAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStatEventProcessor.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatEnvelopeIngestor vehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) {
|
||||
return new VehicleStatEnvelopeIngestor(processor);
|
||||
public VehicleStatEnvelopeIngestor vehicleStatEnvelopeIngestor(
|
||||
VehicleStatEventProcessor processor,
|
||||
ObjectProvider<Jt808MileageStreamProcessor> jt808MileageProcessor) {
|
||||
return new VehicleStatEnvelopeIngestor(processor, jt808MileageProcessor.getIfAvailable());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808LocationPointExtractor jt808LocationPointExtractor() {
|
||||
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")
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "state-store", havingValue = "redis")
|
||||
@ConditionalOnMissingBean(Jt808MileageStateStore.class)
|
||||
public Jt808MileageStateStore redisJt808MileageStateStore(StringRedisTemplate redis,
|
||||
ObjectMapper objectMapper,
|
||||
VehicleStatProperties props) {
|
||||
VehicleStatProperties.Jt808 jt808 = props.getJt808();
|
||||
return new RedisJt808MileageStateStore(redis, objectMapper,
|
||||
jt808.getRedisKeyPrefix(), Duration.ofDays(jt808.getStateTtlDays()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean(Jt808MileageStateStore.class)
|
||||
public Jt808MileageStateStore inMemoryJt808MileageStateStore() {
|
||||
return new InMemoryJt808MileageStateStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({Jt808LocationPointExtractor.class, Jt808GpsMileageCalculator.class,
|
||||
Jt808MileageStateStore.class, Jt808DailyMileageRepository.class})
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808MileageStreamProcessor jt808MileageStreamProcessor(Jt808LocationPointExtractor extractor,
|
||||
Jt808GpsMileageCalculator calculator,
|
||||
Jt808MileageStateStore stateStore,
|
||||
Jt808DailyMileageRepository repository,
|
||||
VehicleStatProperties props) {
|
||||
return new Jt808MileageStreamProcessor(extractor, calculator, stateStore, repository,
|
||||
ZoneId.of(props.getZoneId()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -11,6 +11,8 @@ public class VehicleStatProperties {
|
||||
/** 统计自然日口径,默认按国内业务使用东八区。 */
|
||||
private String zoneId = "Asia/Shanghai";
|
||||
|
||||
private Jt808 jt808 = new Jt808();
|
||||
|
||||
public String getFilePath() {
|
||||
return filePath;
|
||||
}
|
||||
@@ -26,4 +28,87 @@ public class VehicleStatProperties {
|
||||
public void setZoneId(String zoneId) {
|
||||
this.zoneId = zoneId;
|
||||
}
|
||||
|
||||
public Jt808 getJt808() {
|
||||
return jt808;
|
||||
}
|
||||
|
||||
public void setJt808(Jt808 jt808) {
|
||||
this.jt808 = jt808;
|
||||
}
|
||||
|
||||
public static class Jt808 {
|
||||
private boolean enabled;
|
||||
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;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getStateStore() {
|
||||
return stateStore;
|
||||
}
|
||||
|
||||
public void setStateStore(String stateStore) {
|
||||
this.stateStore = stateStore;
|
||||
}
|
||||
|
||||
public String getRedisKeyPrefix() {
|
||||
return redisKeyPrefix;
|
||||
}
|
||||
|
||||
public void setRedisKeyPrefix(String redisKeyPrefix) {
|
||||
this.redisKeyPrefix = redisKeyPrefix;
|
||||
}
|
||||
|
||||
public long getStateTtlDays() {
|
||||
return stateTtlDays;
|
||||
}
|
||||
|
||||
public void setStateTtlDays(long stateTtlDays) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class InMemoryJt808MileageStateStore implements Jt808MileageStateStore {
|
||||
|
||||
private final Map<String, Jt808DailyMileageState> states = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
|
||||
return Optional.ofNullable(states.get(key(vehicleKey, statDate)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Jt808DailyMileageState state) {
|
||||
states.put(key(state.vehicleKey(), state.statDate()), state);
|
||||
}
|
||||
|
||||
private static String key(String vehicleKey, LocalDate statDate) {
|
||||
return statDate + ":" + vehicleKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
|
||||
public final class JdbcJt808DailyMileageRepository implements Jt808DailyMileageRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public JdbcJt808DailyMileageRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@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,
|
||||
gps_mileage_km, speed_integral_km, odometer_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),
|
||||
gps_mileage_km = values(gps_mileage_km),
|
||||
speed_integral_km = values(speed_integral_km),
|
||||
odometer_mileage_km = values(odometer_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.gpsMileageKm(),
|
||||
result.speedIntegralKm(),
|
||||
result.odometerMileageKm(),
|
||||
result.acceptedPoints(),
|
||||
result.badJumpSegments(),
|
||||
result.longGapSegments(),
|
||||
result.outOfOrderPoints(),
|
||||
result.odometerAnomalies(),
|
||||
result.dataQuality());
|
||||
}
|
||||
|
||||
private static Timestamp timestamp(Instant instant) {
|
||||
return instant == null ? null : Timestamp.from(instant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
public interface Jt808DailyMileageRepository {
|
||||
|
||||
void upsert(Jt808DailyMileageResult result);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
Double odometerMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies,
|
||||
String dataQuality
|
||||
) {}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class Jt808DailyMileageState {
|
||||
|
||||
private final String vehicleKey;
|
||||
private final String vin;
|
||||
private final String phone;
|
||||
private final LocalDate statDate;
|
||||
private Instant firstEventTime;
|
||||
private Instant lastEventTime;
|
||||
private Jt808LocationPoint lastPoint;
|
||||
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 outOfOrderPoints;
|
||||
private int odometerAnomalies;
|
||||
|
||||
private Jt808DailyMileageState(String vehicleKey, String vin, String phone, LocalDate statDate) {
|
||||
if (vehicleKey == null || vehicleKey.isBlank()) {
|
||||
throw new IllegalArgumentException("vehicleKey must not be blank");
|
||||
}
|
||||
if (statDate == null) {
|
||||
throw new IllegalArgumentException("statDate must not be null");
|
||||
}
|
||||
this.vehicleKey = vehicleKey;
|
||||
this.vin = vin == null ? "" : vin;
|
||||
this.phone = phone == null ? "" : phone;
|
||||
this.statDate = statDate;
|
||||
}
|
||||
|
||||
public static Jt808DailyMileageState empty(String vehicleKey, String vin, String phone, LocalDate statDate) {
|
||||
return new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
|
||||
}
|
||||
|
||||
public static Jt808DailyMileageState restore(
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
LocalDate statDate,
|
||||
Instant firstEventTime,
|
||||
Instant lastEventTime,
|
||||
Jt808LocationPoint lastPoint,
|
||||
double lastTotalMileageKm,
|
||||
double gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
double odometerMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies) {
|
||||
Jt808DailyMileageState state = new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
|
||||
state.firstEventTime = firstEventTime;
|
||||
state.lastEventTime = lastEventTime;
|
||||
state.lastPoint = lastPoint;
|
||||
state.lastTotalMileageKm = lastTotalMileageKm;
|
||||
state.gpsMileageKm = gpsMileageKm;
|
||||
state.speedIntegralKm = speedIntegralKm;
|
||||
state.odometerMileageKm = odometerMileageKm;
|
||||
state.acceptedPoints = acceptedPoints;
|
||||
state.badJumpSegments = badJumpSegments;
|
||||
state.longGapSegments = longGapSegments;
|
||||
state.outOfOrderPoints = outOfOrderPoints;
|
||||
state.odometerAnomalies = odometerAnomalies;
|
||||
return state;
|
||||
}
|
||||
|
||||
public void apply(Jt808LocationPoint point, Jt808GpsMileageCalculator calculator) {
|
||||
if (point == null || calculator == null) {
|
||||
return;
|
||||
}
|
||||
if (lastPoint != null && !point.eventTime().isAfter(lastPoint.eventTime())) {
|
||||
outOfOrderPoints++;
|
||||
return;
|
||||
}
|
||||
if (lastPoint == null) {
|
||||
firstEventTime = point.eventTime();
|
||||
updateOdometer(point);
|
||||
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);
|
||||
accept(point);
|
||||
}
|
||||
|
||||
public Jt808DailyMileageResult toResult() {
|
||||
return new Jt808DailyMileageResult(
|
||||
statDate,
|
||||
vehicleKey,
|
||||
vin,
|
||||
phone,
|
||||
firstEventTime,
|
||||
lastEventTime,
|
||||
gpsMileageKm,
|
||||
speedIntegralKm,
|
||||
odometerMileageKm > 0.0 ? odometerMileageKm : null,
|
||||
acceptedPoints,
|
||||
badJumpSegments,
|
||||
longGapSegments,
|
||||
outOfOrderPoints,
|
||||
odometerAnomalies,
|
||||
dataQuality());
|
||||
}
|
||||
|
||||
private void accept(Jt808LocationPoint point) {
|
||||
lastPoint = point;
|
||||
lastEventTime = point.eventTime();
|
||||
acceptedPoints++;
|
||||
}
|
||||
|
||||
private void updateOdometer(Jt808LocationPoint point) {
|
||||
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
|
||||
return;
|
||||
}
|
||||
double current = point.totalMileageKm();
|
||||
if (!Double.isFinite(lastTotalMileageKm)) {
|
||||
lastTotalMileageKm = current;
|
||||
return;
|
||||
}
|
||||
if (current < lastTotalMileageKm) {
|
||||
odometerAnomalies++;
|
||||
lastTotalMileageKm = current;
|
||||
return;
|
||||
}
|
||||
odometerMileageKm += current - lastTotalMileageKm;
|
||||
lastTotalMileageKm = current;
|
||||
}
|
||||
|
||||
private String dataQuality() {
|
||||
if (acceptedPoints < 2) {
|
||||
return "PARTIAL";
|
||||
}
|
||||
if (badJumpSegments > 0 || longGapSegments > 0 || vehicleKey.startsWith("jt808:")) {
|
||||
return "PARTIAL";
|
||||
}
|
||||
return "GOOD";
|
||||
}
|
||||
|
||||
public String vehicleKey() {
|
||||
return vehicleKey;
|
||||
}
|
||||
|
||||
public String vin() {
|
||||
return vin;
|
||||
}
|
||||
|
||||
public String phone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public LocalDate statDate() {
|
||||
return statDate;
|
||||
}
|
||||
|
||||
public Instant firstEventTime() {
|
||||
return firstEventTime;
|
||||
}
|
||||
|
||||
public Instant lastEventTime() {
|
||||
return lastEventTime;
|
||||
}
|
||||
|
||||
public Jt808LocationPoint lastPoint() {
|
||||
return lastPoint;
|
||||
}
|
||||
|
||||
public double gpsMileageKm() {
|
||||
return gpsMileageKm;
|
||||
}
|
||||
|
||||
public double speedIntegralKm() {
|
||||
return speedIntegralKm;
|
||||
}
|
||||
|
||||
public double odometerMileageKm() {
|
||||
return odometerMileageKm;
|
||||
}
|
||||
|
||||
public double lastTotalMileageKm() {
|
||||
return lastTotalMileageKm;
|
||||
}
|
||||
|
||||
public int acceptedPoints() {
|
||||
return acceptedPoints;
|
||||
}
|
||||
|
||||
public int badJumpSegments() {
|
||||
return badJumpSegments;
|
||||
}
|
||||
|
||||
public int longGapSegments() {
|
||||
return longGapSegments;
|
||||
}
|
||||
|
||||
public int outOfOrderPoints() {
|
||||
return outOfOrderPoints;
|
||||
}
|
||||
|
||||
public int odometerAnomalies() {
|
||||
return odometerAnomalies;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public final class Jt808GpsMileageCalculator {
|
||||
|
||||
private static final double EARTH_RADIUS_KM = 6371.0088;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Jt808LocationPoint(
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
Instant eventTime,
|
||||
double longitude,
|
||||
double latitude,
|
||||
Double speedKmh,
|
||||
Long statusFlag,
|
||||
Double totalMileageKm
|
||||
) {
|
||||
|
||||
public Jt808LocationPoint {
|
||||
if (vehicleKey == null || vehicleKey.isBlank()) {
|
||||
throw new IllegalArgumentException("vehicleKey must not be blank");
|
||||
}
|
||||
if (eventTime == null) {
|
||||
throw new IllegalArgumentException("eventTime must not be null");
|
||||
}
|
||||
vin = vin == null ? "" : vin;
|
||||
phone = phone == null ? "" : phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class Jt808LocationPointExtractor {
|
||||
|
||||
public Optional<Jt808LocationPoint> extract(VehicleEnvelope envelope) {
|
||||
if (envelope == null
|
||||
|| !"JT808".equalsIgnoreCase(envelope.getSource())
|
||||
|| !envelope.hasTelemetrySnapshot()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, String> fields = fields(envelope);
|
||||
Double longitude = decimal(fields.get("longitude"));
|
||||
Double latitude = decimal(fields.get("latitude"));
|
||||
if (longitude == null || latitude == null || !validCoordinate(longitude, latitude)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String vin = clean(envelope.getVin());
|
||||
String phone = clean(envelope.getMetadataMap().getOrDefault("phone", ""));
|
||||
String vehicleKey = vehicleKey(vin, phone);
|
||||
if (vehicleKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new Jt808LocationPoint(
|
||||
vehicleKey,
|
||||
vin,
|
||||
phone,
|
||||
Instant.ofEpochMilli(envelope.getEventTimeMs()),
|
||||
longitude,
|
||||
latitude,
|
||||
decimal(fields.get("speed_kmh")),
|
||||
integer(fields.get("location_status_raw")),
|
||||
decimal(fields.get("total_mileage_km"))));
|
||||
}
|
||||
|
||||
private static Map<String, String> fields(VehicleEnvelope envelope) {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) {
|
||||
if (!field.getKey().isBlank()) {
|
||||
out.put(field.getKey(), field.getValue());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String vehicleKey(String vin, String phone) {
|
||||
if (!vin.isBlank() && !"unknown".equalsIgnoreCase(vin)) {
|
||||
return vin;
|
||||
}
|
||||
return phone.isBlank() ? "" : "jt808:" + phone;
|
||||
}
|
||||
|
||||
private static boolean validCoordinate(double longitude, double latitude) {
|
||||
return Double.isFinite(longitude)
|
||||
&& Double.isFinite(latitude)
|
||||
&& longitude >= -180.0
|
||||
&& longitude <= 180.0
|
||||
&& latitude >= -90.0
|
||||
&& latitude <= 90.0;
|
||||
}
|
||||
|
||||
private static Double decimal(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
double value = Double.parseDouble(raw);
|
||||
return Double.isFinite(value) ? value : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Long integer(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface Jt808MileageStateStore {
|
||||
|
||||
Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate);
|
||||
|
||||
void save(Jt808DailyMileageState state);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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 ZoneId zoneId;
|
||||
|
||||
public Jt808MileageStreamProcessor(
|
||||
Jt808LocationPointExtractor extractor,
|
||||
Jt808GpsMileageCalculator calculator,
|
||||
Jt808MileageStateStore stateStore,
|
||||
Jt808DailyMileageRepository repository,
|
||||
ZoneId zoneId) {
|
||||
this.extractor = extractor;
|
||||
this.calculator = calculator;
|
||||
this.stateStore = stateStore;
|
||||
this.repository = repository;
|
||||
this.zoneId = zoneId;
|
||||
}
|
||||
|
||||
public synchronized void process(VehicleEnvelope envelope) {
|
||||
extractor.extract(envelope).ifPresent(this::processPoint);
|
||||
}
|
||||
|
||||
private void processPoint(Jt808LocationPoint point) {
|
||||
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);
|
||||
stateStore.save(state);
|
||||
repository.upsert(state.toResult());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.lingniu.ingest.vehiclestat.jt808;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class RedisJt808MileageStateStore implements Jt808MileageStateStore {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String keyPrefix;
|
||||
private final Duration ttl;
|
||||
|
||||
public RedisJt808MileageStateStore(
|
||||
StringRedisTemplate redis,
|
||||
ObjectMapper objectMapper,
|
||||
String keyPrefix,
|
||||
Duration ttl) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
this.keyPrefix = keyPrefix;
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
|
||||
String json = redis.opsForValue().get(key(vehicleKey, statDate));
|
||||
if (json == null || json.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, StateSnapshot.class).toState());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Failed to deserialize JT808 mileage state", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Jt808DailyMileageState state) {
|
||||
try {
|
||||
redis.opsForValue().set(key(state.vehicleKey(), state.statDate()),
|
||||
objectMapper.writeValueAsString(StateSnapshot.from(state)), ttl);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Failed to serialize JT808 mileage state", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String key(String vehicleKey, LocalDate statDate) {
|
||||
return keyPrefix + statDate + ":" + vehicleKey;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record StateSnapshot(
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
String statDate,
|
||||
String firstEventTime,
|
||||
String lastEventTime,
|
||||
PointSnapshot lastPoint,
|
||||
double lastTotalMileageKm,
|
||||
double gpsMileageKm,
|
||||
double speedIntegralKm,
|
||||
double odometerMileageKm,
|
||||
int acceptedPoints,
|
||||
int badJumpSegments,
|
||||
int longGapSegments,
|
||||
int outOfOrderPoints,
|
||||
int odometerAnomalies) {
|
||||
|
||||
static StateSnapshot from(Jt808DailyMileageState state) {
|
||||
return new StateSnapshot(
|
||||
state.vehicleKey(),
|
||||
state.vin(),
|
||||
state.phone(),
|
||||
state.statDate().toString(),
|
||||
format(state.firstEventTime()),
|
||||
format(state.lastEventTime()),
|
||||
PointSnapshot.from(state.lastPoint()),
|
||||
state.lastTotalMileageKm(),
|
||||
state.gpsMileageKm(),
|
||||
state.speedIntegralKm(),
|
||||
state.odometerMileageKm(),
|
||||
state.acceptedPoints(),
|
||||
state.badJumpSegments(),
|
||||
state.longGapSegments(),
|
||||
state.outOfOrderPoints(),
|
||||
state.odometerAnomalies());
|
||||
}
|
||||
|
||||
Jt808DailyMileageState toState() {
|
||||
return Jt808DailyMileageState.restore(
|
||||
vehicleKey,
|
||||
vin,
|
||||
phone,
|
||||
LocalDate.parse(statDate),
|
||||
parseInstant(firstEventTime),
|
||||
parseInstant(lastEventTime),
|
||||
lastPoint == null ? null : lastPoint.toPoint(),
|
||||
lastTotalMileageKm,
|
||||
gpsMileageKm,
|
||||
speedIntegralKm,
|
||||
odometerMileageKm,
|
||||
acceptedPoints,
|
||||
badJumpSegments,
|
||||
longGapSegments,
|
||||
outOfOrderPoints,
|
||||
odometerAnomalies);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record PointSnapshot(
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
String eventTime,
|
||||
double longitude,
|
||||
double latitude,
|
||||
Double speedKmh,
|
||||
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) {
|
||||
return instant == null ? null : instant.toString();
|
||||
}
|
||||
|
||||
private static Instant parseInstant(String value) {
|
||||
return value == null || value.isBlank() ? null : Instant.parse(value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user