Improve collision conflict selection and release handling

This commit is contained in:
sladro 2026-04-30 11:42:40 +08:00
parent 43bf9488d6
commit fb5badbaba
10 changed files with 379 additions and 25 deletions

View File

@ -31,6 +31,17 @@ qaup-postgis
- 如需看路线名称,连接 `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;"` - 如需看路线名称,连接 `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;"`
- 历史上曾出现同一对象编号跨类型或旧路线残留导致误判,注册覆盖时需要同步清理旧路径绑定。 - 历史上曾出现同一对象编号跨类型或旧路线残留导致误判,注册覆盖时需要同步清理旧路径绑定。
### 碰撞现场测试快速流程
- 测试开始后先定位本次独立测试日志:`docker exec qaup-app sh -c 'ls -lt /logs/collision-tests | head -20'`。
- 已知车牌或航班号时,直接按对象编号找日志文件:`docker exec qaup-app sh -c 'grep -R -l "TEST003" /logs/collision-tests 2>/dev/null'`。
- 找到日志后优先看方向锁定和碰撞计算链路:`docker exec qaup-app sh -c 'grep -E "DIRECTION_LOCK|DIRECTION_LOCK_FAILED|PAIR_RESULT|PATH_CONFLICT_POINT_SENT|EVENT_PUBLISHED|CYCLE" /logs/collision-tests/具体文件名.log'`。
- 不知道具体日志名但知道对象编号时,可一条命令定位并查看:`docker exec qaup-app sh -c 'f=$(grep -R -l "TEST003" /logs/collision-tests 2>/dev/null | tail -1); echo $f; grep -E "DIRECTION_LOCK|DIRECTION_LOCK_FAILED|PAIR_RESULT|PATH_CONFLICT_POINT_SENT|EVENT_PUBLISHED|CYCLE" "$f"'`。
- 方向锁定判断:正常应看到 `DIRECTION_LOCK`;如果看到 `DIRECTION_LOCK_FAILED`,先看原因是否为 `initial_position_too_close_to_conflict_point``conflict_points_on_both_sides`
- 本项目碰撞测试要求:车和飞机不必停在路线起点,但应能投影到绑定路线,并且测试开始时不要贴近冲突点,建议距离冲突点至少 `10-20米`,避免定位抖动触发方向无法锁定。
- 前端 WebSocket 调试消息看 `type=path_conflict_status`,重点看 `payload.calculationStatus`、`payload.directionLockStatus`、`payload.directionLockReason`、`payload.vehicleDistanceToConflictMeters`、`payload.aircraftDistanceToConflictMeters`、`payload.object1ForwardDistanceMeters`、`payload.object2ForwardDistanceMeters`。
- 正常测试现象:`directionLockStatus=DIRECTION_LOCKED`,车和飞机到冲突点的距离逐步变小;达到预警/告警阈值后后端发预警/告警,车辆停车;飞机越过冲突点并超过解除距离后车辆恢复通行。
- 如果前端看到距离不变大/不变小,先对照测试日志中的 `forwardDistance`、`PAIR_RESULT` 和路线绑定表,判断是定位数据问题、路线绑定问题,还是方向未锁定问题。
### 红绿灯接入说明 ### 红绿灯接入说明
- 正式环境中qaup-app 的红绿灯接入方式为 MQTT不是 HTTP。 - 正式环境中qaup-app 的红绿灯接入方式为 MQTT不是 HTTP。
- 正式环境配置中 `traffic.light.tcp.enabled=false``traffic.light.mqtt.enabled=true`。 - 正式环境配置中 `traffic.light.tcp.enabled=false``traffic.light.mqtt.enabled=true`。

View File

