From 43bf9488d6c85ac597961b1f3ae2e547208f3f1a Mon Sep 17 00:00:00 2001 From: sladro Date: Thu, 30 Apr 2026 09:15:51 +0800 Subject: [PATCH] Update collision logging and route assignment handling --- AGENTS.md | 24 + .../service/DataCollectorService.java | 16 +- .../service/RoutePersistenceService.java | 13 +- .../service/RoutePreparationService.java | 3 + .../service/AirportCoordinateSystem.java | 39 + .../service/CoordinateSystemService.java | 8 + .../service/DataProcessingService.java | 80 +- .../event/ConflictAlertEventListener.java | 35 +- .../model/dto/ConflictAlertEvent.java | 11 +- .../ObjectRouteAssignmentRepository.java | 12 +- .../service/PathConflictDetectionService.java | 1062 +++++++++++++-- .../service/VehicleCommandService.java | 277 +++- .../CollisionTestSessionLogService.java | 135 ++ .../service/PlatformRuntimeStateService.java | 71 + .../RuleEventWebSocketPublisher.java | 15 +- .../WebSocketMessageBroadcaster.java | 12 +- .../message/MessageTypeConstants.java | 7 +- .../message/PathConflictAlertMessage.java | 59 +- .../PlatformIntegrationControllerTest.java | 23 +- .../service/RoutePersistenceServiceTest.java | 63 + .../service/RoutePreparationServiceTest.java | 7 +- ...ssingServiceCollisionRegistrationTest.java | 38 + .../event/ConflictAlertEventListenerTest.java | 81 ++ .../PathConflictDetectionDirectionalTest.java | 1150 ++++++++++++++++- ...lictDetectionServiceRuntimeConfigTest.java | 10 +- .../service/VehicleCommandServiceTest.java | 378 +++++- ...VehicleCommandServiceTrafficLightTest.java | 9 +- .../CollisionTestSessionLogServiceTest.java | 97 ++ .../PlatformRuntimeStateServiceTest.java | 35 + .../WebSocketMessageBroadcasterTest.java | 21 + 30 files changed, 3602 insertions(+), 189 deletions(-) create mode 100644 qaup-collision/src/main/java/com/qaup/collision/service/CollisionTestSessionLogService.java create mode 100644 qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePersistenceServiceTest.java create mode 100644 qaup-collision/src/test/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListenerTest.java create mode 100644 qaup-collision/src/test/java/com/qaup/collision/service/CollisionTestSessionLogServiceTest.java diff --git a/AGENTS.md b/AGENTS.md index 20f886c..9ad26ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,30 @@ 这是开发环境,运行环境在centos7的容器里,分别为 qaup-app qaup-redis +qaup-postgis + +### Docker/日志/数据库定位 +- 后端应用容器:`qaup-app`。 +- Redis 容器:`qaup-redis`。 +- PostGIS/PostgreSQL 容器:`qaup-postgis`。 +- 后端主日志优先看宿主机路径:`/home/project_20250804/offline-deploy/logs/app/sys-info.log`,历史滚动日志类似 `sys-info.2026-04-28.2.log`。 +- 容器 overlay 中也可能有同一份日志:`/var/lib/docker/overlay2/.../merged/logs/sys-info*.log`。 +- 碰撞测试独立日志在 qaup-app 容器内:`/logs/collision-tests/`。启动时主日志会打印:`Collision test session file log initialized: enabled=true, directory=/logs/collision-tests`。 +- 按车牌或航班查测试日志:`docker exec qaup-app sh -c 'grep -R -l "TEST003" /logs/collision-tests 2>/dev/null'`。 +- `/home/project_20250804/qaup/logs` 下看到的 `mock_traffic_light.log`、`mock_unmanned_vehicle.log` 是模拟器日志,不是 qaup-app 主应用日志。 +- qaup-app 容器内通常没有 `psql`,查库优先用:`docker exec qaup-postgis psql -U postgres -d qaup -c "SQL"`。 +- 已确认数据库环境变量示例:`DB_HOST=172.17.0.4`、`DB_PORT=5432`、`DB_USERNAME=postgres`、`DB_PASSWORD=123456`、`DB_NAME=qaup`。 + +### 碰撞测试流程排查要点 +- `/api/VehicleRegistry` 是全量覆盖注册,不是增量追加。 +- 测试会话日志关键字:`collision test session started`、`collision test session replaced`、`collision-diagnostic`。 +- 诊断日志中重点看:`collisionManagedObjects`、`pairsSupported`、`missingRoute`、`routeDeviation`、`speedTooLow`、`thresholdNotReached`、`eventsPublished`。 +- 如果 `pairsTotal=0`,说明参与碰撞检测的对象不足两个,先查注册对象和实时位置对象是否匹配。 +- 如果 `routeDeviation>0`,说明实时位置距离后端绑定路线过远,优先查本轮路线绑定是否正确。 +- 路径绑定表是 `object_route_assignments`,真实字段为:`id`、`object_type`、`object_name`、`assigned_route_id`、`assigned_at`。不要使用 `object_id`、`route_id`、`route_name` 这些不存在的字段。 +- 查对象路线绑定示例:`docker exec qaup-postgis psql -U postgres -d qaup -c "select object_type, object_name, assigned_route_id, assigned_at from object_route_assignments where object_name in ('MU2465','TEST003') order by assigned_at desc;"` +- 如需看路线名称,连接 `transport_routes`:`docker exec qaup-postgis psql -U postgres -d qaup -c "select a.object_type, a.object_name, a.assigned_route_id, r.name, a.assigned_at from object_route_assignments a left join transport_routes r on r.id = a.assigned_route_id where a.object_name in ('MU2465','TEST003') order by a.assigned_at desc;"` +- 历史上曾出现同一对象编号跨类型或旧路线残留导致误判,注册覆盖时需要同步清理旧路径绑定。 ### 红绿灯接入说明 - 正式环境中,qaup-app 的红绿灯接入方式为 MQTT,不是 HTTP。 diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java index 6f90b27..18037c4 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java @@ -569,7 +569,7 @@ public class DataCollectorService { upsertActiveMovingObject(movingObject); - log.debug("处理航空器数据并更新缓存: (航班号: {}, 位置: {}, {}, 速度: {})", + log.trace("处理航空器数据并更新缓存: (航班号: {}, 位置: {}, {}, 速度: {})", aircraft.getObjectId(), currentPosition.getX(), currentPosition.getY(), @@ -580,7 +580,7 @@ public class DataCollectorService { } } - log.info("航空器数据处理和事件发布完成,处理数量: {}", deduplicatedAircrafts.size()); + log.trace("航空器数据处理和事件发布完成,处理数量: {}", deduplicatedAircrafts.size()); } catch (Exception e) { log.error("采集航空器数据异常", e); @@ -612,7 +612,7 @@ public class DataCollectorService { } int originalCount = vehicles.size(); - log.info("采集到 {} 条机场车辆数据,开始过滤处理", originalCount); + log.trace("采集到 {} 条机场车辆数据,开始过滤处理", originalCount); // 应用车辆位置过滤器 List filteredVehicles = vehicleLocationFilter.filterVehicles(vehicles); @@ -656,7 +656,7 @@ public class DataCollectorService { // 将最新数据更新到缓存(不发送WebSocket消息,统一在周期性检测中发送) upsertActiveMovingObject(movingObject); - log.debug("处理机场车辆数据并更新缓存: (车牌号: {}, 位置: {}, {}, 速度: {})", + log.trace("处理机场车辆数据并更新缓存: (车牌号: {}, 位置: {}, {}, 速度: {})", vehicle.getObjectId(), currentPosition.getX(), currentPosition.getY(), @@ -667,7 +667,7 @@ public class DataCollectorService { } } - log.info("机场车辆数据处理和事件发布完成,处理数量: {}", filteredVehicles.size()); + log.trace("机场车辆数据处理和事件发布完成,处理数量: {}", filteredVehicles.size()); } catch (Exception e) { log.error("采集机场车辆数据异常", e); @@ -710,7 +710,7 @@ public class DataCollectorService { return; } - log.debug("开始采集 {} 辆无人车的状态数据(HTTP轮询)", unmannedVehicleIds.size()); + log.trace("开始采集 {} 辆无人车的状态数据(HTTP轮询)", unmannedVehicleIds.size()); int successCount = 0; for (String vehicleId : unmannedVehicleIds) { @@ -838,7 +838,7 @@ public class DataCollectorService { } successCount++; - log.debug("处理无人车完整状态数据并更新缓存: (车辆ID: {}, 位置: {}, {}, 任务ID: {}, 里程: {}米, 电量: {}%)", + log.trace("处理无人车完整状态数据并更新缓存: (车辆ID: {}, 位置: {}, {}, 任务ID: {}, 里程: {}米, 电量: {}%)", vehicleId, currentPosition.getX(), currentPosition.getY(), @@ -851,7 +851,7 @@ public class DataCollectorService { } } - log.debug("无人车状态数据采集完成,成功处理: {}/{}", successCount, unmannedVehicleIds.size()); + log.trace("无人车状态数据采集完成,成功处理: {}/{}", successCount, unmannedVehicleIds.size()); } catch (Exception e) { log.error("采集无人车数据异常", e); diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePersistenceService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePersistenceService.java index abe8d63..8526578 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePersistenceService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePersistenceService.java @@ -201,7 +201,10 @@ public class RoutePersistenceService { // 如果路由ID完全相同,则跳过重复记录 if (existingRouteId.equals(routeId)) { - log.info("路由分配未变更,跳过重复记录: objectName={}, routeType={}, routeId={}", + ObjectRouteAssignment assignment = existingAssignment.get(); + assignment.setAssignedAt(LocalDateTime.now()); + objectRouteAssignmentRepository.save(assignment); + log.info("路由分配未变更,已刷新分配时间: objectName={}, routeType={}, routeId={}", objectName, routeType, routeId); return; } @@ -210,7 +213,11 @@ public class RoutePersistenceService { Optional existingRoute = transportRouteRepository.findById(existingRouteId); if (existingRoute.isPresent() && existingRoute.get().getRouteName().equals(currentRouteName)) { - log.info("路由名称相同,跳过重复记录: objectName={}, routeName={}, existingRouteId={}, currentRouteId={}", + ObjectRouteAssignment assignment = existingAssignment.get(); + assignment.setAssignedRouteId(routeId); + assignment.setAssignedAt(LocalDateTime.now()); + objectRouteAssignmentRepository.save(assignment); + log.info("路由名称相同,已刷新分配记录: objectName={}, routeName={}, existingRouteId={}, currentRouteId={}", objectName, currentRouteName, existingRouteId, routeId); return; } @@ -233,4 +240,4 @@ public class RoutePersistenceService { objectName, routeType, routeId, e); } } -} \ No newline at end of file +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java index 5b96a27..1ecb136 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java @@ -135,6 +135,9 @@ public class RoutePreparationService { ); if (existingAssignment.isPresent() && Objects.equals(existingAssignment.get().getAssignedRouteId(), routeId)) { + ObjectRouteAssignment assignment = existingAssignment.get(); + assignment.setAssignedAt(LocalDateTime.now()); + objectRouteAssignmentRepository.save(assignment); return; } diff --git a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/AirportCoordinateSystem.java b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/AirportCoordinateSystem.java index d38da58..708d5a1 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/AirportCoordinateSystem.java +++ b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/AirportCoordinateSystem.java @@ -10,7 +10,10 @@ import org.locationtech.jts.geom.Coordinate; public class AirportCoordinateSystem { private final CoordinateReferenceSystem sourceCRS; private final CoordinateReferenceSystem targetCRS; + private final CoordinateReferenceSystem airportProjectedCRS; private final MathTransform transform; + private final MathTransform airportProjectedTransform; + private final MathTransform airportProjectedToWgs84Transform; // 本地坐标系原点 private final double[] origin; @@ -19,9 +22,15 @@ public class AirportCoordinateSystem { this.sourceCRS = CRS.decode("EPSG:4326",true); String utmCode = calculateUtmZone(centerLon, centerLat); this.targetCRS = CRS.decode(utmCode); + int gaussKrugerZone = (int) Math.round(centerLon / 3.0); + double centralMeridian = gaussKrugerZone * 3.0; + double falseEasting = gaussKrugerZone * 1_000_000.0 + 500_000.0; + this.airportProjectedCRS = CRS.parseWKT(buildGaussKrugerWkt(centralMeridian, falseEasting)); // 创建转换器并计算原点 this.transform = CRS.findMathTransform(sourceCRS, targetCRS); + this.airportProjectedTransform = CRS.findMathTransform(sourceCRS, airportProjectedCRS); + this.airportProjectedToWgs84Transform = CRS.findMathTransform(airportProjectedCRS, sourceCRS); this.origin = transformCoordinate(centerLon, centerLat); } @@ -34,6 +43,20 @@ public class AirportCoordinateSystem { }; } + public double[] convertToAirportProjected(double lon, double lat) throws Exception { + Coordinate source = new Coordinate(lon, lat); + Coordinate target = new Coordinate(); + JTS.transform(source, target, airportProjectedTransform); + return new double[]{target.x, target.y}; + } + + public double[] convertAirportProjectedToWgs84(double x, double y) throws Exception { + Coordinate source = new Coordinate(x, y); + Coordinate target = new Coordinate(); + JTS.transform(source, target, airportProjectedToWgs84Transform); + return new double[]{target.x, target.y}; + } + private double[] transformCoordinate(double lon, double lat) throws Exception { Coordinate source = new Coordinate(lon, lat); Coordinate target = new Coordinate(); @@ -46,4 +69,20 @@ public class AirportCoordinateSystem { // 北半球编码 return "EPSG:326" + (zone < 10 ? "0" : "") + zone; } + + private static String buildGaussKrugerWkt(double centralMeridian, double falseEasting) { + return "PROJCS[\"Airport_Gauss_Kruger\"," + + "GEOGCS[\"WGS 84\"," + + "DATUM[\"WGS_1984\"," + + "SPHEROID[\"WGS 84\",6378137,298.257223563]]," + + "PRIMEM[\"Greenwich\",0]," + + "UNIT[\"degree\",0.0174532925199433]]," + + "PROJECTION[\"Transverse_Mercator\"]," + + "PARAMETER[\"latitude_of_origin\",0]," + + "PARAMETER[\"central_meridian\"," + centralMeridian + "]," + + "PARAMETER[\"scale_factor\",1]," + + "PARAMETER[\"false_easting\"," + falseEasting + "]," + + "PARAMETER[\"false_northing\",0]," + + "UNIT[\"metre\",1]]"; + } } diff --git a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/CoordinateSystemService.java b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/CoordinateSystemService.java index 78cad8b..7ade2d9 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/CoordinateSystemService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/CoordinateSystemService.java @@ -28,4 +28,12 @@ public class CoordinateSystemService { public double[] convertToLocalCoordinate(double longitude, double latitude) throws Exception { return airportCoordinateSystem.convertToLocal(longitude, latitude); } + + public double[] convertToAirportProjectedCoordinate(double longitude, double latitude) throws Exception { + return airportCoordinateSystem.convertToAirportProjected(longitude, latitude); + } + + public double[] convertAirportProjectedToWgs84Coordinate(double x, double y) throws Exception { + return airportCoordinateSystem.convertAirportProjectedToWgs84(x, y); + } } diff --git a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java index ec93a8e..5d4d7d3 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java @@ -15,6 +15,7 @@ import com.qaup.collision.websocket.message.VehicleStatusUpdatePayload; import com.qaup.collision.websocket.event.VehicleStatusUpdateEvent; import com.qaup.collision.datacollector.service.DataCollectorService; import com.qaup.collision.common.model.FlightNotification; +import com.qaup.collision.service.CollisionTestSessionLogService; import com.qaup.collision.service.PlatformRuntimeStateService; import com.qaup.collision.websocket.event.FlightNotificationEvent; import com.qaup.common.core.redis.RedisCache; @@ -101,6 +102,9 @@ public class DataProcessingService { @Autowired private PlatformRuntimeStateService platformRuntimeStateService; + @Autowired(required = false) + private CollisionTestSessionLogService collisionTestSessionLogService; + // 从DataCollectorService获取缓存的引用 private Map activeMovingObjectsCache; private final Map lastPublishedPositionSampleTimestamps = new ConcurrentHashMap<>(); @@ -143,7 +147,7 @@ public class DataProcessingService { return; } - log.info("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size()); + log.trace("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size()); // 获取所有活跃对象的快照,避免处理线程回写覆盖采集线程最新状态 List currentActiveObjects = createProcessingSnapshot(activeMovingObjectsCache.values()); @@ -182,7 +186,7 @@ public class DataProcessingService { logCycleDiagnostics(currentActiveObjects.size(), collisionManagedObjects.size(), positionDispatchSummary, collisionManagedResult, conflictDetectionSummary); - log.info("周期性数据处理完成"); + log.trace("周期性数据处理完成"); } private CollisionManagedObjectsResult filterCollisionManagedObjects(List activeObjects) { @@ -247,7 +251,7 @@ public class DataProcessingService { } long currentTime = System.currentTimeMillis(); - log.debug("开始为 {} 个对象计算速度和方向", activeObjects.size()); + log.trace("开始为 {} 个对象计算速度和方向", activeObjects.size()); for (MovingObject movingObject : activeObjects) { try { @@ -269,7 +273,7 @@ public class DataProcessingService { movingObject.setCurrentSpeed(calculatedSpeed); movingObject.setCurrentHeading(calculatedDirection); - log.debug("对象 {} 计算完成: 速度={} km/h, 方向={}度", + log.trace("对象 {} 计算完成: 速度={} km/h, 方向={}度", movingObject.getObjectId(), movingObject.getCurrentSpeed(), movingObject.getCurrentHeading()); } catch (Exception e) { @@ -277,7 +281,7 @@ public class DataProcessingService { } } - log.debug("所有对象的速度和方向计算完成"); + log.trace("所有对象的速度和方向计算完成"); } /** @@ -285,12 +289,12 @@ public class DataProcessingService { */ private PositionDispatchSummary sendPositionUpdatesForActiveObjects(List activeObjects) { if (activeObjects.isEmpty()) { - log.debug("没有活跃对象,跳过位置更新消息发送"); + log.trace("没有活跃对象,跳过位置更新消息发送"); return new PositionDispatchSummary(0, 0, 0); } long currentTime = System.currentTimeMillis(); - log.debug("发送位置更新消息,对象数量: {}", activeObjects.size()); + log.trace("发送位置更新消息,对象数量: {}", activeObjects.size()); int publishedCount = 0; int duplicateSampleSkippedCount = 0; Set currentObjectIds = new HashSet<>(); @@ -329,7 +333,7 @@ public class DataProcessingService { eventPublisher.publishEvent(new PositionUpdateEvent(payload)); publishedCount++; - log.debug("发送位置更新: {} ({}), 位置: ({}, {}), 速度: {}", + log.trace("发送位置更新: {} ({}), 位置: ({}, {}), 速度: {}", movingObject.getObjectId(), movingObject.getObjectType(), movingObject.getCurrentPosition().getX(), @@ -342,7 +346,7 @@ public class DataProcessingService { } cleanupPublishedPositionState(currentObjectIds); - log.info("位置更新消息发送完成,发送数量: {}, 跳过重复采样数量: {}", publishedCount, duplicateSampleSkippedCount); + log.trace("位置更新消息发送完成,发送数量: {}, 跳过重复采样数量: {}", publishedCount, duplicateSampleSkippedCount); return new PositionDispatchSummary(activeObjects.size(), publishedCount, duplicateSampleSkippedCount); } @@ -378,7 +382,7 @@ public class DataProcessingService { log.info( "[test-session={}] [collision-diagnostic] activeObjects={}, collisionManagedObjects={}, positionPublished={}, " + "positionDuplicateSkipped={}, registrationFiltered={}, typeOverrides={}, pairsTotal={}, pairsSupported={}, missingRoute={}, missingPosition={}, " - + "routeDeviation={}, speedTooLow={}, noIntersection={}, intersectionBehind={}, headingMismatch={}, " + + "routeDeviation={}, speedTooLow={}, noIntersection={}, intersectionBehind={}, headingMismatch={}, directionLockFailed={}, " + "thresholdNotReached={}, detectionErrors={}, eventsPublished={}", testSessionId != null ? testSessionId : "none", activeObjectCount, @@ -396,10 +400,36 @@ public class DataProcessingService { conflictDetectionSummary.noIntersectionPairs(), conflictDetectionSummary.intersectionBehindPairs(), conflictDetectionSummary.headingMismatchPairs(), + conflictDetectionSummary.directionLockFailedPairs(), conflictDetectionSummary.thresholdNotReachedPairs(), conflictDetectionSummary.errorPairs(), conflictDetectionSummary.eventsPublished() ); + + if (collisionTestSessionLogService != null && testSessionId != null) { + collisionTestSessionLogService.write( + testSessionId, + "CYCLE activeObjects=" + activeObjectCount + + ", collisionManagedObjects=" + collisionManagedObjectCount + + ", positionPublished=" + positionDispatchSummary.publishedCount() + + ", positionDuplicateSkipped=" + positionDispatchSummary.duplicateSampleSkippedCount() + + ", registrationFiltered=" + collisionManagedResult.registrationFilteredCount() + + ", typeOverrides=" + collisionManagedResult.typeOverrideCount() + + ", pairsTotal=" + conflictDetectionSummary.totalPairs() + + ", pairsSupported=" + conflictDetectionSummary.supportedPairs() + + ", missingRoute=" + conflictDetectionSummary.missingRoutePairs() + + ", missingPosition=" + conflictDetectionSummary.missingPositionPairs() + + ", routeDeviation=" + conflictDetectionSummary.routeDeviationPairs() + + ", speedTooLow=" + conflictDetectionSummary.speedTooLowPairs() + + ", noIntersection=" + conflictDetectionSummary.noIntersectionPairs() + + ", intersectionBehind=" + conflictDetectionSummary.intersectionBehindPairs() + + ", headingMismatch=" + conflictDetectionSummary.headingMismatchPairs() + + ", directionLockFailed=" + conflictDetectionSummary.directionLockFailedPairs() + + ", thresholdNotReached=" + conflictDetectionSummary.thresholdNotReachedPairs() + + ", detectionErrors=" + conflictDetectionSummary.errorPairs() + + ", eventsPublished=" + conflictDetectionSummary.eventsPublished() + ); + } } private List createProcessingSnapshot(java.util.Collection sourceObjects) { @@ -413,12 +443,38 @@ public class DataProcessingService { continue; } + Point copiedPosition = geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate( + position.getX(), position.getY())); + if (source instanceof UnmannedVehicle unmannedVehicle) { + snapshot.add(UnmannedVehicle.builder() + .objectId(source.getObjectId()) + .objectType(source.getObjectType()) + .objectName(source.getObjectName()) + .currentPosition(copiedPosition) + .currentSpeed(source.getCurrentSpeed()) + .currentHeading(source.getCurrentHeading()) + .altitude(source.getAltitude()) + .sourceTimestampMs(source.getSourceTimestampMs()) + .batteryLevel(unmannedVehicle.getBatteryLevel()) + .vehicleStatus(unmannedVehicle.getVehicleStatus()) + .missionId(unmannedVehicle.getMissionId()) + .missionStatus(unmannedVehicle.getMissionStatus()) + .targetPosition(unmannedVehicle.getTargetPosition()) + .missionType(unmannedVehicle.getMissionType()) + .missionStartTime(unmannedVehicle.getMissionStartTime()) + .estimatedEndTime(unmannedVehicle.getEstimatedEndTime()) + .progress(unmannedVehicle.getProgress()) + .totalMileage(unmannedVehicle.getTotalMileage()) + .waypoints(unmannedVehicle.getWaypoints()) + .build()); + 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()))) + .currentPosition(copiedPosition) .currentSpeed(source.getCurrentSpeed()) .currentHeading(source.getCurrentHeading()) .altitude(source.getAltitude()) diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java index 8e1c811..43532c1 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java @@ -50,6 +50,13 @@ public class ConflictAlertEventListener { private void sendWebSocketAlert(ConflictAlertEvent event) { try { + PathConflictAlertMessage.Position conflictPoint = event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null + ? PathConflictAlertMessage.Position.builder() + .latitude(event.getConflictPointLatitude()) + .longitude(event.getConflictPointLongitude()) + .build() + : null; + PathConflictAlertMessage message = PathConflictAlertMessage.builder() .conflictId(event.getConflictId().orElse(null)) .alertType(event.getAlertType().orElse(null)) @@ -63,14 +70,19 @@ public class ConflictAlertEventListener { .objectName(event.getObject2Name()) .objectType(event.getObject2Type().name()) .build()) - .position(event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null - ? PathConflictAlertMessage.Position.builder() - .latitude(event.getConflictPointLatitude()) - .longitude(event.getConflictPointLongitude()) - .build() - : null) - .object1Distance(new BigDecimal(event.getObject1Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) - .object2Distance(new BigDecimal(event.getObject2Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) + .position(conflictPoint) + .conflictPoint(conflictPoint) + .aircraftName(event.getAircraftName()) + .aircraftObjectType(event.getAircraftObjectType()) + .aircraftDistanceToConflictMeters(roundMeters(event.getAircraftDistanceToConflictMeters())) + .aircraftAlertThresholdMeters(event.getAircraftAlertThresholdMeters()) + .vehicleName(event.getVehicleName()) + .vehicleObjectType(event.getVehicleObjectType()) + .vehicleDistanceToConflictMeters(roundMeters(event.getVehicleDistanceToConflictMeters())) + .vehicleAlertThresholdMeters(event.getVehicleAlertThresholdMeters()) + .vehicleMovingTowardConflictPoint(event.getVehicleMovingTowardConflictPoint()) + .object1Distance(roundMeters(event.getObject1Distance())) + .object2Distance(roundMeters(event.getObject2Distance())) .timeToConflict1(event.getEstimatedTimeToConflictObj1()) .timeToConflict2(event.getEstimatedTimeToConflictObj2()) .timeGap(event.getTimeGapSeconds()) @@ -83,4 +95,11 @@ public class ConflictAlertEventListener { log.error("Failed to send WebSocket path conflict alert: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null), e); } } + + private Double roundMeters(Double value) { + if (value == null) { + return null; + } + return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue(); + } } diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/model/dto/ConflictAlertEvent.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/model/dto/ConflictAlertEvent.java index ea0c6ae..82a13f0 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/model/dto/ConflictAlertEvent.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/model/dto/ConflictAlertEvent.java @@ -31,6 +31,15 @@ public class ConflictAlertEvent { private Integer estimatedTimeToConflictObj2; // 对象2预计到达冲突点的时间 (秒) private Double timeGapSeconds; // 两个对象到达冲突点的时间差 (秒) private LocalDateTime eventTime; // 事件发生时间 + private String aircraftName; + private String aircraftObjectType; + private Double aircraftDistanceToConflictMeters; + private Double aircraftAlertThresholdMeters; + private String vehicleName; + private String vehicleObjectType; + private Double vehicleDistanceToConflictMeters; + private Double vehicleAlertThresholdMeters; + private Boolean vehicleMovingTowardConflictPoint; // 辅助方法,用于判断告警级别 public boolean isEmergencyAlert() { @@ -52,4 +61,4 @@ public class ConflictAlertEvent { public Double getConflictPointLongitude() { return conflictPoint != null ? conflictPoint.getX() : null; } -} \ No newline at end of file +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/repository/ObjectRouteAssignmentRepository.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/repository/ObjectRouteAssignmentRepository.java index 43f301f..a695811 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/repository/ObjectRouteAssignmentRepository.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/repository/ObjectRouteAssignmentRepository.java @@ -2,6 +2,7 @@ package com.qaup.collision.pathconflict.repository; import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -42,4 +43,13 @@ public interface ObjectRouteAssignmentRepository extends JpaRepository objectNames); + + @Modifying + void deleteByObjectNameAndObjectTypeNot( + String objectName, + ObjectRouteAssignment.ObjectType objectType + ); +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java index f0e9142..41559bd 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java @@ -10,7 +10,10 @@ import com.qaup.collision.pathconflict.model.entity.TransportRoute; import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import com.qaup.collision.service.CollisionTestSessionLogService; import com.qaup.collision.service.PlatformRuntimeStateService; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; +import com.qaup.collision.websocket.message.PathConflictAlertMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; @@ -21,14 +24,19 @@ import org.locationtech.jts.geom.MultiPoint; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.operation.distance.DistanceOp; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @Service @@ -42,24 +50,36 @@ public class PathConflictDetectionService { private final CoordinateSystemService coordinateSystemService; private final PlatformRuntimeStateService platformRuntimeStateService; private final VehicleCommandService vehicleCommandService; + private final RuleEventWebSocketPublisher webSocketPublisher; + + @Autowired(required = false) + private CollisionTestSessionLogService collisionTestSessionLogService; + + private final Map routeTravelDirections = new ConcurrentHashMap<>(); + private final Map pathConflictStatusPublishedAt = new ConcurrentHashMap<>(); + private volatile String observedDirectionSessionId; private static final int MAX_PREDICTION_TIME_SECONDS = 300; + private static final long PATH_CONFLICT_STATUS_INTERVAL_MS = 2_000L; // Strong accuracy guards private static final double MIN_FORWARD_DISTANCE_METERS = 3.0; private static final double MAX_ROUTE_DEVIATION_METERS = 200.0; - private static final double HEADING_ALIGNMENT_MIN_COS = 0.0; - private static final double HEADING_CHECK_MIN_SPEED_KPH = 3.0; private static final double MIN_EFFECTIVE_SPEED_KPH = 1.0; + private static final String DIRECTION_LOCKED = "DIRECTION_LOCKED"; + private static final String DIRECTION_INVALID = "DIRECTION_INVALID"; + private static final String DIRECTION_LOCK_FAILED = "DIRECTION_LOCK_FAILED"; public ConflictDetectionSummary detectPathConflicts(List activeObjects) { if (activeObjects == null) { - return new ConflictDetectionSummary(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + return new ConflictDetectionSummary(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); } - log.debug("Starting path conflict detection for {} active objects", activeObjects.size()); + clearRouteTravelDirectionsOnTestSessionChange(); + log.debug("Starting path conflict detection for {} active objects", activeObjects.size()); List detectedAlertEvents = new ArrayList<>(); + List recoveryObservations = new ArrayList<>(); DetectionCounters counters = new DetectionCounters(); for (int i = 0; i < activeObjects.size(); i++) { @@ -70,6 +90,7 @@ public class PathConflictDetectionService { PairDetectionResult detectionResult = detectConflictBetweenObjects(obj1, obj2); counters.record(detectionResult.reason()); detectionResult.alertEvent().ifPresent(detectedAlertEvents::add); + detectionResult.recoveryObservation().ifPresent(recoveryObservations::add); } } @@ -79,10 +100,12 @@ public class PathConflictDetectionService { event.getConflictId(), event.getAlertType(), event.getAlertLevel()); } - try { - vehicleCommandService.synchronizeConflictCommands(detectedAlertEvents); - } catch (Exception e) { - log.error("Failed to synchronize vehicle commands for conflict events", e); + if (vehicleCommandService != null) { + try { + vehicleCommandService.synchronizeConflictCommands(detectedAlertEvents, recoveryObservations); + } catch (Exception e) { + log.error("Failed to synchronize vehicle commands for conflict events", e); + } } log.info("Path conflict detection completed, {} events published", detectedAlertEvents.size()); @@ -96,6 +119,7 @@ public class PathConflictDetectionService { try { if (obj1.getCurrentPosition() == null || obj2.getCurrentPosition() == null) { + writeTestSessionLog("PAIR_RESULT result=MISSING_POSITION, pair=" + pairLabel(obj1, obj2)); return PairDetectionResult.of(PairOutcomeReason.MISSING_POSITION); } @@ -103,49 +127,74 @@ public class PathConflictDetectionService { TransportRoute route2 = getObjectRoute(obj2); if (route1 == null || route2 == null || route1.getRouteGeometry() == null || route2.getRouteGeometry() == null) { + writeTestSessionLog("PAIR_RESULT result=MISSING_ROUTE, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); return PairDetectionResult.of(PairOutcomeReason.MISSING_ROUTE); } - LocalRoute localRoute1 = toLocalRoute(route1); - LocalRoute localRoute2 = toLocalRoute(route2); + CoordinateMode coordinateMode = isProjectedRoute(route1.getRouteGeometry().getCoordinates()) + || isProjectedRoute(route2.getRouteGeometry().getCoordinates()) + ? CoordinateMode.AIRPORT_PROJECTED + : CoordinateMode.LOCAL; + + LocalRoute localRoute1 = toLocalRoute(route1, coordinateMode); + LocalRoute localRoute2 = toLocalRoute(route2, coordinateMode); if (localRoute1 == null || localRoute2 == null) { + writeTestSessionLog("PAIR_RESULT result=MISSING_ROUTE_AFTER_COORDINATE_CONVERSION, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); return PairDetectionResult.of(PairOutcomeReason.MISSING_ROUTE); } - Point localObjPos1 = toLocalPoint(obj1.getCurrentPosition()); - Point localObjPos2 = toLocalPoint(obj2.getCurrentPosition()); + List intersectionPoints = calculateRouteIntersections(route1, route2); + if (intersectionPoints.isEmpty()) { + writeTestSessionLog("PAIR_RESULT result=NO_INTERSECTION, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); + return PairDetectionResult.of(PairOutcomeReason.NO_INTERSECTION); + } + + Point localObjPos1 = toComparablePoint(obj1.getCurrentPosition(), coordinateMode); + Point localObjPos2 = toComparablePoint(obj2.getCurrentPosition(), coordinateMode); if (localObjPos1 == null || localObjPos2 == null) { + writeTestSessionLog("PAIR_RESULT result=MISSING_POSITION_AFTER_COORDINATE_CONVERSION, pair=" + pairLabel(obj1, obj2)); return PairDetectionResult.of(PairOutcomeReason.MISSING_POSITION); } ProjectedPosition projected1 = projectToRoute(localObjPos1, localRoute1.localLine); ProjectedPosition projected2 = projectToRoute(localObjPos2, localRoute2.localLine); if (projected1 == null || projected2 == null) { + writeTestSessionLog("PAIR_RESULT result=MISSING_ROUTE_PROJECTION, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); return PairDetectionResult.of(PairOutcomeReason.MISSING_ROUTE); } if (projected1.lateralDistanceMeters > MAX_ROUTE_DEVIATION_METERS || projected2.lateralDistanceMeters > MAX_ROUTE_DEVIATION_METERS) { + for (Point intersectionPoint : intersectionPoints) { + publishPathConflictStatusIfDue( + obj1, + obj2, + toExternalPoint(intersectionPoint), + null, + false, + false + ); + } + writeTestSessionLog("PAIR_RESULT result=ROUTE_DEVIATION_TOO_LARGE, pair=" + pairLabel(obj1, obj2) + + ", obj1LateralDistanceMeters=" + fmt(projected1.lateralDistanceMeters) + + ", obj2LateralDistanceMeters=" + fmt(projected2.lateralDistanceMeters) + + ", maxRouteDeviationMeters=" + fmt(MAX_ROUTE_DEVIATION_METERS)); return PairDetectionResult.of(PairOutcomeReason.ROUTE_DEVIATION_TOO_LARGE); } double speed1Kph = normalizeSpeedKph(obj1.getCurrentSpeed()); double speed2Kph = normalizeSpeedKph(obj2.getCurrentSpeed()); - // Ignore pairs that are effectively static. - if (speed1Kph < MIN_EFFECTIVE_SPEED_KPH || speed2Kph < MIN_EFFECTIVE_SPEED_KPH) { - return PairDetectionResult.of(PairOutcomeReason.SPEED_TOO_LOW); - } - - List intersectionPoints = calculateRouteIntersections(route1, route2); - if (intersectionPoints.isEmpty()) { - return PairDetectionResult.of(PairOutcomeReason.NO_INTERSECTION); - } - - boolean intersectionBehind = false; - boolean headingMismatch = false; - boolean thresholdNotReached = false; + List projectedIntersections = new ArrayList<>(); for (Point intersectionPoint : intersectionPoints) { - Point localIntersection = toLocalPoint(intersectionPoint); + Point localIntersection = toComparablePoint(intersectionPoint, coordinateMode); if (localIntersection == null) { continue; } @@ -155,42 +204,209 @@ public class PathConflictDetectionService { if (projectedIntersection1 == null || projectedIntersection2 == null) { continue; } + projectedIntersections.add(new IntersectionProjection( + intersectionPoint, + localIntersection, + projectedIntersection1, + projectedIntersection2 + )); + } + if (projectedIntersections.isEmpty()) { + writeTestSessionLog("PAIR_RESULT result=NO_VALID_INTERSECTION_PROJECTION, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); + return PairDetectionResult.of(PairOutcomeReason.NO_INTERSECTION); + } - double forwardDistance1 = projectedIntersection1.distanceAlongRouteMeters - projected1.distanceAlongRouteMeters; - double forwardDistance2 = projectedIntersection2.distanceAlongRouteMeters - projected2.distanceAlongRouteMeters; + List projectedIntersections1 = new ArrayList<>(); + List projectedIntersections2 = new ArrayList<>(); + for (IntersectionProjection projection : projectedIntersections) { + projectedIntersections1.add(projection.obj1Intersection()); + projectedIntersections2.add(projection.obj2Intersection()); + } - // Only future conflicts are valid. - if (forwardDistance1 <= MIN_FORWARD_DISTANCE_METERS || forwardDistance2 <= MIN_FORWARD_DISTANCE_METERS) { + DirectionLockResult directionLock1 = resolveRouteTravelDirection( + obj1, route1, localRoute1.localLine, projected1, projectedIntersections1); + DirectionLockResult directionLock2 = resolveRouteTravelDirection( + obj2, route2, localRoute2.localLine, projected2, projectedIntersections2); + if (!directionLock1.locked() || !directionLock2.locked()) { + IntersectionProjection projection = projectedIntersections.get(0); + publishPathConflictStatusIfDue( + obj1, + obj2, + toExternalPoint(projection.intersectionPoint()), + null, + false, + false, + buildDirectionDebug(directionLock1, projected1, projection.obj1Intersection(), false, null), + buildDirectionDebug(directionLock2, projected2, projection.obj2Intersection(), false, null), + DIRECTION_LOCK_FAILED + ); + writeTestSessionLog("PAIR_RESULT result=DIRECTION_LOCK_FAILED, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2) + + ", object1Reason=" + directionLock1.reason() + + ", object2Reason=" + directionLock2.reason()); + return PairDetectionResult.of(PairOutcomeReason.DIRECTION_LOCK_FAILED); + } + + boolean intersectionBehind = false; + boolean thresholdNotReached = false; + VehicleCommandService.ConflictRecoveryObservation recoveryObservation = null; + for (IntersectionProjection projection : projectedIntersections) { + Point intersectionPoint = projection.intersectionPoint(); + Point localIntersection = projection.localIntersection(); + ProjectedPosition projectedIntersection1 = projection.obj1Intersection(); + ProjectedPosition projectedIntersection2 = projection.obj2Intersection(); + + double distanceToIntersection1 = localObjPos1.distance(localIntersection); + double distanceToIntersection2 = localObjPos2.distance(localIntersection); + RouteTravelDirection direction1 = directionLock1.direction(); + RouteTravelDirection direction2 = directionLock2.direction(); + boolean obj1IntersectionAhead = isIntersectionAheadByRouteProgress(projected1, projectedIntersection1, direction1); + boolean obj2IntersectionAhead = isIntersectionAheadByRouteProgress(projected2, projectedIntersection2, direction2); + double forwardDistance1 = obj1IntersectionAhead ? distanceToIntersection1 : -distanceToIntersection1; + double forwardDistance2 = obj2IntersectionAhead ? distanceToIntersection2 : -distanceToIntersection2; + + // Only conflicts ahead of the current movement direction are valid. + if (!obj1IntersectionAhead || !obj2IntersectionAhead + || distanceToIntersection1 <= MIN_FORWARD_DISTANCE_METERS + || distanceToIntersection2 <= MIN_FORWARD_DISTANCE_METERS) { + writePairEvaluation( + "INTERSECTION_BEHIND", + obj1, + obj2, + route1, + route2, + intersectionPoint, + projected1, + projected2, + projectedIntersection1, + projectedIntersection2, + direction1, + direction2, + obj1IntersectionAhead, + obj2IntersectionAhead, + forwardDistance1, + forwardDistance2, + null + ); intersectionBehind = true; - continue; - } - - // Ensure current heading points to the intersection direction when speed is meaningful. - if (!isHeadingAligned(obj1, localObjPos1, localIntersection, speed1Kph) - || !isHeadingAligned(obj2, localObjPos2, localIntersection, speed2Kph)) { - headingMismatch = true; + recoveryObservation = buildRecoveryObservation( + obj1, + obj2, + forwardDistance1, + forwardDistance2, + !obj1IntersectionAhead || distanceToIntersection1 <= MIN_FORWARD_DISTANCE_METERS, + !obj2IntersectionAhead || distanceToIntersection2 <= MIN_FORWARD_DISTANCE_METERS, + !obj1IntersectionAhead, + !obj2IntersectionAhead + ); continue; } ConflictCalculationResult result = calculateConflictDetails( obj1, obj2, forwardDistance1, forwardDistance2, speed1Kph, speed2Kph); + publishPathConflictStatusIfDue( + obj1, + obj2, + toExternalPoint(intersectionPoint), + result, + obj1IntersectionAhead, + obj2IntersectionAhead, + buildDirectionDebug(directionLock1, projected1, projectedIntersection1, obj1IntersectionAhead, forwardDistance1), + buildDirectionDebug(directionLock2, projected2, projectedIntersection2, obj2IntersectionAhead, forwardDistance2), + null + ); + if (result != null && isSignificantConflict(result)) { - return PairDetectionResult.of(buildConflictAlertEvent(obj1, obj2, route1, route2, intersectionPoint, result)); + writeAlertEvent( + obj1, + obj2, + intersectionPoint, + forwardDistance1, + forwardDistance2, + result + ); + writePairEvaluation( + "EVENT_PUBLISHED", + obj1, + obj2, + route1, + route2, + intersectionPoint, + projected1, + projected2, + projectedIntersection1, + projectedIntersection2, + direction1, + direction2, + obj1IntersectionAhead, + obj2IntersectionAhead, + forwardDistance1, + forwardDistance2, + "alertLevel=" + result.getAlertLevel().map(Enum::name).orElse("none") + + ", alertType=" + result.getAlertType().map(Enum::name).orElse("none") + + ", timeToConflict1Seconds=" + result.getTimeToConflict1() + + ", timeToConflict2Seconds=" + result.getTimeToConflict2() + ); + return PairDetectionResult.of(buildConflictAlertEvent( + obj1, + obj2, + route1, + route2, + toExternalPoint(intersectionPoint), + result, + obj1IntersectionAhead, + obj2IntersectionAhead)); } thresholdNotReached = true; + writePairEvaluation( + "THRESHOLD_NOT_REACHED", + obj1, + obj2, + route1, + route2, + intersectionPoint, + projected1, + projected2, + projectedIntersection1, + projectedIntersection2, + direction1, + direction2, + obj1IntersectionAhead, + obj2IntersectionAhead, + forwardDistance1, + forwardDistance2, + "vehicleThresholdMeters=" + fmt(vehicleCrossingThresholdMeters()) + + ", aircraftThresholdMeters=" + fmt(aircraftCrossingThresholdMeters()) + ); + recoveryObservation = buildRecoveryObservation( + obj1, + obj2, + forwardDistance1, + forwardDistance2, + false, + false, + false, + false + ); } if (thresholdNotReached) { - return PairDetectionResult.of(PairOutcomeReason.THRESHOLD_NOT_REACHED); - } - if (headingMismatch) { - return PairDetectionResult.of(PairOutcomeReason.HEADING_MISMATCH); + return PairDetectionResult.of(PairOutcomeReason.THRESHOLD_NOT_REACHED, recoveryObservation); } if (intersectionBehind) { - return PairDetectionResult.of(PairOutcomeReason.INTERSECTION_BEHIND); + return PairDetectionResult.of(PairOutcomeReason.INTERSECTION_BEHIND, recoveryObservation); } + writeTestSessionLog("PAIR_RESULT result=NO_VALID_INTERSECTION_PROJECTION, pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2)); return PairDetectionResult.of(PairOutcomeReason.NO_INTERSECTION); } catch (Exception e) { + writeTestSessionLog("PAIR_RESULT result=ERROR, pair=" + pairLabel(obj1, obj2) + + ", error=" + e.getClass().getSimpleName() + + ", message=" + (e.getMessage() == null ? "" : e.getMessage())); log.error("Error detecting conflict between {} and {}", obj1.getObjectName(), obj2.getObjectName(), e); return PairDetectionResult.of(PairOutcomeReason.ERROR); } @@ -202,7 +418,9 @@ public class PathConflictDetectionService { TransportRoute route1, TransportRoute route2, Point intersectionPoint, - ConflictCalculationResult result) { + ConflictCalculationResult result, + boolean obj1MovingTowardConflictPoint, + boolean obj2MovingTowardConflictPoint) { String description = generateConflictDescription(obj1, obj2, route1, route2); @@ -232,9 +450,265 @@ public class PathConflictDetectionService { .estimatedTimeToConflictObj1(result.getTimeToConflict1()) .estimatedTimeToConflictObj2(result.getTimeToConflict2()) .timeGapSeconds(result.getTimeGap()) + .aircraftName(resolveAircraftName(obj1, obj2)) + .aircraftObjectType(MovingObjectType.AIRCRAFT.name()) + .aircraftDistanceToConflictMeters(resolveAircraftDistance(obj1, result)) + .aircraftAlertThresholdMeters(aircraftCrossingThresholdMeters()) + .vehicleName(resolveVehicleName(obj1, obj2)) + .vehicleObjectType(resolveVehicleObjectType(obj1, obj2)) + .vehicleDistanceToConflictMeters(resolveVehicleDistance(obj1, result)) + .vehicleAlertThresholdMeters(vehicleCrossingThresholdMeters()) + .vehicleMovingTowardConflictPoint(resolveVehicleMovingTowardConflictPoint( + obj1, + obj2, + obj1MovingTowardConflictPoint, + obj2MovingTowardConflictPoint)) .build(); } + private String resolveAircraftName(MovingObject obj1, MovingObject obj2) { + if (obj1.getObjectType() == MovingObjectType.AIRCRAFT) { + return obj1.getObjectName(); + } + if (obj2.getObjectType() == MovingObjectType.AIRCRAFT) { + return obj2.getObjectName(); + } + return null; + } + + private Double resolveAircraftDistance(MovingObject obj1, ConflictCalculationResult result) { + if (result == null) { + return null; + } + return obj1.getObjectType() == MovingObjectType.AIRCRAFT + ? result.getDistance1() + : result.getDistance2(); + } + + private String resolveVehicleName(MovingObject obj1, MovingObject obj2) { + if (isControllableVehicleType(obj1.getObjectType())) { + return obj1.getObjectName(); + } + if (isControllableVehicleType(obj2.getObjectType())) { + return obj2.getObjectName(); + } + return null; + } + + private String resolveVehicleObjectType(MovingObject obj1, MovingObject obj2) { + if (isControllableVehicleType(obj1.getObjectType())) { + return obj1.getObjectType().name(); + } + if (isControllableVehicleType(obj2.getObjectType())) { + return obj2.getObjectType().name(); + } + return null; + } + + private Double resolveVehicleDistance(MovingObject obj1, ConflictCalculationResult result) { + if (result == null) { + return null; + } + return isControllableVehicleType(obj1.getObjectType()) + ? result.getDistance1() + : result.getDistance2(); + } + + private Boolean resolveVehicleMovingTowardConflictPoint( + MovingObject obj1, + MovingObject obj2, + boolean obj1MovingTowardConflictPoint, + boolean obj2MovingTowardConflictPoint) { + + if (isControllableVehicleType(obj1.getObjectType())) { + return obj1MovingTowardConflictPoint; + } + if (isControllableVehicleType(obj2.getObjectType())) { + return obj2MovingTowardConflictPoint; + } + return null; + } + + private void publishPathConflictStatusIfDue( + MovingObject obj1, + MovingObject obj2, + Point conflictPoint, + ConflictCalculationResult result, + boolean obj1MovingTowardConflictPoint, + boolean obj2MovingTowardConflictPoint) { + + publishPathConflictStatusIfDue( + obj1, + obj2, + conflictPoint, + result, + obj1MovingTowardConflictPoint, + obj2MovingTowardConflictPoint, + null, + null, + null + ); + } + + private void publishPathConflictStatusIfDue( + MovingObject obj1, + MovingObject obj2, + Point conflictPoint, + ConflictCalculationResult result, + boolean obj1MovingTowardConflictPoint, + boolean obj2MovingTowardConflictPoint, + DirectionDebug obj1DirectionDebug, + DirectionDebug obj2DirectionDebug, + String calculationStatusOverride) { + + if (webSocketPublisher == null || conflictPoint == null) { + return; + } + + String statusKey = pathConflictStatusKey(obj1, obj2, conflictPoint); + long now = System.currentTimeMillis(); + Long previousPublishedAt = pathConflictStatusPublishedAt.get(statusKey); + if (previousPublishedAt != null && now - previousPublishedAt < PATH_CONFLICT_STATUS_INTERVAL_MS) { + return; + } + + pathConflictStatusPublishedAt.put(statusKey, now); + PathConflictAlertMessage.Position position = PathConflictAlertMessage.Position.builder() + .latitude(conflictPoint.getY()) + .longitude(conflictPoint.getX()) + .build(); + + PathConflictAlertMessage message = PathConflictAlertMessage.builder() + .messageType("PATH_CONFLICT_STATUS") + .message("Path conflict monitoring") + .object1(PathConflictAlertMessage.ConflictObject.builder() + .objectName(obj1.getObjectName()) + .objectType(obj1.getObjectType().name()) + .build()) + .object2(PathConflictAlertMessage.ConflictObject.builder() + .objectName(obj2.getObjectName()) + .objectType(obj2.getObjectType().name()) + .build()) + .position(position) + .conflictPoint(position) + .aircraftName(resolveAircraftName(obj1, obj2)) + .aircraftObjectType(MovingObjectType.AIRCRAFT.name()) + .aircraftDistanceToConflictMeters(roundMeters(resolveAircraftDistance(obj1, result))) + .aircraftAlertThresholdMeters(aircraftCrossingThresholdMeters()) + .vehicleName(resolveVehicleName(obj1, obj2)) + .vehicleObjectType(resolveVehicleObjectType(obj1, obj2)) + .vehicleDistanceToConflictMeters(roundMeters(resolveVehicleDistance(obj1, result))) + .vehicleAlertThresholdMeters(vehicleCrossingThresholdMeters()) + .vehicleMovingTowardConflictPoint(resolveVehicleMovingTowardConflictPoint( + obj1, + obj2, + obj1MovingTowardConflictPoint, + obj2MovingTowardConflictPoint)) + .object1Distance(result == null ? null : roundMeters(result.getDistance1())) + .object2Distance(result == null ? null : roundMeters(result.getDistance2())) + .timeToConflict1(result == null ? null : result.getTimeToConflict1()) + .timeToConflict2(result == null ? null : result.getTimeToConflict2()) + .timeGap(result == null ? null : result.getTimeGap()) + .calculationStatus(calculationStatusOverride != null + ? calculationStatusOverride + : result == null ? "ROUTE_CONFLICT_POINT" : "MONITORING") + .directionLockStatus(resolveDirectionLockStatus(obj1DirectionDebug, obj2DirectionDebug)) + .directionLockReason(resolveDirectionLockReason(obj1DirectionDebug, obj2DirectionDebug)) + .object1DirectionLockStatus(obj1DirectionDebug == null ? null : obj1DirectionDebug.lockStatus()) + .object1DirectionLockReason(obj1DirectionDebug == null ? null : obj1DirectionDebug.lockReason()) + .object1RouteDirection(obj1DirectionDebug == null ? null : obj1DirectionDebug.routeDirection()) + .object1RouteProgressMeters(obj1DirectionDebug == null ? null : roundMeters(obj1DirectionDebug.routeProgressMeters())) + .object1ConflictProgressMeters(obj1DirectionDebug == null ? null : roundMeters(obj1DirectionDebug.conflictProgressMeters())) + .object1ConflictPointAhead(obj1DirectionDebug == null ? null : obj1DirectionDebug.conflictPointAhead()) + .object1ForwardDistanceMeters(obj1DirectionDebug == null ? null : roundMeters(obj1DirectionDebug.forwardDistanceMeters())) + .object2DirectionLockStatus(obj2DirectionDebug == null ? null : obj2DirectionDebug.lockStatus()) + .object2DirectionLockReason(obj2DirectionDebug == null ? null : obj2DirectionDebug.lockReason()) + .object2RouteDirection(obj2DirectionDebug == null ? null : obj2DirectionDebug.routeDirection()) + .object2RouteProgressMeters(obj2DirectionDebug == null ? null : roundMeters(obj2DirectionDebug.routeProgressMeters())) + .object2ConflictProgressMeters(obj2DirectionDebug == null ? null : roundMeters(obj2DirectionDebug.conflictProgressMeters())) + .object2ConflictPointAhead(obj2DirectionDebug == null ? null : obj2DirectionDebug.conflictPointAhead()) + .object2ForwardDistanceMeters(obj2DirectionDebug == null ? null : roundMeters(obj2DirectionDebug.forwardDistanceMeters())) + .eventTime(java.time.LocalDateTime.now()) + .build(); + + webSocketPublisher.publishPathConflictStatus(message); + writeTestSessionLog("EVENT PATH_CONFLICT_POINT_SENT" + + ", pair=" + pairLabel(obj1, obj2) + + ", conflictPoint=" + pointLabel(conflictPoint) + + ", calculationStatus=" + message.getCalculationStatus()); + } + + private String pathConflictStatusKey(MovingObject obj1, MovingObject obj2, Point conflictPoint) { + String left = objectLabel(obj1); + String right = objectLabel(obj2); + if (left.compareTo(right) > 0) { + String temp = left; + left = right; + right = temp; + } + String sessionId = currentTestSessionId(); + return (sessionId == null ? "default" : sessionId) + + "|" + + left + + "|" + + right + + "|" + + fmt(conflictPoint.getX()) + + "|" + + fmt(conflictPoint.getY()); + } + + private Double roundMeters(Double value) { + if (value == null) { + return null; + } + return java.math.BigDecimal.valueOf(value) + .setScale(2, java.math.RoundingMode.HALF_UP) + .doubleValue(); + } + + private DirectionDebug buildDirectionDebug( + DirectionLockResult directionLock, + ProjectedPosition projectedObject, + ProjectedPosition projectedIntersection, + Boolean conflictPointAhead, + Double forwardDistanceMeters) { + + return new DirectionDebug( + directionLock.status(), + directionLock.reason(), + directionLock.direction() == null ? null : directionLock.direction().name(), + projectedObject.distanceAlongRouteMeters(), + projectedIntersection.distanceAlongRouteMeters(), + conflictPointAhead, + forwardDistanceMeters + ); + } + + private String resolveDirectionLockStatus(DirectionDebug obj1DirectionDebug, DirectionDebug obj2DirectionDebug) { + if (obj1DirectionDebug == null && obj2DirectionDebug == null) { + return null; + } + if (obj1DirectionDebug != null && DIRECTION_INVALID.equals(obj1DirectionDebug.lockStatus())) { + return DIRECTION_INVALID; + } + if (obj2DirectionDebug != null && DIRECTION_INVALID.equals(obj2DirectionDebug.lockStatus())) { + return DIRECTION_INVALID; + } + return DIRECTION_LOCKED; + } + + private String resolveDirectionLockReason(DirectionDebug obj1DirectionDebug, DirectionDebug obj2DirectionDebug) { + List reasons = new ArrayList<>(); + if (obj1DirectionDebug != null && DIRECTION_INVALID.equals(obj1DirectionDebug.lockStatus())) { + reasons.add("object1=" + obj1DirectionDebug.lockReason()); + } + if (obj2DirectionDebug != null && DIRECTION_INVALID.equals(obj2DirectionDebug.lockStatus())) { + reasons.add("object2=" + obj2DirectionDebug.lockReason()); + } + return reasons.isEmpty() ? null : String.join("; ", reasons); + } + private TransportRoute getObjectRoute(MovingObject obj) { ObjectRouteAssignment.ObjectType objectType = ObjectRouteAssignment.ObjectType.valueOf(obj.getObjectType().name()); Optional assignmentOptional = findLatestAssignment(obj.getObjectId(), objectType); @@ -259,6 +733,118 @@ public class PathConflictDetectionService { return objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc(identifier, objectType); } + private void writeAlertEvent( + MovingObject obj1, + MovingObject obj2, + Point intersectionPoint, + double forwardDistance1, + double forwardDistance2, + ConflictCalculationResult result) { + + String eventName = result.getAlertLevel() + .map(level -> switch (level) { + case WARNING -> "PATH_CONFLICT_WARNING"; + case CRITICAL, EMERGENCY -> "PATH_CONFLICT_ALERT"; + default -> "PATH_CONFLICT_EVENT"; + }) + .orElse("PATH_CONFLICT_EVENT"); + + writeTestSessionLog("EVENT " + eventName + + ", pair=" + pairLabel(obj1, obj2) + + ", conflictPoint=" + pointLabel(intersectionPoint) + + ", object1DistanceMeters=" + fmt(forwardDistance1) + + ", object2DistanceMeters=" + fmt(forwardDistance2) + + ", alertLevel=" + result.getAlertLevel().map(Enum::name).orElse("none") + + ", alertType=" + result.getAlertType().map(Enum::name).orElse("none") + + ", frontendPublished=true" + + ", vehicleCommandExpected=" + (result.getAlertLevel().filter(level -> level != ConflictAlertLog.AlertLevel.WARNING).isPresent())); + } + + private void writePairEvaluation( + String result, + MovingObject obj1, + MovingObject obj2, + TransportRoute route1, + TransportRoute route2, + Point intersectionPoint, + ProjectedPosition projected1, + ProjectedPosition projected2, + ProjectedPosition projectedIntersection1, + ProjectedPosition projectedIntersection2, + RouteTravelDirection direction1, + RouteTravelDirection direction2, + boolean obj1IntersectionAhead, + boolean obj2IntersectionAhead, + double forwardDistance1, + double forwardDistance2, + String extra) { + + String message = "PAIR_RESULT result=" + result + + ", pair=" + pairLabel(obj1, obj2) + + ", route1=" + routeLabel(route1) + + ", route2=" + routeLabel(route2) + + ", conflictPoint=" + pointLabel(intersectionPoint) + + ", obj1ProgressMeters=" + fmt(projected1.distanceAlongRouteMeters()) + + ", obj1ConflictProgressMeters=" + fmt(projectedIntersection1.distanceAlongRouteMeters()) + + ", obj1Direction=" + direction1.name() + + ", obj1Ahead=" + obj1IntersectionAhead + + ", obj1ForwardDistanceMeters=" + fmt(forwardDistance1) + + ", obj2ProgressMeters=" + fmt(projected2.distanceAlongRouteMeters()) + + ", obj2ConflictProgressMeters=" + fmt(projectedIntersection2.distanceAlongRouteMeters()) + + ", obj2Direction=" + direction2.name() + + ", obj2Ahead=" + obj2IntersectionAhead + + ", obj2ForwardDistanceMeters=" + fmt(forwardDistance2); + if (extra != null && !extra.isBlank()) { + message += ", " + extra; + } + writeTestSessionLog(message); + } + + private void writeTestSessionLog(String message) { + if (collisionTestSessionLogService == null || platformRuntimeStateService == null) { + return; + } + String testSessionId = currentTestSessionId(); + if (testSessionId != null) { + collisionTestSessionLogService.write(testSessionId, message); + } + } + + private String pairLabel(MovingObject obj1, MovingObject obj2) { + return objectLabel(obj1) + " <-> " + objectLabel(obj2); + } + + private String objectLabel(MovingObject object) { + if (object == null) { + return "null"; + } + String name = object.getObjectName() == null || object.getObjectName().isBlank() + ? object.getObjectId() + : object.getObjectName(); + return name + "(" + object.getObjectType() + ")"; + } + + private String routeLabel(TransportRoute route) { + if (route == null) { + return "null"; + } + return route.getRouteName() + "#" + route.getId(); + } + + private String pointLabel(Point point) { + if (point == null) { + return "null"; + } + return "(" + fmt(point.getX()) + "," + fmt(point.getY()) + ")"; + } + + private String fmt(double value) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + return Double.toString(value); + } + return String.format(Locale.ROOT, "%.3f", value); + } + private List calculateRouteIntersections(TransportRoute route1, TransportRoute route2) { List intersections = new ArrayList<>(); @@ -267,21 +853,7 @@ public class PathConflictDetectionService { LineString line2 = route2.getRouteGeometry(); Geometry intersection = line1.intersection(line2); - - if (intersection instanceof Point) { - intersections.add((Point) intersection); - } else if (intersection instanceof MultiPoint) { - MultiPoint multiPoint = (MultiPoint) intersection; - for (int i = 0; i < multiPoint.getNumGeometries(); i++) { - intersections.add((Point) multiPoint.getGeometryN(i)); - } - } else if (intersection instanceof LineString) { - LineString overlapLine = (LineString) intersection; - Point midPoint = overlapLine.getInteriorPoint(); - if (midPoint != null && !midPoint.isEmpty()) { - intersections.add(midPoint); - } - } + collectIntersectionPoints(intersection, intersections); } catch (Exception e) { log.error("Error calculating route intersections", e); } @@ -289,6 +861,34 @@ public class PathConflictDetectionService { return intersections; } + private void collectIntersectionPoints(Geometry intersection, List intersections) { + if (intersection == null || intersection.isEmpty()) { + return; + } + + if (intersection instanceof Point point) { + intersections.add(point); + return; + } + + if (intersection instanceof MultiPoint multiPoint) { + for (int i = 0; i < multiPoint.getNumGeometries(); i++) { + intersections.add((Point) multiPoint.getGeometryN(i)); + } + return; + } + + if (intersection instanceof LineString overlapLine) { + intersections.add(overlapLine.getStartPoint()); + intersections.add(overlapLine.getEndPoint()); + return; + } + + for (int i = 0; i < intersection.getNumGeometries(); i++) { + collectIntersectionPoints(intersection.getGeometryN(i), intersections); + } + } + private ConflictCalculationResult calculateConflictDetails( MovingObject obj1, MovingObject obj2, @@ -301,12 +901,8 @@ public class PathConflictDetectionService { double speed1Ms = speed1Kph * 1000.0 / 3600.0; double speed2Ms = speed2Kph * 1000.0 / 3600.0; - int timeToConflict1 = (int) Math.round(distance1 / speed1Ms); - int timeToConflict2 = (int) Math.round(distance2 / speed2Ms); - - if (timeToConflict1 > MAX_PREDICTION_TIME_SECONDS || timeToConflict2 > MAX_PREDICTION_TIME_SECONDS) { - return null; - } + int timeToConflict1 = estimateTimeToConflictSeconds(distance1, speed1Ms); + int timeToConflict2 = estimateTimeToConflictSeconds(distance2, speed2Ms); Optional alertLevelOptional = evaluateAlertLevel( distance1, @@ -314,6 +910,10 @@ public class PathConflictDetectionService { distance2, obj2.getObjectType() ); + if (alertLevelOptional.isEmpty() + && (timeToConflict1 > MAX_PREDICTION_TIME_SECONDS || timeToConflict2 > MAX_PREDICTION_TIME_SECONDS)) { + return null; + } double timeGap = Math.abs(timeToConflict1 - timeToConflict2); Optional alertTypeOptional = alertLevelOptional.isPresent() ? evaluateAlertType(alertLevelOptional.get()) @@ -336,6 +936,13 @@ public class PathConflictDetectionService { } } + private int estimateTimeToConflictSeconds(double distanceMeters, double speedMetersPerSecond) { + if (speedMetersPerSecond <= 0.0 || Double.isNaN(speedMetersPerSecond) || Double.isInfinite(speedMetersPerSecond)) { + return MAX_PREDICTION_TIME_SECONDS; + } + return (int) Math.round(distanceMeters / speedMetersPerSecond); + } + private Optional evaluateAlertLevel( double distance1, MovingObjectType obj1Type, @@ -359,8 +966,8 @@ public class PathConflictDetectionService { return Optional.empty(); } - double vehicleDistanceThreshold = platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle(); - double aircraftDistanceThreshold = platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft(); + double vehicleDistanceThreshold = vehicleCrossingThresholdMeters(); + double aircraftDistanceThreshold = aircraftCrossingThresholdMeters(); boolean vehicleInZone = vehicleDistance <= vehicleDistanceThreshold; boolean aircraftInZone = aircraftDistance <= aircraftDistanceThreshold; @@ -374,6 +981,51 @@ public class PathConflictDetectionService { } } + private VehicleCommandService.ConflictRecoveryObservation buildRecoveryObservation( + MovingObject obj1, + MovingObject obj2, + double forwardDistance1, + double forwardDistance2, + boolean obj1IntersectionBehind, + boolean obj2IntersectionBehind, + boolean obj1HeadingAway, + boolean obj2HeadingAway) { + + if (obj1 == null || obj2 == null) { + return null; + } + + if (obj1.getObjectType() == MovingObjectType.AIRCRAFT && isControllableVehicleType(obj2.getObjectType())) { + return new VehicleCommandService.ConflictRecoveryObservation( + obj2.getObjectName(), + Math.abs(forwardDistance2), + Math.abs(forwardDistance1), + obj2IntersectionBehind, + obj1IntersectionBehind, + obj2HeadingAway, + obj1HeadingAway + ); + } + + if (obj2.getObjectType() == MovingObjectType.AIRCRAFT && isControllableVehicleType(obj1.getObjectType())) { + return new VehicleCommandService.ConflictRecoveryObservation( + obj1.getObjectName(), + Math.abs(forwardDistance1), + Math.abs(forwardDistance2), + obj1IntersectionBehind, + obj2IntersectionBehind, + obj1HeadingAway, + obj2HeadingAway + ); + } + + return null; + } + + private boolean isControllableVehicleType(MovingObjectType objectType) { + return objectType == MovingObjectType.UNMANNED_VEHICLE || objectType == MovingObjectType.SPECIAL_VEHICLE; + } + private Optional evaluateAlertType(ConflictAlertLog.AlertLevel alertLevel) { if (alertLevel == null) { return Optional.empty(); @@ -404,29 +1056,135 @@ public class PathConflictDetectionService { return speedKph; } - private boolean isHeadingAligned(MovingObject obj, Point currentPos, Point targetPos, double speedKph) { - if (speedKph < HEADING_CHECK_MIN_SPEED_KPH) { - return true; + private void clearRouteTravelDirectionsOnTestSessionChange() { + String currentTestSessionId = currentTestSessionId(); + if (Objects.equals(observedDirectionSessionId, currentTestSessionId)) { + return; } - if (obj.getCurrentHeading() == null) { - return true; + routeTravelDirections.clear(); + pathConflictStatusPublishedAt.clear(); + observedDirectionSessionId = currentTestSessionId; + } + + private DirectionLockResult resolveRouteTravelDirection( + MovingObject object, + TransportRoute route, + LineString routeLine, + ProjectedPosition projectedObject, + List projectedIntersections) { + + String directionKey = routeDirectionKey(object, route); + RouteTravelDirection cachedDirection = routeTravelDirections.get(directionKey); + if (cachedDirection != null) { + return DirectionLockResult.locked(cachedDirection, "cached"); } - double vx = targetPos.getX() - currentPos.getX(); - double vy = targetPos.getY() - currentPos.getY(); - double vNorm = Math.hypot(vx, vy); - if (vNorm < 1e-6) { - return true; + boolean hasAheadConflictPoint = false; + boolean hasBehindConflictPoint = false; + ProjectedPosition selectedConflict = null; + double selectedAbsDelta = Double.MAX_VALUE; + for (ProjectedPosition projectedIntersection : projectedIntersections) { + double progressDeltaToConflict = + projectedIntersection.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters(); + double absDelta = Math.abs(progressDeltaToConflict); + if (absDelta < selectedAbsDelta) { + selectedAbsDelta = absDelta; + selectedConflict = projectedIntersection; + } + if (progressDeltaToConflict > MIN_FORWARD_DISTANCE_METERS) { + hasAheadConflictPoint = true; + } else if (progressDeltaToConflict < -MIN_FORWARD_DISTANCE_METERS) { + hasBehindConflictPoint = true; + } } - // Heading convention: 0 north, 90 east. - double headingRad = Math.toRadians(obj.getCurrentHeading()); - double hx = Math.sin(headingRad); - double hy = Math.cos(headingRad); - double dot = (hx * vx + hy * vy) / vNorm; + if (hasAheadConflictPoint && hasBehindConflictPoint) { + writeDirectionLockFailure(object, route, routeLine, projectedObject, selectedConflict, "conflict_points_on_both_sides"); + return DirectionLockResult.invalid("conflict_points_on_both_sides"); + } + if (!hasAheadConflictPoint && !hasBehindConflictPoint) { + writeDirectionLockFailure(object, route, routeLine, projectedObject, selectedConflict, "initial_position_too_close_to_conflict_point"); + return DirectionLockResult.invalid("initial_position_too_close_to_conflict_point"); + } - return dot >= HEADING_ALIGNMENT_MIN_COS; + RouteTravelDirection direction = hasAheadConflictPoint + ? RouteTravelDirection.START_TO_END + : RouteTravelDirection.END_TO_START; + RouteTravelDirection previousDirection = routeTravelDirections.putIfAbsent(directionKey, direction); + if (previousDirection != null) { + return DirectionLockResult.locked(previousDirection, "cached"); + } + + double progressDeltaToConflict = selectedConflict == null + ? 0.0 + : selectedConflict.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters(); + writeTestSessionLog("DIRECTION_LOCK object=" + objectLabel(object) + + ", route=" + routeLabel(route) + + ", routeLengthMeters=" + fmt(routeLine.getLength()) + + ", objectProgressMeters=" + fmt(projectedObject.distanceAlongRouteMeters()) + + ", conflictProgressMeters=" + (selectedConflict == null ? "null" : fmt(selectedConflict.distanceAlongRouteMeters())) + + ", progressDeltaToConflictMeters=" + fmt(progressDeltaToConflict) + + ", direction=" + direction.name()); + return DirectionLockResult.locked(direction, "locked"); + } + + private void writeDirectionLockFailure( + MovingObject object, + TransportRoute route, + LineString routeLine, + ProjectedPosition projectedObject, + ProjectedPosition selectedConflict, + String reason) { + + double progressDeltaToConflict = selectedConflict == null + ? 0.0 + : selectedConflict.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters(); + writeTestSessionLog("DIRECTION_LOCK_FAILED object=" + objectLabel(object) + + ", route=" + routeLabel(route) + + ", routeLengthMeters=" + fmt(routeLine.getLength()) + + ", objectProgressMeters=" + fmt(projectedObject.distanceAlongRouteMeters()) + + ", conflictProgressMeters=" + (selectedConflict == null ? "null" : fmt(selectedConflict.distanceAlongRouteMeters())) + + ", progressDeltaToConflictMeters=" + fmt(progressDeltaToConflict) + + ", reason=" + reason); + } + + private String routeDirectionKey(MovingObject object, TransportRoute route) { + String sessionId = currentTestSessionId(); + return (sessionId == null ? "default" : sessionId) + + "|" + + object.getObjectName() + + "|" + + route.getId(); + } + + private String currentTestSessionId() { + return platformRuntimeStateService != null + ? platformRuntimeStateService.getCurrentTestSessionId() + : null; + } + + private double vehicleCrossingThresholdMeters() { + return platformRuntimeStateService != null + ? platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle() + : Double.NaN; + } + + private double aircraftCrossingThresholdMeters() { + return platformRuntimeStateService != null + ? platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft() + : Double.NaN; + } + + private boolean isIntersectionAheadByRouteProgress( + ProjectedPosition projectedObject, + ProjectedPosition projectedIntersection, + RouteTravelDirection direction) { + + double progressDelta = projectedIntersection.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters(); + return direction == RouteTravelDirection.START_TO_END + ? progressDelta > MIN_FORWARD_DISTANCE_METERS + : -progressDelta > MIN_FORWARD_DISTANCE_METERS; } private boolean isSupportedConflictPair(MovingObjectType left, MovingObjectType right) { @@ -436,18 +1194,31 @@ public class PathConflictDetectionService { || (left == MovingObjectType.AIRCRAFT && right == MovingObjectType.SPECIAL_VEHICLE); } - private Point toLocalPoint(Point wgs84Point) { + private Point toComparablePoint(Point point, CoordinateMode coordinateMode) { try { - double[] local = coordinateSystemService.convertToLocalCoordinate(wgs84Point.getX(), wgs84Point.getY()); GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 0); + if (point == null) { + return null; + } + + if (isProjectedCoordinate(point.getCoordinate())) { + return geometryFactory.createPoint(new Coordinate(point.getX(), point.getY())); + } + + if (coordinateMode == CoordinateMode.AIRPORT_PROJECTED) { + double[] projected = coordinateSystemService.convertToAirportProjectedCoordinate(point.getX(), point.getY()); + return geometryFactory.createPoint(new Coordinate(projected[0], projected[1])); + } + + double[] local = coordinateSystemService.convertToLocalCoordinate(point.getX(), point.getY()); return geometryFactory.createPoint(new Coordinate(local[0], local[1])); } catch (Exception e) { - log.debug("Failed to convert point to local coordinates", e); + log.debug("Failed to convert point to comparable coordinates", e); return null; } } - private LocalRoute toLocalRoute(TransportRoute route) { + private LocalRoute toLocalRoute(TransportRoute route, CoordinateMode coordinateMode) { try { LineString original = route.getRouteGeometry(); Coordinate[] originalCoords = original.getCoordinates(); @@ -455,20 +1226,58 @@ public class PathConflictDetectionService { return null; } - Coordinate[] localCoords = new Coordinate[originalCoords.length]; + if (coordinateMode == CoordinateMode.AIRPORT_PROJECTED && isProjectedRoute(originalCoords)) { + GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 0); + return new LocalRoute(route, geometryFactory.createLineString(originalCoords)); + } + + Coordinate[] comparableCoords = new Coordinate[originalCoords.length]; for (int i = 0; i < originalCoords.length; i++) { - double[] local = coordinateSystemService.convertToLocalCoordinate(originalCoords[i].x, originalCoords[i].y); - localCoords[i] = new Coordinate(local[0], local[1]); + double[] converted = coordinateMode == CoordinateMode.AIRPORT_PROJECTED + ? coordinateSystemService.convertToAirportProjectedCoordinate(originalCoords[i].x, originalCoords[i].y) + : coordinateSystemService.convertToLocalCoordinate(originalCoords[i].x, originalCoords[i].y); + comparableCoords[i] = new Coordinate(converted[0], converted[1]); } GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 0); - return new LocalRoute(route, geometryFactory.createLineString(localCoords)); + return new LocalRoute(route, geometryFactory.createLineString(comparableCoords)); } catch (Exception e) { - log.debug("Failed to convert route {} to local coordinates", route.getRouteName(), e); + log.debug("Failed to convert route {} to comparable coordinates", route.getRouteName(), e); return null; } } + private boolean isProjectedRoute(Coordinate[] coordinates) { + for (Coordinate coordinate : coordinates) { + if (isProjectedCoordinate(coordinate)) { + return true; + } + } + return false; + } + + private boolean isProjectedCoordinate(Coordinate coordinate) { + if (coordinate == null) { + return false; + } + return Math.abs(coordinate.x) > 180.0 || Math.abs(coordinate.y) > 90.0; + } + + private Point toExternalPoint(Point point) { + if (point == null || !isProjectedCoordinate(point.getCoordinate())) { + return point; + } + + try { + double[] wgs84 = coordinateSystemService.convertAirportProjectedToWgs84Coordinate(point.getX(), point.getY()); + GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + return geometryFactory.createPoint(new Coordinate(wgs84[0], wgs84[1])); + } catch (Exception e) { + log.debug("Failed to convert projected conflict point to WGS84", e); + return point; + } + } + private ProjectedPosition projectToRoute(Point localPoint, LineString localRouteLine) { try { DistanceOp distanceOp = new DistanceOp(localPoint, localRouteLine); @@ -533,10 +1342,53 @@ public class PathConflictDetectionService { return -1.0; } + private enum CoordinateMode { + LOCAL, + AIRPORT_PROJECTED + } + + private enum RouteTravelDirection { + START_TO_END, + END_TO_START + } + private record LocalRoute(TransportRoute route, LineString localLine) {} private record ProjectedPosition(double distanceAlongRouteMeters, double lateralDistanceMeters) {} + private record IntersectionProjection( + Point intersectionPoint, + Point localIntersection, + ProjectedPosition obj1Intersection, + ProjectedPosition obj2Intersection) {} + + private record DirectionLockResult( + String status, + RouteTravelDirection direction, + String reason) { + + private boolean locked() { + return DIRECTION_LOCKED.equals(status) && direction != null; + } + + private static DirectionLockResult locked(RouteTravelDirection direction, String reason) { + return new DirectionLockResult(DIRECTION_LOCKED, direction, reason); + } + + private static DirectionLockResult invalid(String reason) { + return new DirectionLockResult(DIRECTION_INVALID, null, reason); + } + } + + private record DirectionDebug( + String lockStatus, + String lockReason, + String routeDirection, + double routeProgressMeters, + double conflictProgressMeters, + Boolean conflictPointAhead, + Double forwardDistanceMeters) {} + public record ConflictDetectionSummary( int totalPairs, int supportedPairs, @@ -547,18 +1399,34 @@ public class PathConflictDetectionService { int noIntersectionPairs, int intersectionBehindPairs, int headingMismatchPairs, + int directionLockFailedPairs, int thresholdNotReachedPairs, int errorPairs, int eventsPublished) { } - private record PairDetectionResult(Optional alertEvent, PairOutcomeReason reason) { + private record PairDetectionResult( + Optional alertEvent, + Optional recoveryObservation, + PairOutcomeReason reason) { + private static PairDetectionResult of(PairOutcomeReason reason) { - return new PairDetectionResult(Optional.empty(), reason); + return new PairDetectionResult(Optional.empty(), Optional.empty(), reason); + } + + private static PairDetectionResult of( + PairOutcomeReason reason, + VehicleCommandService.ConflictRecoveryObservation recoveryObservation) { + + return new PairDetectionResult( + Optional.empty(), + Optional.ofNullable(recoveryObservation), + reason + ); } private static PairDetectionResult of(ConflictAlertEvent event) { - return new PairDetectionResult(Optional.of(event), PairOutcomeReason.EVENT_PUBLISHED); + return new PairDetectionResult(Optional.of(event), Optional.empty(), PairOutcomeReason.EVENT_PUBLISHED); } } @@ -571,6 +1439,7 @@ public class PathConflictDetectionService { NO_INTERSECTION, INTERSECTION_BEHIND, HEADING_MISMATCH, + DIRECTION_LOCK_FAILED, THRESHOLD_NOT_REACHED, EVENT_PUBLISHED, ERROR @@ -586,6 +1455,7 @@ public class PathConflictDetectionService { private int noIntersectionPairs; private int intersectionBehindPairs; private int headingMismatchPairs; + private int directionLockFailedPairs; private int thresholdNotReachedPairs; private int errorPairs; @@ -601,6 +1471,7 @@ public class PathConflictDetectionService { case NO_INTERSECTION -> noIntersectionPairs++; case INTERSECTION_BEHIND -> intersectionBehindPairs++; case HEADING_MISMATCH -> headingMismatchPairs++; + case DIRECTION_LOCK_FAILED -> directionLockFailedPairs++; case THRESHOLD_NOT_REACHED -> thresholdNotReachedPairs++; case ERROR -> errorPairs++; default -> { @@ -619,6 +1490,7 @@ public class PathConflictDetectionService { noIntersectionPairs, intersectionBehindPairs, headingMismatchPairs, + directionLockFailedPairs, thresholdNotReachedPairs, errorPairs, eventsPublished diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java index 8c99279..779c3b4 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java @@ -7,11 +7,15 @@ import com.qaup.collision.datacollector.model.dto.ExternalVehicleCommandRequest; import com.qaup.collision.datacollector.model.enums.CommandReason; import com.qaup.collision.datacollector.model.enums.CommandType; import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent; +import com.qaup.collision.service.CollisionTestSessionLogService; import com.qaup.collision.service.PlatformRuntimeStateService; import com.qaup.collision.service.PlatformRuntimeStateService.VehicleRegistryType; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; +import com.qaup.collision.websocket.message.PathConflictAlertMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -21,10 +25,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -36,10 +42,16 @@ public class VehicleCommandService { private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final PlatformRuntimeStateService platformRuntimeStateService; + private final RuleEventWebSocketPublisher webSocketPublisher; + + @Autowired(required = false) + private CollisionTestSessionLogService collisionTestSessionLogService; private final Map activeCommandStates = new ConcurrentHashMap<>(); private final AtomicLong transIdSequence = new AtomicLong(); + private volatile String observedTestSessionId; private static final String UNIFIED_VEHICLE_COMMAND_URL = "http://10.232.18.23:8020/api/VehicleCommandInfo"; + private static final int RESUME_CONFIRMATION_CYCLES = 3; @Value("${data.collector.vehicle-api.base-url:}") private String vehicleApiBaseUrl; @@ -48,6 +60,15 @@ public class VehicleCommandService { private String vehicleCommandEndpoint; public void synchronizeConflictCommands(List activeConflicts) { + synchronizeConflictCommands(activeConflicts, List.of()); + } + + public void synchronizeConflictCommands( + List activeConflicts, + List recoveryObservations) { + + clearActiveCommandStatesOnTestSessionChange(); + Map currentCommands = new LinkedHashMap<>(); for (ConflictAlertEvent event : activeConflicts) { VehicleConflictCommand candidate = toVehicleConflictCommand(event); @@ -62,6 +83,29 @@ public class VehicleCommandService { ); } + Map observationsByVehicleId = new LinkedHashMap<>(); + if (recoveryObservations != null) { + for (ConflictRecoveryObservation observation : recoveryObservations) { + if (observation != null && observation.vehicleId() != null && !observation.vehicleId().isBlank()) { + observationsByVehicleId.put(observation.vehicleId(), observation); + } + } + } + + for (String vehicleId : new ArrayList<>(currentCommands.keySet())) { + ActiveVehicleCommandState previousState = activeCommandStates.get(vehicleId); + ConflictRecoveryObservation observation = observationsByVehicleId.get(vehicleId); + if (isRecoveryConfirmedAfterAircraftPassed(previousState, observation)) { + writeTestSessionLog("EVENT PATH_CONFLICT_RELEASE_CONFIRMED" + + ", vehicleId=" + vehicleId + + ", aircraftPassedConflictPoint=true" + + ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters() + + ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft() + + ", currentCommandSuppressed=true"); + currentCommands.remove(vehicleId); + } + } + for (VehicleConflictCommand command : currentCommands.values()) { synchronizeVehicleCommand(command); } @@ -69,11 +113,29 @@ public class VehicleCommandService { List trackedVehicleIds = new ArrayList<>(activeCommandStates.keySet()); for (String vehicleId : trackedVehicleIds) { if (!currentCommands.containsKey(vehicleId)) { - sendResumeIfNeeded(vehicleId); + sendResumeIfNeeded(vehicleId, observationsByVehicleId.get(vehicleId)); } } } + private void clearActiveCommandStatesOnTestSessionChange() { + String currentTestSessionId = platformRuntimeStateService.getCurrentTestSessionId(); + if (Objects.equals(observedTestSessionId, currentTestSessionId)) { + return; + } + + if (!activeCommandStates.isEmpty()) { + log.info( + "Clearing active vehicle command states for collision test session change: previousSession={}, currentSession={}, activeVehicles={}", + observedTestSessionId, + currentTestSessionId, + activeCommandStates.keySet() + ); + } + activeCommandStates.clear(); + observedTestSessionId = currentTestSessionId; + } + private void synchronizeVehicleCommand(VehicleConflictCommand currentCommand) { ActiveVehicleCommandState previousState = activeCommandStates.get(currentCommand.vehicleId()); if (previousState == null) { @@ -81,9 +143,9 @@ public class VehicleCommandService { return; } - // De-escalation is intentionally handled by a separate recovery flow. WARNING follow-up behavior is not implemented here yet. - if (previousState.level() == VehicleRiskLevel.WARNING && currentCommand.level() == VehicleRiskLevel.ALERT) { - issueAlertAndParking(currentCommand); + if (currentCommand.level() == VehicleRiskLevel.WARNING) { + writeTestSessionLog("EVENT VEHICLE_COMMAND_SKIP vehicleId=" + currentCommand.vehicleId() + + ", level=WARNING, reason=warning_not_sent_to_vehicle"); return; } @@ -95,17 +157,22 @@ public class VehicleCommandService { if (parkingIssued) { activeCommandStates.put( currentCommand.vehicleId(), - new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, true) + ActiveVehicleCommandState.fromCommand(VehicleRiskLevel.ALERT, true, 0, currentCommand) ); } + return; } + + activeCommandStates.put( + currentCommand.vehicleId(), + ActiveVehicleCommandState.fromCommand(previousState.level(), previousState.parkingIssued(), 0, currentCommand) + ); } private void issueInitialRiskCommand(VehicleConflictCommand command) { if (command.level() == VehicleRiskLevel.WARNING) { - if (sendCommand(command, CommandType.WARNING, CommandReason.AIRCRAFT_CROSSING)) { - activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.WARNING, true)); - } + writeTestSessionLog("EVENT VEHICLE_COMMAND_SKIP vehicleId=" + command.vehicleId() + + ", level=WARNING, reason=warning_not_sent_to_vehicle"); return; } @@ -119,26 +186,58 @@ public class VehicleCommandService { } boolean parkingIssued = sendCommand(command, CommandType.PARKING, CommandReason.AIRCRAFT_CROSSING); - activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, parkingIssued)); + activeCommandStates.put(command.vehicleId(), ActiveVehicleCommandState.fromCommand(VehicleRiskLevel.ALERT, parkingIssued, 0, command)); } - private void sendResumeIfNeeded(String vehicleId) { + private void sendResumeIfNeeded(String vehicleId, ConflictRecoveryObservation observation) { ActiveVehicleCommandState previousState = activeCommandStates.get(vehicleId); if (previousState == null) { return; } + if (!isRecoveryConfirmedAfterAircraftPassed(previousState, observation)) { + activeCommandStates.put(vehicleId, previousState.withObservation(observation, 0)); + return; + } + + writeTestSessionLog("EVENT PATH_CONFLICT_RELEASE_CONFIRMED" + + ", vehicleId=" + vehicleId + + ", aircraftPassedConflictPoint=true" + + ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters() + + ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft() + + ", confirmationCycle=" + (previousState.missingConflictCycles() + 1) + + ", requiredConfirmationCycles=" + RESUME_CONFIRMATION_CYCLES); + + int confirmedCycles = previousState.missingConflictCycles() + 1; + if (confirmedCycles < RESUME_CONFIRMATION_CYCLES) { + activeCommandStates.put(vehicleId, previousState.withObservation(observation, confirmedCycles)); + return; + } + + ActiveVehicleCommandState updatedState = new ActiveVehicleCommandState( + previousState.level(), + previousState.parkingIssued(), + confirmedCycles, + observation.vehicleDistanceMeters(), + observation.aircraftDistanceMeters() + ); + VehicleConflictCommand resumeContext = new VehicleConflictCommand( vehicleId, previousState.level(), null, null, - resolveRegistryType(vehicleId) + resolveRegistryType(vehicleId), + previousState.vehicleDistanceMeters(), + previousState.aircraftDistanceMeters() ); boolean resumeIssued = sendCommand(resumeContext, CommandType.RESUME, CommandReason.RESUME_TRAFFIC); if (resumeIssued) { activeCommandStates.remove(vehicleId); + publishResumeToFrontend(vehicleId, previousState, observation); + } else { + activeCommandStates.put(vehicleId, updatedState); } } @@ -156,7 +255,9 @@ public class VehicleCommandService { event.getObject1Name(), event.getObject1Type(), level, - event.getConflictPoint() + event.getConflictPoint(), + event.getObject1Distance(), + event.getObject2Distance() ); if (leftCandidate != null) { return leftCandidate; @@ -166,7 +267,9 @@ public class VehicleCommandService { event.getObject2Name(), event.getObject2Type(), level, - event.getConflictPoint() + event.getConflictPoint(), + event.getObject2Distance(), + event.getObject1Distance() ); } @@ -174,7 +277,9 @@ public class VehicleCommandService { String vehicleId, MovingObjectType objectType, VehicleRiskLevel level, - Point conflictPoint) { + Point conflictPoint, + Double candidateDistance, + Double otherDistance) { if (vehicleId == null || vehicleId.isBlank()) { return null; @@ -197,7 +302,18 @@ public class VehicleCommandService { return null; } - return new VehicleConflictCommand(vehicleId, level, conflictPoint, objectType, registryType); + Double vehicleDistance = objectType == MovingObjectType.AIRCRAFT ? otherDistance : candidateDistance; + Double aircraftDistance = objectType == MovingObjectType.AIRCRAFT ? candidateDistance : otherDistance; + + return new VehicleConflictCommand( + vehicleId, + level, + conflictPoint, + objectType, + registryType, + vehicleDistance, + aircraftDistance + ); } private VehicleRiskLevel toRiskLevel(ConflictAlertEvent event) { @@ -242,6 +358,9 @@ public class VehicleCommandService { ); if (response.getStatusCode().value() != 200) { + writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId() + + ", commandType=" + commandType + + ", reason=http_status_" + response.getStatusCode().value()); log.warn( "Vehicle command rejected by HTTP status: vehicleId={}, commandType={}, status={}", command.vehicleId(), @@ -253,6 +372,9 @@ public class VehicleCommandService { JsonNode responseBody = parseResponseBody(response.getBody()); if (responseBody == null || !responseBody.has("code") || !responseBody.has("msg")) { + writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId() + + ", commandType=" + commandType + + ", reason=invalid_response_body"); log.warn( "Vehicle command response missing required fields: vehicleId={}, commandType={}, body={}", command.vehicleId(), @@ -264,6 +386,9 @@ public class VehicleCommandService { int responseCode = responseBody.path("code").asInt(Integer.MIN_VALUE); if (responseCode != 200) { + writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId() + + ", commandType=" + commandType + + ", reason=upstream_code_" + responseCode); log.warn( "Vehicle command rejected by upstream response code: vehicleId={}, commandType={}, code={}, msg={}", command.vehicleId(), @@ -281,8 +406,18 @@ public class VehicleCommandService { commandType, commandReason ); + writeTestSessionLog("EVENT VEHICLE_COMMAND_SENT vehicleId=" + command.vehicleId() + + ", registryType=" + command.registryType() + + ", commandType=" + commandType + + ", reason=" + commandReason + + ", vehicleDistanceMeters=" + command.vehicleDistanceMeters() + + ", aircraftDistanceMeters=" + command.aircraftDistanceMeters()); return true; } catch (Exception e) { + writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId() + + ", commandType=" + commandType + + ", reason=" + e.getClass().getSimpleName() + + ", message=" + (e.getMessage() == null ? "" : e.getMessage())); log.error( "Failed to send vehicle command: vehicleId={}, commandType={}, reason={}", command.vehicleId(), @@ -428,6 +563,68 @@ public class VehicleCommandService { return platformRuntimeStateService.getVehicleRegistryType(vehicleId); } + private boolean isRecoveryConfirmedAfterAircraftPassed( + ActiveVehicleCommandState previousState, + ConflictRecoveryObservation observation) { + + if (previousState == null || observation == null) { + return false; + } + + return observation.aircraftIntersectionBehind() + && observation.aircraftDistanceMeters() > platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft(); + } + + private void publishResumeToFrontend( + String vehicleId, + ActiveVehicleCommandState previousState, + ConflictRecoveryObservation observation) { + + try { + PathConflictAlertMessage message = PathConflictAlertMessage.builder() + .messageType("PATH_CONFLICT_RESUME") + .message("Path conflict resolved, traffic resumed") + .object1(PathConflictAlertMessage.ConflictObject.builder() + .objectName(vehicleId) + .objectType(toMovingObjectTypeName(resolveRegistryType(vehicleId))) + .build()) + .object1Distance(observation != null ? observation.vehicleDistanceMeters() : previousState.vehicleDistanceMeters()) + .object2Distance(observation != null ? observation.aircraftDistanceMeters() : previousState.aircraftDistanceMeters()) + .eventTime(LocalDateTime.now()) + .build(); + + webSocketPublisher.publishPathConflictAlert(message); + log.info("WebSocket path conflict resume sent: vehicleId={}", vehicleId); + writeTestSessionLog("EVENT VEHICLE_RESUME_FRONTEND_SENT vehicleId=" + vehicleId + + ", vehicleDistanceMeters=" + (observation != null ? observation.vehicleDistanceMeters() : previousState.vehicleDistanceMeters()) + + ", aircraftDistanceMeters=" + (observation != null ? observation.aircraftDistanceMeters() : previousState.aircraftDistanceMeters())); + } catch (Exception e) { + log.error("Failed to send WebSocket path conflict resume: vehicleId={}", vehicleId, e); + } + } + + private void writeTestSessionLog(String message) { + if (collisionTestSessionLogService == null || platformRuntimeStateService == null) { + return; + } + String testSessionId = platformRuntimeStateService.getCurrentTestSessionId(); + if (testSessionId != null) { + collisionTestSessionLogService.write(testSessionId, message); + } + } + + private String toMovingObjectTypeName(VehicleRegistryType registryType) { + if (registryType == null) { + return null; + } + return switch (registryType) { + case WUREN -> MovingObjectType.UNMANNED_VEHICLE.name(); + case TEQIN -> MovingObjectType.SPECIAL_VEHICLE.name(); + case HANGKONG -> MovingObjectType.AIRCRAFT.name(); + case PUTONG, JIUYUAN -> null; + }; + } + private String nextTransId() { long serialValue = System.currentTimeMillis() * 1000 + Math.floorMod(transIdSequence.incrementAndGet(), 1000); return Long.toHexString(serialValue); @@ -438,12 +635,58 @@ public class VehicleCommandService { VehicleRiskLevel level, Point conflictPoint, MovingObjectType objectType, - VehicleRegistryType registryType) { + VehicleRegistryType registryType, + Double vehicleDistanceMeters, + Double aircraftDistanceMeters) { } private record ActiveVehicleCommandState( VehicleRiskLevel level, - boolean parkingIssued) { + boolean parkingIssued, + int missingConflictCycles, + Double vehicleDistanceMeters, + Double aircraftDistanceMeters) { + + private static ActiveVehicleCommandState fromCommand( + VehicleRiskLevel level, + boolean parkingIssued, + int missingConflictCycles, + VehicleConflictCommand command) { + + return new ActiveVehicleCommandState( + level, + parkingIssued, + missingConflictCycles, + command.vehicleDistanceMeters(), + command.aircraftDistanceMeters() + ); + } + + private ActiveVehicleCommandState withObservation( + ConflictRecoveryObservation observation, + int missingConflictCycles) { + + if (observation == null) { + return new ActiveVehicleCommandState(level, parkingIssued, missingConflictCycles, vehicleDistanceMeters, aircraftDistanceMeters); + } + return new ActiveVehicleCommandState( + level, + parkingIssued, + missingConflictCycles, + observation.vehicleDistanceMeters(), + observation.aircraftDistanceMeters() + ); + } + } + + public record ConflictRecoveryObservation( + String vehicleId, + double vehicleDistanceMeters, + double aircraftDistanceMeters, + boolean vehicleIntersectionBehind, + boolean aircraftIntersectionBehind, + boolean vehicleHeadingAway, + boolean aircraftHeadingAway) { } private enum VehicleRiskLevel { diff --git a/qaup-collision/src/main/java/com/qaup/collision/service/CollisionTestSessionLogService.java b/qaup-collision/src/main/java/com/qaup/collision/service/CollisionTestSessionLogService.java new file mode 100644 index 0000000..f93bebb --- /dev/null +++ b/qaup-collision/src/main/java/com/qaup/collision/service/CollisionTestSessionLogService.java @@ -0,0 +1,135 @@ +package com.qaup.collision.service; + +import lombok.extern.slf4j.Slf4j; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class CollisionTestSessionLogService { + + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + private static final Duration MAX_SESSION_LOG_DURATION = Duration.ofMinutes(20); + + private final Path logDirectory; + private final Clock clock; + private final Duration maxSessionLogDuration; + private final Map sessionStartedAt = new ConcurrentHashMap<>(); + private final Set expiredSessions = ConcurrentHashMap.newKeySet(); + + @Value("${qaup.collision.test-log.enabled:true}") + private boolean enabled = true; + + public CollisionTestSessionLogService( + @Value("${qaup.collision.test-log.dir:}") String configuredLogDirectory, + @Value("${LOG_PATH:logs}") String logPath) { + this(configuredLogDirectory, logPath, Clock.systemDefaultZone(), MAX_SESSION_LOG_DURATION); + } + + CollisionTestSessionLogService( + String configuredLogDirectory, + String logPath, + Clock clock, + Duration maxSessionLogDuration) { + + this.clock = clock; + this.maxSessionLogDuration = maxSessionLogDuration; + + if (configuredLogDirectory != null && !configuredLogDirectory.isBlank()) { + this.logDirectory = Paths.get(configuredLogDirectory); + } else { + String baseLogPath = logPath == null || logPath.isBlank() ? "logs" : logPath; + this.logDirectory = Paths.get(baseLogPath, "collision-tests"); + } + } + + @PostConstruct + public void logStartupState() { + log.info("Collision test session file log initialized: enabled={}, directory={}", enabled, logDirectory.toAbsolutePath()); + } + + public void startSession(String testSessionId, String message) { + sessionStartedAt.put(testSessionId, Instant.now(clock)); + expiredSessions.remove(testSessionId); + append(testSessionId, "SESSION_START " + safeMessage(message), true, true); + } + + public void endSession(String testSessionId, String message) { + append(testSessionId, "SESSION_END " + safeMessage(message), false, true); + sessionStartedAt.remove(testSessionId); + expiredSessions.remove(testSessionId); + } + + public void write(String testSessionId, String message) { + append(testSessionId, safeMessage(message), false, false); + } + + private synchronized void append(String testSessionId, String message, boolean truncate, boolean forceWrite) { + if (!enabled || testSessionId == null || testSessionId.isBlank()) { + if (!enabled) { + log.warn("Collision test session file log is disabled; skip write: testSessionId={}", testSessionId); + } + return; + } + + if (!forceWrite && isExpired(testSessionId)) { + return; + } + + try { + Files.createDirectories(logDirectory); + String line = LocalDateTime.now(clock).format(TIMESTAMP_FORMATTER) + " | " + message + System.lineSeparator(); + StandardOpenOption[] options = truncate + ? new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE} + : new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE}; + Files.writeString(logFilePath(testSessionId), line, StandardCharsets.UTF_8, options); + } catch (Exception e) { + log.error("Failed to write collision test session log: testSessionId={}", testSessionId, e); + } + } + + private boolean isExpired(String testSessionId) { + Instant startedAt = sessionStartedAt.computeIfAbsent(testSessionId, ignored -> Instant.now(clock)); + if (Duration.between(startedAt, Instant.now(clock)).compareTo(maxSessionLogDuration) <= 0) { + return false; + } + + if (expiredSessions.add(testSessionId)) { + append(testSessionId, + "SESSION_LOG_EXPIRED maxDurationMinutes=" + maxSessionLogDuration.toMinutes() + + ", reason=test_session_not_ended", + false, + true); + } + return true; + } + + private Path logFilePath(String testSessionId) { + return logDirectory.resolve(sanitizeFileName(testSessionId) + ".log"); + } + + private String sanitizeFileName(String value) { + String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return sanitized.isBlank() ? "unknown-session" : sanitized; + } + + private String safeMessage(String message) { + return message == null ? "" : message; + } +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java b/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java index 7a72a14..0a6b0b2 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java @@ -66,6 +66,9 @@ public class PlatformRuntimeStateService { @Autowired(required = false) private ISysConfigService sysConfigService; + @Autowired(required = false) + private CollisionTestSessionLogService collisionTestSessionLogService; + public PlatformRuntimeStateService( @Value("${qaup.runtime-config.runway.warning-zone-radius.aircraft:200.0}") double runwayWarningZoneRadiusAircraft, @Value("${qaup.runtime-config.runway.alert-zone-radius.aircraft:100.0}") double runwayAlertZoneRadiusAircraft, @@ -94,6 +97,7 @@ public class PlatformRuntimeStateService { } } + @Transactional public VehicleRegistryUpdateResult updateVehicleRegistry(List entries) { Objects.requireNonNull(entries, "entries"); @@ -113,16 +117,19 @@ public class PlatformRuntimeStateService { endedTestSessionId = currentTestSessionId; if (endedTestSessionId != null) { log.info("[test-session={}] collision test session ended by empty registry update", endedTestSessionId); + writeTestSessionEnd(endedTestSessionId, "ended by empty registry update"); } currentTestSessionId = null; } else { endedTestSessionId = currentTestSessionId; if (endedTestSessionId != null) { log.info("[test-session={}] collision test session replaced by new registry update", endedTestSessionId); + writeTestSessionEnd(endedTestSessionId, "replaced by new registry update"); } activeTestSessionId = createTestSessionId(entries); currentTestSessionId = activeTestSessionId; log.info("[test-session={}] collision test session started: objects={}", activeTestSessionId, entries); + writeTestSessionStart(activeTestSessionId, describeRegisteredObjects(entries)); } Set controllableVehicleIds = new TreeSet<>(); @@ -140,6 +147,8 @@ public class PlatformRuntimeStateService { } } + cleanupStaleRouteAssignments(previousVehicleIds, entries); + return new VehicleRegistryUpdateResult( System.currentTimeMillis(), entries.size(), @@ -151,6 +160,68 @@ public class PlatformRuntimeStateService { endedTestSessionId); } + private void cleanupStaleRouteAssignments(Set removedVehicleIds, List currentEntries) { + if (objectRouteAssignmentRepository == null) { + return; + } + + TreeSet routeAssignmentObjectIdsToDelete = new TreeSet<>(); + if (removedVehicleIds != null && !removedVehicleIds.isEmpty()) { + routeAssignmentObjectIdsToDelete.addAll(removedVehicleIds); + } + + for (VehicleRegistryEntry entry : currentEntries) { + routeAssignmentObjectIdsToDelete.add(entry.vehicleID()); + } + + if (!routeAssignmentObjectIdsToDelete.isEmpty()) { + objectRouteAssignmentRepository.deleteByObjectNameIn(new ArrayList<>(routeAssignmentObjectIdsToDelete)); + } + } + + private void writeTestSessionStart(String testSessionId, String message) { + if (collisionTestSessionLogService != null) { + collisionTestSessionLogService.startSession(testSessionId, message); + } else { + log.warn("[test-session={}] collision test session file log service is not available; start event was not written", testSessionId); + } + } + + private void writeTestSessionEnd(String testSessionId, String message) { + if (collisionTestSessionLogService != null) { + collisionTestSessionLogService.endSession(testSessionId, message); + } else { + log.warn("[test-session={}] collision test session file log service is not available; end event was not written", testSessionId); + } + } + + private String describeRegisteredObjects(List entries) { + List flightIds = new ArrayList<>(); + List vehicleIds = new ArrayList<>(); + List otherIds = new ArrayList<>(); + + for (VehicleRegistryEntry entry : entries) { + if (entry == null || entry.vehicleID() == null || entry.vehicleID().isBlank()) { + continue; + } + if (entry.vehicleType() == VehicleRegistryType.HANGKONG) { + flightIds.add(entry.vehicleID()); + } else if (entry.vehicleType() == VehicleRegistryType.WUREN || entry.vehicleType() == VehicleRegistryType.TEQIN) { + vehicleIds.add(entry.vehicleID()); + } else { + otherIds.add(entry.vehicleID() + "(" + entry.vehicleType() + ")"); + } + } + + Collections.sort(flightIds); + Collections.sort(vehicleIds); + Collections.sort(otherIds); + return "flightIds=" + flightIds + + ", vehicleIds=" + vehicleIds + + ", otherIds=" + otherIds + + ", rawObjects=" + entries; + } + public String getCurrentTestSessionId() { return currentTestSessionId; } diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/RuleEventWebSocketPublisher.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/RuleEventWebSocketPublisher.java index 1e48df7..0dc93ba 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/RuleEventWebSocketPublisher.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/RuleEventWebSocketPublisher.java @@ -319,6 +319,19 @@ public class RuleEventWebSocketPublisher { } } + public void publishPathConflictStatus(PathConflictAlertMessage message) { + try { + PathConflictAlertWebSocketEvent webSocketEvent = PathConflictAlertWebSocketEvent.create(message); + eventPublisher.publishEvent(webSocketEvent); + + logger.debug("Published path conflict status WebSocket event: vehicle={}, aircraft={}", + message.getVehicleName(), message.getAircraftName()); + } catch (Exception e) { + logger.error("Failed to publish path conflict status WebSocket event: vehicle={}, aircraft={}", + message.getVehicleName(), message.getAircraftName(), e); + } + } + /** * 通过vehicle_id动态查询车牌号 * @@ -344,4 +357,4 @@ public class RuleEventWebSocketPublisher { return "UNKNOWN"; } } -} \ No newline at end of file +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java index 476c1df..ca02cdc 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java @@ -332,16 +332,24 @@ public class WebSocketMessageBroadcaster { @EventListener public void handlePathConflictAlert(PathConflictAlertWebSocketEvent event) { + PathConflictAlertMessage payload = (PathConflictAlertMessage) event.getPayload(); UniversalMessage message = UniversalMessage.builder() - .type(MessageTypeConstants.PATH_CONFLICT_ALERT) + .type(resolvePathConflictMessageType(payload)) .timestamp(event.getTimestamp()) .messageId(generateMessageId()) - .payload((PathConflictAlertMessage) event.getPayload()) + .payload(payload) .build(); broadcastMessageInternal(message); } + private String resolvePathConflictMessageType(PathConflictAlertMessage payload) { + if (payload != null && "PATH_CONFLICT_STATUS".equals(payload.getMessageType())) { + return MessageTypeConstants.PATH_CONFLICT_STATUS; + } + return MessageTypeConstants.PATH_CONFLICT_ALERT; + } + @EventListener public void handleGeofenceAlert(GeofenceAlertWebSocketEvent event) { UniversalMessage message = UniversalMessage.builder() diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/message/MessageTypeConstants.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/message/MessageTypeConstants.java index d53cab8..4954abf 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/message/MessageTypeConstants.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/message/MessageTypeConstants.java @@ -67,6 +67,11 @@ public final class MessageTypeConstants { * 路径冲突告警消息 */ public static final String PATH_CONFLICT_ALERT = "path_conflict_alert"; + + /** + * 路径冲突计算状态消息 + */ + public static final String PATH_CONFLICT_STATUS = "path_conflict_status"; /** * 电子围栏告警消息 @@ -83,4 +88,4 @@ public final class MessageTypeConstants { private MessageTypeConstants() { throw new UnsupportedOperationException("常量类不允许实例化"); } -} \ No newline at end of file +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/message/PathConflictAlertMessage.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/message/PathConflictAlertMessage.java index 863c4f1..706cdaa 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/message/PathConflictAlertMessage.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/message/PathConflictAlertMessage.java @@ -63,6 +63,63 @@ public class PathConflictAlertMessage { * 冲突位置 */ private Position position; + + /** + * Frontend display conflict point. Same coordinates as position, with clearer business meaning. + */ + private Position conflictPoint; + + private String aircraftName; + + private String aircraftObjectType; + + private Double aircraftDistanceToConflictMeters; + + private Double aircraftAlertThresholdMeters; + + private String vehicleName; + + private String vehicleObjectType; + + private Double vehicleDistanceToConflictMeters; + + private Double vehicleAlertThresholdMeters; + + private Boolean vehicleMovingTowardConflictPoint; + + private String calculationStatus; + + private String directionLockStatus; + + private String directionLockReason; + + private String object1DirectionLockStatus; + + private String object1DirectionLockReason; + + private String object1RouteDirection; + + private Double object1RouteProgressMeters; + + private Double object1ConflictProgressMeters; + + private Boolean object1ConflictPointAhead; + + private Double object1ForwardDistanceMeters; + + private String object2DirectionLockStatus; + + private String object2DirectionLockReason; + + private String object2RouteDirection; + + private Double object2RouteProgressMeters; + + private Double object2ConflictProgressMeters; + + private Boolean object2ConflictPointAhead; + + private Double object2ForwardDistanceMeters; /** * 对象1距离冲突点距离 (米) @@ -165,4 +222,4 @@ public class PathConflictAlertMessage { public boolean isAlert() { return this.alertLevel == ConflictAlertLog.AlertLevel.CRITICAL || this.alertLevel == ConflictAlertLog.AlertLevel.EMERGENCY || this.alertType == ConflictAlertLog.AlertType.CONFLICT_ALERT; } -} \ No newline at end of file +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java index 3339bca..d7f7170 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java @@ -13,7 +13,9 @@ import org.springframework.test.util.ReflectionTestUtils; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -24,11 +26,13 @@ class PlatformIntegrationControllerTest { private MockMvc mockMvc; private PlatformRuntimeStateService runtimeStateService; + private ObjectRouteAssignmentRepository objectRouteAssignmentRepository; @BeforeEach void setUp() { runtimeStateService = new PlatformRuntimeStateService(200.0, 150.0, 40.0); - ReflectionTestUtils.setField(runtimeStateService, "objectRouteAssignmentRepository", mock(ObjectRouteAssignmentRepository.class)); + objectRouteAssignmentRepository = mock(ObjectRouteAssignmentRepository.class); + ReflectionTestUtils.setField(runtimeStateService, "objectRouteAssignmentRepository", objectRouteAssignmentRepository); ReflectionTestUtils.setField(runtimeStateService, "transportRouteRepository", mock(TransportRouteRepository.class)); PlatformIntegrationController controller = new PlatformIntegrationController(new ObjectMapper(), runtimeStateService); @@ -203,4 +207,21 @@ class PlatformIntegrationControllerTest { .andExpect(jsonPath("$.testSessionId").doesNotExist()) .andExpect(jsonPath("$.endedTestSessionId").isNotEmpty()); } + + @Test + void shouldClearRouteAssignmentsForRegisteredObjectsWhenNewSessionStarts() throws Exception { + mockMvc.perform(post("/api/VehicleRegistry") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + [ + { "vehicleID": "WR01", "vehicleType": "WUREN" }, + { "vehicleID": "CD423", "vehicleType": "HANGKONG" } + ] + """)) + .andExpect(status().isOk()); + + verify(objectRouteAssignmentRepository).deleteByObjectNameIn(argThat(objectNames -> + objectNames.contains("WR01") && objectNames.contains("CD423") + )); + } } diff --git a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePersistenceServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePersistenceServiceTest.java new file mode 100644 index 0000000..124b816 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePersistenceServiceTest.java @@ -0,0 +1,63 @@ +package com.qaup.collision.datacollector.service; + +import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment; +import com.qaup.collision.pathconflict.model.entity.TransportRoute; +import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; +import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RoutePersistenceServiceTest { + + @Mock + private TransportRouteRepository transportRouteRepository; + + @Mock + private ObjectRouteAssignmentRepository objectRouteAssignmentRepository; + + private RoutePersistenceService service() { + RoutePersistenceService service = new RoutePersistenceService(); + ReflectionTestUtils.setField(service, "transportRouteRepository", transportRouteRepository); + ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", objectRouteAssignmentRepository); + return service; + } + + @Test + void shouldRefreshAssignmentTimeWhenAircraftRouteIdIsUnchanged() { + TransportRoute route = TransportRoute.builder() + .id(1093L) + .routeName("FLIGHT_CD423_OUT") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .build(); + LocalDateTime oldAssignedAt = LocalDateTime.now().minusDays(1); + ObjectRouteAssignment assignment = ObjectRouteAssignment.builder() + .id(10L) + .objectName("CD423") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(1093L) + .assignedAt(oldAssignedAt) + .build(); + + when(transportRouteRepository.findById(1093L)).thenReturn(Optional.of(route)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "CD423", + ObjectRouteAssignment.ObjectType.AIRCRAFT + )).thenReturn(Optional.of(assignment)); + + service().saveRouteAssignment("CD423", 1093L, "OUT"); + + assertTrue(assignment.getAssignedAt().isAfter(oldAssignedAt)); + verify(objectRouteAssignmentRepository).save(assignment); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java index 4f201db..b5ab828 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -180,6 +181,7 @@ class RoutePreparationServiceTest { .objectName("UV-300") .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) .assignedRouteId(31L) + .assignedAt(LocalDateTime.now().minusHours(1)) .build())); when(objectRouteAssignmentRepository.findByObjectNameAndObjectTypeOrderByAssignedAtDesc( "UV-300", @@ -189,7 +191,10 @@ class RoutePreparationServiceTest { service().synchronizeUnmannedVehicleRoute(vehicle); verify(transportRouteRepository, never()).save(any(TransportRoute.class)); - verify(objectRouteAssignmentRepository, never()).save(any(ObjectRouteAssignment.class)); + ArgumentCaptor assignmentCaptor = ArgumentCaptor.forClass(ObjectRouteAssignment.class); + verify(objectRouteAssignmentRepository).save(assignmentCaptor.capture()); + assertEquals(31L, assignmentCaptor.getValue().getAssignedRouteId()); + assertTrue(assignmentCaptor.getValue().getAssignedAt().isAfter(LocalDateTime.now().minusMinutes(1))); } @Test diff --git a/qaup-collision/src/test/java/com/qaup/collision/dataprocessing/service/DataProcessingServiceCollisionRegistrationTest.java b/qaup-collision/src/test/java/com/qaup/collision/dataprocessing/service/DataProcessingServiceCollisionRegistrationTest.java index 1366435..5187069 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/dataprocessing/service/DataProcessingServiceCollisionRegistrationTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/dataprocessing/service/DataProcessingServiceCollisionRegistrationTest.java @@ -1,8 +1,11 @@ package com.qaup.collision.dataprocessing.service; import com.qaup.collision.common.model.MovingObject; +import com.qaup.collision.common.model.UnmannedVehicle; import com.qaup.collision.service.PlatformRuntimeStateService; import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Method; @@ -13,6 +16,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; class DataProcessingServiceCollisionRegistrationTest { + private final GeometryFactory geometryFactory = new GeometryFactory(); + @Test void shouldUseRegisteredCollisionTypeInsteadOfCollectedSourceType() throws Exception { DataProcessingService service = new DataProcessingService(); @@ -54,4 +59,37 @@ class DataProcessingServiceCollisionRegistrationTest { assertEquals(MovingObject.MovingObjectType.UNMANNED_VEHICLE, wr01.getObjectType()); assertEquals(MovingObject.MovingObjectType.AIRCRAFT, cd423.getObjectType()); } + + @Test + void shouldPreserveUnmannedVehicleMissionDataInProcessingSnapshot() throws Exception { + DataProcessingService service = new DataProcessingService(); + UnmannedVehicle source = UnmannedVehicle.builder() + .objectId("WR01") + .objectName("WR01") + .objectType(MovingObject.MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(120.0834104, 36.35406879))) + .missionId("MISSION-1") + .missionStatus(UnmannedVehicle.MissionStatus.IN_PROGRESS) + .waypoints(List.of( + UnmannedVehicle.WaypointInfo.builder() + .waypointId("1") + .latitude(36.35416879) + .longitude(120.0835104) + .status(UnmannedVehicle.WaypointStatus.PENDING) + .build() + )) + .build(); + + Method method = DataProcessingService.class.getDeclaredMethod("createProcessingSnapshot", java.util.Collection.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + List snapshot = (List) method.invoke(service, List.of(source)); + + assertEquals(1, snapshot.size()); + assertEquals(UnmannedVehicle.class, snapshot.get(0).getClass()); + UnmannedVehicle copied = (UnmannedVehicle) snapshot.get(0); + assertEquals("MISSION-1", copied.getMissionId()); + assertEquals(UnmannedVehicle.MissionStatus.IN_PROGRESS, copied.getMissionStatus()); + assertEquals(1, copied.getWaypoints().size()); + } } diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListenerTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListenerTest.java new file mode 100644 index 0000000..cb905fa --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListenerTest.java @@ -0,0 +1,81 @@ +package com.qaup.collision.pathconflict.event; + +import com.qaup.collision.common.model.MovingObject.MovingObjectType; +import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent; +import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; +import com.qaup.collision.websocket.message.PathConflictAlertMessage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ConflictAlertEventListenerTest { + + @Mock + private RuleEventWebSocketPublisher webSocketPublisher; + + @InjectMocks + private ConflictAlertEventListener listener; + + @Test + void shouldPublishFrontendDisplayFieldsByBusinessRole() { + GeometryFactory geometryFactory = new GeometryFactory(); + ConflictAlertEvent event = ConflictAlertEvent.builder() + .conflictId(Optional.of(99L)) + .alertType(Optional.of(ConflictAlertLog.AlertType.CONFLICT_ALERT)) + .alertLevel(Optional.of(ConflictAlertLog.AlertLevel.CRITICAL)) + .message("route conflict") + .object1Name("UV-1") + .object1Type(MovingObjectType.UNMANNED_VEHICLE) + .object2Name("AC-1") + .object2Type(MovingObjectType.AIRCRAFT) + .conflictPoint(geometryFactory.createPoint(new Coordinate(120.5, 31.2))) + .object1Distance(42.345) + .object2Distance(188.765) + .estimatedTimeToConflictObj1(8) + .estimatedTimeToConflictObj2(34) + .timeGapSeconds(26.0) + .eventTime(LocalDateTime.of(2026, 4, 29, 10, 0)) + .aircraftName("AC-1") + .aircraftObjectType(MovingObjectType.AIRCRAFT.name()) + .aircraftDistanceToConflictMeters(188.765) + .aircraftAlertThresholdMeters(200.0) + .vehicleName("UV-1") + .vehicleObjectType(MovingObjectType.UNMANNED_VEHICLE.name()) + .vehicleDistanceToConflictMeters(42.345) + .vehicleAlertThresholdMeters(50.0) + .vehicleMovingTowardConflictPoint(true) + .build(); + + listener.handleConflictAlert(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher).publishPathConflictAlert(captor.capture()); + + PathConflictAlertMessage message = captor.getValue(); + assertEquals(31.2, message.getConflictPoint().getLatitude()); + assertEquals(120.5, message.getConflictPoint().getLongitude()); + assertEquals("AC-1", message.getAircraftName()); + assertEquals("AIRCRAFT", message.getAircraftObjectType()); + assertEquals(188.77, message.getAircraftDistanceToConflictMeters()); + assertEquals(200.0, message.getAircraftAlertThresholdMeters()); + assertEquals("UV-1", message.getVehicleName()); + assertEquals("UNMANNED_VEHICLE", message.getVehicleObjectType()); + assertEquals(42.35, message.getVehicleDistanceToConflictMeters()); + assertEquals(50.0, message.getVehicleAlertThresholdMeters()); + assertTrue(message.getVehicleMovingTowardConflictPoint()); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java index 54e8ed1..baacd84 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java @@ -9,7 +9,10 @@ import com.qaup.collision.pathconflict.model.entity.TransportRoute; import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import com.qaup.collision.service.CollisionTestSessionLogService; import com.qaup.collision.service.PlatformRuntimeStateService; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; +import com.qaup.collision.websocket.message.PathConflictAlertMessage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.locationtech.jts.geom.Coordinate; @@ -17,16 +20,22 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.Point; import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -55,14 +64,20 @@ class PathConflictDetectionDirectionalTest { @Mock private VehicleCommandService vehicleCommandService; + @Mock + private RuleEventWebSocketPublisher webSocketPublisher; + + @Mock + private CollisionTestSessionLogService collisionTestSessionLogService; + @InjectMocks private PathConflictDetectionService pathConflictDetectionService; @Test - void shouldNotAlertWhenIntersectionIsBehindMovingDirection() throws Exception { + void shouldSkipCollisionWhenDirectionCannotBeLockedTooCloseToConflictPoint() throws Exception { GeometryFactory geometryFactory = new GeometryFactory(); - // Route 1: horizontal line, intersection at (0,0), object currently east of intersection. + // Route 1: horizontal line, intersection at (0,0), object currently too close to prove direction. LineString routeLine1 = geometryFactory.createLineString(new Coordinate[]{ new Coordinate(-1.0, 0.0), new Coordinate(1.0, 0.0) @@ -83,7 +98,7 @@ class PathConflictDetectionDirectionalTest { .objectType(MovingObjectType.UNMANNED_VEHICLE) .currentPosition(obj1Pos) .currentSpeed(20.0) - .currentHeading(90.0) // East, away from intersection at x=0 + .currentHeading(90.0) .altitude(0.0) .build(); @@ -141,7 +156,870 @@ class PathConflictDetectionDirectionalTest { verify(conflictAlertLogRepository, never()).save(any()); verify(eventPublisher, never()).publishEvent(any()); org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.directionLockFailedPairs()); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture()); + PathConflictAlertMessage status = statusCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("DIRECTION_LOCK_FAILED", status.getCalculationStatus()); + org.junit.jupiter.api.Assertions.assertEquals( + "DIRECTION_INVALID", + ReflectionTestUtils.getField(status, "directionLockStatus")); + assertTrue(((String) ReflectionTestUtils.getField(status, "directionLockReason")) + .contains("initial_position_too_close_to_conflict_point")); + } + + @Test + void shouldPublishConflictByInitialEndpointEvenWhenHeadingIsNoisy() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(-50.0, 0.0), + new Coordinate(50.0, 0.0) + }); + LineString aircraftRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, -50.0), + new Coordinate(0.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(-25.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(270.0) + .altitude(0.0) + .build(); + + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(0.0, -25.0))) + .currentSpeed(20.0) + .currentHeading(180.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute route1 = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRoute) + .build(); + TransportRoute route2 = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRoute) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(route1)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(route2)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, atLeastOnce()).save(any()); + verify(eventPublisher, atLeastOnce()).publishEvent(any(ConflictAlertEvent.class)); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.intersectionBehindPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } + + @Test + void shouldDetectApproachingConflictWhenRouteCoordinatesAreReversed() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString reversedVehicleRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(50.0, 0.0), + new Coordinate(-50.0, 0.0) + }); + LineString aircraftRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, -50.0), + new Coordinate(0.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(-25.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(0.0, -25.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("REVERSED_UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(reversedVehicleRoute) + .build(); + TransportRoute route2 = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRoute) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(route2)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, atLeastOnce()).save(any()); + verify(eventPublisher, atLeastOnce()).publishEvent(any(ConflictAlertEvent.class)); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.intersectionBehindPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.headingMismatchPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } + + @Test + void shouldLockDirectionTowardConflictPointInsteadOfNearestRouteEndpoint() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(80.0, -50.0), + new Coordinate(80.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(60.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(80.0, -20.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(50.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(50.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, atLeastOnce()).save(any()); + verify(eventPublisher, atLeastOnce()).publishEvent(any(ConflictAlertEvent.class)); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.intersectionBehindPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } + + @Test + void shouldLockObjectRouteDirectionOnceAndKeepLaterBehindConflictBehind() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aheadAircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(30.0, -50.0), + new Coordinate(30.0, 50.0) + }); + LineString behindAircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(5.0, -50.0), + new Coordinate(5.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(10.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aheadAircraft = MovingObject.builder() + .objectId("AC-AHEAD") + .objectName("AC-AHEAD") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(30.0, -10.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + MovingObject behindAircraft = MovingObject.builder() + .objectId("AC-BEHIND") + .objectName("AC-BEHIND") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(5.0, -10.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aheadAircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-AHEAD") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + ObjectRouteAssignment behindAircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-BEHIND") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(3L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aheadAircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_AHEAD_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aheadAircraftRouteLine) + .build(); + TransportRoute behindAircraftRoute = TransportRoute.builder() + .id(3L) + .routeName("AC_BEHIND_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(behindAircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-AHEAD", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aheadAircraftAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-BEHIND", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(behindAircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aheadAircraftRoute)); + when(routeRepository.findById(3L)).thenReturn(Optional.of(behindAircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(100.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(100.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aheadAircraft, behindAircraft)); + + org.junit.jupiter.api.Assertions.assertEquals(2, summary.supportedPairs()); org.junit.jupiter.api.Assertions.assertEquals(1, summary.intersectionBehindPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture()); + PathConflictAlertMessage status = statusCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("PATH_CONFLICT_STATUS", status.getMessageType()); + org.junit.jupiter.api.Assertions.assertEquals("MONITORING", status.getCalculationStatus()); + org.junit.jupiter.api.Assertions.assertEquals(0.0, status.getConflictPoint().getLatitude()); + org.junit.jupiter.api.Assertions.assertEquals(30.0, status.getConflictPoint().getLongitude()); + org.junit.jupiter.api.Assertions.assertEquals("UV-1", status.getVehicleName()); + org.junit.jupiter.api.Assertions.assertEquals(20.0, status.getVehicleDistanceToConflictMeters()); + assertTrue(status.getVehicleMovingTowardConflictPoint()); + org.junit.jupiter.api.Assertions.assertEquals("AC-AHEAD", status.getAircraftName()); + org.junit.jupiter.api.Assertions.assertEquals(10.0, status.getAircraftDistanceToConflictMeters()); + org.junit.jupiter.api.Assertions.assertEquals( + "DIRECTION_LOCKED", + ReflectionTestUtils.getField(status, "directionLockStatus")); + org.junit.jupiter.api.Assertions.assertEquals( + "START_TO_END", + ReflectionTestUtils.getField(status, "object1RouteDirection")); + org.junit.jupiter.api.Assertions.assertEquals( + 10.0, + (Double) ReflectionTestUtils.getField(status, "object1RouteProgressMeters"), + 0.001); + org.junit.jupiter.api.Assertions.assertEquals( + 30.0, + (Double) ReflectionTestUtils.getField(status, "object1ConflictProgressMeters"), + 0.001); + } + + @Test + void shouldPublishDirectionLockFailureStatusAndSkipConflictWhenConflictPointsAreOnBothSides() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(25.0, -10.0), + new Coordinate(25.0, 10.0), + new Coordinate(75.0, 10.0), + new Coordinate(75.0, -10.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(50.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(25.0, -5.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(100.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(100.0); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.directionLockFailedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.eventsPublished()); + verify(conflictAlertLogRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture()); + PathConflictAlertMessage status = statusCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("PATH_CONFLICT_STATUS", status.getMessageType()); + org.junit.jupiter.api.Assertions.assertEquals("DIRECTION_LOCK_FAILED", status.getCalculationStatus()); + org.junit.jupiter.api.Assertions.assertEquals( + "DIRECTION_INVALID", + ReflectionTestUtils.getField(status, "directionLockStatus")); + assertTrue(((String) ReflectionTestUtils.getField(status, "directionLockReason")) + .contains("object1=conflict_points_on_both_sides")); + org.junit.jupiter.api.Assertions.assertEquals( + 50.0, + (Double) ReflectionTestUtils.getField(status, "object1RouteProgressMeters"), + 0.001); + } + + @Test + void shouldWriteDirectionLockAndDistanceEvaluationToTestSessionLog() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(30.0, -50.0), + new Coordinate(30.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(10.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(30.0, -10.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(platformRuntimeStateService.getCurrentTestSessionId()).thenReturn("session-1"); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(100.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(100.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + ReflectionTestUtils.setField( + pathConflictDetectionService, + "collisionTestSessionLogService", + collisionTestSessionLogService); + + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(collisionTestSessionLogService, atLeastOnce()) + .write(eq("session-1"), contains("DIRECTION_LOCK object=UV-1(UNMANNED_VEHICLE)")); + verify(collisionTestSessionLogService, atLeastOnce()) + .write(eq("session-1"), contains("obj1ForwardDistanceMeters=")); + verify(collisionTestSessionLogService, atLeastOnce()) + .write(eq("session-1"), contains("EVENT PATH_CONFLICT_ALERT")); + } + + @Test + void shouldUseOverlapEntryPointInsteadOfOverlapMiddleAsConflictPoint() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(45.0, 10.0), + new Coordinate(50.0, 0.0), + new Coordinate(90.0, 0.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(40.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(45.0, 10.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(20.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(20.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ConflictAlertEvent.class); + verify(eventPublisher, atLeastOnce()).publishEvent(eventCaptor.capture()); + ConflictAlertEvent event = eventCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(50.0, event.getConflictPoint().getX()); + org.junit.jupiter.api.Assertions.assertEquals(0.0, event.getConflictPoint().getY()); + org.junit.jupiter.api.Assertions.assertEquals("AC-1", event.getAircraftName()); + org.junit.jupiter.api.Assertions.assertEquals(MovingObjectType.AIRCRAFT.name(), event.getAircraftObjectType()); + org.junit.jupiter.api.Assertions.assertEquals(Math.sqrt(125.0), event.getAircraftDistanceToConflictMeters(), 0.001); + org.junit.jupiter.api.Assertions.assertEquals(20.0, event.getAircraftAlertThresholdMeters()); + org.junit.jupiter.api.Assertions.assertEquals("UV-1", event.getVehicleName()); + org.junit.jupiter.api.Assertions.assertEquals(MovingObjectType.UNMANNED_VEHICLE.name(), event.getVehicleObjectType()); + org.junit.jupiter.api.Assertions.assertEquals(10.0, event.getVehicleDistanceToConflictMeters(), 0.001); + org.junit.jupiter.api.Assertions.assertEquals(20.0, event.getVehicleAlertThresholdMeters()); + org.junit.jupiter.api.Assertions.assertTrue(event.getVehicleMovingTowardConflictPoint()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } + + @Test + void shouldPublishMonitoringStatusForValidConflictPointBeforeAlertThreshold() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(100.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(50.0, -80.0), + new Coordinate(50.0, 80.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(40.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(50.0, -20.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(5.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(5.0); + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + org.mockito.ArgumentCaptor messageCaptor = + org.mockito.ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher).publishPathConflictStatus(messageCaptor.capture()); + PathConflictAlertMessage message = messageCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("PATH_CONFLICT_STATUS", message.getMessageType()); + org.junit.jupiter.api.Assertions.assertEquals("MONITORING", message.getCalculationStatus()); + org.junit.jupiter.api.Assertions.assertEquals(50.0, message.getConflictPoint().getLongitude()); + org.junit.jupiter.api.Assertions.assertEquals(0.0, message.getConflictPoint().getLatitude()); + org.junit.jupiter.api.Assertions.assertEquals(20.0, message.getAircraftDistanceToConflictMeters()); + org.junit.jupiter.api.Assertions.assertEquals(10.0, message.getVehicleDistanceToConflictMeters()); + org.junit.jupiter.api.Assertions.assertTrue(message.getVehicleMovingTowardConflictPoint()); + } + + @Test + void shouldKeepInitialEndpointDirectionForCurrentTestSessionAfterObjectPassesIntersection() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(-50.0, 0.0), + new Coordinate(50.0, 0.0) + }); + LineString aircraftRouteLine = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, -50.0), + new Coordinate(0.0, 50.0) + }); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute vehicleRoute = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRouteLine) + .build(); + TransportRoute aircraftRoute = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRouteLine) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(vehicleRoute)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(aircraftRoute)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + MovingObject approachingVehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(-25.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject approachingAircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(0.0, -25.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + PathConflictDetectionService.ConflictDetectionSummary firstSummary = + pathConflictDetectionService.detectPathConflicts(List.of(approachingVehicle, approachingAircraft)); + org.junit.jupiter.api.Assertions.assertEquals(1, firstSummary.eventsPublished()); + clearInvocations(conflictAlertLogRepository, eventPublisher); + + MovingObject passedVehicleWithNoisyHeading = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(25.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(270.0) + .altitude(0.0) + .build(); + + PathConflictDetectionService.ConflictDetectionSummary secondSummary = + pathConflictDetectionService.detectPathConflicts(List.of(passedVehicleWithNoisyHeading, approachingAircraft)); + + verify(conflictAlertLogRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + org.junit.jupiter.api.Assertions.assertEquals(1, secondSummary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, secondSummary.intersectionBehindPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, secondSummary.eventsPublished()); } @Test @@ -176,6 +1054,89 @@ class PathConflictDetectionDirectionalTest { org.junit.jupiter.api.Assertions.assertEquals(0, summary.supportedPairs()); } + @Test + void shouldPublishRouteConflictPointEvenWhenObjectsAreFarFromAssignedRoutes() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(-50.0, 0.0), + new Coordinate(50.0, 0.0) + }); + LineString aircraftRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, -50.0), + new Coordinate(0.0, 50.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("TEST003") + .objectName("TEST003") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(1000.0, 1000.0))) + .currentSpeed(20.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + MovingObject aircraft = MovingObject.builder() + .objectId("MU2465") + .objectName("MU2465") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(-1000.0, -1000.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("TEST003") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("MU2465") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute route1 = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRoute) + .build(); + TransportRoute route2 = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRoute) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("TEST003", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("MU2465", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(route1)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(route2)); + when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); + verify(webSocketPublisher).publishPathConflictStatus(messageCaptor.capture()); + PathConflictAlertMessage message = messageCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("PATH_CONFLICT_STATUS", message.getMessageType()); + org.junit.jupiter.api.Assertions.assertEquals("ROUTE_CONFLICT_POINT", message.getCalculationStatus()); + org.junit.jupiter.api.Assertions.assertEquals(0.0, message.getConflictPoint().getLongitude()); + org.junit.jupiter.api.Assertions.assertEquals(0.0, message.getConflictPoint().getLatitude()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.routeDeviationPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.eventsPublished()); + } + @Test void shouldStillEvaluateConflictWhenAircraftIsWithinTwoHundredMetersOfAssignedRoute() throws Exception { GeometryFactory geometryFactory = new GeometryFactory(); @@ -241,7 +1202,7 @@ class PathConflictDetectionDirectionalTest { .thenReturn(Optional.of(aircraftAssignment)); when(routeRepository.findById(1L)).thenReturn(Optional.of(route1)); when(routeRepository.findById(2L)).thenReturn(Optional.of(route2)); - when(coordinateSystemService.convertToLocalCoordinate(anyDouble(), anyDouble())) + when(coordinateSystemService.convertToAirportProjectedCoordinate(anyDouble(), anyDouble())) .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); @@ -259,4 +1220,185 @@ class PathConflictDetectionDirectionalTest { org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); } + + @Test + void shouldStillPublishConflictWhenVehicleIsStoppedInsideConflictDistance() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(0.0, 0.0), + new Coordinate(300.0, 0.0) + }); + LineString aircraftRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(150.0, -300.0), + new Coordinate(150.0, 300.0) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(30.0, 0.0))) + .currentSpeed(0.0) + .currentHeading(90.0) + .altitude(0.0) + .build(); + + MovingObject aircraft = MovingObject.builder() + .objectId("AC-1") + .objectName("AC-1") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(150.0, -150.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("UV-1") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(1L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("AC-1") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(2L) + .build(); + + TransportRoute route1 = TransportRoute.builder() + .id(1L) + .routeName("UV_ROUTE") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRoute) + .build(); + TransportRoute route2 = TransportRoute.builder() + .id(2L) + .routeName("AC_ROUTE") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRoute) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("UV-1", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("AC-1", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(1L)).thenReturn(Optional.of(route1)); + when(routeRepository.findById(2L)).thenReturn(Optional.of(route2)); + when(coordinateSystemService.convertToAirportProjectedCoordinate(anyDouble(), anyDouble())) + .thenAnswer(invocation -> new double[]{invocation.getArgument(0), invocation.getArgument(1)}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, atLeastOnce()).save(any()); + verify(eventPublisher, atLeastOnce()).publishEvent(any(ConflictAlertEvent.class)); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.speedTooLowPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } + + @Test + void shouldEvaluateProjectedRouteCoordinatesWithoutWgs84Conversion() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + LineString vehicleRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(40508513.641064, 4024186.813497), + new Coordinate(40508966.275693, 4024330.441807) + }); + LineString aircraftRoute = geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(40508865.56466504, 4024284.618796356), + new Coordinate(40509239.30588185, 4024638.325468728) + }); + + MovingObject vehicle = MovingObject.builder() + .objectId("WR01") + .objectName("WR01") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(120.0970, 36.3481))) + .currentSpeed(18.0) + .currentHeading(70.0) + .altitude(0.0) + .build(); + + MovingObject aircraft = MovingObject.builder() + .objectId("CD423") + .objectName("CD423") + .objectType(MovingObjectType.AIRCRAFT) + .currentPosition(geometryFactory.createPoint(new Coordinate(120.0978, 36.3483))) + .currentSpeed(20.0) + .currentHeading(45.0) + .altitude(0.0) + .build(); + + ObjectRouteAssignment vehicleAssignment = ObjectRouteAssignment.builder() + .objectName("WR01") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(911L) + .build(); + ObjectRouteAssignment aircraftAssignment = ObjectRouteAssignment.builder() + .objectName("CD423") + .objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT) + .assignedRouteId(1093L) + .build(); + + TransportRoute route1 = TransportRoute.builder() + .id(911L) + .routeName("807-805") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(vehicleRoute) + .build(); + TransportRoute route2 = TransportRoute.builder() + .id(1093L) + .routeName("FLIGHT_CD423_OUT") + .routeType(TransportRoute.RouteType.AIRCRAFT) + .status(TransportRoute.RouteStatus.ACTIVE) + .routeGeometry(aircraftRoute) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("WR01", ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)) + .thenReturn(Optional.of(vehicleAssignment)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc("CD423", ObjectRouteAssignment.ObjectType.AIRCRAFT)) + .thenReturn(Optional.of(aircraftAssignment)); + when(routeRepository.findById(911L)).thenReturn(Optional.of(route1)); + when(routeRepository.findById(1093L)).thenReturn(Optional.of(route2)); + when(coordinateSystemService.convertToAirportProjectedCoordinate(120.0970, 36.3481)) + .thenReturn(new double[]{40508703.0, 4024247.0}); + when(coordinateSystemService.convertToAirportProjectedCoordinate(120.0978, 36.3483)) + .thenReturn(new double[]{40508880.0, 4024298.0}); + when(coordinateSystemService.convertAirportProjectedToWgs84Coordinate(anyDouble(), anyDouble())) + .thenReturn(new double[]{120.0974, 36.3482}); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()).thenReturn(200.0); + when(platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()).thenReturn(200.0); + when(conflictAlertLogRepository.save(any())).thenAnswer(invocation -> { + com.qaup.collision.pathconflict.model.entity.ConflictAlertLog logEntry = invocation.getArgument(0); + logEntry.setId(1L); + return logEntry; + }); + + PathConflictDetectionService.ConflictDetectionSummary summary = + pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraft)); + + verify(conflictAlertLogRepository, atLeastOnce()).save(any()); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ConflictAlertEvent.class); + verify(eventPublisher, atLeastOnce()).publishEvent(eventCaptor.capture()); + ConflictAlertEvent event = eventCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(120.0974, event.getConflictPointLongitude()); + org.junit.jupiter.api.Assertions.assertEquals(36.3482, event.getConflictPointLatitude()); + verify(coordinateSystemService, never()).convertToLocalCoordinate(anyDouble(), anyDouble()); + verify(coordinateSystemService, atLeastOnce()).convertToAirportProjectedCoordinate(anyDouble(), anyDouble()); + verify(coordinateSystemService, atLeastOnce()).convertAirportProjectedToWgs84Coordinate(anyDouble(), anyDouble()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.supportedPairs()); + org.junit.jupiter.api.Assertions.assertEquals(0, summary.routeDeviationPairs()); + org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); + } } diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java index c568f53..d56b7d1 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java @@ -9,6 +9,7 @@ import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; import com.qaup.collision.service.PlatformRuntimeStateService; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @@ -32,7 +33,8 @@ class PathConflictDetectionServiceRuntimeConfigTest { mock(ApplicationEventPublisher.class), mock(CoordinateSystemService.class), runtimeStateService, - mock(VehicleCommandService.class) + mock(VehicleCommandService.class), + mock(RuleEventWebSocketPublisher.class) ); @SuppressWarnings("unchecked") @@ -78,7 +80,8 @@ class PathConflictDetectionServiceRuntimeConfigTest { mock(ApplicationEventPublisher.class), mock(CoordinateSystemService.class), runtimeStateService, - mock(VehicleCommandService.class) + mock(VehicleCommandService.class), + mock(RuleEventWebSocketPublisher.class) ); @SuppressWarnings("unchecked") @@ -108,7 +111,8 @@ class PathConflictDetectionServiceRuntimeConfigTest { mock(ApplicationEventPublisher.class), mock(CoordinateSystemService.class), runtimeStateService, - mock(VehicleCommandService.class) + mock(VehicleCommandService.class), + mock(RuleEventWebSocketPublisher.class) ); MovingObject aircraft = MovingObject.builder() diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java index 411197e..495d253 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java @@ -13,6 +13,7 @@ import org.locationtech.jts.geom.Point; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; import org.springframework.web.client.RestTemplate; import org.springframework.test.web.client.MockRestServiceServer; @@ -26,6 +27,10 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers. import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; class VehicleCommandServiceTest { @@ -35,39 +40,24 @@ class VehicleCommandServiceTest { private MockRestServiceServer server; private PlatformRuntimeStateService runtimeStateService; private GeometryFactory geometryFactory; + private RuleEventWebSocketPublisher webSocketPublisher; @BeforeEach void setUp() { RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0); - vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService); + webSocketPublisher = mock(RuleEventWebSocketPublisher.class); + vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService, webSocketPublisher); geometryFactory = new GeometryFactory(); } @Test - void shouldSendWarningOnceAndResumeAfterConflictClears() { + void shouldNotSendVehicleCommandForWarning() { runtimeStateService.updateVehicleRegistry(List.of( new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) )); - server.expect(once(), requestTo(COMMAND_URL)) - .andExpect(method(HttpMethod.POST)) - .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("$.vehicleID").value("UV-001")) - .andExpect(jsonPath("$.commandType").value("WARNING")) - .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) - .andExpect(jsonPath("$.latitude").value(36.3544)) - .andExpect(jsonPath("$.longitude").value(120.085)) - .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo(COMMAND_URL)) - .andExpect(method(HttpMethod.POST)) - .andExpect(jsonPath("$.vehicleID").value("UV-001")) - .andExpect(jsonPath("$.commandType").value("RESUME")) - .andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC")) - .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); - ConflictAlertEvent warningEvent = buildEvent( "UV-001", MovingObject.MovingObjectType.UNMANNED_VEHICLE, @@ -76,9 +66,336 @@ class VehicleCommandServiceTest { vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); - vehicleCommandService.synchronizeConflictCommands(List.of()); + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 45.0, 45.0, false, false, false, false))); server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> "PATH_CONFLICT_RESUME".equals(message.getMessageType()))); + } + + @Test + void shouldNotResumeWhenAircraftDistanceIncreasesBeforeConflictPoint() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andExpect(jsonPath("$.latitude").value(36.3544)) + .andExpect(jsonPath("$.longitude").value(120.085)) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + for (int i = 0; i < 3; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 45.0 + i, false, false, false, false))); + } + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldResumeAfterAircraftPassesConflictPointAndExceedsReleaseDistanceAfterAlert() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + runtimeStateService.updateCollisionDivergingReleaseDistance(25.0); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("RESUME")) + .andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + for (int i = 0; i < 3; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 30.0 + i, false, true, false, true))); + } + + server.verify(); + verify(webSocketPublisher).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldNotResumeAfterAircraftPassesConflictPointButBeforeReleaseDistance() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + runtimeStateService.updateCollisionDivergingReleaseDistance(25.0); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + for (int i = 0; i < 3; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 20.0 + i, false, true, false, true))); + } + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldResumeWhenAircraftPassedConflictPointAndReleaseDistanceEvenIfConflictStillReported() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + runtimeStateService.updateCollisionDivergingReleaseDistance(25.0); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("RESUME")) + .andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + for (int i = 0; i < 3; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 30.0 + i, false, true, false, true))); + } + + server.verify(); + verify(webSocketPublisher).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldNotResumeWhenParkedVehicleStillReceivesActiveAlertBeforeIntersection() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + runtimeStateService.updateCollisionDivergingReleaseDistance(25.0); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent initialAlert = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL, + 12.0, + 20.0 + ); + ConflictAlertEvent activeAlertBeforeIntersection = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL, + 62.0, + 76.0 + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(initialAlert)); + for (int i = 0; i < 3; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(activeAlertBeforeIntersection)); + } + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldNotResumeOnSingleRecoveryObservationAfterAlert() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 45.0, false, false, false, true))); + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldNotResumeWhenOnlyVehicleMovesAway() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + for (int i = 0; i < 5; i++) { + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 45.0 + i, 35.0, false, false, false, false))); + } + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); + } + + @Test + void shouldClearActiveVehicleCommandStateWhenRegistryStartsNewSession() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("AC-001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent alertEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("AC-001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation( + "UV-001", 12.0, 45.0, false, false, false, true))); + + server.verify(); + verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> + "PATH_CONFLICT_RESUME".equals(message.getMessageType()) + )); } @Test @@ -87,13 +404,6 @@ class VehicleCommandServiceTest { new PlatformRuntimeStateService.VehicleRegistryEntry("SP-001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN) )); - server.expect(once(), requestTo(COMMAND_URL)) - .andExpect(method(HttpMethod.POST)) - .andExpect(jsonPath("$.vehicleID").value("SP-001")) - .andExpect(jsonPath("$.commandType").value("WARNING")) - .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) - .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); - server.expect(once(), requestTo(COMMAND_URL)) .andExpect(method(HttpMethod.POST)) .andExpect(jsonPath("$.vehicleID").value("SP-001")) @@ -144,6 +454,16 @@ class VehicleCommandServiceTest { MovingObject.MovingObjectType vehicleType, ConflictAlertLog.AlertLevel alertLevel) { + return buildEvent(vehicleId, vehicleType, alertLevel, 12.0, 35.0); + } + + private ConflictAlertEvent buildEvent( + String vehicleId, + MovingObject.MovingObjectType vehicleType, + ConflictAlertLog.AlertLevel alertLevel, + double vehicleDistance, + double aircraftDistance) { + Point conflictPoint = geometryFactory.createPoint(new Coordinate(120.0850, 36.3544)); return ConflictAlertEvent.builder() @@ -160,8 +480,8 @@ class VehicleCommandServiceTest { .object2Name("CA1234") .object2Type(MovingObject.MovingObjectType.AIRCRAFT) .conflictPoint(conflictPoint) - .object1Distance(12.0) - .object2Distance(35.0) + .object1Distance(vehicleDistance) + .object2Distance(aircraftDistance) .estimatedTimeToConflictObj1(10) .estimatedTimeToConflictObj2(20) .timeGapSeconds(10.0) diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTrafficLightTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTrafficLightTest.java index fcbd2ab..5f24e3f 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTrafficLightTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTrafficLightTest.java @@ -2,6 +2,7 @@ package com.qaup.collision.pathconflict.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.qaup.collision.service.PlatformRuntimeStateService; +import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -18,6 +19,7 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers. import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.mockito.Mockito.mock; class VehicleCommandServiceTrafficLightTest { @@ -32,7 +34,12 @@ class VehicleCommandServiceTrafficLightTest { RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0); - vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService); + vehicleCommandService = new VehicleCommandService( + restTemplate, + new ObjectMapper(), + runtimeStateService, + mock(RuleEventWebSocketPublisher.class) + ); } @Test diff --git a/qaup-collision/src/test/java/com/qaup/collision/service/CollisionTestSessionLogServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/service/CollisionTestSessionLogServiceTest.java new file mode 100644 index 0000000..94afbce --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/service/CollisionTestSessionLogServiceTest.java @@ -0,0 +1,97 @@ +package com.qaup.collision.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CollisionTestSessionLogServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldCreateDedicatedFilePerCollisionTestSession() throws Exception { + CollisionTestSessionLogService service = new CollisionTestSessionLogService(tempDir.toString(), "ignored"); + + service.startSession("collision-test-1/unsafe", "flightIds=CA123, vehicleIds=V001"); + service.write("collision-test-1/unsafe", "direction locked"); + service.startSession("collision-test-2", "second session started"); + + Path firstSessionLog = tempDir.resolve("collision-test-1_unsafe.log"); + Path secondSessionLog = tempDir.resolve("collision-test-2.log"); + + assertTrue(Files.exists(firstSessionLog)); + assertTrue(Files.exists(secondSessionLog)); + + String firstLog = Files.readString(firstSessionLog); + String secondLog = Files.readString(secondSessionLog); + + assertTrue(firstLog.contains("SESSION_START")); + assertTrue(firstLog.contains("flightIds=CA123")); + assertTrue(firstLog.contains("vehicleIds=V001")); + assertTrue(firstLog.contains("direction locked")); + assertFalse(firstLog.contains("second session started")); + assertTrue(secondLog.contains("second session started")); + } + + @Test + void shouldStopAppendingAfterSessionLogDurationLimit() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-04-29T10:00:00Z")); + CollisionTestSessionLogService service = new CollisionTestSessionLogService( + tempDir.toString(), + "ignored", + clock, + Duration.ofMinutes(20)); + + service.startSession("collision-test-long", "started"); + service.write("collision-test-long", "before expiry"); + clock.advance(Duration.ofMinutes(21)); + service.write("collision-test-long", "after expiry"); + service.write("collision-test-long", "after expiry again"); + service.endSession("collision-test-long", "ended"); + + String log = Files.readString(tempDir.resolve("collision-test-long.log")); + assertTrue(log.contains("SESSION_START")); + assertTrue(log.contains("before expiry")); + assertTrue(log.contains("SESSION_LOG_EXPIRED maxDurationMinutes=20")); + assertFalse(log.contains("after expiry")); + assertFalse(log.contains("after expiry again")); + assertTrue(log.contains("SESSION_END")); + } + + private static final class MutableClock extends Clock { + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java index ec2465f..5ba9a93 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java @@ -1,6 +1,7 @@ package com.qaup.collision.service; import com.qaup.collision.common.model.MovingObject; +import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.system.domain.SysConfig; import com.qaup.system.service.ISysConfigService; import org.junit.jupiter.api.Test; @@ -12,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -127,6 +129,39 @@ class PlatformRuntimeStateServiceTest { assertEquals(List.of("AC001", "TQ001"), second.removedVehicleIDs()); } + @Test + void shouldCleanStaleRouteAssignmentsWhenVehicleRegistryIsResubmitted() { + PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + ObjectRouteAssignmentRepository assignmentRepository = mock(ObjectRouteAssignmentRepository.class); + ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", assignmentRepository); + + service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("WR01", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("CD423", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG), + new PlatformRuntimeStateService.VehicleRegistryEntry("TEST003", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("WR01", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("CD423", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + + verify(assignmentRepository, times(2)).deleteByObjectNameIn(eq(List.of("CD423", "TEST003", "WR01"))); + } + + @Test + void shouldCleanRouteAssignmentsForObjectsRegisteredAsNonCollisionTypes() { + PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + ObjectRouteAssignmentRepository assignmentRepository = mock(ObjectRouteAssignmentRepository.class); + ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", assignmentRepository); + + service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("TEST003", PlatformRuntimeStateService.VehicleRegistryType.PUTONG) + )); + + verify(assignmentRepository).deleteByObjectNameIn(eq(List.of("TEST003"))); + } + @Test void shouldCreateAndEndCollisionTestSessionFromRegistryUpdates() { PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); diff --git a/qaup-collision/src/test/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcasterTest.java b/qaup-collision/src/test/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcasterTest.java index 2cbfba6..0b8b8e2 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcasterTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcasterTest.java @@ -1,8 +1,10 @@ package com.qaup.collision.websocket.broadcaster; import com.fasterxml.jackson.databind.ObjectMapper; +import com.qaup.collision.websocket.event.PathConflictAlertWebSocketEvent; import com.qaup.collision.websocket.cache.MessageCacheService; import com.qaup.collision.websocket.handler.CollisionWebSocketHandler; +import com.qaup.collision.websocket.message.PathConflictAlertMessage; import com.qaup.collision.websocket.message.PositionUpdatePayload; import org.junit.jupiter.api.Test; @@ -11,6 +13,7 @@ import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; class WebSocketMessageBroadcasterTest { @@ -33,6 +36,24 @@ class WebSocketMessageBroadcasterTest { assertEquals(120.100100, sanitized.getPosition().getLongitude()); } + @Test + void shouldBroadcastPathConflictStatusWithStatusMessageType() { + CollisionWebSocketHandler handler = mock(CollisionWebSocketHandler.class); + WebSocketMessageBroadcaster broadcaster = new WebSocketMessageBroadcaster( + mock(MessageCacheService.class), + handler, + new ObjectMapper() + ); + PathConflictAlertMessage payload = PathConflictAlertMessage.builder() + .messageType("PATH_CONFLICT_STATUS") + .calculationStatus("MONITORING") + .build(); + + broadcaster.handlePathConflictAlert(PathConflictAlertWebSocketEvent.create(payload)); + + verify(handler).broadcastMessage(org.mockito.ArgumentMatchers.contains("\"type\":\"path_conflict_status\"")); + } + private PositionUpdatePayload invokeSanitize(WebSocketMessageBroadcaster broadcaster, PositionUpdatePayload payload) throws Exception { Method method = WebSocketMessageBroadcaster.class.getDeclaredMethod("sanitizePositionPayload", PositionUpdatePayload.class);