添加速度计算服务,优化活跃对象处理逻辑,增强数据稳定性

This commit is contained in:
sladro 2026-03-02 13:02:00 +08:00
parent 3ef7ef33a7
commit 190d62cd3f
4 changed files with 231 additions and 77 deletions

View File

@ -0,0 +1,122 @@
# 飞机GIS链路稳定性与前端显示优化审查报告
- 审查日期: 2026-03-02
- 审查方式: 静态代码审查(未启动服务、未运行测试)
- 审查范围: GIS接收 -> 速度/角度计算 -> 平滑/防跳点 -> WebSocket下发
## 1. 审查结论(先说结论)
当前链路已经有“去抖 + 限跳 + 合并推送”的基础能力,但距离“专业级稳定显示”还有关键缺口。
最主要风险集中在3点
1. 处理线程与采集线程并发写同一状态对象,存在旧数据回写覆盖新数据的可能,会导致前端回跳、抖动。
2. 非飞机目标在“异常跳点重获”时采用“丢2次后直接接受原始点”的策略仍可能出现突然跳远。
3. 速度/航向在不可计算时使用固定默认值40/25/20 km/h, heading=0会向前端输出伪物理状态。
## 2. 关键问题清单(按严重度)
### P0-1 并发回写导致轨迹回跳(高风险)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:122`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:201`
- `qaup-collision/src/main/java/com/qaup/collision/common/config/SchedulerConfig.java:19`
- 现象: 先拍快照再计算,计算后再 `put` 回缓存;在多线程调度下,采集线程可能已经写入更新位置,随后被处理线程“旧快照对象”覆盖。
- 直接后果: 前端位置回退、路径折返、局部抖动。
### P0-2 非飞机对象“重获”策略仍可能瞬移(高风险)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:522`
- `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:533`
- `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:536`
- 现象: 非飞机跳点超限时前2次丢弃第3次直接接受原始坐标。
- 直接后果: 仍可能“一下子跳出去很远”尤其在短时GPS漂移或上游坐标抖动时。
### P0-3 输出伪速度/伪航向(高风险)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:170`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:190`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:234`
- 现象: 速度/方向算不出来时直接下发默认速度和朝向40/25/20、0度
- 直接后果: 前端若按速度做插值,会出现“方向突变”或“虚假运动”。
### P1-1 飞机同批去重只按 flightNo时间戳并列时无稳定择优中高
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:415`
- `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:425`
- `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:438`
- 现象: 同 flightNo 多点时只看 `sourceTimestampMs`,并列或空时间戳时选择不稳定。
- 直接后果: 同一周期内点位可能来回切换,路径“绕一下”或折线异常。
### P1-2 速度历史缓存缺少生命周期对齐(中高)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:32`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:174`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:181`
- 现象: `positionHistory` 长驻;对象在活跃缓存被清理后,速度历史未同步清理。
- 直接后果: 对象重现时可能引用过旧历史,造成首帧速度/方向不稳定。
### P1-3 时间戳单位双轨(微秒/毫秒)增加前端误用风险(中)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/websocket/event/PositionUpdateEvent.java:32`
- `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:260`
- `qaup-collision/src/main/java/com/qaup/collision/websocket/message/UniversalMessage.java:28`
- 现象: 外层消息 `timestamp` 为微秒payload `timestamp` 为毫秒。
- 直接后果: 前端若误用外层时间做动画插值,会出现时间比例错误和轨迹异常。
### P2-1 非飞机滤波参数未配置化落地,依赖代码默认值(中)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:58`
- `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:130`
- `qaup-admin/src/main/resources/application-dev.yml:80`
- `qaup-admin/src/main/resources/application-prod.yml:58`
- 现象: 平滑参数大量由 `@Value` 默认值承载,环境缺少显式配置基线。
- 直接后果: 不同机场/地图比例下难以精细调参,稳定性受限。
### P2-2 x/y 与 lat/lon 语义依赖约定,缺少强校验(中)
- 证据:
- `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:361`
- `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/VehicleStatusAggregationService.java:64`
- 现象: 某些来源使用 x/y代码按“x=lat,y=lon”解释若上游调整语义容易引入反转问题。
- 直接后果: 轨迹漂移、跳点、方向异常。
## 3. 专业级优化方案(不改代码版建议)
### P0 立即改(先止血)
1. 统一“位置状态单写者”模型。
- 采集线程只写原始缓存,处理线程只读不回写位置。
- 速度/航向另设派生字段缓存,避免覆盖位置主状态。
2. 调整非飞机重获策略。
- 禁止“第3次直接接收原始点”改为“限步过渡”直到收敛。
- 这样可消除大跳点。
3. 去掉默认伪速度/伪航向下发。
- 不可计算时下发 `null``valid=false` 标记。
- 前端接到无效值时保持上一帧方向,位置只按真实点更新。
### P1 本周内改(提升稳定)
1. 飞机去重引入稳定择优键。
- 建议排序键: `sourceTimestampMs` -> `trackNumber` -> 与上一点距离最小。
2. 速度历史增加TTL并与对象清理联动。
- 对象清理时同步 `clearHistory(objectId)`
3. 统一消息时间语义。
- 约定只让前端使用 `payload.timestamp(ms)` 做运动插值。
- 外层时间仅用于日志/排序。
### P2 持续优化(工程化)
1. 把 `websocket.position.filter.*` 全量放入 `application-*.yml`,分环境调参。
2. 增加坐标语义守卫。
- 对 x/y 来源记录 schema 版本并做运行时一致性检测。
3. 输出质量指标。
- 每对象统计 `drop_rate`, `outlier_count`, `max_step_meter`, `reacquire_count`
## 4. 前端“专业级稳定显示”验收指标(建议)
1. 单帧位移上限: 非飞机 <= 20m可按业务调飞机 <= 80m。
2. 跳点率: `outlier_drop / total_points < 1%`
3. 抖动率: 静止目标在30秒窗口内位移标准差 <= 1m。
4. 轨迹连续性: 不允许出现“反向回跳再前进”锯齿(除非上游真实回退)。
5. 时间一致性: 前端插值仅使用毫秒时间戳,严禁混用微秒。
## 5. 其它观察
- 已抽查关键文件编码均为 UTF-8 无 BOM编码规范执行良好。
- 发现调试日志以 `error` 级别输出的临时内容,建议后续清理,避免掩盖真实异常。
## 6. 结语
该项目已经具备基础防抖和限跳框架,但要达到你要求的“前端稳定、专业级显示”,核心是先解决并发回写与重获瞬移策略,再统一速度/时间语义。按本报告的 P0 -> P1 顺序推进,可以明显降低“跳出去很远、路径绕行、频繁抖动”问题。

