feat(javaTest): rank routes by live traffic quality

This commit is contained in:
zgw
2026-08-06 17:19:13 +08:00
parent fe079a10a4
commit 2d834965c0
6 changed files with 552 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
package com.ft546.wcs.demo.traffic;
/** 把一条地图候选与同一交通快照下算出的质量证据绑定。 */
public final class RouteCandidateEvaluation {
private final RouteCandidate candidate;
private final RouteQuality quality;
RouteCandidateEvaluation(RouteCandidate candidate, RouteQuality quality) {
this.candidate = candidate;
this.quality = quality;
}
/** 返回被评价的候选路线。 */
public RouteCandidate getCandidate() { return candidate; }
/** 返回该候选的完整业务质量元组。 */
public RouteQuality getQuality() { return quality; }
}

View File

@@ -0,0 +1,37 @@
package com.ft546.wcs.demo.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 一次接近段或服务段规划的最终选择及全部审计证据。 */
public final class RoutePlanDecision {
private final RouteCandidateSet candidateSet;
private final List<RouteCandidateEvaluation> evaluations;
private final RouteCandidateEvaluation selected;
private final String decisionReason;
RoutePlanDecision(RouteCandidateSet candidateSet,
List<RouteCandidateEvaluation> evaluations,
RouteCandidateEvaluation selected, String decisionReason) {
this.candidateSet = candidateSet;
this.evaluations = Collections.unmodifiableList(
new ArrayList<RouteCandidateEvaluation>(evaluations));
this.selected = selected;
this.decisionReason = decisionReason;
}
public RouteCandidateSet getCandidateSet() { return candidateSet; }
public List<RouteCandidateEvaluation> getEvaluations() { return evaluations; }
public RouteCandidateEvaluation getSelected() { return selected; }
public List<String> getSelectedPath() { return selected.getCandidate().getPath(); }
public RouteQuality getSelectedQuality() { return selected.getQuality(); }
public String getDecisionReason() { return decisionReason; }
/** 返回选中距离相对物理最短距离的倍率,1.0 表示没有绕行。 */
public double getDetourRatio() {
double shortest = candidateSet.getShortestPhysicalDistance();
return shortest == 0.0d ? 1.0d
: selected.getCandidate().getPhysicalDistance() / shortest;
}
}

View File

@@ -0,0 +1,70 @@
package com.ft546.wcs.demo.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 一条候选路线按固定业务顺序组成的可解释比较元组。 */
public final class RouteQuality implements Comparable<RouteQuality> {
private final int oppositeDirectionConflictCount;
private final int occupiedNodeCount;
private final int incompatibleReservedEdgeCount;
private final int activeRouteOverlapEdgeCount;
private final int turnCount;
private final double physicalDistance;
private final List<String> canonicalPath;
RouteQuality(int oppositeDirectionConflictCount, int occupiedNodeCount,
int incompatibleReservedEdgeCount, int activeRouteOverlapEdgeCount,
int turnCount, double physicalDistance, List<String> canonicalPath) {
this.oppositeDirectionConflictCount = oppositeDirectionConflictCount;
this.occupiedNodeCount = occupiedNodeCount;
this.incompatibleReservedEdgeCount = incompatibleReservedEdgeCount;
this.activeRouteOverlapEdgeCount = activeRouteOverlapEdgeCount;
this.turnCount = turnCount;
this.physicalDistance = physicalDistance;
this.canonicalPath = Collections.unmodifiableList(
new ArrayList<String>(canonicalPath));
}
public int getOppositeDirectionConflictCount() { return oppositeDirectionConflictCount; }
public int getOccupiedNodeCount() { return occupiedNodeCount; }
public int getIncompatibleReservedEdgeCount() { return incompatibleReservedEdgeCount; }
public int getActiveRouteOverlapEdgeCount() { return activeRouteOverlapEdgeCount; }
public int getTurnCount() { return turnCount; }
public double getPhysicalDistance() { return physicalDistance; }
public List<String> getCanonicalPath() { return canonicalPath; }
/**
* 按中心调度确认的硬顺序逐项比较,不使用会掩盖高优先级风险的加权总分。
*/
@Override
public int compareTo(RouteQuality other) {
int result = Integer.compare(oppositeDirectionConflictCount,
other.oppositeDirectionConflictCount);
if (result != 0) return result;
result = Integer.compare(occupiedNodeCount, other.occupiedNodeCount);
if (result != 0) return result;
result = Integer.compare(incompatibleReservedEdgeCount,
other.incompatibleReservedEdgeCount);
if (result != 0) return result;
result = Integer.compare(activeRouteOverlapEdgeCount,
other.activeRouteOverlapEdgeCount);
if (result != 0) return result;
result = Integer.compare(turnCount, other.turnCount);
if (result != 0) return result;
result = Double.compare(physicalDistance, other.physicalDistance);
if (result != 0) return result;
return comparePaths(canonicalPath, other.canonicalPath);
}
/** 以完整站点序列作最终稳定决胜,确保重复仿真得到同一路线。 */
private int comparePaths(List<String> left, List<String> right) {
int common = Math.min(left.size(), right.size());
for (int index = 0; index < common; index++) {
int result = left.get(index).compareTo(right.get(index));
if (result != 0) return result;
}
return Integer.compare(left.size(), right.size());
}
}