@ -103,11 +103,11 @@ public class PlatformIntegrationController {
} }
/** /**
* Updates path crossing distance thresholds. * Updates path crossing alert thresholds or the conflict release distance.
* *
* Supported payloads: * Supported payloads:
* - {"value": 40} * - {"value": 40}
* Applies the same threshold to both vehicle and aircraft. * Updates the release distance after the aircraft has passed the crossing point.
* - {"vehicleDistance": 50, "aircraftDistance": 80} * - {"vehicleDistance": 50, "aircraftDistance": 80}
* Updates each threshold independently. * Updates each threshold independently.
* *
@ -115,7 +115,7 @@ public class PlatformIntegrationController {
* - vehicleDistance: vehicle distance to the crossing point * - vehicleDistance: vehicle distance to the crossing point
* - aircraftDistance: aircraft distance to the crossing point * - aircraftDistance: aircraft distance to the crossing point
* *
* Alert evaluation: * Alert threshold evaluation:
* - both sides inside their thresholds -> alert * - both sides inside their thresholds -> alert
* - only one side inside its threshold -> warning * - only one side inside its threshold -> warning
*/ */
@ -219,6 +219,7 @@ public class PlatformIntegrationController {
thresholds.put("vehicleDistance", platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle()); thresholds.put("vehicleDistance", platformRuntimeStateService.getCollisionDivergingReleaseDistanceForVehicle());
thresholds.put("aircraftDistance", platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()); thresholds.put("aircraftDistance", platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft());
payload.put("thresholds", thresholds); payload.put("thresholds", thresholds);
payload.put("releaseDistance", platformRuntimeStateService.getCollisionDivergingReleaseDistance());
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload); return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload);
} catch (Exception e) { } catch (Exception e) {
return internalError(e); return internalError(e);
@ -374,7 +375,7 @@ public class PlatformIntegrationController {
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
payload.put("status", "success"); payload.put("status", "success");
payload.put("area", "collision"); payload.put("area", "collision");
payload.put("field", "crossing_distance_threshold"); payload.put("field", "collision.diverging_release_distance");
payload.put("old", oldValue); payload.put("old", oldValue);
payload.put("new", newValue); payload.put("new", newValue);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload); return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload);

View File