View File

@ -95,6 +95,9 @@ public class DataCollectorService {
@Autowired
private com.qaup.collision.dataprocessing.service.DataProcessingService dataProcessingService; // 注入数据处理服务
@Autowired
private com.qaup.collision.dataprocessing.service.SpeedCalculationService speedCalculationService;
@Autowired
private VehicleLocationFilter vehicleLocationFilter; // 注入车辆位置过滤器
@ -137,6 +140,15 @@ public class DataCollectorService {
activeMovingObjectsLastUpdatedAtMs.put(movingObject.getObjectId(), System.currentTimeMillis());
}
private void removeActiveMovingObject(String objectId) {
if (objectId == null || objectId.isBlank()) {
return;
}
activeMovingObjectsCache.remove(objectId);
activeMovingObjectsLastUpdatedAtMs.remove(objectId);
speedCalculationService.clearHistory(objectId);
}
// 无人车ID列表来自上游 HTTPGET /api/vehicle_details
private final Set<String> unmannedVehicleIds = ConcurrentHashMap.newKeySet();
@ -445,16 +457,53 @@ public class DataCollectorService {
Long existingTs = existing.getSourceTimestampMs();
Long candidateTs = candidate.getSourceTimestampMs();
if (existingTs == null && candidateTs == null) {
return true;
int timestampCompare = compareNullableLong(candidateTs, existingTs);
if (timestampCompare != 0) {
return timestampCompare > 0;
}
if (existingTs == null) {
return true;
// 时间戳并列时引入稳定择优键避免同批次点位来回切换
int trackCompare = compareNullableText(candidate.getTrackNumber(), existing.getTrackNumber());
if (trackCompare != 0) {
return trackCompare > 0;
}
if (candidateTs == null) {
return false;
return false;
}
private int compareNullableLong(Long left, Long right) {
if (left == null && right == null) {
return 0;
}
return candidateTs >= existingTs;
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return Long.compare(left, right);
}
private int compareNullableText(String left, String right) {
String normalizedLeft = left == null ? null : left.trim();
String normalizedRight = right == null ? null : right.trim();
if (normalizedLeft == null || normalizedLeft.isEmpty()) {
normalizedLeft = null;
}
if (normalizedRight == null || normalizedRight.isEmpty()) {
normalizedRight = null;
}
if (normalizedLeft == null && normalizedRight == null) {
return 0;
}
if (normalizedLeft == null) {
return -1;
}
if (normalizedRight == null) {
return 1;
}
return normalizedLeft.compareTo(normalizedRight);
}
// Fixed-delay scheduling prevents overlap without dropping cycles.
@ -1247,6 +1296,7 @@ public class DataCollectorService {
activeMovingObjectsLastUpdatedAtMs.entrySet().removeIf(entry -> {
Long last = entry.getValue();
if (last == null || last < cutoff) {
speedCalculationService.clearHistory(entry.getKey());
activeMovingObjectsCache.remove(entry.getKey());
return true;
}
@ -1262,10 +1312,7 @@ public class DataCollectorService {
.sorted(java.util.Map.Entry.comparingByValue())
.limit(needRemove)
.map(java.util.Map.Entry::getKey)
.forEach(key -> {
activeMovingObjectsCache.remove(key);
activeMovingObjectsLastUpdatedAtMs.remove(key);
});
.forEach(this::removeActiveMovingObject);
log.warn("activeMovingObjectsCache 超过最大限制 {},已丢弃最旧 {} 条以保持实时性(当前: {}",
maxCacheSize, needRemove, activeMovingObjectsCache.size());
}
@ -1312,6 +1359,7 @@ public class DataCollectorService {
activeMovingObjectsCache.clear();
activeMovingObjectsLastUpdatedAtMs.clear();
flightNotificationCache.clear();
speedCalculationService.clearAllHistory();
log.info("缓存已清理");
}
}

View File

@ -44,9 +44,6 @@ import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.PrecisionModel;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 数据处理服务
*
@ -134,8 +131,8 @@ public class DataProcessingService {
log.info("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size());
// 获取所有活跃对象的快照
List<MovingObject> currentActiveObjects = new ArrayList<>(activeMovingObjectsCache.values());
// 获取所有活跃对象的快照避免处理线程回写覆盖采集线程最新状态
List<MovingObject> currentActiveObjects = createProcessingSnapshot(activeMovingObjectsCache.values());
// 第一步计算速度和方向
calculateSpeedAndDirectionForAllObjects(currentActiveObjects);
@ -189,44 +186,12 @@ public class DataProcessingService {
movingObject.getCurrentPosition().getX(), // longitude
currentTime);
// 处理速度值 - 如果计算返回null使用合理默认值
Double finalSpeed = calculatedSpeed;
if (finalSpeed == null) {
switch (movingObject.getObjectType()) {
case AIRCRAFT:
finalSpeed = 40.0; // 航空器默认速度
break;
case SPECIAL_VEHICLE:
case NORMAL_VEHICLE:
finalSpeed = 25.0; // 机场车辆默认速度
break;
case UNMANNED_VEHICLE:
finalSpeed = 20.0; // 无人车默认速度
break;
default:
finalSpeed = 0.0;
}
log.debug("对象 {} 速度计算返回null使用默认值: {} km/h",
movingObject.getObjectId(), finalSpeed);
}
// 处理方向值 - 如果计算返回null使用合理默认值
Double finalDirection = calculatedDirection;
if (finalDirection == null) {
finalDirection = 0.0; // 默认朝北
log.debug("对象 {} 方向计算返回null使用默认值: 0.0度",
movingObject.getObjectId());
}
// 更新MovingObject的速度和方向
movingObject.setCurrentSpeed(finalSpeed);
movingObject.setCurrentHeading(finalDirection);
// 更新缓存中的对象
activeMovingObjectsCache.put(movingObject.getObjectId(), movingObject);
movingObject.setCurrentSpeed(calculatedSpeed);
movingObject.setCurrentHeading(calculatedDirection);
log.debug("对象 {} 计算完成: 速度={} km/h, 方向={}度",
movingObject.getObjectId(), finalSpeed, finalDirection);
movingObject.getObjectId(), movingObject.getCurrentSpeed(), movingObject.getCurrentHeading());
} catch (Exception e) {
log.error("计算对象 {} 的速度和方向时发生异常", movingObject.getObjectId(), e);
@ -256,31 +221,8 @@ public class DataProcessingService {
.longitude(movingObject.getCurrentPosition().getX())
.build();
// 处理速度值 - 如果为null使用合理的默认值而不是0
Double finalSpeed = movingObject.getCurrentSpeed();
if (finalSpeed == null) {
// 根据对象类型使用合理的默认速度
switch (movingObject.getObjectType()) {
case AIRCRAFT:
finalSpeed = 40.0; // 航空器默认速度
break;
case SPECIAL_VEHICLE:
case NORMAL_VEHICLE:
finalSpeed = 25.0; // 机场车辆默认速度
break;
case UNMANNED_VEHICLE:
finalSpeed = 20.0; // 无人车默认速度
break;
default:
finalSpeed = 0.0;
}
}
// 处理方向值 - 如果为null使用合理的默认值而不是0
Double finalHeading = movingObject.getCurrentHeading();
if (finalHeading == null) {
finalHeading = 0.0; // 默认朝北
}
long payloadTimestamp = movingObject.getSourceTimestampMs() != null
? movingObject.getSourceTimestampMs()
@ -291,7 +233,7 @@ public class DataProcessingService {
.objectType(movingObject.getObjectType().name())
.position(positionPayload)
.heading(finalHeading)
.speed(new BigDecimal(finalSpeed).setScale(2, RoundingMode.HALF_UP).doubleValue())
.speed(roundTo2(finalSpeed))
.timestamp(payloadTimestamp)
.build();
@ -313,6 +255,39 @@ public class DataProcessingService {
log.info("位置更新消息发送完成,发送数量: {}", activeObjects.size());
}
private List<MovingObject> createProcessingSnapshot(java.util.Collection<MovingObject> sourceObjects) {
List<MovingObject> snapshot = new ArrayList<>(sourceObjects.size());
for (MovingObject source : sourceObjects) {
if (source == null || source.getObjectId() == null || source.getObjectType() == null) {
continue;
}
Point position = source.getCurrentPosition();
if (position == null) {
continue;
}
snapshot.add(MovingObject.builder()
.objectId(source.getObjectId())
.objectType(source.getObjectType())
.objectName(source.getObjectName())
.currentPosition(geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate(
position.getX(), position.getY())))
.currentSpeed(source.getCurrentSpeed())
.currentHeading(source.getCurrentHeading())
.altitude(source.getAltitude())
.sourceTimestampMs(source.getSourceTimestampMs())
.build());
}
return snapshot;
}
private Double roundTo2(Double value) {
if (value == null) {
return null;
}
return Math.round(value * 100.0) / 100.0;
}
/**
* 执行违规检测
*/

View File

@ -533,8 +533,17 @@ public class WebSocketMessageBroadcaster {
return null;
} else {
state.rejectedCount = 0;
filteredLatitude = latitude;
filteredLongitude = longitude;
double transitionStepLimit = Math.max(maxAllowedJump, adaptive.jitterMeter() * 2.0);
if (rawDistance > transitionStepLimit) {
double ratio = transitionStepLimit / rawDistance;
filteredLatitude = state.lastFilteredLatitude + ratio * (latitude - state.lastFilteredLatitude);
filteredLongitude = state.lastFilteredLongitude + ratio * (longitude - state.lastFilteredLongitude);
log.warn("[TMP-POS-FIX] reason=non_aircraft_jump_step_limit objectId={} rawLatitude={} rawLongitude={} jumpMeter={} allowedStepMeter={}",
safeObjectId(payload), rawLatitude, rawLongitude, rawDistance, transitionStepLimit);
} else {
filteredLatitude = latitude;
filteredLongitude = longitude;
}
}
}