View File

@@ -0,0 +1,139 @@
package com.ft546.wcs.demo.traffic;
import com.ft546.wcs.demo.model.Site;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/** 使用同一个实时交通快照为有界候选排序并选择业务质量最优路线。 */
public final class RouteQualityEvaluator {
/**
* 评价全部候选并按固定质量元组选择第一名。
*
* @param map 权威站点坐标
* @param candidates 已通过物理距离边界的候选集合
* @param snapshot 同一 tick 冻结的交通证据
* @param taskId 当前任务,用于排除自己已持有的资源
* @param vehicleId 当前车辆,用于排除自己的起点占用
* @return 包含所有候选质量和最终业务原因的决定
*/
public RoutePlanDecision select(GridMap map, RouteCandidateSet candidates,
TrafficRouteSnapshot snapshot,
String taskId, String vehicleId) {
if (candidates == null || candidates.getCandidates().isEmpty()) {
throw new IllegalArgumentException("at least one route candidate is required");
}
List<RouteCandidateEvaluation> evaluations =
new ArrayList<RouteCandidateEvaluation>();
RouteCandidateEvaluation baseline = null;
for (RouteCandidate candidate : candidates.getCandidates()) {
RouteCandidateEvaluation evaluation = new RouteCandidateEvaluation(candidate,
evaluate(map, candidate, snapshot, taskId, vehicleId));
evaluations.add(evaluation);
if (candidate.getPath().equals(candidates.getShortestPath())) baseline = evaluation;
}
Collections.sort(evaluations, Comparator.comparing(
RouteCandidateEvaluation::getQuality));
RouteCandidateEvaluation selected = evaluations.get(0);
if (baseline == null) baseline = selected;
return new RoutePlanDecision(candidates, evaluations, selected,
decisionReason(selected, baseline));
}
/**
* 计算单条候选的交通风险、操作复杂度和物理距离元组。
*/
public RouteQuality evaluate(GridMap map, RouteCandidate candidate,
TrafficRouteSnapshot snapshot,
String taskId, String vehicleId) {
if (map == null || candidate == null || snapshot == null
|| taskId == null || vehicleId == null) {
throw new IllegalArgumentException("map, candidate, snapshot and owners are required");
}
List<String> path = candidate.getPath();
int opposite = 0;
int occupied = 0;
int incompatible = 0;
int overlap = 0;
// 路径下标 0 是当前车辆所在点,只有未来节点被其他车辆占用才形成候选风险。
for (int index = 1; index < path.size(); index++) {
String from = path.get(index - 1);
String to = path.get(index);
if (snapshot.isOccupiedByOther(to, vehicleId)) occupied++;
boolean oppositeOnEdge = snapshot.hasOppositeDirectionOwnedByOther(
from, to, taskId);
if (oppositeOnEdge) opposite++;
// 同一物理边已计为对向风险时不在“不兼容边预约”中重复处罚。
if (!oppositeOnEdge
&& snapshot.isExclusiveEdgeOwnedByOther(from, to, taskId)) {
incompatible++;
}
if (snapshot.overlapsOtherActiveRoute(from, to, taskId)) overlap++;
}
return new RouteQuality(opposite, occupied, incompatible, overlap,
countTurns(map, path), candidate.getPhysicalDistance(), path);
}
/** 计算连续边方向向量改变次数;同一直线上的不同边长不算转弯。 */
private int countTurns(GridMap map, List<String> path) {
int turns = 0;
for (int index = 2; index < path.size(); index++) {
Site first = map.getSite(path.get(index - 2));
Site middle = map.getSite(path.get(index - 1));
Site last = map.getSite(path.get(index));
if (first == null || middle == null || last == null) {
throw new IllegalArgumentException("candidate references an unknown site");
}
if (first.getFloor() != middle.getFloor()
|| middle.getFloor() != last.getFloor()) {
continue;
}
int firstX = normalize(middle.getX() - first.getX());
int firstY = normalize(middle.getY() - first.getY());
int secondX = normalize(last.getX() - middle.getX());
int secondY = normalize(last.getY() - middle.getY());
if (firstX != secondX || firstY != secondY) turns++;
}
return turns;
}
/** 当前地图为正交网格,方向只需要保留坐标增量的符号。 */
private int normalize(int value) {
return Integer.compare(value, 0);
}
/** 解释选中路线相对物理最短路线最先胜出的业务指标。 */
private String decisionReason(RouteCandidateEvaluation selected,
RouteCandidateEvaluation baseline) {
if (selected.getCandidate().getPath().equals(baseline.getCandidate().getPath())) {
return "SHORTEST_AND_BEST";
}
RouteQuality winner = selected.getQuality();
RouteQuality shortest = baseline.getQuality();
if (winner.getOppositeDirectionConflictCount()
< shortest.getOppositeDirectionConflictCount()) {
return "FEWER_OPPOSITE_DIRECTION_CONFLICTS";
}
if (winner.getOccupiedNodeCount() < shortest.getOccupiedNodeCount()) {
return "FEWER_OCCUPIED_NODES";
}
if (winner.getIncompatibleReservedEdgeCount()
< shortest.getIncompatibleReservedEdgeCount()) {
return "FEWER_INCOMPATIBLE_RESERVED_EDGES";
}
if (winner.getActiveRouteOverlapEdgeCount()
< shortest.getActiveRouteOverlapEdgeCount()) {
return "FEWER_ACTIVE_ROUTE_OVERLAPS";
}
if (winner.getTurnCount() < shortest.getTurnCount()) return "FEWER_TURNS";
if (winner.getPhysicalDistance() < shortest.getPhysicalDistance()) {
return "SHORTER_PHYSICAL_DISTANCE";
}
return "CANONICAL_PATH_TIE_BREAK";
}
}