@ -56,6 +56,7 @@ public class PathConflictDetectionService {
private CollisionTestSessionLogService collisionTestSessionLogService; private CollisionTestSessionLogService collisionTestSessionLogService;
private final Map<String, RouteTravelDirection> routeTravelDirections = new ConcurrentHashMap<>(); private final Map<String, RouteTravelDirection> routeTravelDirections = new ConcurrentHashMap<>();
private final Map<String, Double> lockedConflictProgressMeters = new ConcurrentHashMap<>();
private final Map<String, Long> pathConflictStatusPublishedAt = new ConcurrentHashMap<>(); private final Map<String, Long> pathConflictStatusPublishedAt = new ConcurrentHashMap<>();
private volatile String observedDirectionSessionId; private volatile String observedDirectionSessionId;
@ -250,10 +251,24 @@ public class PathConflictDetectionService {
return PairDetectionResult.of(PairOutcomeReason.DIRECTION_LOCK_FAILED); return PairDetectionResult.of(PairOutcomeReason.DIRECTION_LOCK_FAILED);
} }
RouteTravelDirection direction1 = directionLock1.direction();
RouteTravelDirection direction2 = directionLock2.direction();
List<IntersectionProjection> effectiveIntersections = selectLockedConflictPoint(
obj1,
obj2,
route1,
route2,
projected1,
projected2,
direction1,
direction2,
projectedIntersections
);
boolean intersectionBehind = false; boolean intersectionBehind = false;
boolean thresholdNotReached = false; boolean thresholdNotReached = false;
VehicleCommandService.ConflictRecoveryObservation recoveryObservation = null; VehicleCommandService.ConflictRecoveryObservation recoveryObservation = null;
for (IntersectionProjection projection : projectedIntersections) { for (IntersectionProjection projection : effectiveIntersections) {
Point intersectionPoint = projection.intersectionPoint(); Point intersectionPoint = projection.intersectionPoint();
Point localIntersection = projection.localIntersection(); Point localIntersection = projection.localIntersection();
ProjectedPosition projectedIntersection1 = projection.obj1Intersection(); ProjectedPosition projectedIntersection1 = projection.obj1Intersection();
@ -261,8 +276,6 @@ public class PathConflictDetectionService {
double distanceToIntersection1 = localObjPos1.distance(localIntersection); double distanceToIntersection1 = localObjPos1.distance(localIntersection);
double distanceToIntersection2 = localObjPos2.distance(localIntersection); double distanceToIntersection2 = localObjPos2.distance(localIntersection);
RouteTravelDirection direction1 = directionLock1.direction();
RouteTravelDirection direction2 = directionLock2.direction();
boolean obj1IntersectionAhead = isIntersectionAheadByRouteProgress(projected1, projectedIntersection1, direction1); boolean obj1IntersectionAhead = isIntersectionAheadByRouteProgress(projected1, projectedIntersection1, direction1);
boolean obj2IntersectionAhead = isIntersectionAheadByRouteProgress(projected2, projectedIntersection2, direction2); boolean obj2IntersectionAhead = isIntersectionAheadByRouteProgress(projected2, projectedIntersection2, direction2);
double forwardDistance1 = obj1IntersectionAhead ? distanceToIntersection1 : -distanceToIntersection1; double forwardDistance1 = obj1IntersectionAhead ? distanceToIntersection1 : -distanceToIntersection1;
@ -272,6 +285,23 @@ public class PathConflictDetectionService {
if (!obj1IntersectionAhead || !obj2IntersectionAhead if (!obj1IntersectionAhead || !obj2IntersectionAhead
|| distanceToIntersection1 <= MIN_FORWARD_DISTANCE_METERS || distanceToIntersection1 <= MIN_FORWARD_DISTANCE_METERS
|| distanceToIntersection2 <= MIN_FORWARD_DISTANCE_METERS) { || distanceToIntersection2 <= MIN_FORWARD_DISTANCE_METERS) {
ConflictCalculationResult behindStatus = buildStatusOnlyResult(
obj1,
obj2,
forwardDistance1,
forwardDistance2
);
publishPathConflictStatusIfDue(
obj1,
obj2,
toExternalPoint(intersectionPoint),
behindStatus,
obj1IntersectionAhead,
obj2IntersectionAhead,
buildDirectionDebug(directionLock1, projected1, projectedIntersection1, obj1IntersectionAhead, forwardDistance1),
buildDirectionDebug(directionLock2, projected2, projectedIntersection2, obj2IntersectionAhead, forwardDistance2),
"INTERSECTION_BEHIND"
);
writePairEvaluation( writePairEvaluation(
"INTERSECTION_BEHIND", "INTERSECTION_BEHIND",
obj1, obj1,
@ -412,6 +442,25 @@ public class PathConflictDetectionService {
} }
} }
private ConflictCalculationResult buildStatusOnlyResult(
MovingObject obj1,
MovingObject obj2,
double forwardDistance1,
double forwardDistance2) {
return new ConflictCalculationResult(
Math.abs(forwardDistance1),
Math.abs(forwardDistance2),
MAX_PREDICTION_TIME_SECONDS,
MAX_PREDICTION_TIME_SECONDS,
0.0,
Optional.empty(),
Optional.empty(),
obj1.getObjectType(),
obj2.getObjectType()
);
}
private ConflictAlertEvent buildConflictAlertEvent( private ConflictAlertEvent buildConflictAlertEvent(
MovingObject obj1, MovingObject obj1,
MovingObject obj2, MovingObject obj2,
@ -1063,10 +1112,95 @@ public class PathConflictDetectionService {
} }
routeTravelDirections.clear(); routeTravelDirections.clear();
lockedConflictProgressMeters.clear();
pathConflictStatusPublishedAt.clear(); pathConflictStatusPublishedAt.clear();
observedDirectionSessionId = currentTestSessionId; observedDirectionSessionId = currentTestSessionId;
} }
private List<IntersectionProjection> selectLockedConflictPoint(
MovingObject obj1,
MovingObject obj2,
TransportRoute route1,
TransportRoute route2,
ProjectedPosition projected1,
ProjectedPosition projected2,
RouteTravelDirection direction1,
RouteTravelDirection direction2,
List<IntersectionProjection> projectedIntersections) {
if (projectedIntersections.size() <= 1) {
cacheLockedConflictProgress(obj1, obj2, route1, route2, projectedIntersections.get(0).obj1Intersection(), projectedIntersections.get(0).obj2Intersection());
return projectedIntersections;
}
String key1 = conflictProgressKey(obj1, obj2, route1, route2);
String key2 = conflictProgressKey(obj2, obj1, route2, route1);
Double cached1 = lockedConflictProgressMeters.get(key1);
Double cached2 = lockedConflictProgressMeters.get(key2);
IntersectionProjection selected = null;
double selectedScore = Double.MAX_VALUE;
for (IntersectionProjection projection : projectedIntersections) {
double score;
if (cached1 != null || cached2 != null) {
double score1 = cached1 == null ? 0.0 : Math.abs(projection.obj1Intersection().distanceAlongRouteMeters() - cached1);
double score2 = cached2 == null ? 0.0 : Math.abs(projection.obj2Intersection().distanceAlongRouteMeters() - cached2);
score = score1 + score2;
} else {
double forward1 = signedForwardProgress(projected1, projection.obj1Intersection(), direction1);
double forward2 = signedForwardProgress(projected2, projection.obj2Intersection(), direction2);
boolean bothAhead = forward1 > MIN_FORWARD_DISTANCE_METERS && forward2 > MIN_FORWARD_DISTANCE_METERS;
score = Math.abs(forward1) + Math.abs(forward2);
if (!bothAhead) {
score += 1_000_000.0;
}
}
if (score < selectedScore) {
selectedScore = score;
selected = projection;
}
}
if (selected == null) {
return projectedIntersections;
}
cacheLockedConflictProgress(obj1, obj2, route1, route2, selected.obj1Intersection(), selected.obj2Intersection());
return List.of(selected);
}
private void cacheLockedConflictProgress(
MovingObject obj1,
MovingObject obj2,
TransportRoute route1,
TransportRoute route2,
ProjectedPosition projectedIntersection1,
ProjectedPosition projectedIntersection2) {
lockedConflictProgressMeters.putIfAbsent(
conflictProgressKey(obj1, obj2, route1, route2),
projectedIntersection1.distanceAlongRouteMeters()
);
lockedConflictProgressMeters.putIfAbsent(
conflictProgressKey(obj2, obj1, route2, route1),
projectedIntersection2.distanceAlongRouteMeters()
);
}
private String conflictProgressKey(MovingObject object, MovingObject otherObject, TransportRoute route, TransportRoute otherRoute) {
String sessionId = currentTestSessionId();
return (sessionId == null ? "default" : sessionId)
+ "|"
+ object.getObjectName()
+ "|"
+ route.getId()
+ "|"
+ otherObject.getObjectName()
+ "|"
+ otherRoute.getId();
}
private DirectionLockResult resolveRouteTravelDirection( private DirectionLockResult resolveRouteTravelDirection(
MovingObject object, MovingObject object,
TransportRoute route, TransportRoute route,
@ -1181,10 +1315,18 @@ public class PathConflictDetectionService {
ProjectedPosition projectedIntersection, ProjectedPosition projectedIntersection,
RouteTravelDirection direction) { RouteTravelDirection direction) {
return signedForwardProgress(projectedObject, projectedIntersection, direction) > MIN_FORWARD_DISTANCE_METERS;
}
private double signedForwardProgress(
ProjectedPosition projectedObject,
ProjectedPosition projectedIntersection,
RouteTravelDirection direction) {
double progressDelta = projectedIntersection.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters(); double progressDelta = projectedIntersection.distanceAlongRouteMeters() - projectedObject.distanceAlongRouteMeters();
return direction == RouteTravelDirection.START_TO_END return direction == RouteTravelDirection.START_TO_END
? progressDelta > MIN_FORWARD_DISTANCE_METERS ? progressDelta
: -progressDelta > MIN_FORWARD_DISTANCE_METERS; : -progressDelta;
} }
private boolean isSupportedConflictPair(MovingObjectType left, MovingObjectType right) { private boolean isSupportedConflictPair(MovingObjectType left, MovingObjectType right) {

View File

@ -100,7 +100,7 @@ public class VehicleCommandService {
+ ", vehicleId=" + vehicleId + ", vehicleId=" + vehicleId
+ ", aircraftPassedConflictPoint=true" + ", aircraftPassedConflictPoint=true"
+ ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters() + ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters()
+ ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft() + ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistance()
+ ", currentCommandSuppressed=true"); + ", currentCommandSuppressed=true");
currentCommands.remove(vehicleId); currentCommands.remove(vehicleId);
} }
@ -204,7 +204,7 @@ public class VehicleCommandService {
+ ", vehicleId=" + vehicleId + ", vehicleId=" + vehicleId
+ ", aircraftPassedConflictPoint=true" + ", aircraftPassedConflictPoint=true"
+ ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters() + ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters()
+ ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft() + ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistance()
+ ", confirmationCycle=" + (previousState.missingConflictCycles() + 1) + ", confirmationCycle=" + (previousState.missingConflictCycles() + 1)
+ ", requiredConfirmationCycles=" + RESUME_CONFIRMATION_CYCLES); + ", requiredConfirmationCycles=" + RESUME_CONFIRMATION_CYCLES);
@ -572,7 +572,7 @@ public class VehicleCommandService {
} }
return observation.aircraftIntersectionBehind() return observation.aircraftIntersectionBehind()
&& observation.aircraftDistanceMeters() > platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft(); && observation.aircraftDistanceMeters() > platformRuntimeStateService.getCollisionDivergingReleaseDistance();
} }
private void publishResumeToFrontend( private void publishResumeToFrontend(

View File

@ -2,6 +2,7 @@ package com.qaup.collision.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -36,6 +37,7 @@ public class CollisionTestSessionLogService {
@Value("${qaup.collision.test-log.enabled:true}") @Value("${qaup.collision.test-log.enabled:true}")
private boolean enabled = true; private boolean enabled = true;
@Autowired
public CollisionTestSessionLogService( public CollisionTestSessionLogService(
@Value("${qaup.collision.test-log.dir:}") String configuredLogDirectory, @Value("${qaup.collision.test-log.dir:}") String configuredLogDirectory,
@Value("${LOG_PATH:logs}") String logPath) { @Value("${LOG_PATH:logs}") String logPath) {

View File

@ -35,10 +35,13 @@ import java.util.concurrent.atomic.AtomicLong;
public class PlatformRuntimeStateService { public class PlatformRuntimeStateService {
private static final String CONFIG_KEY_CROSSING_VEHICLE_DISTANCE = "qaup.collision.crossing.vehicleDistance"; private static final String CONFIG_KEY_CROSSING_VEHICLE_DISTANCE = "qaup.collision.crossing.vehicleDistance";
private static final String CONFIG_KEY_CROSSING_AIRCRAFT_DISTANCE = "qaup.collision.crossing.aircraftDistance"; private static final String CONFIG_KEY_CROSSING_AIRCRAFT_DISTANCE = "qaup.collision.crossing.aircraftDistance";
private static final String CONFIG_KEY_RELEASE_DISTANCE = "qaup.collision.release.distance";
private static final String CONFIG_NAME_CROSSING_VEHICLE_DISTANCE = "Path crossing threshold for vehicle"; private static final String CONFIG_NAME_CROSSING_VEHICLE_DISTANCE = "Path crossing threshold for vehicle";
private static final String CONFIG_NAME_CROSSING_AIRCRAFT_DISTANCE = "Path crossing threshold for aircraft"; private static final String CONFIG_NAME_CROSSING_AIRCRAFT_DISTANCE = "Path crossing threshold for aircraft";
private static final String CONFIG_NAME_RELEASE_DISTANCE = "Path conflict release distance";
private static final String CONFIG_REMARK_CROSSING_VEHICLE_DISTANCE = "Vehicle distance threshold to the route crossing point"; private static final String CONFIG_REMARK_CROSSING_VEHICLE_DISTANCE = "Vehicle distance threshold to the route crossing point";
private static final String CONFIG_REMARK_CROSSING_AIRCRAFT_DISTANCE = "Aircraft distance threshold to the route crossing point"; private static final String CONFIG_REMARK_CROSSING_AIRCRAFT_DISTANCE = "Aircraft distance threshold to the route crossing point";
private static final String CONFIG_REMARK_RELEASE_DISTANCE = "Aircraft distance after passing the conflict point before traffic resumes";
private static final String CONFIG_UPDATER = "system"; private static final String CONFIG_UPDATER = "system";
private final ConcurrentHashMap<String, VehicleRegistryType> vehicleTypes = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, VehicleRegistryType> vehicleTypes = new ConcurrentHashMap<>();
@ -53,6 +56,9 @@ public class PlatformRuntimeStateService {
// Threshold used when the aircraft approaches a route crossing point. // Threshold used when the aircraft approaches a route crossing point.
private volatile double collisionDivergingReleaseDistanceForAircraft; private volatile double collisionDivergingReleaseDistanceForAircraft;
// Distance after the aircraft has passed the crossing point before the vehicle may resume.
private volatile double collisionDivergingReleaseDistance;
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
private static final String DEFAULT_ROUTE_SOURCE = "VehicleRouteIngestion"; private static final String DEFAULT_ROUTE_SOURCE = "VehicleRouteIngestion";
private static final String DEFAULT_ROUTE_DESCRIPTION = "Manual route uploaded via platform integration API"; private static final String DEFAULT_ROUTE_DESCRIPTION = "Manual route uploaded via platform integration API";
@ -78,6 +84,7 @@ public class PlatformRuntimeStateService {
this.runwayAlertZoneRadiusAircraft = runwayAlertZoneRadiusAircraft; this.runwayAlertZoneRadiusAircraft = runwayAlertZoneRadiusAircraft;
this.collisionDivergingReleaseDistanceForVehicle = collisionDivergingReleaseDistance; this.collisionDivergingReleaseDistanceForVehicle = collisionDivergingReleaseDistance;
this.collisionDivergingReleaseDistanceForAircraft = collisionDivergingReleaseDistance; this.collisionDivergingReleaseDistanceForAircraft = collisionDivergingReleaseDistance;
this.collisionDivergingReleaseDistance = collisionDivergingReleaseDistance;
} }
@PostConstruct @PostConstruct
@ -95,6 +102,11 @@ public class PlatformRuntimeStateService {
if (persistedAircraftDistance != null) { if (persistedAircraftDistance != null) {
collisionDivergingReleaseDistanceForAircraft = persistedAircraftDistance; collisionDivergingReleaseDistanceForAircraft = persistedAircraftDistance;
} }
Double persistedReleaseDistance = readConfigDouble(CONFIG_KEY_RELEASE_DISTANCE);
if (persistedReleaseDistance != null) {
collisionDivergingReleaseDistance = persistedReleaseDistance;
}
} }
@Transactional @Transactional
@ -338,23 +350,19 @@ public class PlatformRuntimeStateService {
} }
/** /**
* Returns the shared crossing threshold when both vehicle and aircraft use the same value. * Returns the release distance used after the aircraft has passed the conflict point.
* The value mirrors the vehicle threshold for backward compatibility.
*/ */
public double getCollisionDivergingReleaseDistance() { public double getCollisionDivergingReleaseDistance() {
return collisionDivergingReleaseDistanceForVehicle; return collisionDivergingReleaseDistance;
} }
/** /**
* Updates both vehicle and aircraft crossing thresholds to the same value. * Updates the release distance used after the aircraft has passed the conflict point.
* This keeps the existing API contract that accepts a single numeric value.
*/ */
public double updateCollisionDivergingReleaseDistance(double newValue) { public double updateCollisionDivergingReleaseDistance(double newValue) {
double oldValue = collisionDivergingReleaseDistanceForVehicle; double oldValue = collisionDivergingReleaseDistance;
collisionDivergingReleaseDistanceForVehicle = newValue; collisionDivergingReleaseDistance = newValue;
collisionDivergingReleaseDistanceForAircraft = newValue; persistCrossingThreshold(CONFIG_KEY_RELEASE_DISTANCE, CONFIG_NAME_RELEASE_DISTANCE, newValue, CONFIG_REMARK_RELEASE_DISTANCE);
persistCrossingThreshold(CONFIG_KEY_CROSSING_VEHICLE_DISTANCE, CONFIG_NAME_CROSSING_VEHICLE_DISTANCE, newValue, CONFIG_REMARK_CROSSING_VEHICLE_DISTANCE);
persistCrossingThreshold(CONFIG_KEY_CROSSING_AIRCRAFT_DISTANCE, CONFIG_NAME_CROSSING_AIRCRAFT_DISTANCE, newValue, CONFIG_REMARK_CROSSING_AIRCRAFT_DISTANCE);
return oldValue; return oldValue;
} }

View File

@ -113,6 +113,23 @@ class PlatformIntegrationControllerTest {
.andExpect(jsonPath("$.message").value("Missing field: value")); .andExpect(jsonPath("$.message").value("Missing field: value"));
} }
@Test
void shouldUpdateReleaseDistanceWithoutChangingCrossingAlertDistances() throws Exception {
mockMvc.perform(post("/config/collision/diverging_release_distance")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value\":25}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.field").value("collision.diverging_release_distance"))
.andExpect(jsonPath("$.old").value(40.0))
.andExpect(jsonPath("$.new").value(25.0));
mockMvc.perform(post("/api/collision/preparation/status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.thresholds.vehicleDistance").value(40.0))
.andExpect(jsonPath("$.thresholds.aircraftDistance").value(40.0))
.andExpect(jsonPath("$.releaseDistance").value(25.0));
}
@Test @Test
void shouldRejectInvalidNumericConfigValueType() throws Exception { void shouldRejectInvalidNumericConfigValueType() throws Exception {
mockMvc.perform(post("/config/runway/alert_zone_radius/aircraft") mockMvc.perform(post("/config/runway/alert_zone_radius/aircraft")
@ -184,7 +201,8 @@ class PlatformIntegrationControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.testSessionId").doesNotExist()) .andExpect(jsonPath("$.testSessionId").doesNotExist())
.andExpect(jsonPath("$.thresholds.vehicleDistance").value(40.0)) .andExpect(jsonPath("$.thresholds.vehicleDistance").value(40.0))
.andExpect(jsonPath("$.thresholds.aircraftDistance").value(40.0)); .andExpect(jsonPath("$.thresholds.aircraftDistance").value(40.0))
.andExpect(jsonPath("$.releaseDistance").value(40.0));
} }
@Test @Test

View File

@ -27,6 +27,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@ -533,7 +534,10 @@ class PathConflictDetectionDirectionalTest {
ArgumentCaptor<PathConflictAlertMessage> statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class); ArgumentCaptor<PathConflictAlertMessage> statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class);
verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture()); verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture());
PathConflictAlertMessage status = statusCaptor.getValue(); PathConflictAlertMessage status = statusCaptor.getAllValues().stream()
.filter(message -> "MONITORING".equals(message.getCalculationStatus()))
.findFirst()
.orElseThrow();
org.junit.jupiter.api.Assertions.assertEquals("PATH_CONFLICT_STATUS", status.getMessageType()); 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("MONITORING", status.getCalculationStatus());
org.junit.jupiter.api.Assertions.assertEquals(0.0, status.getConflictPoint().getLatitude()); org.junit.jupiter.api.Assertions.assertEquals(0.0, status.getConflictPoint().getLatitude());
@ -839,6 +843,125 @@ class PathConflictDetectionDirectionalTest {
org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished()); org.junit.jupiter.api.Assertions.assertEquals(1, summary.eventsPublished());
} }
@Test
void shouldKeepOverlapEntryPointLockedAfterAircraftPassesIt() 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, 0.0),
new Coordinate(80.0, 0.0)
});
MovingObject vehicle = MovingObject.builder()
.objectId("UV-1")
.objectName("UV-1")
.objectType(MovingObjectType.UNMANNED_VEHICLE)
.currentPosition(geometryFactory.createPoint(new Coordinate(20.0, 0.0)))
.currentSpeed(20.0)
.currentHeading(90.0)
.altitude(0.0)
.build();
MovingObject aircraftBeforeEntry = MovingObject.builder()
.objectId("AC-1")
.objectName("AC-1")
.objectType(MovingObjectType.AIRCRAFT)
.currentPosition(geometryFactory.createPoint(new Coordinate(30.0, 10.0)))
.currentSpeed(20.0)
.currentHeading(270.0)
.altitude(0.0)
.build();
MovingObject aircraftAfterEntry = MovingObject.builder()
.objectId("AC-1")
.objectName("AC-1")
.objectType(MovingObjectType.AIRCRAFT)
.currentPosition(geometryFactory.createPoint(new Coordinate(50.0, 0.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(platformRuntimeStateService.getCurrentTestSessionId()).thenReturn("session-overlap");
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.detectPathConflicts(List.of(vehicle, aircraftBeforeEntry));
clearInvocations(vehicleCommandService);
clearInvocations(webSocketPublisher);
@SuppressWarnings("unchecked")
Map<String, Long> publishedStatusTimes = (Map<String, Long>) ReflectionTestUtils.getField(
pathConflictDetectionService,
"pathConflictStatusPublishedAt");
publishedStatusTimes.clear();
PathConflictDetectionService.ConflictDetectionSummary summary =
pathConflictDetectionService.detectPathConflicts(List.of(vehicle, aircraftAfterEntry));
org.junit.jupiter.api.Assertions.assertEquals(1, summary.intersectionBehindPairs());
org.junit.jupiter.api.Assertions.assertEquals(0, summary.thresholdNotReachedPairs());
@SuppressWarnings("unchecked")
ArgumentCaptor<List<VehicleCommandService.ConflictRecoveryObservation>> observationCaptor =
ArgumentCaptor.forClass(List.class);
verify(vehicleCommandService).synchronizeConflictCommands(any(), observationCaptor.capture());
List<VehicleCommandService.ConflictRecoveryObservation> observations = observationCaptor.getValue();
org.junit.jupiter.api.Assertions.assertEquals(1, observations.size());
org.junit.jupiter.api.Assertions.assertTrue(observations.get(0).aircraftIntersectionBehind());
ArgumentCaptor<PathConflictAlertMessage> statusCaptor = ArgumentCaptor.forClass(PathConflictAlertMessage.class);
verify(webSocketPublisher, atLeastOnce()).publishPathConflictStatus(statusCaptor.capture());
PathConflictAlertMessage status = statusCaptor.getValue();
org.junit.jupiter.api.Assertions.assertEquals("INTERSECTION_BEHIND", status.getCalculationStatus());
org.junit.jupiter.api.Assertions.assertEquals(20.0, status.getAircraftDistanceToConflictMeters(), 0.001);
org.junit.jupiter.api.Assertions.assertEquals(
-20.0,
(Double) ReflectionTestUtils.getField(status, "object2ForwardDistanceMeters"),
0.001);
}
@Test @Test
void shouldPublishMonitoringStatusForValidConflictPointBeforeAlertThreshold() throws Exception { void shouldPublishMonitoringStatusForValidConflictPointBeforeAlertThreshold() throws Exception {
GeometryFactory geometryFactory = new GeometryFactory(); GeometryFactory geometryFactory = new GeometryFactory();

View File

@ -160,6 +160,50 @@ class VehicleCommandServiceTest {
)); ));
} }
@Test
void shouldUseReleaseDistanceInsteadOfAircraftAlertDistanceWhenResuming() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
runtimeStateService.updateCollisionDivergingReleaseDistanceForAircraft(100.0);
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"))
.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 @Test
void shouldNotResumeAfterAircraftPassesConflictPointButBeforeReleaseDistance() { void shouldNotResumeAfterAircraftPassesConflictPointButBeforeReleaseDistance() {
runtimeStateService.updateVehicleRegistry(List.of( runtimeStateService.updateVehicleRegistry(List.of(

View File

@ -63,6 +63,8 @@ class PlatformRuntimeStateServiceTest {
assertEquals(40.0, service.updateCollisionDivergingReleaseDistance(55.0)); assertEquals(40.0, service.updateCollisionDivergingReleaseDistance(55.0));
assertEquals(55.0, service.getCollisionDivergingReleaseDistance()); assertEquals(55.0, service.getCollisionDivergingReleaseDistance());
assertEquals(40.0, service.getCollisionDivergingReleaseDistanceForVehicle());
assertEquals(40.0, service.getCollisionDivergingReleaseDistanceForAircraft());
} }
@Test @Test
@ -74,8 +76,9 @@ class PlatformRuntimeStateServiceTest {
service.updateCollisionDivergingReleaseDistanceForVehicle(60.0); service.updateCollisionDivergingReleaseDistanceForVehicle(60.0);
service.updateCollisionDivergingReleaseDistanceForAircraft(80.0); service.updateCollisionDivergingReleaseDistanceForAircraft(80.0);
service.updateCollisionDivergingReleaseDistance(25.0);
verify(sysConfigService, times(2)).insertConfig(any(SysConfig.class)); verify(sysConfigService, times(3)).insertConfig(any(SysConfig.class));
} }
@Test @Test
@ -84,12 +87,14 @@ class PlatformRuntimeStateServiceTest {
ISysConfigService sysConfigService = mock(ISysConfigService.class); ISysConfigService sysConfigService = mock(ISysConfigService.class);
when(sysConfigService.selectConfigByKey("qaup.collision.crossing.vehicleDistance")).thenReturn("65"); when(sysConfigService.selectConfigByKey("qaup.collision.crossing.vehicleDistance")).thenReturn("65");
when(sysConfigService.selectConfigByKey("qaup.collision.crossing.aircraftDistance")).thenReturn("85"); when(sysConfigService.selectConfigByKey("qaup.collision.crossing.aircraftDistance")).thenReturn("85");
when(sysConfigService.selectConfigByKey("qaup.collision.release.distance")).thenReturn("30");
ReflectionTestUtils.setField(service, "sysConfigService", sysConfigService); ReflectionTestUtils.setField(service, "sysConfigService", sysConfigService);
service.loadPersistedCrossingThresholds(); service.loadPersistedCrossingThresholds();
assertEquals(65.0, service.getCollisionDivergingReleaseDistanceForVehicle()); assertEquals(65.0, service.getCollisionDivergingReleaseDistanceForVehicle());
assertEquals(85.0, service.getCollisionDivergingReleaseDistanceForAircraft()); assertEquals(85.0, service.getCollisionDivergingReleaseDistanceForAircraft());
assertEquals(30.0, service.getCollisionDivergingReleaseDistance());
} }
@Test @Test