View File

@@ -0,0 +1,166 @@
package com.ft546.wcs.demo.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 保存一次路线比较所使用的不可变实时交通证据。
*
* <p>同一候选集合必须使用同一快照,避免前一条路线和后一条路线读取到不同 tick 的占用状态。
*/
public final class TrafficRouteSnapshot {
private final Map<String, Set<String>> occupiedNodes;
private final Map<String, Set<String>> exclusiveEdges;
private final Map<String, Set<String>> directedReservations;
private final Map<String, List<String>> activeRoutes;
private TrafficRouteSnapshot(Builder builder) {
this.occupiedNodes = immutableSets(builder.occupiedNodes);
this.exclusiveEdges = immutableSets(builder.exclusiveEdges);
this.directedReservations = immutableSets(builder.directedReservations);
Map<String, List<String>> routes = new LinkedHashMap<String, List<String>>();
for (Map.Entry<String, List<String>> entry : builder.activeRoutes.entrySet()) {
routes.put(entry.getKey(), Collections.unmodifiableList(
new ArrayList<String>(entry.getValue())));
}
this.activeRoutes = Collections.unmodifiableMap(routes);
}
/** 创建快照构造器,调用方可从车辆、资源台账和走廊预约逐项填充证据。 */
public static Builder builder() {
return new Builder();
}
/** 返回没有任何临时交通风险的空快照,适用于纯地图规划和单元测试。 */
public static TrafficRouteSnapshot empty() {
return builder().build();
}
/** 判断候选未来节点是否由其他车辆占用;当前车辆自己的节点不形成风险。 */
boolean isOccupiedByOther(String siteId, String vehicleId) {
return containsOther(occupiedNodes.get(siteId), vehicleId);
}
/** 判断物理边是否由其他任务独占;当前任务已经合法持有的边可以继续使用。 */
boolean isExclusiveEdgeOwnedByOther(String from, String to, String taskId) {
return containsOther(exclusiveEdges.get(canonicalEdge(from, to)), taskId);
}
/** 判断同一走廊是否已经被其他任务预约为候选移动的相反方向。 */
boolean hasOppositeDirectionOwnedByOther(String from, String to, String taskId) {
return containsOther(directedReservations.get(directedEdge(to, from)), taskId);
}
/** 判断候选物理边是否与其他活动任务尚未执行的路线重合。 */
boolean overlapsOtherActiveRoute(String from, String to, String taskId) {
String edge = canonicalEdge(from, to);
for (Map.Entry<String, List<String>> entry : activeRoutes.entrySet()) {
if (entry.getKey().equals(taskId)) continue;
List<String> route = entry.getValue();
for (int index = 1; index < route.size(); index++) {
if (edge.equals(canonicalEdge(route.get(index - 1), route.get(index)))) {
return true;
}
}
}
return false;
}
/** 返回集合中是否至少有一个非当前业务所有者。 */
private boolean containsOther(Set<String> owners, String currentOwner) {
if (owners == null) return false;
for (String owner : owners) {
if (!owner.equals(currentOwner)) return true;
}
return false;
}
/** 将内部可变集合复制为不可变证据,后续资源变化不会污染本次决策。 */
private static Map<String, Set<String>> immutableSets(Map<String, Set<String>> source) {
Map<String, Set<String>> result = new LinkedHashMap<String, Set<String>>();
for (Map.Entry<String, Set<String>> entry : source.entrySet()) {
result.put(entry.getKey(), Collections.unmodifiableSet(
new LinkedHashSet<String>(entry.getValue())));
}
return Collections.unmodifiableMap(result);
}
/** 把无向资源边规范为相同键,使 A-B 与 B-A 指向同一物理边。 */
static String canonicalEdge(String left, String right) {
return left.compareTo(right) <= 0 ? left + "\u0000" + right : right + "\u0000" + left;
}
/** 保留方向生成走廊预约键,使 A→B 与 B→A 可以分别判断。 */
static String directedEdge(String from, String to) {
return from + "\u0000" + to;
}
/** 在决策时收集当前 tick 交通状态的可变构造器。 */
public static final class Builder {
private final Map<String, Set<String>> occupiedNodes =
new LinkedHashMap<String, Set<String>>();
private final Map<String, Set<String>> exclusiveEdges =
new LinkedHashMap<String, Set<String>>();
private final Map<String, Set<String>> directedReservations =
new LinkedHashMap<String, Set<String>>();
private final Map<String, List<String>> activeRoutes =
new LinkedHashMap<String, List<String>>();
/** 登记车辆当前实际占据的节点。 */
public Builder occupyNode(String siteId, String vehicleId) {
add(occupiedNodes, siteId, vehicleId);
return this;
}
/** 登记资源台账中不能与其他任务同时持有的物理边。 */
public Builder reserveEdge(String from, String to, String taskId) {
add(exclusiveEdges, canonicalEdge(from, to), taskId);
return this;
}
/** 登记走廊从 from 到 to 的方向预约。 */
public Builder reserveDirection(String from, String to, String taskId) {
add(directedReservations, directedEdge(from, to), taskId);
return this;
}
/** 登记其他活动任务从当前位置开始尚未走完的节点序列。 */
public Builder activeRoute(String taskId, List<String> remainingRoute) {
requireText(taskId, "task id");
if (remainingRoute == null || remainingRoute.isEmpty()) {
throw new IllegalArgumentException("active route is required");
}
activeRoutes.put(taskId, new ArrayList<String>(remainingRoute));
return this;
}
/** 冻结本轮交通证据,后续候选评价只读该对象。 */
public TrafficRouteSnapshot build() {
return new TrafficRouteSnapshot(this);
}
/** 向同一资源追加合法所有者,支持同向走廊存在多个任务预约。 */
private void add(Map<String, Set<String>> target, String resource, String owner) {
requireText(resource, "resource");
requireText(owner, "owner");
Set<String> owners = target.get(resource);
if (owners == null) {
owners = new LinkedHashSet<String>();
target.put(resource, owners);
}
owners.add(owner);
}
/** 拒绝空业务标识,避免快照中出现无法解释的匿名资源。 */
private void requireText(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
}
}
}

View File

@@ -0,0 +1,122 @@
package com.ft546.wcs.demo.traffic;
import com.ft546.wcs.demo.model.Site;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RouteQualityEvaluatorTest {
@Test
void countsLiveTrafficRiskWithoutChargingResourcesOwnedByCurrentTaskOrVehicle() {
GridMap map = map();
RouteCandidate candidate = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d);
TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder()
.occupyNode("A", "AGV-1")
.occupyNode("B", "AGV-2")
.reserveDirection("D", "B", "TASK-2")
.reserveEdge("B", "D", "TASK-2")
.activeRoute("TASK-2", Arrays.asList("D", "B"))
.reserveEdge("A", "B", "TASK-1")
.build();
RouteQuality quality = new RouteQualityEvaluator().evaluate(
map, candidate, snapshot, "TASK-1", "AGV-1");
assertEquals(1, quality.getOppositeDirectionConflictCount());
assertEquals(1, quality.getOccupiedNodeCount());
assertEquals(0, quality.getIncompatibleReservedEdgeCount());
assertEquals(1, quality.getActiveRouteOverlapEdgeCount());
assertEquals(0, quality.getTurnCount());
}
@Test
void lexicographicOrderPrefersConflictAvoidanceBeforeDistance() {
GridMap map = map();
RouteCandidate shortestWithOppositeConflict =
new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d);
RouteCandidate longerWithoutConflict =
new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d);
RouteCandidateSet candidates = new RouteCandidateSet(
shortestWithOppositeConflict.getPath(), 2.0d,
Arrays.asList(shortestWithOppositeConflict, longerWithoutConflict), false);
TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder()
.reserveDirection("D", "B", "OTHER")
.build();
RoutePlanDecision decision = new RouteQualityEvaluator().select(
map, candidates, snapshot, "TASK-1", "AGV-1");
assertEquals(Arrays.asList("A", "C", "D"), decision.getSelectedPath());
assertEquals("FEWER_OPPOSITE_DIRECTION_CONFLICTS", decision.getDecisionReason());
assertEquals(2, decision.getEvaluations().size());
assertEquals(1.4d, decision.getDetourRatio(), 0.000001d);
}
@Test
void lowerTupleFieldsDecideOnlyWhenAllHigherFieldsAreEqual() {
GridMap map = map();
RouteCandidate occupied = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d);
RouteCandidate reserved = new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d);
TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder()
.occupyNode("B", "OTHER-AGV")
.reserveEdge("A", "C", "OTHER-TASK")
.build();
RoutePlanDecision occupiedLoses = new RouteQualityEvaluator().select(
map, new RouteCandidateSet(occupied.getPath(), 2.0d,
Arrays.asList(occupied, reserved), false),
snapshot, "TASK-1", "AGV-1");
assertEquals(reserved.getPath(), occupiedLoses.getSelectedPath());
RouteCandidate canonicalFirst = new RouteCandidate(
Arrays.asList("A", "B", "D"), 2.0d);
RouteCandidate canonicalSecond = new RouteCandidate(
Arrays.asList("A", "E", "D"), 2.0d);
RoutePlanDecision canonicalDecision = new RouteQualityEvaluator().select(
map, new RouteCandidateSet(canonicalFirst.getPath(), 2.0d,
Arrays.asList(canonicalSecond, canonicalFirst), false),
TrafficRouteSnapshot.empty(), "TASK-1", "AGV-1");
assertEquals(canonicalFirst.getPath(), canonicalDecision.getSelectedPath());
}
@Test
void countsTurnsUsingDirectionChangesAndActiveOverlapAsUndirectedEdges() {
GridMap map = map();
RouteCandidate turning = new RouteCandidate(Arrays.asList("A", "B", "F"), 2.0d);
TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder()
.activeRoute("OTHER", Arrays.asList("F", "B"))
.build();
RouteQuality quality = new RouteQualityEvaluator().evaluate(
map, turning, snapshot, "TASK-1", "AGV-1");
assertEquals(1, quality.getTurnCount());
assertEquals(1, quality.getActiveRouteOverlapEdgeCount());
}
private GridMap map() {
GridMap map = new GridMap();
add(map, "A", 0, 0);
add(map, "B", 1, 0);
add(map, "C", 1, 1);
add(map, "D", 2, 0);
add(map, "E", 1, -1);
add(map, "F", 1, 1);
map.connect("A", "B");
map.connect("B", "D");
map.connect("A", "C");
map.connect("C", "D");
map.connect("A", "E");
map.connect("E", "D");
map.connect("B", "F");
return map;
}
private void add(GridMap map, String id, int x, int y) {
map.addSite(new Site(id, 1, x, y, "MAIN"));
}
}